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(); }} /> {pastSearches.map(s =>