Quick save
This commit is contained in:
parent
e5d5819098
commit
2906b01734
25 changed files with 1070 additions and 237 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import { formatValue, formatFilterValue, calculateHistogramMean, FEATURE_FORMATS } from '../../lib/format';
|
||||
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
|
|
@ -11,6 +11,8 @@ import StackedEnumChart from './StackedEnumChart';
|
|||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
|
|
@ -28,6 +30,10 @@ interface AreaPaneProps {
|
|||
hexagonLocation: HexagonLocation | null;
|
||||
filters: FeatureFilters;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
aiSummary?: string;
|
||||
aiSummaryLoading?: boolean;
|
||||
aiSummaryError?: string | null;
|
||||
onRetryAiSummary?: () => void;
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
|
|
@ -42,11 +48,24 @@ export default function AreaPane({
|
|||
hexagonLocation,
|
||||
filters,
|
||||
onNavigateToSource,
|
||||
aiSummary,
|
||||
aiSummaryLoading,
|
||||
aiSummaryError,
|
||||
onRetryAiSummary,
|
||||
}: AreaPaneProps) {
|
||||
// For postcodes, use local data for count
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleGroup = (name: string) =>
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
|
||||
const numericByName = useMemo(() => {
|
||||
if (!stats) return new Map();
|
||||
|
|
@ -78,12 +97,17 @@ export default function AreaPane({
|
|||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">
|
||||
{isPostcode ? hexagonId : 'Area Statistics'}
|
||||
</h2>
|
||||
{isPostcode && (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">
|
||||
{isPostcode ? hexagonId : 'Area Statistics'}
|
||||
</h2>
|
||||
{isPostcode && (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
|
||||
)}
|
||||
</div>
|
||||
{loading && stats && (
|
||||
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<IconButton onClick={onClose} title="Close">
|
||||
|
|
@ -109,17 +133,68 @@ export default function AreaPane({
|
|||
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
|
||||
)}
|
||||
|
||||
{/* AI Summary Card */}
|
||||
{(aiSummary || aiSummaryLoading || aiSummaryError) && (
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
|
||||
</div>
|
||||
{aiSummaryError ? (
|
||||
<div className="text-xs text-warm-600 dark:text-warm-400">
|
||||
<span>Failed to generate summary. </span>
|
||||
{onRetryAiSummary && (
|
||||
<button
|
||||
onClick={onRetryAiSummary}
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : aiSummaryLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
{aiSummary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && !stats ? (
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div className="p-3 space-y-4">
|
||||
{stats.price_history && stats.price_history.length > 0 && (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
<div>
|
||||
{/* Histogram color legend */}
|
||||
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the distribution in this selected area
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the overall distribution across all areas
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span> indicates the global average
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{featureGroups.map((group) => {
|
||||
const hasData = group.features.some(
|
||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||
|
|
@ -136,12 +211,28 @@ export default function AreaPane({
|
|||
) as string[] ?? []
|
||||
);
|
||||
|
||||
const isExpanded = !collapsedGroups.has(group.name);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||
{group.name}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<CollapsibleGroupHeader
|
||||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
/>
|
||||
{isExpanded && <div className="px-3 py-2 space-y-3">
|
||||
{/* Price History in Property group */}
|
||||
{group.name === 'Property' && stats.price_history && (() => {
|
||||
// Only show chart if there are at least 2 unique years
|
||||
const uniqueYears = new Set(stats.price_history.map(p => Math.floor(p.year)));
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
{stackedCharts
|
||||
? // Render stacked charts for this group
|
||||
stackedCharts.map((chart) => {
|
||||
|
|
@ -218,7 +309,7 @@ export default function AreaPane({
|
|||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean, FEATURE_FORMATS[feature.name])}
|
||||
{formatValue(numericStats.mean, feature)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram && (
|
||||
|
|
@ -343,10 +434,29 @@ export default function AreaPane({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Google Street View */}
|
||||
{hexagonLocation && (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
|
||||
Street View
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
|
||||
<iframe
|
||||
className="w-full"
|
||||
style={{ height: 240, border: 0 }}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
src={`https://maps.google.com/maps?layer=c&cbll=${hexagonLocation.lat},${hexagonLocation.lon}&cbp=11,0,0,0,0&output=svembed`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { memo, useState, useMemo, useEffect } from 'react';
|
|||
import { Slider } from '../ui/Slider';
|
||||
import { Label } from '../ui/Label';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { ChevronIcon, FilterIcon, LightbulbIcon } from '../ui/icons';
|
||||
import { FilterIcon, LightbulbIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
|
|
@ -95,18 +96,16 @@ function FeatureBrowser({
|
|||
const isExpanded = isSearching || expandedGroups.has(group.name);
|
||||
return (
|
||||
<div key={group.name} className="shrink-0">
|
||||
<button
|
||||
onClick={() => toggleGroup(group.name)}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
<CollapsibleGroupHeader
|
||||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span>{group.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
</span>
|
||||
<ChevronIcon direction={isExpanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</button>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{isExpanded &&
|
||||
group.features.map((f) => {
|
||||
const isPinned = pinnedFeature === f.name;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
|
|||
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-warm-200 pointer-events-none z-50 min-w-[180px] max-w-[260px]"
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-white pointer-events-none z-50 min-w-[180px] max-w-[260px]"
|
||||
style={{
|
||||
left: x,
|
||||
top: y - 12,
|
||||
|
|
@ -61,14 +61,14 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
|
|||
<div className="relative">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100 truncate">
|
||||
<span className="font-semibold text-navy-950 dark:text-white truncate">
|
||||
{isPostcode ? id : 'Area'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Property count */}
|
||||
{count != null && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-300 mb-2">
|
||||
{count.toLocaleString()} {count === 1 ? 'property' : 'properties'}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -78,8 +78,8 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
|
|||
<div className="space-y-1 border-t border-warm-200 dark:border-warm-700 pt-2">
|
||||
{displayStats.map((stat) => (
|
||||
<div key={stat.name} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-warm-500 dark:text-warm-400 truncate">{stat.name}</span>
|
||||
<span className="font-medium text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
<span className="text-warm-500 dark:text-warm-300 truncate">{stat.name}</span>
|
||||
<span className="font-medium text-teal-700 dark:text-teal-300 whitespace-nowrap">
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -89,7 +89,7 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
|
|||
|
||||
{/* Hint */}
|
||||
{data && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 mt-2 text-center">
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-400 mt-2 text-center">
|
||||
Click for details
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ interface MapProps {
|
|||
initialViewState?: ViewState;
|
||||
theme?: 'light' | 'dark';
|
||||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
filters?: FeatureFilters;
|
||||
searchedPostcode?: SearchedPostcode | null;
|
||||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
|
|
@ -109,6 +110,7 @@ export default memo(function Map({
|
|||
initialViewState,
|
||||
theme = 'light',
|
||||
screenshotMode = false,
|
||||
ogMode = false,
|
||||
filters = {},
|
||||
searchedPostcode,
|
||||
onPostcodeSearched,
|
||||
|
|
@ -452,7 +454,7 @@ export default memo(function Map({
|
|||
getPosition: (f) => f.properties.centroid,
|
||||
getText: (f) => f.properties.postcode,
|
||||
getSize: 12,
|
||||
getColor: theme === 'dark' ? [220, 220, 220, 220] : [40, 40, 40, 220],
|
||||
getColor: theme === 'dark' ? [255, 255, 255, 240] : [40, 40, 40, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
|
|
@ -530,8 +532,15 @@ export default memo(function Map({
|
|||
return baseLayers;
|
||||
}, [usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverPosition(null);
|
||||
setHoveredPostcode(null);
|
||||
setPopupInfo(null);
|
||||
onHexagonHoverRef.current(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full relative" ref={containerRef}>
|
||||
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}>
|
||||
<MapGL
|
||||
{...viewState}
|
||||
onMove={handleMove}
|
||||
|
|
@ -550,25 +559,27 @@ export default memo(function Map({
|
|||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
</MapGL>
|
||||
{screenshotMode ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
|
||||
<h1
|
||||
className="text-5xl font-bold text-white drop-shadow-lg"
|
||||
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
|
||||
>
|
||||
Your perfect postcodes
|
||||
</h1>
|
||||
</div>
|
||||
ogMode ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
|
||||
<h1
|
||||
className="text-5xl font-bold text-white drop-shadow-lg"
|
||||
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
|
||||
>
|
||||
Your perfect postcodes
|
||||
</h1>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
|
||||
{viewSource === 'eye' && viewFeature && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
|
||||
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||
<span className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||
Previewing “{viewFeature}”
|
||||
</span>
|
||||
<button
|
||||
onClick={onCancelPin}
|
||||
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
|
||||
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-white hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
@ -596,7 +607,7 @@ export default memo(function Map({
|
|||
)}
|
||||
{popupInfo && (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
|
||||
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-white"
|
||||
style={{
|
||||
left: popupInfo.x,
|
||||
top: popupInfo.y - 40,
|
||||
|
|
@ -604,8 +615,8 @@ export default memo(function Map({
|
|||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<strong>{popupInfo.name}</strong>
|
||||
<div className="text-warm-500 dark:text-warm-400 text-xs">{popupInfo.category}</div>
|
||||
<strong className="dark:text-white">{popupInfo.name}</strong>
|
||||
<div className="text-warm-500 dark:text-warm-300 text-xs">{popupInfo.category}</div>
|
||||
{osmIdToUrl(popupInfo.id) && (
|
||||
<a
|
||||
href={osmIdToUrl(popupInfo.id)!}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ export default function MapLegend({
|
|||
const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
||||
|
||||
return (
|
||||
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
|
||||
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-sm">{featureLabel}</span>
|
||||
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
|
||||
{showCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
|
|
@ -38,7 +38,7 @@ export default function MapLegend({
|
|||
)}
|
||||
</div>
|
||||
<div className="h-3 rounded" style={{ background: gradientStyle }} />
|
||||
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
|
||||
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
|
||||
{mode === 'density' ? (
|
||||
<>
|
||||
<span>Few</span>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { usePOIData } from '../../hooks/usePOIData';
|
|||
import { useFilters } from '../../hooks/useFilters';
|
||||
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||||
import { usePaneResize } from '../../hooks/usePaneResize';
|
||||
import { useAreaSummary } from '../../hooks/useAreaSummary';
|
||||
import { useUrlSync } from '../../hooks/useUrlSync';
|
||||
import { apiUrl, buildFilterString } from '../../lib/api';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
||||
|
|
@ -36,6 +38,7 @@ interface MapPageProps {
|
|||
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
|
||||
onExportStateChange?: (state: ExportState) => void;
|
||||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
}
|
||||
|
||||
export default function MapPage({
|
||||
|
|
@ -52,34 +55,8 @@ export default function MapPage({
|
|||
onNavigateTo,
|
||||
onExportStateChange,
|
||||
screenshotMode,
|
||||
ogMode,
|
||||
}: MapPageProps) {
|
||||
if (screenshotMode) {
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<Map
|
||||
data={[]}
|
||||
postcodeData={[]}
|
||||
usePostcodeView={false}
|
||||
pois={[]}
|
||||
onViewChange={() => {}}
|
||||
viewFeature={null}
|
||||
colorRange={null}
|
||||
filterRange={null}
|
||||
viewSource={null}
|
||||
onCancelPin={() => {}}
|
||||
features={features}
|
||||
selectedHexagonId={null}
|
||||
hoveredHexagonId={null}
|
||||
onHexagonClick={() => {}}
|
||||
onHexagonHover={() => {}}
|
||||
initialViewState={initialViewState}
|
||||
theme={theme}
|
||||
screenshotMode
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
|
||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(initialPOICategories);
|
||||
|
||||
|
|
@ -137,6 +114,9 @@ export default function MapPage({
|
|||
// POI data
|
||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
|
||||
// Sync current state to URL
|
||||
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab);
|
||||
|
||||
// Set initial view and tab from URL state
|
||||
useEffect(() => {
|
||||
mapData.setInitialView(initialViewState);
|
||||
|
|
@ -146,10 +126,30 @@ export default function MapPage({
|
|||
// Compute hexagon location for external links
|
||||
const hexagonLocation = useMemo(() => {
|
||||
const hexId = selection.selectedHexagon?.id;
|
||||
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
||||
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
|
||||
}, [selection.selectedHexagon?.id, mapData.data, mapData.resolution]);
|
||||
const isPostcode = selection.selectedHexagon?.type === 'postcode';
|
||||
|
||||
if (isPostcode) {
|
||||
// For postcodes, get centroid from postcodeData
|
||||
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
|
||||
if (!postcodeFeature?.properties.centroid) return null;
|
||||
const [lon, lat] = postcodeFeature.properties.centroid;
|
||||
return { lat, lon, resolution: mapData.resolution };
|
||||
} else {
|
||||
// For hexagons, get lat/lon from hexagon data
|
||||
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
||||
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
|
||||
}
|
||||
}, [selection.selectedHexagon?.id, selection.selectedHexagon?.type, mapData.data, mapData.postcodeData, mapData.resolution]);
|
||||
|
||||
// AI area summary
|
||||
const aiSummary = useAreaSummary({
|
||||
stats: selection.areaStats,
|
||||
hexagonId: selection.selectedHexagon?.id || null,
|
||||
isPostcode: selection.selectedHexagon?.type === 'postcode',
|
||||
filters,
|
||||
features,
|
||||
});
|
||||
|
||||
// Export to Excel
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
|
@ -185,6 +185,41 @@ export default function MapPage({
|
|||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
}, [handleExport, exporting, onExportStateChange]);
|
||||
|
||||
// Signal screenshot readiness once map data has loaded
|
||||
useEffect(() => {
|
||||
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
|
||||
window.__og_ready = true;
|
||||
}
|
||||
}, [screenshotMode, mapData.loading, mapData.data.length]);
|
||||
|
||||
if (screenshotMode) {
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<Map
|
||||
data={mapData.data}
|
||||
postcodeData={mapData.postcodeData}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={[]}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={viewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
onCancelPin={() => {}}
|
||||
features={features}
|
||||
selectedHexagonId={null}
|
||||
hoveredHexagonId={null}
|
||||
onHexagonClick={() => {}}
|
||||
onHexagonHover={() => {}}
|
||||
initialViewState={initialViewState}
|
||||
theme={theme}
|
||||
screenshotMode
|
||||
ogMode={ogMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{initialLoading && (
|
||||
|
|
@ -295,6 +330,10 @@ export default function MapPage({
|
|||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
|
||||
aiSummary={aiSummary.summary}
|
||||
aiSummaryLoading={aiSummary.loading}
|
||||
aiSummaryError={aiSummary.error}
|
||||
onRetryAiSummary={aiSummary.retry}
|
||||
/>
|
||||
) : selection.rightPaneTab === 'properties' ? (
|
||||
<PropertiesPane
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export default function PostcodeSearch({
|
|||
setError(null);
|
||||
}}
|
||||
placeholder="Search postcode..."
|
||||
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-warm-100 dark:placeholder-warm-500"
|
||||
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-white dark:placeholder-warm-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -72,7 +72,7 @@ export default function PostcodeSearch({
|
|||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">
|
||||
<span className="text-xs text-red-600 dark:text-red-300 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import type { PricePoint } from '../../types';
|
||||
import { formatValue, FEATURE_FORMATS } from '../../lib/format';
|
||||
import { formatValue } from '../../lib/format';
|
||||
|
||||
interface PriceHistoryChartProps {
|
||||
points: PricePoint[];
|
||||
|
|
@ -8,7 +8,7 @@ interface PriceHistoryChartProps {
|
|||
|
||||
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
|
||||
const HEIGHT = 120;
|
||||
const priceFmt = FEATURE_FORMATS['Last known price'];
|
||||
const priceFmt = { prefix: '£' };
|
||||
|
||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export function PropertiesPane({
|
|||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && properties.length === 0 ? (
|
||||
<div className="p-4 dark:text-warm-400">Loading...</div>
|
||||
<PropertyLoadingSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{filtered.map((property, idx) => (
|
||||
|
|
@ -99,9 +99,16 @@ export function PropertiesPane({
|
|||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={loading}
|
||||
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50"
|
||||
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
`Load More (${total - properties.length} remaining)`
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -111,6 +118,32 @@ export function PropertiesPane({
|
|||
);
|
||||
}
|
||||
|
||||
function PropertyLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{Array.from({ length: 5 }).map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse"
|
||||
>
|
||||
{/* Address */}
|
||||
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
|
||||
{/* Postcode */}
|
||||
<div className="h-4 w-24 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
|
||||
{/* Price */}
|
||||
<div className="h-6 w-32 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
|
||||
{/* Property details grid */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-4 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const price = getNum(property, 'Last known price', 'latest_price');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
||||
|
|
|
|||
167
frontend/src/components/saved-searches/SavedSearchesPage.tsx
Normal file
167
frontend/src/components/saved-searches/SavedSearchesPage.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
import { TrashIcon } from '../ui/icons/TrashIcon';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { formatRelativeTime } from '../../lib/format';
|
||||
import { summarizeParams } from '../../lib/url-state';
|
||||
|
||||
export default function SavedSearchesPage({
|
||||
searches,
|
||||
loading,
|
||||
onDelete,
|
||||
onOpen,
|
||||
}: {
|
||||
searches: SavedSearch[];
|
||||
loading: boolean;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onOpen: (params: string) => void;
|
||||
}) {
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
await onDelete(deleteConfirmId);
|
||||
setDeleteConfirmId(null);
|
||||
}, [deleteConfirmId, onDelete]);
|
||||
|
||||
const handleShare = useCallback((params: string, id: string) => {
|
||||
const url = `${window.location.origin}/?${params}`;
|
||||
const onSuccess = () => {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(url).then(onSuccess);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = url;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
onSuccess();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto bg-warm-50 dark:bg-warm-900">
|
||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-6">Saved Searches</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
</div>
|
||||
) : searches.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<BookmarkIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
|
||||
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
|
||||
No saved searches yet
|
||||
</p>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-500">
|
||||
Save your dashboard filters and view to quickly return to them later.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{searches.map((search) => (
|
||||
<div
|
||||
key={search.id}
|
||||
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden"
|
||||
>
|
||||
{search.screenshotUrl ? (
|
||||
<img
|
||||
src={search.screenshotUrl}
|
||||
alt={search.name}
|
||||
className="w-full h-36 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-36 bg-gradient-to-br from-teal-600/20 to-navy-900/30 dark:from-teal-400/10 dark:to-navy-900/40 flex items-center justify-center">
|
||||
<BookmarkIcon className="w-10 h-10 text-warm-300 dark:text-warm-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
|
||||
{search.name}
|
||||
</h3>
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
|
||||
{formatRelativeTime(search.created)}
|
||||
</p>
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-3">
|
||||
{summarizeParams(search.params)}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onOpen(search.params)}
|
||||
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleShare(search.params, search.id)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
>
|
||||
{copiedId === search.id ? 'Copied!' : 'Share'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(search.id)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteConfirmId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setDeleteConfirmId(null)}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Delete search</h2>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="px-5 pb-4 text-sm text-warm-700 dark:text-warm-300">
|
||||
Are you sure you want to delete this saved search? This cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end px-5 pb-5">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteConfirm}
|
||||
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +1,37 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
|
||||
type Tab = 'login' | 'register';
|
||||
type View = 'login' | 'register' | 'forgot';
|
||||
|
||||
export default function AuthModal({
|
||||
onClose,
|
||||
onLogin,
|
||||
onRegister,
|
||||
onForgotPassword,
|
||||
loading,
|
||||
error,
|
||||
onClearError,
|
||||
initialTab = 'login',
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onLogin: (email: string, password: string) => Promise<void>;
|
||||
onRegister: (email: string, password: string, name?: string) => Promise<void>;
|
||||
onForgotPassword: (email: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onClearError: () => void;
|
||||
initialTab?: 'login' | 'register';
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>('login');
|
||||
const [view, setView] = useState<View>(initialTab);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
|
||||
const switchTab = useCallback(
|
||||
(newTab: Tab) => {
|
||||
setTab(newTab);
|
||||
const switchView = useCallback(
|
||||
(newView: View) => {
|
||||
setView(newView);
|
||||
setResetSent(false);
|
||||
onClearError();
|
||||
},
|
||||
[onClearError]
|
||||
|
|
@ -35,66 +41,73 @@ export default function AuthModal({
|
|||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (tab === 'login') {
|
||||
if (view === 'login') {
|
||||
await onLogin(email, password);
|
||||
} else {
|
||||
onClose();
|
||||
} else if (view === 'register') {
|
||||
await onRegister(email, password, name || undefined);
|
||||
onClose();
|
||||
} else {
|
||||
await onForgotPassword(email);
|
||||
setResetSent(true);
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
},
|
||||
[tab, email, password, name, onLogin, onRegister, onClose]
|
||||
[view, email, password, name, onLogin, onRegister, onForgotPassword, onClose]
|
||||
);
|
||||
|
||||
const title =
|
||||
view === 'login' ? 'Log in' : view === 'register' ? 'Create account' : 'Reset password';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||
{tab === 'login' ? 'Log in' : 'Create account'}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex px-5 gap-4 border-b border-warm-200 dark:border-warm-700">
|
||||
<button
|
||||
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === 'login'
|
||||
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
|
||||
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
onClick={() => switchTab('login')}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === 'register'
|
||||
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
|
||||
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
onClick={() => switchTab('register')}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
{/* Tabs (hidden in forgot view) */}
|
||||
{view !== 'forgot' && (
|
||||
<div className="flex px-5 gap-4 border-b border-warm-200 dark:border-warm-700">
|
||||
<button
|
||||
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
view === 'login'
|
||||
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
|
||||
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
onClick={() => switchView('login')}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
view === 'register'
|
||||
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
|
||||
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
onClick={() => switchView('register')}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{tab === 'register' && (
|
||||
{view === 'register' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Name
|
||||
|
|
@ -103,7 +116,7 @@ export default function AuthModal({
|
|||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="Your name (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -118,35 +131,70 @@ export default function AuthModal({
|
|||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
|
||||
placeholder={tab === 'register' ? 'Min 8 characters' : 'Your password'}
|
||||
/>
|
||||
</div>
|
||||
{view !== 'forgot' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
|
||||
/>
|
||||
{view === 'login' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchView('forgot')}
|
||||
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
{view === 'forgot' && resetSent && (
|
||||
<p className="text-sm text-teal-700 dark:text-teal-400">
|
||||
Check your email for a reset link.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{loading ? 'Please wait...' : tab === 'login' ? 'Log in' : 'Create account'}
|
||||
</button>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
||||
|
||||
{!(view === 'forgot' && resetSent) && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
||||
>
|
||||
{loading
|
||||
? 'Please wait...'
|
||||
: view === 'login'
|
||||
? 'Log in'
|
||||
: view === 'register'
|
||||
? 'Create account'
|
||||
: 'Send reset link'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{view === 'forgot' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchView('login')}
|
||||
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
30
frontend/src/components/ui/CollapsibleGroupHeader.tsx
Normal file
30
frontend/src/components/ui/CollapsibleGroupHeader.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { ChevronIcon } from './icons/ChevronIcon';
|
||||
|
||||
interface CollapsibleGroupHeaderProps {
|
||||
name: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CollapsibleGroupHeader({
|
||||
name,
|
||||
expanded,
|
||||
onToggle,
|
||||
className = '',
|
||||
children,
|
||||
}: CollapsibleGroupHeaderProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-full flex items-center justify-between ${className}`}
|
||||
>
|
||||
<span>{name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
<ChevronIcon direction={expanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { DownloadIcon } from './icons/DownloadIcon';
|
||||
import { BookmarkIcon } from './icons/BookmarkIcon';
|
||||
import { MapPinIcon } from './icons/MapPinIcon';
|
||||
import { CheckIcon } from './icons/CheckIcon';
|
||||
import { ClipboardIcon } from './icons/ClipboardIcon';
|
||||
import { SunIcon } from './icons/SunIcon';
|
||||
import { MoonIcon } from './icons/MoonIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
import UserMenu from './UserMenu';
|
||||
|
||||
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
|
||||
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches';
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
|
|
@ -17,8 +19,11 @@ export default function Header({
|
|||
onToggleTheme,
|
||||
onExport,
|
||||
exporting,
|
||||
onSaveSearch,
|
||||
savingSearch,
|
||||
user,
|
||||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onLogout,
|
||||
}: {
|
||||
activePage: Page;
|
||||
|
|
@ -27,17 +32,34 @@ export default function Header({
|
|||
onToggleTheme: () => void;
|
||||
onExport: (() => void) | null;
|
||||
exporting: boolean;
|
||||
onSaveSearch: (() => void) | null;
|
||||
savingSearch: boolean;
|
||||
user: AuthUser | null;
|
||||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
const url = window.location.href;
|
||||
const onSuccess = () => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(url).then(onSuccess);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = url;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
onSuccess();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tabClass = (page: Page) =>
|
||||
|
|
@ -67,20 +89,30 @@ export default function Header({
|
|||
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
|
||||
FAQ
|
||||
</button>
|
||||
{user && (
|
||||
<button className={tabClass('saved-searches')} onClick={() => onPageChange('saved-searches')}>
|
||||
Saved
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activePage === 'dashboard' && (
|
||||
<>
|
||||
<button
|
||||
onClick={onExport ?? undefined}
|
||||
disabled={!onExport || exporting}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
|
||||
title="Export to Excel"
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</button>
|
||||
{onSaveSearch && (
|
||||
<button
|
||||
onClick={onSaveSearch}
|
||||
disabled={savingSearch}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{savingSearch ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<BookmarkIcon className="w-4 h-4" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
|
|
@ -97,17 +129,34 @@ export default function Header({
|
|||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onExport ?? undefined}
|
||||
disabled={!onExport || exporting}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
|
||||
title="Export to Excel"
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{user ? (
|
||||
<UserMenu user={user} onLogout={onLogout} />
|
||||
) : (
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
|
|
|
|||
95
frontend/src/components/ui/SaveSearchModal.tsx
Normal file
95
frontend/src/components/ui/SaveSearchModal.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
|
||||
export default function SaveSearchModal({
|
||||
onClose,
|
||||
onSave,
|
||||
saving,
|
||||
error,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: (name: string) => Promise<void>;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || saving) return;
|
||||
try {
|
||||
await onSave(name.trim());
|
||||
onClose();
|
||||
} catch {
|
||||
// Error displayed in modal
|
||||
}
|
||||
},
|
||||
[name, saving, onSave, onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Save Search</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="My search"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || saving}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/ui/icons/BookmarkIcon.tsx
Normal file
11
frontend/src/components/ui/icons/BookmarkIcon.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BookmarkIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/ui/icons/TrashIcon.tsx
Normal file
11
frontend/src/components/ui/icons/TrashIcon.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TrashIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue