perfect-postcode/frontend/src/components/map/FeatureBrowser.tsx
2026-05-12 22:00:56 +01:00

225 lines
9.3 KiB
TypeScript

import { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
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';
import { EmptyState } from '../ui/EmptyState';
import type { FeatureMeta } from '../../types';
import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { PlusIcon, InfoIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
import {
TRANSPORT_MODES,
MODE_ICONS,
useTranslatedModes,
type TransportMode,
type TravelTimeEntry,
} from '../../hooks/useTravelTime';
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
allFeatures: FeatureMeta[];
pinnedFeature: string | null;
onAddFilter: (name: string) => void;
onTogglePin: (name: string) => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
}
export default function FeatureBrowser({
availableFeatures,
allFeatures,
pinnedFeature,
onAddFilter,
onTogglePin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
travelTimeEntries: _travelTimeEntries,
onAddTravelTimeEntry,
}: FeatureBrowserProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [travelInfoMode, setTravelInfoMode] = useState<TransportMode | null>(null);
const [isGroupExpanded, toggleGroup] = useCollapsibleGroups(true);
const availableTravelModes = useTravelModes();
useEffect(() => {
if (openInfoFeature) {
const feat = allFeatures.find((f) => f.name === openInfoFeature);
if (feat) setInfoFeature(feat);
onClearOpenInfoFeature?.();
}
}, [openInfoFeature, allFeatures, onClearOpenInfoFeature]);
const filtered = useMemo(() => {
if (!search) return availableFeatures;
const lower = search.toLowerCase();
return availableFeatures.filter((f) =>
[f.name, f.description, f.detail, f.group]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLowerCase().includes(lower))
);
}, [availableFeatures, search]);
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
// When searching, expand all groups so results are visible
const isSearching = search.length > 0;
// Only show modes that have precomputed travel time data
const visibleModes = useMemo(
() => (availableTravelModes ? TRANSPORT_MODES.filter((m) => availableTravelModes.has(m)) : []),
[availableTravelModes]
);
const showTravelModes =
visibleModes.length > 0 &&
(!search ||
'travel time journey commute car bicycle walking transit transport station tube train'.includes(
search.toLowerCase()
));
// Ensure "Transport" group exists first when travel modes should be shown.
const mergedGrouped = useMemo(() => {
if (!showTravelModes) return grouped;
if (grouped.some((g) => g.name === 'Transport')) return grouped;
return [{ name: 'Transport', features: [] }, ...grouped];
}, [grouped, showTravelModes]);
return (
<>
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('filters.searchFeatures')}
/>
{!search && (
<p className="mt-2 px-1 text-xs leading-relaxed text-warm-500 dark:text-warm-400">
{t('filters.chooseFilters')}
</p>
)}
</div>
<div>
{mergedGrouped.map((group) => {
const isExpanded = isSearching || isGroupExpanded(group.name);
return (
<div key={group.name} className="shrink-0">
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
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">
{group.features.length +
(group.name === 'Transport' && showTravelModes ? visibleModes.length : 0)}
</span>
</CollapsibleGroupHeader>
{isExpanded && (
<>
{group.name === 'Transport' &&
showTravelModes &&
visibleModes.map((mode) => {
const ModeIcon = MODE_ICONS[mode];
return (
<div
key={mode}
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" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
{modes.label(mode)}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
{modes.desc(mode)}
</span>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<IconButton
onClick={() => setTravelInfoMode(mode)}
title={t('filters.aboutData')}
size="md"
>
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
<button
type="button"
onClick={() => onAddTravelTimeEntry(mode)}
title={t('travel.addTravelTime', { mode: modes.label(mode) })}
aria-label={t('travel.addTravelTime', { mode: modes.label(mode) })}
className="inline-flex items-center gap-1 rounded-md bg-teal-50 px-2 py-1 text-xs font-semibold text-teal-700 hover:bg-teal-100 dark:bg-teal-900/30 dark:text-teal-300 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-4 h-4" strokeWidth={2.5} />
<span>{t('filters.addFilterAction')}</span>
</button>
</div>
</div>
);
})}
{group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
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} size="sm" description={f.description} />
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setInfoFeature}
onAdd={onAddFilter}
showText
/>
</div>
);
})}
</>
)}
</div>
);
})}
{mergedGrouped.length === 0 ? (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={search ? t('filters.noMatchingFeatures') : t('filters.allFeaturesActive')}
description={search ? t('filters.tryDifferentSearch') : t('filters.removeFilterHint')}
className="px-3 py-4"
/>
) : null}
</div>
{infoFeature && (
<FeatureInfoPopup
feature={infoFeature}
onClose={() => setInfoFeature(null)}
onNavigateToSource={onNavigateToSource}
/>
)}
{travelInfoMode && (
<TravelTimeInfoPopup mode={travelInfoMode} onClose={() => setTravelInfoMode(null)} />
)}
</>
);
}