places ui / data
This commit is contained in:
parent
506800b087
commit
4789e0ba19
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 } : {}),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 });
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(', ');
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user