diff --git a/packages/ui/src/components/GalleryLarge.tsx b/packages/ui/src/components/GalleryLarge.tsx
index 85361a19..70a01883 100644
--- a/packages/ui/src/components/GalleryLarge.tsx
+++ b/packages/ui/src/components/GalleryLarge.tsx
@@ -24,6 +24,7 @@ interface GalleryLargeProps {
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
visibilityFilter?: 'invisible' | 'private';
center?: boolean;
+ preset?: any;
}
const GalleryLarge = ({
@@ -36,7 +37,8 @@ const GalleryLarge = ({
categoryIds,
contentType,
visibilityFilter,
- center
+ center,
+ preset
}: GalleryLargeProps) => {
const { user } = useAuth();
const navigate = useNavigate();
@@ -146,6 +148,7 @@ const GalleryLarge = ({
job={item.job}
responsive={item.responsive}
variant="feed"
+ preset={preset}
/>
);
})}
diff --git a/packages/ui/src/components/ListLayout.tsx b/packages/ui/src/components/ListLayout.tsx
index cd567a10..4225d8bd 100644
--- a/packages/ui/src/components/ListLayout.tsx
+++ b/packages/ui/src/components/ListLayout.tsx
@@ -22,9 +22,10 @@ interface ListLayoutProps {
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
visibilityFilter?: 'invisible' | 'private';
center?: boolean;
+ preset?: any;
}
-const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolean, onClick: () => void }) => {
+const ListItem = ({ item, isSelected, onClick, preset }: { item: any, isSelected: boolean, onClick: () => void, preset?: any }) => {
const isExternal = item.type === 'page-external';
const domain = isExternal && item.meta?.url ? new URL(item.meta.url).hostname : null;
@@ -46,9 +47,11 @@ const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolea
)}
-
- {item.description}
-
+ {preset?.showDescription !== false && item.description && (
+
+ {item.description}
+
+ )}
{ e.stopPropagation(); }}>
@@ -120,7 +123,8 @@ export const ListLayout = ({
categoryIds,
contentType,
visibilityFilter,
- center
+ center,
+ preset
}: ListLayoutProps) => {
const navigate = useNavigate();
const isMobile = useIsMobile();
@@ -221,6 +225,7 @@ export const ListLayout = ({
item={post}
isSelected={!isMobileView && selectedId === post.id}
onClick={() => handleItemClick(post)}
+ preset={preset}
/>
));
@@ -250,6 +255,7 @@ export const ListLayout = ({
item={post}
isSelected={!isMobileView && selectedId === post.id}
onClick={() => handleItemClick(post)}
+ preset={preset}
/>
))
diff --git a/packages/ui/src/components/widgets/CategoryFeedWidget.tsx b/packages/ui/src/components/widgets/CategoryFeedWidget.tsx
index a216c8e5..e837ba2a 100644
--- a/packages/ui/src/components/widgets/CategoryFeedWidget.tsx
+++ b/packages/ui/src/components/widgets/CategoryFeedWidget.tsx
@@ -63,6 +63,8 @@ const CategoryFeedWidget: React.FC = ({
showFooter,
center,
columns,
+ showTitle,
+ showDescription,
variables,
searchQuery,
...rest
@@ -184,6 +186,8 @@ const CategoryFeedWidget: React.FC = ({
showSortBar={showSortBar}
showLayoutToggles={showLayoutToggles}
showFooter={showFooter}
+ showTitle={showTitle}
+ showDescription={showDescription}
center={center}
columns={columns === 'auto' ? 'auto' : (Number(columns) || 4)}
variables={variables}
diff --git a/packages/ui/src/components/widgets/HomeWidget.tsx b/packages/ui/src/components/widgets/HomeWidget.tsx
index f83df75b..5d5b44a6 100644
--- a/packages/ui/src/components/widgets/HomeWidget.tsx
+++ b/packages/ui/src/components/widgets/HomeWidget.tsx
@@ -14,6 +14,8 @@ export interface HomeWidgetProps {
showFooter?: boolean;
center?: boolean;
columns?: number | 'auto';
+ showTitle?: boolean;
+ showDescription?: boolean;
heading?: string;
headingLevel?: 'h1' | 'h2' | 'h3' | 'h4';
variables?: Record;
@@ -57,6 +59,8 @@ const HomeWidget: React.FC = ({
showSortBar = true,
showLayoutToggles = true,
showFooter = true,
+ showTitle = true,
+ showDescription = false,
center = false,
columns = 'auto',
heading,
@@ -340,11 +344,11 @@ const HomeWidget: React.FC = ({
}
if (viewMode === 'grid') {
- return ;
+ return ;
} else if (viewMode === 'large') {
- return ;
+ return ;
}
- return ;
+ return ;
};
return (
diff --git a/packages/ui/src/lib/registerWidgets.ts b/packages/ui/src/lib/registerWidgets.ts
index 94a6bab3..354e1082 100644
--- a/packages/ui/src/lib/registerWidgets.ts
+++ b/packages/ui/src/lib/registerWidgets.ts
@@ -1022,6 +1022,8 @@ export function registerAllWidgets() {
showSortBar: true,
showLayoutToggles: true,
showFooter: false,
+ showTitle: true,
+ showDescription: false,
center: false,
columns: 'auto',
variables: {}
@@ -1120,6 +1122,18 @@ export function registerAllWidgets() {
description: 'Show the site footer below the feed',
default: false
},
+ showTitle: {
+ type: 'boolean',
+ label: 'Show Title',
+ description: 'Display the picture/post title',
+ default: true
+ },
+ showDescription: {
+ type: 'boolean',
+ label: 'Show Description',
+ description: 'Display the picture/post description beneath the title',
+ default: false
+ },
center: {
type: 'boolean',
label: 'Center Content',
diff --git a/packages/ui/src/modules/contacts/hooks/useContactsManager.ts b/packages/ui/src/modules/contacts/hooks/useContactsManager.ts
index 4b080b3e..cefbd5d9 100644
--- a/packages/ui/src/modules/contacts/hooks/useContactsManager.ts
+++ b/packages/ui/src/modules/contacts/hooks/useContactsManager.ts
@@ -6,14 +6,13 @@ import {
type GridFilterModel, type GridSortModel, type GridColumnVisibilityModel, type GridPaginationModel
} from '@mui/x-data-grid';
import {
- filterModelToParams, paramsToFilterModel,
- sortModelToParams, paramsToSortModel,
- visibilityModelToParams, paramsToVisibilityModel,
+ paramsToFilterModel,
+ paramsToSortModel,
+ paramsToVisibilityModel,
} from '@/components/grids/gridUtils';
import {
Contact, ContactGroup,
fetchContacts, createContact, updateContact, deleteContact, batchDeleteContacts,
- importContacts, exportContacts,
fetchContactGroups, createContactGroup, deleteContactGroup,
addGroupMembers, fetchGroupMembers, removeGroupMember,
} from '@/modules/contacts/client-contacts';
diff --git a/packages/ui/src/modules/places/client-gridsearch.ts b/packages/ui/src/modules/places/client-gridsearch.ts
index 002cee55..1a6ca13b 100644
--- a/packages/ui/src/modules/places/client-gridsearch.ts
+++ b/packages/ui/src/modules/places/client-gridsearch.ts
@@ -133,11 +133,12 @@ export const retryPlacesGridSearchJob = async (id: string): Promise => {
export const expandPlacesGridSearch = async (
parentId: string,
areas: { gid: string; name: string; level: number; raw?: any }[],
- settings?: Record
+ settings?: Record,
+ viewportData?: { viewportSearch: boolean; viewportCenter: { lat: number; lng: number }; viewportZoom: number; types?: string[] }
): Promise<{ message: string; jobId: string }> => {
return apiClient<{ message: string; jobId: string }>(`/api/places/gridsearch/${parentId}/expand`, {
method: 'POST',
- body: JSON.stringify({ areas, settings }),
+ body: JSON.stringify({ areas, settings, ...viewportData }),
});
};
diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx
index 1ae3a33a..09638b6f 100644
--- a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx
+++ b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx
@@ -14,10 +14,10 @@ import { POSTER_THEMES } from '../utils/poster-themes';
import { type PlaceFull } from '@polymech/shared';
import { type LogEntry } from '@/contexts/LogContext';
import ChatLogBrowser from '@/components/ChatLogBrowser';
-import { GripVertical, Download, Code, FileSpreadsheet } from 'lucide-react';
+import { GripVertical, Download, Code, FileSpreadsheet, Compass } from 'lucide-react';
import { LocationDetailView } from '../LocationDetail';
import { exportToCSV, exportToJSON } from './exportUtils';
-import { T } from '@/i18n';
+import { T, translate } from '@/i18n';
import {
DropdownMenu,
DropdownMenuContent,
@@ -84,6 +84,17 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
gid: a.gid, name: a.name, level: a.level,
})) || undefined;
+ const defaultDiscoverQuery = restoredState?.run?.request?.search?.types?.[0] || restoredState?.run?.request?.types?.[0];
+ const [discoverQuery, setDiscoverQuery] = useState("");
+ const discoverQueryInitialized = React.useRef(false);
+
+ React.useEffect(() => {
+ if (restoredState && !discoverQueryInitialized.current) {
+ setDiscoverQuery(defaultDiscoverQuery || 'business,shop,factory,service,industry,company');
+ discoverQueryInitialized.current = true;
+ }
+ }, [restoredState, defaultDiscoverQuery]);
+
// Track regions picked in the map's GADM picker
const [pickedRegions, setPickedRegions] = useState([]);
const [mapSimSettings, setMapSimSettings] = useState(null);
@@ -105,13 +116,20 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
setExpanding(true);
setExpandError(null);
try {
- await expandPlacesGridSearch(jobId, freshRegions.map(r => ({
+ const res = await expandPlacesGridSearch(jobId, freshRegions.map(r => ({
gid: r.gid,
name: r.name,
level: r.level,
raw: r.raw || { level: r.level, gadmName: r.name, gid: r.gid },
})), mapSimSettings || undefined);
onExpandSubmitted?.();
+
+ setSearchParams(prev => {
+ const next = new URLSearchParams(prev);
+ next.set('jobId', res.jobId);
+ next.set('view', 'map');
+ return next;
+ });
} catch (e: any) {
setExpandError(e.message || 'Failed to expand search');
console.error('Expand failed:', e);
@@ -120,6 +138,39 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
}
}, [jobId, freshRegions, onExpandSubmitted]);
+ const handleViewportExpand = useCallback(async () => {
+ const url = new URL(window.location.href);
+ const lat = parseFloat(url.searchParams.get('mapLat') || '0');
+ const lng = parseFloat(url.searchParams.get('mapLng') || '0');
+ const zoom = parseFloat(url.searchParams.get('mapZoom') || '15');
+
+ if (!discoverQuery.trim()) return;
+
+ setExpanding(true);
+ setExpandError(null);
+ try {
+ const res = await expandPlacesGridSearch(jobId, [], mapSimSettings || undefined, {
+ viewportSearch: true,
+ viewportCenter: { lat, lng },
+ viewportZoom: zoom,
+ types: [discoverQuery.trim()]
+ });
+ onExpandSubmitted?.();
+
+ setSearchParams(prev => {
+ const next = new URLSearchParams(prev);
+ next.set('jobId', res.jobId);
+ next.set('view', 'map');
+ return next;
+ });
+ } catch (e: any) {
+ setExpandError(e.message || 'Failed to start discover');
+ console.error('Discover failed:', e);
+ } finally {
+ setExpanding(false);
+ }
+ }, [jobId, mapSimSettings, onExpandSubmitted, discoverQuery]);
+
const [viewMode, setViewMode] = useState(() => {
const urlView = searchParams.get('view') as ViewMode;
if (urlView && ['grid', 'thumb', 'map', 'meta', 'report', 'poster', ...(import.meta.env.DEV ? ['log'] : [])].includes(urlView)) return urlView;
@@ -256,6 +307,36 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
Expand +{freshRegions.length}
)}
+ {isOwner && viewMode === 'map' && (
+
+ setDiscoverQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleViewportExpand();
+ }
+ }}
+ />
+
+
+ )}
{isOwner && expandError && (
{expandError}
)}
diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx
index 17646c14..676be8bb 100644
--- a/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx
+++ b/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx
@@ -64,6 +64,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
const [searchLimit, setSearchLimit] = useState(initialSettings?.searchLimit ?? 20);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
+ const [mapState, setMapState] = useState<{lat: number, lng: number, zoom: number} | null>(null);
const [pastSearches, setPastSearches] = useState(() => {
try {
@@ -88,7 +89,9 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
const handleNext = async () => {
- if (step === 2 && !searchQuery.trim()) return;
+ if (step === 2 && !searchQuery.trim()) {
+ setSearchQuery('business,shop,factory,service,industry,company');
+ }
if (step === 1) { setStep(2); return; }
@@ -139,8 +142,8 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
};
const res = await submitPlacesGridSearchJob({
- region: regionNames,
- types: [searchQuery],
+ region: regionNames || 'Manual Selection',
+ types: [searchQuery.trim() || 'business,shop,factory,service,industry,company'],
excludeTypes,
limitPerArea: searchLimit,
enrichers: enableEnrichments ? ['meta', 'emails'] : [],
@@ -154,6 +157,40 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
setSubmitting(false);
};
+ const handleDiscover = async () => {
+ setSubmitting(true);
+ setError('');
+ try {
+ const excludeTypes = excludeTypesStr.split(',').map(s => s.trim()).filter(Boolean);
+ await saveGridSearchExcludeTypes(excludeTypes);
+
+ const q = searchQuery.trim() || 'business,shop,factory,service,industry,company';
+ if (!searchQuery.trim()) setSearchQuery(q);
+
+ // Save search to history
+ const newPastSearches = Array.from(new Set([q, ...pastSearches])).filter(Boolean).slice(0, 15);
+ localStorage.setItem('gridSearchPastQueries', JSON.stringify(newPastSearches));
+ setPastSearches(newPastSearches);
+
+ const res = await submitPlacesGridSearchJob({
+ region: 'Viewport Discover',
+ types: [q],
+ excludeTypes,
+ limitPerArea: searchLimit,
+ enrichers: enableEnrichments ? ['meta', 'emails'] : [],
+ viewportSearch: true,
+ viewportCenter: { lat: mapState?.lat || 0, lng: mapState?.lng || 0 },
+ viewportZoom: mapState?.zoom || 15,
+ guided: { areas: [], settings: simulatorSettings }
+ } as any);
+ onJobSubmitted(res.jobId);
+ setStep(5);
+ } catch (err: any) {
+ setError(err.message || translate('Failed to submit job'));
+ }
+ setSubmitting(false);
+ };
+
const handleReset = () => {
setStep(1);
setCollectedNodes([]);
@@ -228,7 +265,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
autoFocus
- onKeyDown={e => { if (e.key === 'Enter' && searchQuery) handleNext(); }}
+ onKeyDown={e => { if (e.key === 'Enter') handleNext(); }}
/>