places ui / data

This commit is contained in:
lovebird 2026-04-15 13:54:40 +02:00
parent 506800b087
commit 4789e0ba19
17 changed files with 1275 additions and 332 deletions

View File

@ -190,8 +190,6 @@ const AppWrapper = () => {
<Route path="/products/gridsearch" element={<React.Suspense fallback={<div>Loading...</div>}><GridSearch /></React.Suspense>} />
<Route path="/products/places/detail/:place_id" element={<React.Suspense fallback={<div>Loading...</div>}><LocationDetail /></React.Suspense>} />
<Route path="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
{/* Playground Routes */}

View File

@ -204,6 +204,11 @@ export const ListLayout = ({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedId, feedPosts, isMobile]);
// Reset selection when category changes (must run before auto-select below, or it clears the first item)
useEffect(() => {
setSelectedId(null);
}, [categorySlugs?.join(',')]);
// Select first item by default on desktop if nothing selected
useEffect(() => {
if (!isMobile && !selectedId && feedPosts.length > 0) {
@ -211,11 +216,6 @@ export const ListLayout = ({
}
}, [feedPosts, isMobile, selectedId]);
// Reset selection when category changes
useEffect(() => {
setSelectedId(null);
}, [categorySlugs?.join(',')]);
if (loading && feedPosts.length === 0) {
return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
}
@ -278,10 +278,12 @@ export const ListLayout = ({
if (!isMobile) {
// Desktop Split Layout
return (
<div className={`flex h-full overflow-hidden border rounded-lg shadow-sm dark:bg-slate-900/10 ${center ? 'max-w-7xl mx-auto' : ''}`}>
{/* Left: List */}
<div className="w-[350px] lg:w-[400px] border-r flex flex-col bg-card shrink-0">
<div className="flex-1 overflow-y-auto scrollbar-custom relative">
<div
className={`flex h-full min-h-0 w-full min-w-0 max-h-full flex-1 basis-0 overflow-hidden border rounded-lg shadow-sm dark:bg-slate-900/10 ${center ? 'max-w-7xl mx-auto' : ''}`}
>
{/* Left: list scrolls independently from detail pane */}
<div className="flex h-full min-h-0 max-h-full w-[350px] shrink-0 flex-col overflow-hidden border-r bg-card lg:w-[400px]">
<div className="relative min-h-0 flex-1 overflow-y-auto overscroll-y-contain scrollbar-custom">
{renderItems(false)}
{hasMore && (
<div className="p-4 text-center">
@ -291,8 +293,8 @@ export const ListLayout = ({
</div>
</div>
{/* Right: Detail */}
<div className="flex-1 min-w-0 bg-background overflow-hidden relative flex flex-col h-[inherit]">
{/* Right: detail scrolls independently (post / page / place handle their own overflow) */}
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
{selectedId ? (
(() => {
const selectedPost = feedPosts.find((p: any) => p.id === selectedId);
@ -343,7 +345,7 @@ export const ListLayout = ({
key={selectedId}
postId={selectedId}
embedded
className="h-[inherit] overflow-y-auto scrollbar-custom"
className="h-full min-h-0 overflow-hidden flex flex-col"
/>
</React.Suspense>
);

View File

@ -65,6 +65,7 @@ const CategoryFeedWidget: React.FC<CategoryFeedWidgetProps> = ({
showSocial,
variables,
searchQuery,
fillHeight,
...rest
}) => {
const appConfig = useAppConfig();
@ -210,6 +211,7 @@ const CategoryFeedWidget: React.FC<CategoryFeedWidgetProps> = ({
initialContentType={filterType}
heading={heading}
headingLevel={headingLevel}
fillHeight={fillHeight}
/>
</div>
);

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import type { FeedSortOption } from '@/hooks/useFeedData';
import { useMediaRefresh } from '@/contexts/MediaRefreshContext';
@ -57,6 +57,12 @@ export interface HomeWidgetProps {
/** Widget editor: composite widgets use this; HomeWidget ignores it */
isEditMode?: boolean;
onPropsChange?: (props: Record<string, any>) => void;
/**
* When true, the widget fills the parent height (`h-full`) and uses flex + `min-h-0` so list/grid
* layouts scroll inside the widget instead of stretching the page. Use when embedding in CMS pages
* or any container with a defined height.
*/
fillHeight?: boolean;
}
const SIDEBAR_KEY = 'categorySidebarSize';
@ -86,6 +92,7 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
initialVisibilityFilter,
isEditMode: _isEditMode,
onPropsChange: _onPropsChange,
fillHeight = false,
}) => {
const { refreshKey } = useMediaRefresh();
const isMobile = useIsMobile();
@ -174,6 +181,15 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
return propCategorySlugs.split(',').map(s => s.trim()).filter(Boolean);
}, [propCategorySlugs]);
/** Same targets as {@link CategoryTreeView} category selection: `/categories/…` or home. */
const headingLinkTo = useMemo(
() =>
categorySlugs && categorySlugs.length > 0
? `/categories/${categorySlugs.join('/')}`
: '/',
[categorySlugs],
);
const cardPreset = useMemo(
() => ({
showTitle,
@ -403,8 +419,25 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
return <ListLayout key={refreshKey} navigationSource={feedSource} navigationSourceId={feedSourceId} sortBy={sortBy} categorySlugs={categorySlugs} categoryIds={propCategoryId ? [propCategoryId] : undefined} contentType={contentType} visibilityFilter={visibilityFilter} center={center} preset={cardPreset} />;
};
// Fixed height (not only min-height) so children can use h-full / % and grid min-h-0 works.
const listDesktopShellClass = cn(
'flex flex-col overflow-hidden',
fillHeight ? 'min-h-0 flex-1' : 'h-[calc(100vh-8rem)] min-h-0',
);
// With categories: toolbar sits above the panel group — use flex-1 inside listDesktopShellClass, not a second full-viewport height.
const feedPanelGroupClass = cn(
'flex flex-col overflow-hidden',
fillHeight || viewMode === 'list' ? 'min-h-0 flex-1' : 'min-h-[calc(100vh-8rem)]',
);
return (
<div className={cn("dark:bg-slate-800/50 lg:rounded-lg p-2 md:p-6", center && "container mx-auto max-w-7xl")}>
<div
className={cn(
'dark:bg-slate-800/50 lg:rounded-lg p-2 md:p-6',
center && 'container mx-auto max-w-7xl',
fillHeight && 'flex h-full min-h-0 flex-col',
)}
>
<SEO title="PolyMech Home" />
{/* Mobile: Sheet for category navigation */}
@ -422,15 +455,26 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
</Sheet>
)}
<div className="md:py-2">
<div className={cn('md:py-2', fillHeight && 'flex min-h-0 flex-1 flex-col overflow-hidden')}>
{isMobile ? (
/* ---- Mobile layout ---- */
<div className="md:hidden">
<div className={cn('md:hidden', fillHeight && 'flex min-h-0 flex-1 flex-col')}>
<div className="flex justify-between items-center px-1 py-2 bg-muted/40 border-b">
<div className="flex items-center gap-1">
{heading && (() => {
const H = headingLevel || 'h2';
return <H className="text-sm font-bold truncate max-w-[120px]"><T>{heading}</T></H>;
return (
<div className="px-2 py-1">
<H className="font-bold truncate max-w-[120px]">
<Link
to={headingLinkTo}
className="block truncate text-inherit hover:underline"
>
<T>{heading}</T>
</Link>
</H>
</div>
);
})()}
{showSortBar && (
<ToggleGroup type="single" value={sortBy} onValueChange={handleSortChange}>
@ -450,11 +494,13 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
</ToggleGroup>
)}
</div>
{categorySlugs && categorySlugs.length > 0 && (
<span className="flex-1 text-center text-sm font-semibold text-foreground/70 truncate px-2 capitalize">
{categorySlugs.map(s => s.replace(/-/g, ' ')).join(', ')}
</span>
)}
<div className="flex items-center gap-1">
{showLayoutToggles && (
<ToggleGroup type="single" value={viewMode === 'list' ? 'list' : 'grid'} onValueChange={(v) => v && setViewMode(v as any)}>
@ -531,13 +577,28 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
</div>
) : showCategories ? (
/* ---- Desktop with category sidebar ---- */
<div className="hidden md:block">
<div className="flex justify-between items-center px-4 mb-4">
<div
className={cn(
'hidden md:flex md:flex-col md:min-h-0 overflow-hidden',
fillHeight && 'md:flex-1',
viewMode === 'list' && listDesktopShellClass,
)}
>
<div className="flex shrink-0 justify-between items-center px-4 mb-4">
<div className="flex items-center gap-3">
{heading && (() => {
const H = headingLevel || 'h2';
const cls = H === 'h1' ? 'text-2xl' : H === 'h2' ? 'text-xl' : H === 'h3' ? 'text-lg' : 'text-base';
return <H className={`${cls} font-bold text-foreground max-w-[50vw] truncate`}><T>{heading}</T></H>;
return (
<H className={`${cls} font-bold text-foreground max-w-[50vw] truncate`}>
<Link
to={headingLinkTo}
className="block truncate text-inherit hover:underline"
>
<T>{heading}</T>
</Link>
</H>
);
})()}
{showSortBar && renderSortBar()}
</div>
@ -556,7 +617,7 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
)}
</div>
<ResizablePanelGroup direction="horizontal" className="min-h-[calc(100vh-8rem)]">
<ResizablePanelGroup direction="horizontal" className={cn(feedPanelGroupClass, 'min-h-0')}>
<ResizablePanel
defaultSize={sidebarSize}
minSize={10}
@ -571,20 +632,41 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={100 - sidebarSize}>
{renderFeed()}
<ResizablePanel defaultSize={100 - sidebarSize} className={viewMode === 'list' ? 'min-h-0' : undefined}>
<div
className={cn(
viewMode === 'list' && 'flex h-full min-h-0 flex-col overflow-hidden',
)}
>
{renderFeed()}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
) : (
/* ---- Desktop without sidebar ---- */
<div className="hidden md:block">
<div className="flex justify-between items-center px-4 mb-4">
<div
className={cn(
'hidden md:flex md:flex-col md:min-h-0',
fillHeight && 'md:flex-1',
viewMode === 'list' && listDesktopShellClass,
)}
>
<div className="flex shrink-0 justify-between items-center px-4 mb-4">
<div className="flex items-center gap-3">
{heading && (() => {
const H = headingLevel || 'h2';
const cls = H === 'h1' ? 'text-2xl' : H === 'h2' ? 'text-xl' : H === 'h3' ? 'text-lg' : 'text-base';
return <H className={`${cls} font-bold text-foreground max-w-[50vw] truncate`}><T>{heading}</T></H>;
return (
<H className={`${cls} font-bold text-foreground max-w-[50vw] truncate`}>
<Link
to={headingLinkTo}
className="block truncate text-inherit hover:underline"
>
<T>{heading}</T>
</Link>
</H>
);
})()}
{showSortBar && renderSortBar()}
</div>
@ -602,7 +684,14 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
</ToggleGroup>
)}
</div>
{renderFeed()}
<div
className={cn(
(viewMode === 'list' || fillHeight) &&
'flex min-h-0 flex-1 flex-col overflow-hidden',
)}
>
{renderFeed()}
</div>
</div>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
import { useSearchParams } from 'react-router-dom';
import { type GridPreset, getPresetVisibilityModel } from './useGridColumns';
import { type GridPreset, getPresetVisibilityModel, GRID_TYPES_FILTER_EXCLUDES_ANY_OF } from './useGridColumns';
import {
DataGrid,
useGridApiRef,
@ -13,7 +13,8 @@ import {
type GridFilterModel,
type GridPaginationModel,
type GridSortModel,
type GridColumnVisibilityModel
type GridColumnVisibilityModel,
GridLogicOperator,
} from '@mui/x-data-grid';
import { type PlaceFull } from '@polymech/shared';
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
@ -32,6 +33,22 @@ import {
} from './gridUtils';
import { useGridColumns } from './useGridColumns';
/** Matches email column `valueGetter` / `EmailCell` — used when seeding type excludes so the only MUI filter row can stay on `types` (Community grid allows one filter item). */
function rowHasLeadEmail(row: any): boolean {
const emails = row.emails || row.meta?.emails || [];
if (Array.isArray(emails)) {
const s = emails.map((e: any) => (typeof e === 'string' ? e : e?.email)).filter(Boolean).join(', ');
return s.trim().length > 0;
}
if (typeof emails === 'string') return emails.trim().length > 0;
return false;
}
export type CompetitorsGridViewHandle = {
/** Row IDs (place ids) that pass the current grid filter model and quick filter. */
getFilteredPlaceIds: () => string[];
};
interface PlacesGridViewProps {
competitors: PlaceFull[];
loading: boolean;
@ -42,6 +59,10 @@ interface PlacesGridViewProps {
isOwner?: boolean;
isPublic?: boolean;
preset?: GridPreset;
/** When set, seeds `types` column filters (doesNotContain) so rows with these types can be removed from the filter panel without changing saved settings. */
seedExcludeTypeFilters?: string[];
/** When false, filter/sort/pagination changes are not written to the URL (avoids collisions with multiple `types` filters). */
persistFiltersInUrl?: boolean;
}
const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => {
@ -64,7 +85,7 @@ const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => {
);
};
export const CompetitorsGridView: React.FC<PlacesGridViewProps> = ({
export const CompetitorsGridView = forwardRef<CompetitorsGridViewHandle, PlacesGridViewProps>(function CompetitorsGridView({
competitors,
loading,
settings,
@ -73,25 +94,41 @@ export const CompetitorsGridView: React.FC<PlacesGridViewProps> = ({
onSelectPlace,
isOwner = false,
isPublic = false,
preset = 'full'
}) => {
preset = 'full',
seedExcludeTypeFilters,
persistFiltersInUrl = true,
}, ref) {
const muiTheme = useMuiTheme();
const [searchParams, setSearchParams] = useSearchParams();
// Initialize state from URL params
const [filterModel, setFilterModel] = useState<GridFilterModel>(() => {
const fromUrl = paramsToFilterModel(searchParams);
if (fromUrl.items.length === 0 && !searchParams.has('nofilter')) {
// Only apply default "valid leads" filter if we are NOT in a public stripped view
// Community `DataGrid` forces a single filter item — encode all type excludes in one `types` filter (see `excludesAnyOf` in useGridColumns). Email "is not empty" is applied via `displayRows` when seeding type excludes.
if (seedExcludeTypeFilters?.length) {
const list = seedExcludeTypeFilters.map((t) => String(t).trim()).filter(Boolean);
return {
items: list.length
? [
{
field: 'types',
operator: GRID_TYPES_FILTER_EXCLUDES_ANY_OF,
value: JSON.stringify(list),
id: 'seed-excludes-types',
},
]
: [],
logicOperator: GridLogicOperator.And,
};
}
const fromUrl = persistFiltersInUrl ? paramsToFilterModel(searchParams) : { items: [] as GridFilterModel['items'] };
let items = [...(fromUrl.items || [])];
if (items.length === 0 && !searchParams.has('nofilter')) {
const shouldHideEmptyEmails = !isPublic || isOwner;
if (shouldHideEmptyEmails) {
return {
items: [{ field: 'email', operator: 'isNotEmpty' }]
};
items.push({ field: 'email', operator: 'isNotEmpty', id: 'default-email' });
}
}
return fromUrl;
return { items, logicOperator: GridLogicOperator.And };
});
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>(() => {
@ -114,6 +151,14 @@ export const CompetitorsGridView: React.FC<PlacesGridViewProps> = ({
return true;
});
}, [competitors]);
const displayRows = React.useMemo(() => {
if (!seedExcludeTypeFilters?.length) return filteredCompetitors;
const shouldHideEmptyEmails = !isPublic || isOwner;
if (!shouldHideEmptyEmails) return filteredCompetitors;
return filteredCompetitors.filter(rowHasLeadEmail);
}, [filteredCompetitors, seedExcludeTypeFilters, isPublic, isOwner]);
// Column Widths state
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
try {
@ -142,6 +187,18 @@ export const CompetitorsGridView: React.FC<PlacesGridViewProps> = ({
const [highlightedRowId, setHighlightedRowId] = useState<string | null>(null);
const [anchorRowId, setAnchorRowId] = useState<string | null>(null);
useImperativeHandle(ref, () => ({
getFilteredPlaceIds: () => {
const api = apiRef.current;
if (!api) return [];
const allSortedIds = api.getSortedRowIds?.() || [];
const filteredRowsLookup = (api as any).state?.filter?.filteredRowsLookup || {};
return allSortedIds
.filter((id) => filteredRowsLookup[id] !== false)
.map((id) => String(id));
},
}), []);
// Sync local highlighted state with global selectedPlaceId
useEffect(() => {
if (selectedPlaceId !== highlightedRowId) {
@ -170,6 +227,10 @@ export const CompetitorsGridView: React.FC<PlacesGridViewProps> = ({
const handleFilterModelChange = (newFilterModel: GridFilterModel) => {
setFilterModel(newFilterModel);
if (!persistFiltersInUrl) {
return;
}
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
@ -425,7 +486,7 @@ export const CompetitorsGridView: React.FC<PlacesGridViewProps> = ({
<MuiThemeProvider theme={muiTheme}>
<DataGrid
apiRef={apiRef}
rows={filteredCompetitors}
rows={displayRows}
columns={orderedColumns}
getRowId={(row) => row.place_id || row.placeId || row.id}
loading={loading}
@ -548,4 +609,4 @@ export const CompetitorsGridView: React.FC<PlacesGridViewProps> = ({
</div>
);
};
});

View File

@ -127,7 +127,7 @@ interface PlacesMapViewProps {
export const PlacesMapView: React.FC<PlacesMapViewProps> = ({ places, onMapCenterUpdate, initialCenter, initialZoom, initialPitch, initialBearing, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange, isPosterMode, onClosePosterMode, posterTheme: controlledPosterTheme, setPosterTheme: setControlledPosterTheme, selectedPlaceId, onSelectPlace, onMapReady }) => {
const features: MapFeatures = useMemo(() => {
if (isPosterMode) {
return { ...MAP_PRESETS['Minimal'], enableSidebarTools: false };
return { ...MAP_PRESETS['Minimal'], enableSidebarTools: false, ...customFeatures };
}
return {
...MAP_PRESETS[preset],

View File

@ -176,10 +176,18 @@ export const fetchPlacesGridSearchExport = async (jobId: string, format: 'md' |
return apiClient<string>(`/api/places/gridsearch/export?search=${jobId}&format=${format}`);
};
export const exportGridSearchToContacts = async (jobId: string, groupId?: string): Promise<{ imported: number; skipped: number }> => {
export const exportGridSearchToContacts = async (
jobId: string,
groupId?: string,
opts?: { placeIds?: string[]; excludedTypes?: string[] },
): Promise<{ imported: number; skipped: number }> => {
return apiClient<{ imported: number; skipped: number }>(`/api/places/gridsearch/${jobId}/export-to-contacts`, {
method: 'POST',
body: JSON.stringify({ groupId })
body: JSON.stringify({
groupId,
...(opts?.placeIds !== undefined ? { placeIds: opts.placeIds } : {}),
...(opts?.excludedTypes !== undefined ? { excludedTypes: opts.excludedTypes } : {}),
}),
});
};

View File

@ -12,6 +12,19 @@ export interface LocationLayersProps {
const emptyFc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
/** Keep cluster / point layers above GADM region overlays (picker may add layers later). */
function bringLocationLayersToFront(m: maplibregl.Map) {
const order = ['clusters', 'cluster-count', 'unclustered-point-circle', 'unclustered-point-label'];
for (const id of order) {
if (!m.getLayer(id)) continue;
try {
m.moveLayer(id);
} catch {
/* style swap race */
}
}
}
export function LocationLayers({
map,
competitors,
@ -189,6 +202,8 @@ export function LocationLayers({
map.on('mouseleave', 'clusters', () => map.getCanvas().style.cursor = '');
map.on('mouseleave', 'unclustered-point-circle', () => map.getCanvas().style.cursor = '');
queueMicrotask(() => bringLocationLayersToFront(map));
return () => {
map.off('click', 'clusters', handleClusterClick);
map.off('click', 'unclustered-point-circle', handlePointClick);

View File

@ -152,14 +152,20 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
const setupMapLayers = () => {
if (!map.getStyle()) return;
if (!map.getSource('gadm-picker-features')) {
// Draw GADM picker polygons below place clusters (clusters are added first; opening the sidebar after would otherwise stack GADM on top).
const beforeId = map.getLayer('clusters')
? 'clusters'
: map.getLayer('unclustered-point-circle')
? 'unclustered-point-circle'
: undefined;
map.addSource('gadm-picker-features', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 'gadm-picker-fill', type: 'fill', source: 'gadm-picker-features', paint: { 'fill-color': ['get', '_fillColor'], 'fill-opacity': 0.15 } });
map.addLayer({ id: 'gadm-picker-line', type: 'line', source: 'gadm-picker-features', paint: { 'line-color': ['get', '_lineColor'], 'line-width': ['case', ['==', ['get', 'isOuter'], true], 3, 1] } });
map.addLayer({ id: 'gadm-picker-fill', type: 'fill', source: 'gadm-picker-features', paint: { 'fill-color': ['get', '_fillColor'], 'fill-opacity': 0.15 } }, beforeId);
map.addLayer({ id: 'gadm-picker-line', type: 'line', source: 'gadm-picker-features', paint: { 'line-color': ['get', '_lineColor'], 'line-width': ['case', ['==', ['get', 'isOuter'], true], 3, 1] } }, beforeId);
updateMapFeatures();
if (!map.getSource('gadm-picker-highlight')) {
map.addSource('gadm-picker-highlight', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 'gadm-picker-highlight-fill', type: 'fill', source: 'gadm-picker-highlight', paint: { 'fill-color': '#0ea5e9', 'fill-opacity': 0.25 } });
map.addLayer({ id: 'gadm-picker-highlight-line', type: 'line', source: 'gadm-picker-highlight', paint: { 'line-color': '#0ea5e9', 'line-width': 2 } });
map.addLayer({ id: 'gadm-picker-highlight-fill', type: 'fill', source: 'gadm-picker-highlight', paint: { 'fill-color': '#0ea5e9', 'fill-opacity': 0.25 } }, beforeId);
map.addLayer({ id: 'gadm-picker-highlight-line', type: 'line', source: 'gadm-picker-highlight', paint: { 'line-color': '#0ea5e9', 'line-width': 2 } }, beforeId);
}
}
};

View File

@ -1,9 +1,9 @@
import React, { useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen, Merge, Pause, Play, Square } from 'lucide-react';
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen, Merge, Pause, Play, Square, SlidersHorizontal } from 'lucide-react';
import { CompetitorsGridView } from '../PlacesGridView';
import { PlacesMapView } from '../PlacesMapView';
import { CompetitorsGridView, type CompetitorsGridViewHandle } from '../PlacesGridView';
import { PlacesMapView, type MapFeatures } from '../PlacesMapView';
import { PlacesThumbView } from '../PlacesThumbView';
import { PlacesMetaView } from '../PlacesMetaView';
import { PlacesReportView } from './PlacesReportView';
@ -64,6 +64,17 @@ import { ImportContactsDialog } from '../../contacts/ImportContactsDialog';
import { exportGridSearchToContacts } from '../client-gridsearch';
import { UserPlus } from 'lucide-react';
/** URL/localStorage toggle for map layer visibility; same storage prefix as `useGridSearchState`. */
function readGridsearchPersistentToggle(key: string, urlValue: string | null, defaultValue: boolean): boolean {
if (typeof window === 'undefined') return defaultValue;
if (urlValue !== null) {
localStorage.setItem(`gridsearch_${key}`, urlValue);
return urlValue === '1';
}
const stored = localStorage.getItem(`gridsearch_${key}`);
if (stored !== null) return stored === '1';
return defaultValue;
}
export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted, onMergeSubmitted, isOwner = false, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: GridSearchResultsProps) => {
const { controlPaused, stats: streamStats, statusMessage: streamStatus } = useGridSearchStream();
@ -105,8 +116,63 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
});
}, [competitors, excludedTypes]);
isOwner = true;
const [searchParams, setSearchParams] = useSearchParams();
const showMapRegions = React.useMemo(
() => readGridsearchPersistentToggle('regions', searchParams.get('regions'), true),
[searchParams]
);
const mapCustomFeatures = React.useMemo((): Partial<MapFeatures> => {
const base: Partial<MapFeatures> = { showRegions: showMapRegions };
if (!isOwner) {
base.enableSidebarTools = false;
base.enableSimulator = false;
base.enableInfoPanel = false;
}
return base;
}, [isOwner, showMapRegions]);
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const urlView = searchParams.get('view') as ViewMode;
const allowed: ViewMode[] = ['grid', 'thumb', 'map', 'meta', 'report', 'poster', ...(import.meta.env.DEV ? (['log'] as const) : [])];
if (urlView && allowed.includes(urlView)) {
if (urlView === 'log' && !isOwner) return 'grid';
return urlView;
}
return 'grid';
});
const [posterTheme, setPosterTheme] = useState(() => searchParams.get('theme') || 'terracotta');
/** When true, list view shows all rows and applies saved excludes as MUI column filters (editable in the filter panel). When false, rows are pre-filtered (previous behavior). */
const [applySavedExcludesInGrid, setApplySavedExcludesInGrid] = useState(false);
/** Bumps on every Grid filters toggle so `CompetitorsGridView` remounts and drops in-memory filter state (types filter must not survive turning the mode off). */
const [gridFilterRemountKey, setGridFilterRemountKey] = useState(0);
const gridExportRef = useRef<CompetitorsGridViewHandle | null>(null);
const toggleApplySavedExcludesInGrid = useCallback(() => {
setApplySavedExcludesInGrid((v) => !v);
setGridFilterRemountKey((k) => k + 1);
// Clear filter query params on both on and off: off avoids rehydrating e.g. `filter_types_excludesAnyOf` from the URL into the prefilter grid; on matches previous behavior.
setSearchParams((prev) => {
const p = new URLSearchParams(prev);
Array.from(p.keys()).forEach((k) => {
if (k.startsWith('filter_')) p.delete(k);
});
p.delete('nofilter');
return p;
}, { replace: true });
}, [setSearchParams]);
const getRowsMatchingCurrentFilters = useCallback(() => {
if (applySavedExcludesInGrid && viewMode === 'grid' && gridExportRef.current) {
const ids = new Set(gridExportRef.current.getFilteredPlaceIds());
return competitors.filter((c) => ids.has(String((c as any).place_id || (c as any).placeId || (c as any).id)));
}
return filteredCompetitors;
}, [applySavedExcludesInGrid, viewMode, competitors, filteredCompetitors]);
const { state: restoredState } = useRestoredSearch();
const restoredGadmAreas = restoredState?.run?.request?.guided?.areas?.map((a: any) => ({
gid: a.gid, name: a.name, level: a.level,
@ -199,15 +265,6 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
}
}, [jobId, mapSimSettings, onExpandSubmitted, discoverQuery]);
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const urlView = searchParams.get('view') as ViewMode;
if (urlView && ['grid', 'thumb', 'map', 'meta', 'report', 'poster', ...(import.meta.env.DEV ? ['log'] : [])].includes(urlView)) return urlView;
return 'grid';
});
const [posterTheme, setPosterTheme] = useState(() => searchParams.get('theme') || 'terracotta');
// Selection and Sidebar Panel state
const [selectedPlaceId, setSelectedPlaceId] = useState<string | null>(null);
const [showDetails, setShowDetails] = useState(false);
@ -270,6 +327,21 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
}, { replace: true });
}, [setSearchParams]);
/** Shared / anon viewers cannot use log view; normalize URL and state. */
React.useEffect(() => {
if (isOwner) return;
if (searchParams.get('view') === 'log') {
setSearchParams((prev) => {
const p = new URLSearchParams(prev);
p.set('view', 'grid');
return p;
}, { replace: true });
}
if (viewMode === 'log') {
handleViewChange('grid');
}
}, [isOwner, searchParams, setSearchParams, viewMode, handleViewChange]);
// Mock functions for now till we have real enrichment tracking in grid search results
const dummyEnrich = async () => { };
const handleMapCenterUpdate = () => { };
@ -487,7 +559,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
<FileText className="h-4 w-4" />
<span className="hidden md:inline text-xs font-medium">Report</span>
</button>
{import.meta.env.DEV && sseLogs && (
{isOwner && import.meta.env.DEV && sseLogs && (
<button
onClick={() => handleViewChange('log')}
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${viewMode === 'log' ? 'bg-amber-50 text-amber-600 dark:bg-amber-900/50 dark:text-amber-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
@ -499,9 +571,31 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
)}
</div>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1" />
{isOwner && (
<>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1" />
{(
<button
type="button"
onClick={toggleApplySavedExcludesInGrid}
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${
applySavedExcludesInGrid
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/50 dark:text-amber-200'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
title={translate(
'When on, saved type excludes apply as DataGrid filters — remove a rule to show that type. Import to contacts uses the rows you see in List view.',
)}
>
<SlidersHorizontal className="h-4 w-4" />
<span className="hidden lg:inline text-xs font-medium max-w-[10rem] truncate">
<T>Grid filters</T>
</span>
</button>
</>
)}
{isOwner && (
<button
onClick={() => setShowMergeDialog(true)}
className="flex items-center gap-1.5 p-1.5 rounded-md transition-all text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
@ -512,52 +606,54 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-1.5 p-1.5 rounded-md transition-all text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-100 dark:hover:bg-gray-700"
title="Export Data"
>
<Download className="h-4 w-4" />
<span className="hidden md:inline text-xs font-medium">Export</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Export Results</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => {
const q = restoredState?.run?.request?.search?.types?.[0] || restoredState?.run?.request?.types?.[0] || restoredState?.run?.request?.guided?.query || 'places';
const r = restoredState?.run?.request?.search?.region || restoredState?.run?.request?.region || restoredState?.run?.request?.guided?.areas?.[0]?.name || '';
const baseName = r ? `${q} @ ${r}` : q;
const safeName = baseName.replace(/[\\/:*?"<>|]/g, '_');
exportToCSV(filteredCompetitors, `${safeName}.csv`);
}}>
<FileText className="mr-2 h-4 w-4" />
<span>Export as CSV</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const q = restoredState?.run?.request?.search?.types?.[0] || restoredState?.run?.request?.types?.[0] || restoredState?.run?.request?.guided?.query || 'places';
const r = restoredState?.run?.request?.search?.region || restoredState?.run?.request?.region || restoredState?.run?.request?.guided?.areas?.[0]?.name || '';
const baseName = r ? `${q} @ ${r}` : q;
const safeName = baseName.replace(/[\\/:*?"<>|]/g, '_');
exportToJSON(filteredCompetitors, `${safeName}.json`);
}}>
<Code className="mr-2 h-4 w-4" />
<span>Export as JSON</span>
</DropdownMenuItem>
{isOwner && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-1.5 p-1.5 rounded-md transition-all text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-100 dark:hover:bg-gray-700"
title="Export Data"
>
<Download className="h-4 w-4" />
<span className="hidden md:inline text-xs font-medium">Export</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Export Results</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => {
const q = restoredState?.run?.request?.search?.types?.[0] || restoredState?.run?.request?.types?.[0] || restoredState?.run?.request?.guided?.query || 'places';
const r = restoredState?.run?.request?.search?.region || restoredState?.run?.request?.region || restoredState?.run?.request?.guided?.areas?.[0]?.name || '';
const baseName = r ? `${q} @ ${r}` : q;
const safeName = baseName.replace(/[\\/:*?"<>|]/g, '_');
exportToCSV(getRowsMatchingCurrentFilters(), `${safeName}.csv`);
}}>
<FileText className="mr-2 h-4 w-4" />
<span>Export as CSV</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const q = restoredState?.run?.request?.search?.types?.[0] || restoredState?.run?.request?.types?.[0] || restoredState?.run?.request?.guided?.query || 'places';
const r = restoredState?.run?.request?.search?.region || restoredState?.run?.request?.region || restoredState?.run?.request?.guided?.areas?.[0]?.name || '';
const baseName = r ? `${q} @ ${r}` : q;
const safeName = baseName.replace(/[\\/:*?"<>|]/g, '_');
exportToJSON(getRowsMatchingCurrentFilters(), `${safeName}.json`);
}}>
<Code className="mr-2 h-4 w-4" />
<span>Export as JSON</span>
</DropdownMenuItem>
{import.meta.env.VITE_ENABLE_CONTACTS === 'true' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowImportToContactsDialog(true)}>
<UserPlus className="mr-2 h-4 w-4" />
<span>Import to Contacts</span>
</DropdownMenuItem>
</>
)}
{import.meta.env.VITE_ENABLE_CONTACTS === 'true' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowImportToContactsDialog(true)}>
<UserPlus className="mr-2 h-4 w-4" />
<span>Import to Contacts</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
)}
{isOwner && viewMode === 'poster' && (
<>
@ -576,7 +672,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
</div>
</>
)}
{isOwner ? (
{isOwner && (
<>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1 self-center" />
<button
@ -587,17 +683,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
<Share2 className="h-4 w-4" />
</button>
</>
) : isPublic ? (
<>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1 self-center" />
<div
className="p-1.5 rounded-md flex items-center justify-center text-emerald-600 dark:text-emerald-400 cursor-help"
title="Shared View (Read Only)"
>
<Share2 className="h-4 w-4" />
</div>
</>
) : null}
)}
</div>
</div>
@ -605,7 +691,9 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
<div id="gridsearch-scroll-area" className="flex-1 min-h-0 flex flex-col overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
{viewMode === 'grid' && (
<CompetitorsGridView
competitors={filteredCompetitors}
key={`grid-filters-${applySavedExcludesInGrid ? 'on' : 'off'}-${gridFilterRemountKey}`}
ref={gridExportRef}
competitors={applySavedExcludesInGrid ? competitors : filteredCompetitors}
loading={false}
settings={settings}
updateExcludedTypes={updateExcludedTypes || (async () => { })}
@ -614,6 +702,8 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
isOwner={isOwner}
isPublic={isPublic}
preset={(isPublic && !isOwner) || showDetails ? 'min' : 'full'}
seedExcludeTypeFilters={applySavedExcludesInGrid ? excludedTypes : undefined}
persistFiltersInUrl={!applySavedExcludesInGrid}
/>
)}
@ -628,6 +718,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
{(viewMode === 'map' || viewMode === 'poster') && (
<PlacesMapView
preset="SearchView"
customFeatures={mapCustomFeatures}
places={filteredCompetitors}
isPosterMode={viewMode === 'poster'}
posterTheme={posterTheme}
@ -727,7 +818,11 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
onOpenChange={setShowImportToContactsDialog}
type="callback"
onImport={async (groupId) => {
return exportGridSearchToContacts(jobId, groupId);
if (viewMode === 'grid' && gridExportRef.current) {
const placeIds = gridExportRef.current.getFilteredPlaceIds();
return exportGridSearchToContacts(jobId, groupId, { placeIds });
}
return exportGridSearchToContacts(jobId, groupId, { excludedTypes });
}}
/>

View File

@ -324,7 +324,8 @@ export const JobViewer = React.memo(({ jobId, isSidebarOpen, onToggleSidebar }:
toast.success(translate("Search results merged!"));
refetch();
}}
isOwner={jobId === restoredState?.run?.id && !!(window as any).isOwner}
isOwner={jobData?.isOwner === true || restoredState?.run?.isOwner === true}
isPublic={jobData?.isPublic === true || restoredState?.run?.isPublic === true || isSharingTarget}
onTogglePublic={handleTogglePublic}
isSidebarOpen={isSidebarOpen}
onToggleSidebar={onToggleSidebar}

View File

@ -71,7 +71,7 @@ export function PlacesReportView({ jobId }: { jobId: string }) {
}
return (
<div className="p-6 max-w-5xl mx-auto w-full h-full overflow-y-auto pb-24">
<div className="p-2 mx-auto w-full h-full overflow-y-auto pb-24">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-100">Grid Search Report</h2>
<button

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Link } from 'react-router-dom';
import type { GridColDef, GridRenderCellParams } from '@mui/x-data-grid';
import type { GridColDef, GridFilterOperator, GridRenderCellParams } from '@mui/x-data-grid';
import { getGridStringOperators, GridFilterInputValue } from '@mui/x-data-grid';
import { Globe, Instagram, Facebook, Linkedin, Youtube, Twitter, Github, Star } from 'lucide-react';
import { EmailCell } from './EmailCell';
import { TypeCell } from './TypeCell';
@ -50,6 +51,34 @@ export const findSocialUrl = (row: any, platform: string): string => {
'';
};
/** Community `@mui/x-data-grid` allows only one `filterModel` item — use this operator to encode many excluded types at once. */
export const GRID_TYPES_FILTER_EXCLUDES_ANY_OF = 'excludesAnyOf';
function buildExcludesAnyOfTypesOperator(): GridFilterOperator<any, string, any> {
return {
label: translate('Excludes any of (types)'),
value: GRID_TYPES_FILTER_EXCLUDES_ANY_OF,
getApplyFilterFn: (filterItem) => {
const raw = filterItem.value;
if (raw == null || String(raw).trim() === '') return null;
let excluded: string[];
try {
const parsed = JSON.parse(String(raw));
excluded = Array.isArray(parsed) ? parsed.map((x) => String(x).trim()).filter(Boolean) : [];
} catch {
return null;
}
if (excluded.length === 0) return null;
const excludedSet = new Set(excluded.map((t) => t.toLowerCase()));
return (_cellValue: any, row: any) => {
const types = row.types || [];
return !types.some((t: string) => excludedSet.has(String(t).toLowerCase()));
};
},
InputComponent: GridFilterInputValue,
};
}
export const useGridColumns = ({
settings,
updateExcludedTypes
@ -202,6 +231,7 @@ export const useGridColumns = ({
field: 'types',
headerName: translate('Types'),
width: 200,
filterOperators: [...getGridStringOperators(), buildExcludesAnyOfTypesOperator()],
valueGetter: (value: any, row: any) => {
const types = row.types || [];
return types.join(', ');

View File

@ -930,7 +930,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
*/
return (
<div className={`bg-background ${className}`}>
<div className={embedded ? `bg-background ${className} min-h-0` : `bg-background ${className}`}>
{post && (
<SEO
title={post.title || mediaItem?.title}
@ -939,7 +939,13 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
type={isVideo ? 'video.other' : 'article'}
/>
)}
<div className={embedded ? "w-full h-[inherit]" : "w-full max-w-[1600px] mx-auto h-full"}>
<div
className={
embedded
? `w-full h-full min-h-0 flex-1 flex flex-col ${viewMode === 'thumbs' ? 'overflow-y-auto scrollbar-custom' : 'overflow-hidden'}`
: 'w-full max-w-[1600px] mx-auto h-full'
}
>
{viewMode === 'thumbs' ? (
<ThumbsRenderer {...rendererProps} />

View File

@ -1,189 +1,195 @@
import React, { } from "react";
import { useNavigate } from "react-router-dom";
import { useOrganization } from "@/contexts/OrganizationContext";
import { PostRendererProps } from '../types';
import { useMediaQuery } from "@/hooks/use-media-query";
import { isVideoType, normalizeMediaType, detectMediaType } from "@/lib/mediaRegistry";
// Extracted Components
import { MobileGroupedFeed } from "./components/MobileGroupedFeed";
import { CompactPostHeader } from "./components/CompactPostHeader";
import { CompactMediaDetails } from "./components/CompactMediaDetails";
import { Gallery } from "./components/Gallery";
// Lazy load ImageEditor
const ImageEditor = React.lazy(() => import("@/components/ImageEditor").then(module => ({ default: module.ImageEditor })));
export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
const {
post, authorProfile, mediaItems, mediaItem,
isOwner, isLiked, likesCount,
onEditPost, onViewModeChange, onExportMarkdown,
onDeletePost, onDeletePicture, onLike, onEditPicture,
onMediaSelect, onExpand, onDownload, onCategoryManagerOpen,
currentImageIndex, videoPlaybackUrl, videoPosterUrl,
versionImages, handlePrevImage, handleNavigate, navigationData,
isEditMode, localPost, setLocalPost, localMediaItems, setLocalMediaItems, onMoveItem,
onEditModeToggle, onSaveChanges, onGalleryPickerOpen
} = props;
const [expandedComments, setExpandedComments] = React.useState<Record<string, boolean>>({});
const [expandedDescriptions, setExpandedDescriptions] = React.useState<Record<string, boolean>>({});
const [showImageEditor, setShowImageEditor] = React.useState(false);
const [cacheBustKeys, setCacheBustKeys] = React.useState<Record<string, number>>({});
const navigate = useNavigate();
const { orgSlug } = useOrganization();
const isDesktop = useMediaQuery("(min-width: 1024px)");
const showDesktopLayout = isDesktop;
const effectiveType = mediaItem.type || detectMediaType(mediaItem.image_url);
const isVideo = isVideoType(normalizeMediaType(effectiveType));
return (
<div className={props.className || 'h-[inherit]'}>
{/* Mobile Header - Controls and Info at Top */}
<div className="lg:hidden landscape:hidden py-4 bg-card ">
<CompactPostHeader
isEditMode={!!isEditMode}
post={post}
localPost={localPost}
setLocalPost={setLocalPost!}
mediaItem={mediaItem}
authorProfile={authorProfile!}
isOwner={!!isOwner}
embedded={props.embedded}
onViewModeChange={onViewModeChange!}
onExportMarkdown={onExportMarkdown!}
onSaveChanges={onSaveChanges!}
onEditModeToggle={onEditModeToggle!}
onEditPost={onEditPost!}
onDeletePicture={onDeletePicture!}
onDeletePost={onDeletePost!}
onCategoryManagerOpen={onCategoryManagerOpen}
mediaItems={mediaItems}
localMediaItems={localMediaItems}
/>
</div>
{/* Desktop layout: Media on left, content on right */}
<div className="overflow-hidden-x group h-[inherit]">
<div className="grid grid-cols-1 lg:grid-cols-2 h-[inherit]">
{/* Left Column - Media */}
<div className={`${isVideo ? 'aspect-video' : 'aspect-square'} lg:aspect-auto bg-background border flex flex-col relative h-full w-full`}>
{/* Desktop Gallery - Combines Media Viewer + Filmstrip */}
<div className="hidden lg:block h-full">
<Gallery
mediaItems={mediaItems}
selectedItem={mediaItem}
onMediaSelect={onMediaSelect}
onExpand={onExpand}
isOwner={!!isOwner}
isEditMode={!!isEditMode}
localMediaItems={localMediaItems}
setLocalMediaItems={setLocalMediaItems}
onDeletePicture={onDeletePicture!}
onGalleryPickerOpen={onGalleryPickerOpen!}
cacheBustKeys={cacheBustKeys}
navigationData={navigationData}
handleNavigate={handleNavigate!}
navigate={navigate}
videoPlaybackUrl={videoPlaybackUrl}
videoPosterUrl={videoPosterUrl}
showDesktopLayout={!!showDesktopLayout}
/>
</div>
{/* Mobile View - Grouped Feed - Hidden on Desktop */}
<MobileGroupedFeed
mediaItems={mediaItems}
isOwner={!!isOwner}
isLiked={!!isLiked}
onMediaSelect={onMediaSelect}
onLike={onLike}
onDeletePicture={onDeletePicture!}
onGalleryPickerOpen={onGalleryPickerOpen!}
onExpand={onExpand}
onEditPicture={onEditPicture!}
onDownload={onDownload!}
setShowImageEditor={setShowImageEditor}
post={post}
orgSlug={orgSlug}
expandedComments={expandedComments}
setExpandedComments={setExpandedComments}
expandedDescriptions={expandedDescriptions}
setExpandedDescriptions={setExpandedDescriptions}
cacheBustKeys={cacheBustKeys}
/>
</div>
{/* Right Column - Content */}
<div className="hidden lg:flex landscape:flex flex-col lg:h-full landscape:h-full lg:overflow-y-auto landscape:overflow-y-auto scrollbar-custom">
<div className="hidden lg:block landscape:block">
<CompactPostHeader
isEditMode={!!isEditMode}
post={post}
localPost={localPost}
setLocalPost={setLocalPost!}
mediaItem={mediaItem}
authorProfile={authorProfile!}
isOwner={!!isOwner}
embedded={props.embedded}
onViewModeChange={onViewModeChange!}
onExportMarkdown={onExportMarkdown!}
onSaveChanges={onSaveChanges!}
onEditModeToggle={onEditModeToggle!}
onEditPost={onEditPost!}
onDeletePicture={onDeletePicture!}
onDeletePost={onDeletePost!}
onCategoryManagerOpen={onCategoryManagerOpen}
mediaItems={mediaItems}
localMediaItems={localMediaItems}
/>
</div>
<CompactMediaDetails
isEditMode={!!isEditMode}
localMediaItems={localMediaItems}
setLocalMediaItems={setLocalMediaItems}
mediaItem={mediaItem}
versionImages={versionImages} // @ts-ignore or fix upstream
isLiked={!!isLiked}
likesCount={likesCount}
onLike={onLike}
onExpand={onExpand}
isOwner={!!isOwner}
setShowImageEditor={setShowImageEditor}
onDeletePicture={onDeletePicture!}
onAddAfter={() => onGalleryPickerOpen!(currentImageIndex + 1)}
onEditPicture={onEditPicture!}
onDownload={onDownload!}
isVideo={isVideo}
post={post}
orgSlug={orgSlug}
embedded={!!props.embedded}
/>
</div>
</div>
</div>
{showImageEditor && (
<div className="fixed inset-0 z-[100] bg-background">
<React.Suspense fallback={<div className="flex items-center justify-center h-full"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
<ImageEditor
imageUrl={mediaItem.image_url}
pictureId={mediaItem.id}
onClose={() => setShowImageEditor(false)}
onSave={() => {
setCacheBustKeys(prev => ({ ...prev, [mediaItem.id]: Date.now() }));
setShowImageEditor(false);
onLike();
}}
/>
</React.Suspense>
</div>
)}
</div>
);
}
import React, { } from "react";
import { useNavigate } from "react-router-dom";
import { useOrganization } from "@/contexts/OrganizationContext";
import { PostRendererProps } from '../types';
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { isVideoType, normalizeMediaType, detectMediaType } from "@/lib/mediaRegistry";
// Extracted Components
import { MobileGroupedFeed } from "./components/MobileGroupedFeed";
import { CompactPostHeader } from "./components/CompactPostHeader";
import { CompactMediaDetails } from "./components/CompactMediaDetails";
import { Gallery } from "./components/Gallery";
// Lazy load ImageEditor
const ImageEditor = React.lazy(() => import("@/components/ImageEditor").then(module => ({ default: module.ImageEditor })));
export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
const {
post, authorProfile, mediaItems, mediaItem,
isOwner, isLiked, likesCount,
onEditPost, onViewModeChange, onExportMarkdown,
onDeletePost, onDeletePicture, onLike, onEditPicture,
onMediaSelect, onExpand, onDownload, onCategoryManagerOpen,
currentImageIndex, videoPlaybackUrl, videoPosterUrl,
versionImages, handlePrevImage, handleNavigate, navigationData,
isEditMode, localPost, setLocalPost, localMediaItems, setLocalMediaItems, onMoveItem,
onEditModeToggle, onSaveChanges, onGalleryPickerOpen
} = props;
const [expandedComments, setExpandedComments] = React.useState<Record<string, boolean>>({});
const [expandedDescriptions, setExpandedDescriptions] = React.useState<Record<string, boolean>>({});
const [showImageEditor, setShowImageEditor] = React.useState(false);
const [cacheBustKeys, setCacheBustKeys] = React.useState<Record<string, number>>({});
const navigate = useNavigate();
const { orgSlug } = useOrganization();
const isDesktop = useMediaQuery("(min-width: 1024px)");
const showDesktopLayout = isDesktop;
const effectiveType = mediaItem.type || detectMediaType(mediaItem.image_url);
const isVideo = isVideoType(normalizeMediaType(effectiveType));
return (
<div className={cn(props.className || 'h-[inherit]', 'min-h-0 overflow-hidden flex flex-col', props.embedded && 'flex-1')}>
{/* Mobile Header - Controls and Info at Top */}
<div className="lg:hidden landscape:hidden py-4 bg-card ">
<CompactPostHeader
isEditMode={!!isEditMode}
post={post}
localPost={localPost}
setLocalPost={setLocalPost!}
mediaItem={mediaItem}
authorProfile={authorProfile!}
isOwner={!!isOwner}
embedded={props.embedded}
onViewModeChange={onViewModeChange!}
onExportMarkdown={onExportMarkdown!}
onSaveChanges={onSaveChanges!}
onEditModeToggle={onEditModeToggle!}
onEditPost={onEditPost!}
onDeletePicture={onDeletePicture!}
onDeletePost={onDeletePost!}
onCategoryManagerOpen={onCategoryManagerOpen}
mediaItems={mediaItems}
localMediaItems={localMediaItems}
/>
</div>
{/* Desktop layout: Media on left, content on right — grid row must not grow with markdown (min-h-0 grid items) */}
<div className="group min-h-0 flex flex-1 flex-col overflow-x-hidden">
<div className="grid min-h-0 flex-1 grid-cols-1 lg:h-full lg:min-h-0 lg:max-h-full lg:grid-cols-2 lg:grid-rows-1">
{/* Left Column - Media */}
<div
className={cn(
isVideo ? 'aspect-video' : 'aspect-square',
'relative flex h-full w-full min-h-0 flex-col overflow-hidden border bg-background lg:aspect-auto lg:min-h-0 lg:max-h-full',
)}
>
{/* Desktop Gallery - Combines Media Viewer + Filmstrip */}
<div className="hidden lg:block h-full">
<Gallery
mediaItems={mediaItems}
selectedItem={mediaItem}
onMediaSelect={onMediaSelect}
onExpand={onExpand}
isOwner={!!isOwner}
isEditMode={!!isEditMode}
localMediaItems={localMediaItems}
setLocalMediaItems={setLocalMediaItems}
onDeletePicture={onDeletePicture!}
onGalleryPickerOpen={onGalleryPickerOpen!}
cacheBustKeys={cacheBustKeys}
navigationData={navigationData}
handleNavigate={handleNavigate!}
navigate={navigate}
videoPlaybackUrl={videoPlaybackUrl}
videoPosterUrl={videoPosterUrl}
showDesktopLayout={!!showDesktopLayout}
/>
</div>
{/* Mobile View - Grouped Feed - Hidden on Desktop */}
<MobileGroupedFeed
mediaItems={mediaItems}
isOwner={!!isOwner}
isLiked={!!isLiked}
onMediaSelect={onMediaSelect}
onLike={onLike}
onDeletePicture={onDeletePicture!}
onGalleryPickerOpen={onGalleryPickerOpen!}
onExpand={onExpand}
onEditPicture={onEditPicture!}
onDownload={onDownload!}
setShowImageEditor={setShowImageEditor}
post={post}
orgSlug={orgSlug}
expandedComments={expandedComments}
setExpandedComments={setExpandedComments}
expandedDescriptions={expandedDescriptions}
setExpandedDescriptions={setExpandedDescriptions}
cacheBustKeys={cacheBustKeys}
/>
</div>
{/* Right Column - Content (scrolls; post description lives in header) */}
<div className="hidden min-h-0 flex-col overflow-y-auto overscroll-y-contain scrollbar-custom lg:flex lg:min-h-0 lg:max-h-full landscape:flex landscape:h-full landscape:overflow-y-auto">
<div className="hidden lg:block landscape:block">
<CompactPostHeader
isEditMode={!!isEditMode}
post={post}
localPost={localPost}
setLocalPost={setLocalPost!}
mediaItem={mediaItem}
authorProfile={authorProfile!}
isOwner={!!isOwner}
embedded={props.embedded}
onViewModeChange={onViewModeChange!}
onExportMarkdown={onExportMarkdown!}
onSaveChanges={onSaveChanges!}
onEditModeToggle={onEditModeToggle!}
onEditPost={onEditPost!}
onDeletePicture={onDeletePicture!}
onDeletePost={onDeletePost!}
onCategoryManagerOpen={onCategoryManagerOpen}
mediaItems={mediaItems}
localMediaItems={localMediaItems}
/>
</div>
<CompactMediaDetails
isEditMode={!!isEditMode}
localMediaItems={localMediaItems}
setLocalMediaItems={setLocalMediaItems}
mediaItem={mediaItem}
versionImages={versionImages} // @ts-ignore or fix upstream
isLiked={!!isLiked}
likesCount={likesCount}
onLike={onLike}
onExpand={onExpand}
isOwner={!!isOwner}
setShowImageEditor={setShowImageEditor}
onDeletePicture={onDeletePicture!}
onAddAfter={() => onGalleryPickerOpen!(currentImageIndex + 1)}
onEditPicture={onEditPicture!}
onDownload={onDownload!}
isVideo={isVideo}
post={post}
orgSlug={orgSlug}
embedded={!!props.embedded}
/>
</div>
</div>
</div>
{showImageEditor && (
<div className="fixed inset-0 z-[100] bg-background">
<React.Suspense fallback={<div className="flex items-center justify-center h-full"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
<ImageEditor
imageUrl={mediaItem.image_url}
pictureId={mediaItem.id}
onClose={() => setShowImageEditor(false)}
onSave={() => {
setCacheBustKeys(prev => ({ ...prev, [mediaItem.id]: Date.now() }));
setShowImageEditor(false);
onLike();
}}
/>
</React.Suspense>
</div>
)}
</div>
);
}

View File

@ -248,8 +248,13 @@ const Index = () => {
</div>
) : showCategories ? (
/* ---- Desktop with category sidebar ---- */
<div className="hidden md:block">
<div className="flex justify-between px-4 mb-4">
<div
className={cn(
'hidden md:flex md:flex-col md:min-h-0 overflow-hidden',
viewMode === 'list' && 'h-[calc(100vh-8rem)] min-h-0',
)}
>
<div className="flex shrink-0 justify-between px-4 mb-4">
<div className="flex items-center gap-3">
{renderSortBar()}
</div>
@ -266,7 +271,13 @@ const Index = () => {
</ToggleGroup>
</div>
<ResizablePanelGroup direction="horizontal">
<ResizablePanelGroup
direction="horizontal"
className={cn(
'min-h-0 flex flex-col overflow-hidden',
viewMode === 'list' ? 'min-h-0 flex-1' : 'min-h-[calc(100vh-8rem)]',
)}
>
<ResizablePanel
defaultSize={sidebarSize}
minSize={10}
@ -281,15 +292,22 @@ const Index = () => {
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={100 - sidebarSize}>
{renderFeed()}
<ResizablePanel defaultSize={100 - sidebarSize} className={viewMode === 'list' ? 'min-h-0' : undefined}>
<div className={cn(viewMode === 'list' && 'flex h-full min-h-0 flex-col overflow-hidden')}>
{renderFeed()}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
) : (
/* ---- Desktop without sidebar ---- */
<div className="hidden md:block">
<div className="flex justify-between px-4 mb-4">
<div
className={cn(
'hidden md:flex md:flex-col md:min-h-0 overflow-hidden',
viewMode === 'list' && 'h-[calc(100vh-8rem)] min-h-0 flex flex-col',
)}
>
<div className="flex shrink-0 justify-between px-4 mb-4">
<div className="flex items-center gap-3">
{renderSortBar()}
</div>
@ -303,9 +321,11 @@ const Index = () => {
<ToggleGroupItem value="list" aria-label="List View">
<List className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</ToggleGroup>
</div>
<div className={cn(viewMode === 'list' && 'flex min-h-0 flex-1 flex-col overflow-hidden')}>
{renderFeed()}
</div>
{renderFeed()}
</div>
)}
</div>