Move into folders

This commit is contained in:
Andras Schmelczer 2026-02-07 13:34:50 +00:00
parent ee73ab77fd
commit 5cbb180c57
24 changed files with 181 additions and 185 deletions

View file

@ -0,0 +1,232 @@
import { useState, useRef, useCallback } from 'react';
import type { POICategoryGroup } from '../../types';
import { useClickOutside } from '../../hooks/useClickOutside';
import InfoPopup from '../shared/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { InfoIcon, ChevronIcon } 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;
}
export default function POIPane({
groups,
selectedCategories,
onCategoriesChange,
poiCount,
onNavigateToSource,
}: POIPaneProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [showInfo, setShowInfo] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useClickOutside(dropdownRef, () => setDropdownOpen(false));
const allCategories = groups.flatMap((g) => g.categories);
const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
onCategoriesChange(newSet);
};
const selectAll = () => {
onCategoriesChange(new Set(allCategories));
};
const selectNone = () => {
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 toggleCollapse = (groupName: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupName)) {
next.delete(groupName);
} else {
next.add(groupName);
}
return next;
});
};
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="w-72 p-4 bg-white dark:bg-navy-950 shadow-lg space-y-4 overflow-y-auto max-h-screen">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
<InfoIcon />
</IconButton>
</div>
{showInfo && (
<InfoPopup
title="Points of Interest"
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: 'View data source',
onClick: () => {
onNavigateToSource('osm-pois');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Points of interest are sourced from OpenStreetMap via Geofabrik extracts. Categories
include public transport stops, shops, restaurants, healthcare facilities, leisure
venues, and more. Data is filtered and mapped to friendly names with exhaustive category
coverage.
</p>
</InfoPopup>
)}
<div className="space-y-2" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 dark:border-navy-700 rounded hover:border-warm-400 bg-white dark:bg-navy-800 dark:text-warm-200"
>
<span className="truncate text-left">
{selectedCount === 0
? 'Select categories...'
: selectedCount === allCategories.length
? 'All categories'
: `${selectedCount} selected`}
</span>
<ChevronIcon
direction={dropdownOpen ? 'up' : 'down'}
className="w-4 h-4 ml-2 flex-shrink-0"
/>
</button>
{dropdownOpen && (
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search categories..."
/>
</div>
<div className="max-h-96 overflow-y-auto py-1">
{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 = collapsedGroups.has(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-y border-warm-100 dark:border-navy-700">
<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>
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={allInGroupSelected}
ref={(el) => {
if (el) el.indeterminate = someInGroupSelected;
}}
onChange={() => toggleGroup(group.name)}
className="rounded accent-teal-600"
/>
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">
{group.name}
</span>
</label>
<span className="text-xs text-warm-400">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed &&
group.categories.map((category) => (
<label
key={category}
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded accent-teal-600"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
</div>
);
})}
</div>
</div>
)}
</div>
{selectedCount > 0 && (
<div className="p-3 bg-teal-50 dark:bg-teal-900/30 rounded text-sm">
<div className="font-medium text-teal-900 dark:text-teal-300">
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
</div>
<div className="text-xs text-teal-700 dark:text-teal-400 mt-1">
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
</div>
</div>
)}
<div className="p-3 bg-warm-100 dark:bg-navy-800 rounded text-xs text-warm-600 dark:text-warm-400">
<p>Select categories to display POIs on the map.</p>
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
</div>
</div>
);
}