UI improvements

This commit is contained in:
Andras Schmelczer 2026-03-15 09:02:10 +00:00
parent 83dd2ca87e
commit 1569d116a9
14 changed files with 222 additions and 92 deletions

View file

@ -280,11 +280,10 @@ function SavedPropertiesTab({
key={prop.id}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4"
>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="mb-1">
<h3 className="font-medium text-navy-950 dark:text-warm-100 leading-tight">
{prop.address}
</h3>
<BookmarkIcon className="w-4 h-4 shrink-0 text-teal-600 dark:text-teal-400 mt-0.5" filled />
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-1">{prop.postcode}</p>
{price && (
@ -304,17 +303,6 @@ function SavedPropertiesTab({
>
Open postcode
</button>
{prop.data.listingUrl && (
<a
href={prop.data.listingUrl}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
title="View listing"
>
View listing &rarr;
</a>
)}
<button
onClick={() => setDeleteConfirmId(prop.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"
@ -323,6 +311,16 @@ function SavedPropertiesTab({
<TrashIcon className="w-4 h-4" />
</button>
</div>
{prop.data.listingUrl && (
<a
href={prop.data.listingUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-2 block text-center px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
View listing &rarr;
</a>
)}
</div>
);
})}
@ -359,7 +357,9 @@ export function SavedPage({
onDeleteProperty: (id: string) => Promise<void>;
onOpenProperty: (postcode: string) => void;
}) {
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>('searches');
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>(
window.location.hash === '#properties' ? 'properties' : 'searches'
);
const tabClass = (tab: string) =>
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
@ -448,20 +448,37 @@ function InviteTable({
No invites generated yet
</p>
) : (
<table className="w-full text-sm">
<table className="w-full table-fixed text-sm">
<thead>
<tr className="border-b border-warm-200 dark:border-warm-700 text-left">
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Link</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Status</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Created</th>
<th className="px-5 py-2" />
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">Status</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">Created</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-100 dark:divide-warm-800">
{invites.map((inv) => (
<tr key={inv.code}>
<td className="px-5 py-2.5 text-navy-950 dark:text-warm-200 font-mono text-xs truncate max-w-[200px]">
{inv.code}
<td className="px-5 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<span
className="text-navy-950 dark:text-warm-200 font-mono text-xs truncate min-w-0"
style={{ direction: 'rtl', textAlign: 'left' }}
>
{inv.url}
</span>
<button
onClick={() => handleCopy(inv.url, inv.code)}
className="shrink-0 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Copy invite link"
>
{copiedCode === inv.code ? (
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
) : (
<ClipboardIcon className="w-4 h-4" />
)}
</button>
</div>
</td>
<td className="px-5 py-2.5">
<span
@ -477,19 +494,6 @@ function InviteTable({
<td className="px-5 py-2.5 text-warm-500 dark:text-warm-400 text-xs">
{formatRelativeTime(inv.created)}
</td>
<td className="px-5 py-2.5 text-right">
<button
onClick={() => handleCopy(inv.url, inv.code)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Copy invite link"
>
{copiedCode === inv.code ? (
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
) : (
<ClipboardIcon className="w-4 h-4" />
)}
</button>
</td>
</tr>
))}
</tbody>

View file

@ -25,7 +25,7 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
const hasContent = query.trim().length > 0;
return (
<div className="px-3 py-2">
<div className="px-3 py-2" data-tutorial="ai-filters">
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
<div className="relative flex-1">
<SparklesIcon className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-teal-500 dark:text-teal-400 pointer-events-none" />

View file

@ -1,5 +1,6 @@
import { useState, useMemo, useEffect } from 'react';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { useTravelModes } from '../../hooks/useTravelModes';
import { SearchInput } from '../ui/SearchInput';
import { FilterIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
@ -11,7 +12,6 @@ import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
import type { ComponentType } from 'react';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
@ -53,6 +53,7 @@ export default function FeatureBrowser({
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [expandedGroups, toggleGroup] = useCollapsibleGroups();
const availableTravelModes = useTravelModes();
useEffect(() => {
if (openInfoFeature) {
@ -73,9 +74,15 @@ export default function FeatureBrowser({
// When searching, expand all groups so results are visible
const isSearching = search.length > 0;
// All modes are always available (can add multiple entries per mode)
// Only show modes that have precomputed travel time data
const visibleModes = useMemo(
() => (availableTravelModes ? TRANSPORT_MODES.filter((m) => availableTravelModes.has(m)) : []),
[availableTravelModes],
);
const showTravelModes =
!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase());
visibleModes.length > 0 &&
(!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
return (
<>
@ -92,15 +99,15 @@ export default function FeatureBrowser({
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{TRANSPORT_MODES.length}
{visibleModes.length}
</span>
</CollapsibleGroupHeader>
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
{(isSearching || expandedGroups.has('Travel Time')) && visibleModes.map((mode) => {
const ModeIcon = MODE_ICONS[mode];
return (
<div
key={mode}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
@ -114,9 +121,13 @@ export default function FeatureBrowser({
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md">
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
<button
onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`}
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
</button>
</div>
</div>
);
@ -143,7 +154,7 @@ export default function FeatureBrowser({
return (
<div
key={f.name}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />

View file

@ -212,9 +212,6 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe
const displayLegs = j.legs ? invertLegs(j.legs) : null;
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
const totalMin = j.minutes ?? legSum;
const waitingMin = j.minutes != null ? Math.max(0, j.minutes - legSum) : null;
const bestWaitingMin =
j.bestMinutes != null ? Math.max(0, j.bestMinutes - legSum) : null;
return (
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
@ -238,22 +235,6 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe
{displayLegs.map((leg, i) => (
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
))}
{waitingMin != null && waitingMin > 0 && (
<div className="mt-1.5 pt-1.5 border-t border-warm-200 dark:border-warm-700 flex items-baseline justify-between">
<span className="text-[11px] text-warm-500 dark:text-warm-400">
Waiting & transfers
</span>
<span className="text-[11px] text-warm-600 dark:text-warm-300">
{waitingMin} min
{bestWaitingMin != null && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
(best: {bestWaitingMin === 0 ? '~0' : bestWaitingMin} min)
</span>
)}
</span>
</div>
)}
</div>
) : (
<span className="text-xs text-warm-500 dark:text-warm-400">

View file

@ -33,6 +33,7 @@ import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
export interface ExportState {
onExport: () => void;
@ -101,6 +102,22 @@ export default function MapPage({
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
const bookmarkToastDismissed = useRef(
localStorage.getItem('bookmark_toast_dismissed') === '1'
);
const handleSavePropertyWithToast = useCallback(
(property: Property) => {
onSaveProperty?.(property);
if (!bookmarkToastDismissed.current) {
setShowBookmarkToast(true);
bookmarkToastDismissed.current = true;
}
},
[onSaveProperty]
);
const {
filters,
activeFeature,
@ -358,6 +375,31 @@ export default function MapPage({
}
}, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]);
const bookmarkToast = showBookmarkToast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
<BookmarkIcon className="w-4 h-4 text-teal-400 shrink-0" filled />
<span>Property saved!</span>
<button
onClick={() => {
setShowBookmarkToast(false);
onNavigateTo('saved', 'properties');
}}
className="px-3 py-1 rounded bg-teal-600 hover:bg-teal-500 text-white text-xs font-medium whitespace-nowrap"
>
View saved
</button>
<button
onClick={() => {
setShowBookmarkToast(false);
localStorage.setItem('bookmark_toast_dismissed', '1');
}}
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
>
Don&apos;t show again
</button>
</div>
);
if (screenshotMode) {
return (
<div className="h-full w-full">
@ -416,7 +458,7 @@ export default function MapPage({
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onSaveProperty={onSaveProperty}
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
getSavedPropertyId={getSavedPropertyId}
@ -592,6 +634,8 @@ export default function MapPage({
/>
)}
{bookmarkToast}
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
@ -730,6 +774,8 @@ export default function MapPage({
</div>
)}
{bookmarkToast}
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}

View file

@ -76,11 +76,15 @@ export function TravelTimeCard({
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time ({MODE_LABELS[mode]})
</span>
<button
onClick={() => setShowInfo(true)}
className="p-1 -m-0.5 rounded text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 hover:bg-warm-100 dark:hover:bg-warm-700 shrink-0"
title="Feature info"
>
<InfoIcon className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center gap-0.5">
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
<InfoIcon className="w-3.5 h-3.5" />
</IconButton>
{slug && (
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />

View file

@ -35,9 +35,13 @@ export function FeatureActions({
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
{onAdd && (
<IconButton onClick={() => onAdd(feature.name)} title="Add filter" size="md">
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
<button
onClick={() => onAdd(feature.name)}
title="Add filter"
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
</button>
)}
{onRemove && (
<IconButton onClick={() => onRemove(feature.name)} title="Remove filter">

View file

@ -1,15 +1,16 @@
interface IconProps {
interface PlusIconProps {
className?: string;
strokeWidth?: number;
}
export function PlusIcon({ className = 'w-7 h-7' }: IconProps) {
export function PlusIcon({ className = 'w-7 h-7', strokeWidth = 2 }: PlusIconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeWidth={strokeWidth}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
</svg>

View file

@ -414,12 +414,47 @@ export function useDeckLayers({
data: postcodeData as PostcodeFeature[],
getFillColor: (f) => {
const d = f.properties;
const dark = isDarkRef.current;
const entries = travelTimeEntriesRef.current;
// Dim-filter: all travel entries with timeRange dim postcodes outside range
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.timeRange || !entry.slug) continue;
const fk = travelFieldKey(entry);
const modeVal = d[`avg_${fk}`];
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
}
}
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
const dark = isDarkRef.current;
if (vf && clr && cfm) {
if (vf && clr) {
// Travel time feature: dim postcodes with no data
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
}
return getFeatureFillColor(
ttVal as number,
ttVal as number,
ttVal as number,
clr,
null,
0,
densityGradientRef.current,
dark,
180
);
}
// Regular feature
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
@ -436,6 +471,7 @@ export function useDeckLayers({
enumCountRef.current
);
}
}
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);

View file

@ -0,0 +1,34 @@
import { useState, useEffect } from 'react';
import { logNonAbortError } from '../lib/api';
import type { TransportMode } from './useTravelTime';
interface TravelModeInfo {
mode: TransportMode;
destinations: number;
}
/** Fetches which transport modes have precomputed travel time data. */
export function useTravelModes() {
const [availableModes, setAvailableModes] = useState<Set<TransportMode> | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch('/api/travel-modes', { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data: { modes: TravelModeInfo[] }) => {
const modes = new Set<TransportMode>(
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode),
);
setAvailableModes(modes);
})
.catch((err) => logNonAbortError('travel modes', err));
return () => controller.abort();
}, []);
return availableModes;
}

View file

@ -13,6 +13,14 @@ const STEPS: Step[] = [
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tutorial="ai-filters"]',
title: 'AI-Powered Filters',
content:
'Describe your ideal area in plain English — like "quiet neighbourhood with good schools" — and AI will set up the right filters for you automatically.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tutorial="map"]',
title: 'Explore the Map',

View file

@ -35,7 +35,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 13, resolution: 9 },
] as const;
export const POSTCODE_ZOOM_THRESHOLD = 15;
export const POSTCODE_ZOOM_THRESHOLD = 16;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },

View file

@ -52,6 +52,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
return {
version: 8,
sprite: `${window.location.origin}/assets/sprites/${theme}`,
glyphs: GLYPHS_URL,
sources: {
protomaps: {

View file

@ -3,7 +3,7 @@
Uses pyosmium's FileProcessor with area assembly to convert OSM ways/relations
into Shapely polygons, reprojects to BNG (EPSG:27700), and saves as parquet.
Reuses the same great-britain-latest.osm.pbf as pois.py.
Reuses the same england-latest.osm.pbf as pois.py.
"""
import argparse