223 lines
8.2 KiB
TypeScript
223 lines
8.2 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ts } from '../../i18n/server';
|
|
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
|
import { trackEvent } from '../../lib/analytics';
|
|
import { POI_CATEGORY_LOGOS } from '../../lib/consts';
|
|
import type { POICategoryGroup } from '../../types';
|
|
import InfoPopup from '../ui/InfoPopup';
|
|
import { SearchInput } from '../ui/SearchInput';
|
|
import { PillToggle } from '../ui/PillToggle';
|
|
import { PillGroup } from '../ui/PillGroup';
|
|
import { InfoIcon, ChevronIcon, CloseIcon } from '../ui/icons';
|
|
import { IconButton } from '../ui/IconButton';
|
|
|
|
interface POIPaneProps {
|
|
groups: POICategoryGroup[];
|
|
selectedCategories: Set<string>;
|
|
onCategoriesChange: (categories: Set<string>) => void;
|
|
poiCount: number;
|
|
onNavigateToSource?: (slug: string) => void;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
export default function POIPane({
|
|
groups,
|
|
selectedCategories,
|
|
onCategoriesChange,
|
|
poiCount: _poiCount,
|
|
onNavigateToSource,
|
|
onClose,
|
|
}: POIPaneProps) {
|
|
const { t } = useTranslation();
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [isGroupExpanded, toggleCollapse] = useCollapsibleGroups();
|
|
const [showInfo, setShowInfo] = useState(false);
|
|
|
|
const allCategories = groups.flatMap((g) => g.categories);
|
|
|
|
const toggleCategory = (category: string) => {
|
|
const newSet = new Set(selectedCategories);
|
|
const wasSelected = newSet.has(category);
|
|
if (wasSelected) {
|
|
newSet.delete(category);
|
|
} else {
|
|
newSet.add(category);
|
|
}
|
|
trackEvent('POI Toggle', { category, selected: String(!wasSelected) });
|
|
onCategoriesChange(newSet);
|
|
};
|
|
|
|
const selectAll = () => {
|
|
trackEvent('POI Select All');
|
|
onCategoriesChange(new Set(allCategories));
|
|
};
|
|
|
|
const selectNone = () => {
|
|
trackEvent('POI Select None');
|
|
onCategoriesChange(new Set());
|
|
};
|
|
|
|
const toggleGroup = useCallback(
|
|
(groupName: string) => {
|
|
const group = groups.find((g) => g.name === groupName);
|
|
if (!group) return;
|
|
const allSelected = group.categories.every((c) => selectedCategories.has(c));
|
|
const newSet = new Set(selectedCategories);
|
|
if (allSelected) {
|
|
group.categories.forEach((c) => newSet.delete(c));
|
|
} else {
|
|
group.categories.forEach((c) => newSet.add(c));
|
|
}
|
|
onCategoriesChange(newSet);
|
|
},
|
|
[groups, selectedCategories, onCategoriesChange]
|
|
);
|
|
|
|
const lowerSearch = searchTerm.toLowerCase();
|
|
|
|
const filteredGroups = groups
|
|
.map((group) => {
|
|
if (!searchTerm) return group;
|
|
const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch));
|
|
const groupMatches = group.name.toLowerCase().includes(lowerSearch);
|
|
if (groupMatches) return group;
|
|
if (matchingCats.length === 0) return null;
|
|
return { ...group, categories: matchingCats };
|
|
})
|
|
.filter(Boolean) as POICategoryGroup[];
|
|
|
|
const selectedCount = selectedCategories.size;
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
|
|
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
|
{t('poiPane.pois')}
|
|
</span>
|
|
<span className="text-xs text-warm-400 dark:text-warm-500">
|
|
{selectedCount}/{allCategories.length}
|
|
</span>
|
|
<IconButton onClick={() => setShowInfo(true)} title={t('poiPane.dataSourceInfo')}>
|
|
<InfoIcon />
|
|
</IconButton>
|
|
<div className="flex gap-1 ml-auto items-center">
|
|
<button
|
|
onClick={selectAll}
|
|
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
|
>
|
|
{t('common.all')}
|
|
</button>
|
|
<button
|
|
onClick={selectNone}
|
|
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
|
>
|
|
{t('common.none')}
|
|
</button>
|
|
{onClose && (
|
|
<button
|
|
onClick={onClose}
|
|
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
|
title={t('common.close')}
|
|
>
|
|
<CloseIcon className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showInfo && (
|
|
<InfoPopup
|
|
title={t('poiPane.pointsOfInterest')}
|
|
onClose={() => setShowInfo(false)}
|
|
sourceLink={
|
|
onNavigateToSource
|
|
? {
|
|
label: t('common.viewDataSource'),
|
|
onClick: () => {
|
|
onNavigateToSource('osm-pois');
|
|
setShowInfo(false);
|
|
},
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
|
{t('poiPane.poiDescription')}
|
|
</p>
|
|
</InfoPopup>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 dark:border-warm-700">
|
|
<div className="px-3 pt-2 pb-1">
|
|
<SearchInput
|
|
value={searchTerm}
|
|
onChange={setSearchTerm}
|
|
placeholder={t('poiPane.searchCategories')}
|
|
/>
|
|
</div>
|
|
{filteredGroups.map((group) => {
|
|
const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length;
|
|
const allInGroupSelected = groupSelected === group.categories.length;
|
|
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
|
|
const isCollapsed = !isGroupExpanded(group.name) && !searchTerm;
|
|
|
|
return (
|
|
<div key={group.name}>
|
|
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-b border-warm-100 dark:border-navy-700 sticky top-0 z-10">
|
|
<button
|
|
onClick={() => toggleCollapse(group.name)}
|
|
className={`p-0.5 text-warm-400 hover:text-warm-600 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
|
>
|
|
<ChevronIcon direction="right" className="w-3 h-3" />
|
|
</button>
|
|
<PillToggle
|
|
label={ts(group.name)}
|
|
active={allInGroupSelected}
|
|
indeterminate={someInGroupSelected}
|
|
onClick={() => toggleGroup(group.name)}
|
|
size="xs"
|
|
/>
|
|
<span className="text-xs text-warm-400 ml-auto">
|
|
{groupSelected}/{group.categories.length}
|
|
</span>
|
|
</div>
|
|
{!isCollapsed && (
|
|
<div className="px-3 py-2">
|
|
<PillGroup>
|
|
{group.categories.map((category) => {
|
|
const logo = POI_CATEGORY_LOGOS[category];
|
|
return (
|
|
<PillToggle
|
|
key={category}
|
|
label={ts(category)}
|
|
icon={
|
|
logo ? (
|
|
<img
|
|
src={logo}
|
|
alt=""
|
|
aria-hidden="true"
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
className="h-4 w-4 shrink-0 rounded-[3px] bg-white object-contain p-0.5"
|
|
/>
|
|
) : undefined
|
|
}
|
|
active={selectedCategories.has(category)}
|
|
onClick={() => toggleCategory(category)}
|
|
size="xs"
|
|
/>
|
|
);
|
|
})}
|
|
</PillGroup>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|