Quick save
This commit is contained in:
parent
e5d5819098
commit
2906b01734
25 changed files with 1070 additions and 237 deletions
|
|
@ -4,14 +4,17 @@ import MapPage, { type ExportState } from './components/map/MapPage';
|
|||
import DataSourcesPage from './components/data-sources/DataSourcesPage';
|
||||
import FAQPage from './components/faq/FAQPage';
|
||||
import HomePage from './components/home/HomePage';
|
||||
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
import AuthModal from './components/ui/AuthModal';
|
||||
import SaveSearchModal from './components/ui/SaveSearchModal';
|
||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||
import { fetchWithRetry, apiUrl } from './lib/api';
|
||||
import { parseUrlState } from './lib/url-state';
|
||||
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useSavedSearches } from './hooks/useSavedSearches';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -19,6 +22,25 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
function pageToPath(page: Page): string {
|
||||
switch (page) {
|
||||
case 'dashboard': return '/dashboard';
|
||||
case 'data-sources': return '/data-sources';
|
||||
case 'faq': return '/faq';
|
||||
case 'saved-searches': return '/saved';
|
||||
default: return '/';
|
||||
}
|
||||
}
|
||||
|
||||
function pathToPage(pathname: string): Page | null {
|
||||
if (pathname === '/dashboard') return 'dashboard';
|
||||
if (pathname === '/data-sources') return 'data-sources';
|
||||
if (pathname === '/faq') return 'faq';
|
||||
if (pathname === '/saved') return 'saved-searches';
|
||||
if (pathname === '/') return 'home';
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const urlState = useMemo(() => parseUrlState(), []);
|
||||
const initialViewState = useMemo(() => urlState.viewState || INITIAL_VIEW_STATE, []);
|
||||
|
|
@ -27,6 +49,10 @@ export default function App() {
|
|||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('screenshot') === '1';
|
||||
}, []);
|
||||
const isOgMode = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('og') === '1';
|
||||
}, []);
|
||||
|
||||
// Core data
|
||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||
|
|
@ -37,11 +63,27 @@ export default function App() {
|
|||
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
||||
const [activePage, setActivePage] = useState<Page>(() => {
|
||||
if (isScreenshotMode) return 'dashboard';
|
||||
|
||||
// Derive page from URL pathname
|
||||
const fromPath = pathToPage(window.location.pathname);
|
||||
if (fromPath) return fromPath;
|
||||
|
||||
// Restore from history state (e.g. popstate)
|
||||
if (window.history.state?.page) return window.history.state.page;
|
||||
|
||||
// Backward compat: dashboard params on unknown path
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.has('v') || params.has('f') || params.has('poi') || params.has('tab')
|
||||
? 'dashboard'
|
||||
: 'home';
|
||||
if (params.has('v') || params.has('f') || params.has('poi') || params.has('tab')) {
|
||||
// Rewrite URL to /dashboard keeping query params
|
||||
window.history.replaceState(
|
||||
{ page: 'dashboard' },
|
||||
'',
|
||||
`/dashboard${window.location.search}`
|
||||
);
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
return 'home';
|
||||
});
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
|
@ -52,9 +94,14 @@ export default function App() {
|
|||
login,
|
||||
register,
|
||||
logout,
|
||||
requestPasswordReset,
|
||||
clearError,
|
||||
} = useAuth();
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
||||
|
||||
const savedSearches = useSavedSearches(user?.id ?? null);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
|
||||
// Load features and POI categories on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -92,21 +139,15 @@ export default function App() {
|
|||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// Screenshot mode ready signal
|
||||
useEffect(() => {
|
||||
if (isScreenshotMode && !initialLoading && features.length > 0) {
|
||||
window.__og_ready = true;
|
||||
}
|
||||
}, [isScreenshotMode, initialLoading, features]);
|
||||
// Screenshot mode ready signal — MapPage sets __og_ready once map data loads
|
||||
|
||||
// Navigation
|
||||
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
|
||||
if (infoFeature) {
|
||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||
}
|
||||
const url = hash
|
||||
? `${window.location.pathname}${window.location.search}#${hash}`
|
||||
: `${window.location.pathname}${window.location.search}`;
|
||||
const path = pageToPath(page);
|
||||
const url = hash ? `${path}#${hash}` : path;
|
||||
window.history.pushState({ page }, '', url);
|
||||
setActivePage(page);
|
||||
trackPageview();
|
||||
|
|
@ -114,7 +155,11 @@ export default function App() {
|
|||
|
||||
useEffect(() => {
|
||||
if (!window.history.state?.page) {
|
||||
window.history.replaceState({ page: activePage }, '');
|
||||
window.history.replaceState(
|
||||
{ page: activePage },
|
||||
'',
|
||||
pageToPath(activePage) + window.location.search + window.location.hash
|
||||
);
|
||||
}
|
||||
const handlePopState = (e: PopStateEvent) => {
|
||||
if (e.state?.page) {
|
||||
|
|
@ -122,12 +167,23 @@ export default function App() {
|
|||
if (e.state.infoFeature) {
|
||||
setPendingInfoFeature(e.state.infoFeature);
|
||||
}
|
||||
} else {
|
||||
// Fall back to deriving page from pathname
|
||||
const page = pathToPage(window.location.pathname);
|
||||
setActivePage(page || 'home');
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch saved searches when page becomes active
|
||||
useEffect(() => {
|
||||
if (activePage === 'saved-searches') {
|
||||
savedSearches.fetchSearches();
|
||||
}
|
||||
}, [activePage, savedSearches.fetchSearches]);
|
||||
|
||||
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||
|
||||
if (isScreenshotMode) {
|
||||
|
|
@ -145,6 +201,7 @@ export default function App() {
|
|||
onClearPendingInfoFeature={() => {}}
|
||||
onNavigateTo={() => {}}
|
||||
screenshotMode
|
||||
ogMode={isOgMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -158,8 +215,17 @@ export default function App() {
|
|||
onToggleTheme={toggleTheme}
|
||||
onExport={exportState?.onExport ?? null}
|
||||
exporting={exportState?.exporting ?? false}
|
||||
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
|
||||
savingSearch={savedSearches.saving}
|
||||
user={user}
|
||||
onLoginClick={() => setShowAuthModal(true)}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onLogout={logout}
|
||||
/>
|
||||
{activePage === 'home' ? (
|
||||
|
|
@ -168,6 +234,15 @@ export default function App() {
|
|||
<DataSourcesPage />
|
||||
) : activePage === 'faq' ? (
|
||||
<FAQPage />
|
||||
) : activePage === 'saved-searches' ? (
|
||||
<SavedSearchesPage
|
||||
searches={savedSearches.searches}
|
||||
loading={savedSearches.loading}
|
||||
onDelete={savedSearches.deleteSearch}
|
||||
onOpen={(params) => {
|
||||
window.location.href = `/?${params}`;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MapPage
|
||||
features={features}
|
||||
|
|
@ -189,9 +264,19 @@ export default function App() {
|
|||
onClose={() => setShowAuthModal(false)}
|
||||
onLogin={login}
|
||||
onRegister={register}
|
||||
onForgotPassword={requestPasswordReset}
|
||||
loading={authLoading}
|
||||
error={authError}
|
||||
onClearError={clearError}
|
||||
initialTab={authModalTab}
|
||||
/>
|
||||
)}
|
||||
{showSaveModal && (
|
||||
<SaveSearchModal
|
||||
onClose={() => setShowSaveModal(false)}
|
||||
onSave={savedSearches.saveSearch}
|
||||
saving={savedSearches.saving}
|
||||
error={savedSearches.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,7 +17,9 @@ interface UseAreaSummaryResult {
|
|||
retry: () => void;
|
||||
}
|
||||
|
||||
const FORBIDDEN_FEATURES = ['% White', '% Black', '% Asian', '% Mixed', '% Other'];
|
||||
const FORBIDDEN_FEATURES = ['% White', '% Black', '% Asian', '% Mixed', '% Other',
|
||||
'Environmental risk', 'Collapsible deposits risk', 'Compressible ground risk', 'Landslide risk', 'Running sand risk', 'Shrink-swell risk', 'Soluble rocks risk'
|
||||
];
|
||||
|
||||
export function useAreaSummary({
|
||||
stats,
|
||||
|
|
|
|||
|
|
@ -100,9 +100,23 @@ export function useAuth() {
|
|||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const requestPasswordReset = useCallback(async (email: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('users').requestPasswordReset(email);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Password reset request failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { user, loading, error, login, register, loginWithOAuth, logout, clearError };
|
||||
return { user, loading, error, login, register, loginWithOAuth, logout, requestPasswordReset, clearError };
|
||||
}
|
||||
|
|
|
|||
101
frontend/src/hooks/useSavedSearches.ts
Normal file
101
frontend/src/hooks/useSavedSearches.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import pb from '../lib/pocketbase';
|
||||
import { apiUrl } from '../lib/api';
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
name: string;
|
||||
params: string;
|
||||
screenshotUrl: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export function useSavedSearches(userId: string | null) {
|
||||
const [searches, setSearches] = useState<SavedSearch[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSearches = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const records = await pb.collection('saved_searches').getFullList({
|
||||
sort: '-created',
|
||||
filter: `user = "${userId}"`,
|
||||
});
|
||||
setSearches(
|
||||
records.map((r) => ({
|
||||
id: r.id,
|
||||
name: (r as Record<string, unknown>).name as string,
|
||||
params: (r as Record<string, unknown>).params as string,
|
||||
screenshotUrl: (r as Record<string, unknown>).screenshot
|
||||
? pb.files.getURL(r, (r as Record<string, unknown>).screenshot as string)
|
||||
: '',
|
||||
created: r.created,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load searches');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveSearch = useCallback(
|
||||
async (name: string) => {
|
||||
if (!userId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = window.location.search.replace(/^\?/, '');
|
||||
|
||||
// Try to capture a screenshot via the OG image endpoint
|
||||
let screenshotBlob: Blob | null = null;
|
||||
try {
|
||||
const ogUrl = apiUrl('og-image', new URLSearchParams(params));
|
||||
const res = await fetch(ogUrl);
|
||||
if (res.ok) {
|
||||
screenshotBlob = await res.blob();
|
||||
}
|
||||
} catch {
|
||||
// Screenshot is optional — save without it
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('user', userId);
|
||||
formData.append('name', name);
|
||||
formData.append('params', params);
|
||||
if (screenshotBlob) {
|
||||
formData.append('screenshot', screenshotBlob, 'screenshot.png');
|
||||
}
|
||||
|
||||
await pb.collection('saved_searches').create(formData);
|
||||
await fetchSearches();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save search';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[userId, fetchSearches]
|
||||
);
|
||||
|
||||
const deleteSearch = useCallback(
|
||||
async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_searches').delete(id);
|
||||
setSearches((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete search');
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { searches, loading, saving, error, fetchSearches, saveSearch, deleteSearch };
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ export const MAX_RETRY_MS = 10000;
|
|||
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
|
||||
export const MAP_MIN_ZOOM = 5.5;
|
||||
|
||||
export const BUFFER_MULTIPLIER = 1.5;
|
||||
|
||||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
longitude: -1.5,
|
||||
|
|
@ -30,7 +31,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
|||
{ maxZoom: Infinity, resolution: 10 },
|
||||
] as const;
|
||||
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 17.5;
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 16;
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,48 +15,6 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
|
|||
return `${p}${value.toFixed(1)}${s}`;
|
||||
}
|
||||
|
||||
/** Lookup table for feature-specific formatting */
|
||||
export const FEATURE_FORMATS: Record<string, ValueFormat> = {
|
||||
// Property
|
||||
'Last known price': { prefix: '£' },
|
||||
'Price per sqm': { prefix: '£' },
|
||||
'Total floor area (sqm)': { suffix: ' sqm' },
|
||||
'Number of bedrooms & living rooms': { suffix: ' rooms' },
|
||||
'Transaction year': { raw: true },
|
||||
'Construction age': { raw: true },
|
||||
// Transport
|
||||
'Public transport to Bank (mins)': { suffix: ' mins' },
|
||||
'Public transport to Fitzrovia (mins)': { suffix: ' mins' },
|
||||
'Cycling to Bank (mins)': { suffix: ' mins' },
|
||||
'Cycling to Fitzrovia (mins)': { suffix: ' mins' },
|
||||
// Crime
|
||||
'Anti-social behaviour (avg/yr)': { suffix: '/yr' },
|
||||
'Violence and sexual offences (avg/yr)': { suffix: '/yr' },
|
||||
'Criminal damage and arson (avg/yr)': { suffix: '/yr' },
|
||||
'Burglary (avg/yr)': { suffix: '/yr' },
|
||||
'Vehicle crime (avg/yr)': { suffix: '/yr' },
|
||||
'Robbery (avg/yr)': { suffix: '/yr' },
|
||||
'Other theft (avg/yr)': { suffix: '/yr' },
|
||||
'Shoplifting (avg/yr)': { suffix: '/yr' },
|
||||
'Drugs (avg/yr)': { suffix: '/yr' },
|
||||
'Possession of weapons (avg/yr)': { suffix: '/yr' },
|
||||
'Public order (avg/yr)': { suffix: '/yr' },
|
||||
'Bicycle theft (avg/yr)': { suffix: '/yr' },
|
||||
'Theft from the person (avg/yr)': { suffix: '/yr' },
|
||||
'Other crime (avg/yr)': { suffix: '/yr' },
|
||||
'Serious crime (avg/yr)': { suffix: '/yr' },
|
||||
'Minor crime (avg/yr)': { suffix: '/yr' },
|
||||
// Demographics
|
||||
'% White': { suffix: '%' },
|
||||
'% Asian': { suffix: '%' },
|
||||
'% Black': { suffix: '%' },
|
||||
'% Mixed': { suffix: '%' },
|
||||
'% Other': { suffix: '%' },
|
||||
// Environment
|
||||
'Noise (dB)': { suffix: ' dB' },
|
||||
'Max available download speed (Mbps)': { suffix: ' Mbps', raw: true },
|
||||
};
|
||||
|
||||
export function formatFilterValue(value: number): string {
|
||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||
|
|
@ -81,6 +39,21 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
|
|||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatRelativeTime(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 60) return 'just now';
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 30) return `${diffDay}d ago`;
|
||||
return new Date(isoDate).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Calculate weighted mean from histogram with outlier bins.
|
||||
// Bin 0 = [min, p1), bins 1..n-2 = [p1, p99) evenly, bin n-1 = [p99, max].
|
||||
export function calculateHistogramMean(histogram: {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
DENSITY_GRADIENT_DARK,
|
||||
ZOOM_TO_RESOLUTION_THRESHOLDS,
|
||||
TWEMOJI_BASE,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
BUFFER_MULTIPLIER,
|
||||
} from './consts';
|
||||
|
||||
// Re-export constants for backwards compatibility
|
||||
|
|
@ -21,9 +21,12 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|||
// Use absolute URL for tiles - required by MapLibre
|
||||
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
|
||||
const baseLayers = layers('protomaps', flavor, { lang: 'en' });
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Reduce road layer opacity so hexagons are more visible
|
||||
// In dark mode, make all text white with dark outline
|
||||
const modifiedLayers = baseLayers.map((layer) => {
|
||||
// Modify road opacity
|
||||
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
|
||||
if (layer.type === 'line') {
|
||||
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
|
||||
|
|
@ -31,6 +34,20 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|||
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
|
||||
}
|
||||
}
|
||||
|
||||
// Modify text colors in dark mode
|
||||
if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
|
||||
return {
|
||||
...layer,
|
||||
paint: {
|
||||
...layer.paint,
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': '#1a1a1a',
|
||||
'text-halo-width': 1.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return layer;
|
||||
});
|
||||
|
||||
|
|
@ -41,7 +58,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|||
protomaps: {
|
||||
type: 'vector',
|
||||
tiles: [tileUrl],
|
||||
maxzoom: POSTCODE_ZOOM_THRESHOLD,
|
||||
maxzoom: 17,
|
||||
},
|
||||
},
|
||||
layers: modifiedLayers,
|
||||
|
|
@ -139,15 +156,18 @@ export function getBoundsFromViewState(
|
|||
const scale = Math.pow(2, zoom);
|
||||
const worldSize = TILE_SIZE * scale;
|
||||
|
||||
const bufferedWidth = width * BUFFER_MULTIPLIER;
|
||||
const bufferedHeight = height * BUFFER_MULTIPLIER;
|
||||
|
||||
const degreesPerPixelLng = 360 / worldSize;
|
||||
const halfWidthDeg = (width / 2) * degreesPerPixelLng;
|
||||
const halfWidthDeg = (bufferedWidth / 2) * degreesPerPixelLng;
|
||||
|
||||
const latRad = (clampedLat * Math.PI) / 180;
|
||||
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
|
||||
const centerPixelY = mercatorY * worldSize;
|
||||
|
||||
const topPixelY = centerPixelY - height / 2;
|
||||
const bottomPixelY = centerPixelY + height / 2;
|
||||
const topPixelY = centerPixelY - bufferedHeight / 2;
|
||||
const bottomPixelY = centerPixelY + bufferedHeight / 2;
|
||||
|
||||
const pixelYToLat = (pixelY: number): number => {
|
||||
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
|
||||
|
|
|
|||
|
|
@ -104,3 +104,33 @@ export function stateToParams(
|
|||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function summarizeParams(queryString: string): string {
|
||||
const params = new URLSearchParams(queryString);
|
||||
const parts: string[] = [];
|
||||
|
||||
const f = params.get('f');
|
||||
if (f) {
|
||||
const filterNames = f.split(',').map((seg) => {
|
||||
const colonIdx = seg.indexOf(':');
|
||||
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
|
||||
}).filter(Boolean);
|
||||
if (filterNames.length > 0) {
|
||||
parts.push(
|
||||
filterNames.length <= 2
|
||||
? filterNames.join(', ')
|
||||
: `${filterNames.length} filters`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const poi = params.get('poi');
|
||||
if (poi) {
|
||||
const count = poi.split(',').filter(Boolean).length;
|
||||
if (count > 0) {
|
||||
parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' + ') : 'No filters';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ export interface FeatureMeta {
|
|||
description?: string;
|
||||
detail?: string;
|
||||
source?: string;
|
||||
// Display format fields
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
export interface FeatureGroup {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue