7673fc63ea
- Updated toolbar styles for improved layout and appearance, including padding and margin adjustments. - Introduced new styles for scale menu and compact dropdowns. - Enhanced map pin tooltips with better positioning and hover effects. - Added new styles for map sheet filters, including search and date range controls. - Improved editor styles for a more cohesive look and feel, including transport controls for video editing. - Expanded GeoLocation interface to include additional address fields and formatted address. - Updated MediaItem interface to include storage reference and uploadedBy fields. - Enhanced MediaComment interface with author details for better user identification. - Introduced GalleryUser interface to represent signed-in users in the host app.
734 lines
27 KiB
TypeScript
734 lines
27 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import { MAP_TILES } from '../../constants';
|
||
import { groupByTime } from '../../lib/grouping';
|
||
import { Icon } from '../../icons';
|
||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||
import {
|
||
clusterByLocation,
|
||
type LocationCluster,
|
||
liveMedia,
|
||
locatedMedia,
|
||
searchMedia,
|
||
} from '../../store/selectors';
|
||
import type { MediaItem } from '../../types';
|
||
import { MediaGrid } from '../MediaGrid';
|
||
import { MosaicGrid } from './MosaicGrid';
|
||
|
||
/** Thumbnails shown in a multi-photo pin's hover strip before the "+N" chip. */
|
||
const TIP_STRIP_MAX = 5;
|
||
/** How long each frame of the hover mini-slider stays up. */
|
||
const TIP_FRAME_MS = 900;
|
||
|
||
/** Parse a `<input type="date">` value into a local-midnight epoch, or null. */
|
||
function parseDateInput(value: string, endOfDay: boolean): number | null {
|
||
if (!value) return null;
|
||
const [y, m, d] = value.split('-').map(Number);
|
||
if (!y || !m || !d) return null;
|
||
return endOfDay
|
||
? new Date(y, m - 1, d, 23, 59, 59, 999).getTime()
|
||
: new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
|
||
}
|
||
|
||
/** Format a Date to a `<input type="date">` value (local `YYYY-MM-DD`). */
|
||
function toInputDate(d: Date): string {
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
/** Short human label for one `YYYY-MM-DD` value. */
|
||
function labelDate(value: string): string {
|
||
const [y, m, d] = value.split('-').map(Number);
|
||
if (!y || !m || !d) return '';
|
||
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: 'numeric',
|
||
});
|
||
}
|
||
|
||
/** Button label for the current range ("All dates" when unset). */
|
||
function rangeLabel(from: string, to: string): string {
|
||
if (!from && !to) return 'All dates';
|
||
if (from && to) return `${labelDate(from)} – ${labelDate(to)}`;
|
||
if (from) return `From ${labelDate(from)}`;
|
||
return `Until ${labelDate(to)}`;
|
||
}
|
||
|
||
/**
|
||
* A single, polished "date range" control: a button showing the current range
|
||
* that opens a popover of quick presets + a custom From/To pair. It drives the
|
||
* SAME from/to strings the MapView already filters by — so search/object chips,
|
||
* the live count, and Clear all keep working unchanged.
|
||
*/
|
||
function DateRangeControl({
|
||
from,
|
||
to,
|
||
onChange,
|
||
}: {
|
||
from: string;
|
||
to: string;
|
||
onChange: (from: string, to: string) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onDown = (e: PointerEvent) => {
|
||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||
};
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') setOpen(false);
|
||
};
|
||
window.addEventListener('pointerdown', onDown, true);
|
||
window.addEventListener('keydown', onKey);
|
||
return () => {
|
||
window.removeEventListener('pointerdown', onDown, true);
|
||
window.removeEventListener('keydown', onKey);
|
||
};
|
||
}, [open]);
|
||
|
||
const today = new Date();
|
||
const daysAgo = (n: number) => {
|
||
const d = new Date();
|
||
d.setDate(today.getDate() - n);
|
||
return d;
|
||
};
|
||
const presets: Array<{ label: string; from: string; to: string }> = [
|
||
{ label: 'All dates', from: '', to: '' },
|
||
{ label: 'Last 7 days', from: toInputDate(daysAgo(6)), to: toInputDate(today) },
|
||
{ label: 'Last 30 days', from: toInputDate(daysAgo(29)), to: toInputDate(today) },
|
||
{
|
||
label: 'This year',
|
||
from: toInputDate(new Date(today.getFullYear(), 0, 1)),
|
||
to: toInputDate(today),
|
||
},
|
||
];
|
||
|
||
const active = Boolean(from || to);
|
||
|
||
return (
|
||
<div className="apg-daterange" ref={ref}>
|
||
<button
|
||
type="button"
|
||
className={['apg-daterange__button', active ? 'apg-daterange__button--active' : '']
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
aria-haspopup="dialog"
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((o) => !o)}
|
||
>
|
||
<Icon name="clock" size={14} />
|
||
<span className="apg-daterange__label">{rangeLabel(from, to)}</span>
|
||
<Icon name="chevron-down" size={14} />
|
||
</button>
|
||
{open ? (
|
||
<div className="apg-daterange__pop" role="dialog" aria-label="Choose a date range">
|
||
<div className="apg-daterange__presets">
|
||
{presets.map((p) => {
|
||
const on = p.from === from && p.to === to;
|
||
return (
|
||
<button
|
||
key={p.label}
|
||
type="button"
|
||
className={['apg-daterange__preset', on ? 'apg-daterange__preset--on' : '']
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
aria-pressed={on}
|
||
onClick={() => {
|
||
onChange(p.from, p.to);
|
||
setOpen(false);
|
||
}}
|
||
>
|
||
{p.label}
|
||
{on ? <Icon name="check" size={14} /> : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="apg-daterange__custom">
|
||
<div className="apg-daterange__custom-label">Custom range</div>
|
||
<div className="apg-daterange__fields">
|
||
<label className="apg-daterange__field">
|
||
<span>From</span>
|
||
<input
|
||
type="date"
|
||
value={from}
|
||
max={to || undefined}
|
||
onChange={(e) => onChange(e.target.value, to)}
|
||
/>
|
||
</label>
|
||
<label className="apg-daterange__field">
|
||
<span>To</span>
|
||
<input
|
||
type="date"
|
||
value={to}
|
||
min={from || undefined}
|
||
onChange={(e) => onChange(from, e.target.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function MapView() {
|
||
const api = useGalleryStoreApi();
|
||
const mapMode = useGallery((s) => s.mapMode);
|
||
const media = useGallery((s) => s.media);
|
||
const mapFocus = useGallery((s) => s.mapFocus);
|
||
const located = locatedMedia(media);
|
||
|
||
// The location pin the user tapped → its photos shown date-grouped in a sheet.
|
||
const [cluster, setCluster] = useState<LocationCluster | null>(null);
|
||
// Sheet height as a fraction of the viewport (drag the handle up → toward full).
|
||
const [sheetH, setSheetH] = useState(0.5);
|
||
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
||
|
||
// ---- sheet filters (search + date range + object chips), AND-combined ----
|
||
const [query, setQuery] = useState('');
|
||
const [fromDate, setFromDate] = useState('');
|
||
const [toDate, setToDate] = useState('');
|
||
const [objectFilter, setObjectFilter] = useState<string | null>(null);
|
||
|
||
const resetFilters = () => {
|
||
setQuery('');
|
||
setFromDate('');
|
||
setToDate('');
|
||
setObjectFilter(null);
|
||
};
|
||
|
||
const onHandleDown = (e: React.PointerEvent) => {
|
||
dragRef.current = { startY: e.clientY, startH: sheetH };
|
||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||
};
|
||
const onHandleMove = (e: React.PointerEvent) => {
|
||
if (!dragRef.current) return;
|
||
const dy = dragRef.current.startY - e.clientY; // dragging up is positive
|
||
const next = dragRef.current.startH + dy / window.innerHeight;
|
||
setSheetH(Math.max(0.18, Math.min(0.98, next)));
|
||
};
|
||
const onHandleUp = () => {
|
||
if (dragRef.current && sheetH <= 0.2) setCluster(null); // dragged down → dismiss
|
||
dragRef.current = null;
|
||
};
|
||
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
// Leaflet instances kept in refs (typed loosely to avoid an SSR import).
|
||
const mapRef = useRef<any>(null);
|
||
const tileRef = useRef<any>(null);
|
||
const markersRef = useRef<any>(null);
|
||
const leafletRef = useRef<any>(null);
|
||
// Every hover-slider interval currently running, so teardown can kill them all
|
||
// (a leaked interval keeps mutating detached DOM forever).
|
||
const tipTimersRef = useRef(new Set<ReturnType<typeof setInterval>>());
|
||
const clearTipTimers = () => {
|
||
for (const t of tipTimersRef.current) clearInterval(t);
|
||
tipTimersRef.current.clear();
|
||
};
|
||
// addMarkers reads the latest `located` + setCluster via refs (Leaflet callbacks
|
||
// are created once, so closing over state directly would go stale).
|
||
const locatedRef = useRef(located);
|
||
locatedRef.current = located;
|
||
// Current sheet height in px, read by the fly-to offset (the sheet covers the
|
||
// bottom of the map, so the pin must end up above it).
|
||
const sheetHRef = useRef(sheetH);
|
||
sheetHRef.current = sheetH;
|
||
|
||
/** Zoom to the pin, keeping it clear of the sheet, then open the sheet. */
|
||
const openClusterRef = useRef<(c: LocationCluster) => void>(() => {});
|
||
openClusterRef.current = (c) => {
|
||
const map = mapRef.current;
|
||
if (map) {
|
||
const current = map.getZoom?.() ?? 2;
|
||
// Never zoom OUT: a single photo warrants street level, a cluster stays wide
|
||
// enough that its members remain distinguishable.
|
||
const target = Math.max(current, c.items.length > 1 ? 12 : 14);
|
||
const size = map.getSize?.();
|
||
const mapH: number = size?.y ?? 0;
|
||
// The sheet is about to occupy the bottom `sheetH` of the map, so shift the
|
||
// centre up by half of that — the pin then sits in the visible upper band.
|
||
const sheetPx = mapH * 0.5; // the sheet always (re)opens at half height
|
||
map.flyTo([c.lat, c.lng], target, { animate: true, duration: 0.6 });
|
||
if (sheetPx > 0) {
|
||
// panBy after the fly settles, else Leaflet cancels the in-flight animation.
|
||
// Leaflet's panBy moves the map pane BY the offset, so content shifts the
|
||
// opposite way: a POSITIVE y lifts the pin toward the top of the map,
|
||
// which is the direction that gets it clear of the sheet.
|
||
map.once('moveend', () => map.panBy([0, sheetPx / 2], { animate: true }));
|
||
}
|
||
}
|
||
setCluster(c);
|
||
setSheetH(0.5); // reset to half height each time a pin is opened
|
||
resetFilters(); // a new pin starts with a clean filter
|
||
};
|
||
|
||
// Opening one photo from a pin's hover strip.
|
||
const openLightboxRef = useRef<(id: string) => void>(() => {});
|
||
openLightboxRef.current = (id) => api.getState().openLightbox(id);
|
||
|
||
// Create the map once when entering a map tile mode.
|
||
useEffect(() => {
|
||
if (mapMode === 'grid' || !containerRef.current || mapRef.current) return;
|
||
let cancelled = false;
|
||
|
||
void import('leaflet').then((mod) => {
|
||
const L = (mod as any).default ?? mod;
|
||
if (cancelled || !containerRef.current) return;
|
||
leafletRef.current = L;
|
||
const map = L.map(containerRef.current, {
|
||
zoomControl: false,
|
||
attributionControl: true,
|
||
worldCopyJump: true,
|
||
}).setView([20, 0], 2);
|
||
mapRef.current = map;
|
||
addTiles();
|
||
addMarkers();
|
||
// Re-cluster pins each time the zoom changes (precision is zoom-dependent).
|
||
map.on('zoomend', addMarkers);
|
||
// Center on a focused photo (from the Info mini-map), else fit to all markers.
|
||
if (mapFocus) {
|
||
map.setView([mapFocus.lat, mapFocus.lng], 12, { animate: false });
|
||
} else if (located.length) {
|
||
const bounds = L.latLngBounds(located.map((m) => [m.location!.lat, m.location!.lng]));
|
||
map.fitBounds(bounds.pad(0.3), { animate: false });
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [mapMode]);
|
||
|
||
// Tear the map down when leaving map tile modes.
|
||
useEffect(() => {
|
||
if (mapMode === 'grid' && mapRef.current) {
|
||
clearTipTimers();
|
||
mapRef.current.remove();
|
||
mapRef.current = null;
|
||
tileRef.current = null;
|
||
markersRef.current = null;
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [mapMode]);
|
||
|
||
// Destroy on unmount.
|
||
useEffect(() => {
|
||
return () => {
|
||
clearTipTimers();
|
||
if (mapRef.current) {
|
||
mapRef.current.remove();
|
||
mapRef.current = null;
|
||
tileRef.current = null;
|
||
markersRef.current = null;
|
||
}
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const addTiles = () => {
|
||
const L = leafletRef.current;
|
||
const map = mapRef.current;
|
||
if (!L || !map) return;
|
||
if (tileRef.current) {
|
||
map.removeLayer(tileRef.current);
|
||
tileRef.current = null;
|
||
}
|
||
const cfg = mapMode === 'satellite' ? MAP_TILES.satellite : MAP_TILES.map;
|
||
tileRef.current = L.tileLayer(cfg.url, {
|
||
attribution: cfg.attribution,
|
||
maxZoom: cfg.maxZoom,
|
||
}).addTo(map);
|
||
};
|
||
|
||
const addMarkers = () => {
|
||
const L = leafletRef.current;
|
||
const map = mapRef.current;
|
||
if (!L || !map) return;
|
||
// Keep markers in a dedicated layer group so they can be re-synced wholesale.
|
||
if (!markersRef.current) markersRef.current = L.layerGroup().addTo(map);
|
||
// Every marker is about to be destroyed — kill their hover timers first.
|
||
clearTipTimers();
|
||
markersRef.current.clearLayers();
|
||
|
||
// Tighter clustering as you zoom in, so pins split apart (macOS behaviour).
|
||
const zoom = map.getZoom?.() ?? 2;
|
||
const precision = zoom < 4 ? 0 : zoom < 7 ? 1 : zoom < 11 ? 2 : 3;
|
||
const clusters = clusterByLocation(locatedRef.current, precision);
|
||
|
||
for (const c of clusters) {
|
||
const cover = c.items[0]!;
|
||
const multi = c.items.length > 1;
|
||
// Static markup only (no interpolated data) so Leaflet's innerHTML can't be
|
||
// injected; the thumbnail src + count are set via DOM after the marker mounts.
|
||
const icon = L.divIcon({
|
||
className: 'apg-pin-wrap',
|
||
// Static markup only (data set via DOM after mount → XSS-safe). Includes a
|
||
// hover tooltip: one preview for a single photo, an auto-advancing strip
|
||
// of thumbnails for a cluster.
|
||
html:
|
||
'<span class="apg-pin"><img class="apg-pin__img" alt=""/>' +
|
||
'<span class="apg-pin__count"></span>' +
|
||
'<span class="apg-pin__tip"><img class="apg-pin__tip-img" alt=""/>' +
|
||
'<span class="apg-pin__strip"></span>' +
|
||
'<span class="apg-pin__tip-cap"></span></span></span>',
|
||
iconSize: [56, 64],
|
||
iconAnchor: [28, 64],
|
||
});
|
||
const marker = L.marker([c.lat, c.lng], { icon, keyboard: false });
|
||
marker.on('add', () => {
|
||
const el: HTMLElement | null = marker.getElement();
|
||
if (!el) return;
|
||
const thumb = cover.thumbnail ?? cover.src;
|
||
const img = el.querySelector('.apg-pin__img') as HTMLImageElement | null;
|
||
if (img) img.src = thumb;
|
||
const tipImg = el.querySelector('.apg-pin__tip-img') as HTMLImageElement | null;
|
||
const strip = el.querySelector('.apg-pin__strip') as HTMLElement | null;
|
||
const cap = el.querySelector('.apg-pin__tip-cap') as HTMLElement | null;
|
||
|
||
// Keep the hover tooltip inside the map: on enter, measure it against the map
|
||
// container and slide it horizontally (--tip-dx) / flip it below (--below) so it
|
||
// never overflows and gets clipped by the gallery's rounded, overflow-hidden shell.
|
||
const pinEl = el.querySelector('.apg-pin') as HTMLElement | null;
|
||
const tipEl = el.querySelector('.apg-pin__tip') as HTMLElement | null;
|
||
const clampTip = () => {
|
||
if (!tipEl) return;
|
||
const mapEl = el.closest('.leaflet-container') as HTMLElement | null;
|
||
if (!mapEl) return;
|
||
tipEl.style.setProperty('--tip-dx', '0px');
|
||
tipEl.classList.remove('apg-pin__tip--below');
|
||
const m = mapEl.getBoundingClientRect();
|
||
const t = tipEl.getBoundingClientRect();
|
||
const pad = 8;
|
||
let dx = 0;
|
||
if (t.left < m.left + pad) dx = m.left + pad - t.left;
|
||
else if (t.right > m.right - pad) dx = m.right - pad - t.right;
|
||
if (dx) tipEl.style.setProperty('--tip-dx', `${Math.round(dx)}px`);
|
||
if (t.top < m.top + pad) tipEl.classList.add('apg-pin__tip--below');
|
||
};
|
||
pinEl?.addEventListener('mouseenter', clampTip);
|
||
if (cap) {
|
||
// textContent (never innerHTML) — place names are attacker-influencable.
|
||
const place = cover.location?.place ?? '';
|
||
const n = c.items.length;
|
||
cap.textContent = place ? `${place}${n > 1 ? ` · ${n} photos` : ''}` : `${n} photo${n === 1 ? '' : 's'}`;
|
||
}
|
||
const badge = el.querySelector('.apg-pin__count') as HTMLElement | null;
|
||
if (badge) {
|
||
// The badge always carries the cluster's TOTAL, and only appears when
|
||
// there is genuinely more than one photo here.
|
||
badge.textContent = String(c.items.length);
|
||
badge.style.display = multi ? '' : 'none';
|
||
}
|
||
|
||
if (!multi || !strip) {
|
||
if (tipImg) tipImg.src = thumb;
|
||
return;
|
||
}
|
||
|
||
// ---- multi-photo pin: horizontal strip + auto-advancing preview ----
|
||
if (tipImg) tipImg.src = thumb;
|
||
const shown = c.items.slice(0, TIP_STRIP_MAX);
|
||
const cells: HTMLElement[] = [];
|
||
for (const item of shown) {
|
||
const cell = document.createElement('button');
|
||
cell.type = 'button';
|
||
cell.className = 'apg-pin__strip-cell';
|
||
cell.title = item.name;
|
||
const thumbEl = document.createElement('img');
|
||
thumbEl.alt = '';
|
||
thumbEl.src = item.thumbnail ?? item.src;
|
||
cell.appendChild(thumbEl);
|
||
// Opening a specific photo must not also open the cluster sheet.
|
||
cell.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
ev.preventDefault();
|
||
openLightboxRef.current(item.id);
|
||
});
|
||
strip.appendChild(cell);
|
||
cells.push(cell);
|
||
}
|
||
if (c.items.length > TIP_STRIP_MAX) {
|
||
const more = document.createElement('span');
|
||
more.className = 'apg-pin__strip-more';
|
||
more.textContent = `+${c.items.length - TIP_STRIP_MAX}`;
|
||
strip.appendChild(more);
|
||
}
|
||
|
||
// Auto-advance the big preview (and the highlighted cell) while hovered.
|
||
const pin = el.querySelector('.apg-pin') as HTMLElement | null;
|
||
if (!pin) return;
|
||
let timer: ReturnType<typeof setInterval> | null = null;
|
||
let frame = 0;
|
||
const paint = () => {
|
||
const item = c.items[frame % c.items.length]!;
|
||
if (tipImg) tipImg.src = item.thumbnail ?? item.src;
|
||
cells.forEach((cell, i) =>
|
||
cell.classList.toggle('apg-pin__strip-cell--on', i === frame % c.items.length),
|
||
);
|
||
};
|
||
const stop = () => {
|
||
if (timer === null) return;
|
||
clearInterval(timer);
|
||
tipTimersRef.current.delete(timer);
|
||
timer = null;
|
||
frame = 0;
|
||
if (tipImg) tipImg.src = thumb;
|
||
cells.forEach((cell) => cell.classList.remove('apg-pin__strip-cell--on'));
|
||
};
|
||
const start = () => {
|
||
if (timer !== null) return;
|
||
frame = 0;
|
||
paint();
|
||
timer = setInterval(() => {
|
||
frame += 1;
|
||
paint();
|
||
}, TIP_FRAME_MS);
|
||
tipTimersRef.current.add(timer);
|
||
};
|
||
pin.addEventListener('mouseenter', start);
|
||
pin.addEventListener('mouseleave', stop);
|
||
// Leaflet destroys the element on clearLayers(); `remove` fires first.
|
||
marker.on('remove', stop);
|
||
});
|
||
marker.on('click', () => openClusterRef.current(c));
|
||
markersRef.current.addLayer(marker);
|
||
}
|
||
};
|
||
|
||
// Swap tile layer when Map/Satellite toggles.
|
||
useEffect(() => {
|
||
if (mapRef.current) addTiles();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [mapMode]);
|
||
|
||
// Re-sync markers when the located set changes while the map stays mounted.
|
||
useEffect(() => {
|
||
if (mapRef.current) addMarkers();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [media]);
|
||
|
||
// Recenter when a photo's location is focused (from the Info mini-map).
|
||
useEffect(() => {
|
||
if (mapFocus && mapRef.current) {
|
||
mapRef.current.setView([mapFocus.lat, mapFocus.lng], 12, { animate: true });
|
||
}
|
||
}, [mapFocus]);
|
||
|
||
// Distinct object labels present in the open cluster, most common first.
|
||
const clusterObjects = useMemo(() => {
|
||
if (!cluster) return [] as Array<{ label: string; count: number }>;
|
||
const counts = new Map<string, number>();
|
||
for (const m of cluster.items) {
|
||
for (const label of new Set(m.objectLabels)) {
|
||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||
}
|
||
}
|
||
return [...counts.entries()]
|
||
.map(([label, count]) => ({ label, count }))
|
||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
||
}, [cluster]);
|
||
|
||
// Search + date range + object chip, combined with AND.
|
||
const filtered = useMemo<MediaItem[]>(() => {
|
||
if (!cluster) return [];
|
||
let items = cluster.items;
|
||
if (objectFilter) items = items.filter((m) => m.objectLabels.includes(objectFilter));
|
||
const from = parseDateInput(fromDate, false);
|
||
const to = parseDateInput(toDate, true);
|
||
if (from !== null) items = items.filter((m) => m.takenAt >= from);
|
||
if (to !== null) items = items.filter((m) => m.takenAt <= to);
|
||
// Same matcher as the global search bar, so "car" means the same thing here.
|
||
if (query.trim()) items = searchMedia(items, query);
|
||
return items;
|
||
}, [cluster, query, fromDate, toDate, objectFilter]);
|
||
|
||
const filterActive = Boolean(query.trim() || fromDate || toDate || objectFilter);
|
||
|
||
if (mapMode === 'grid') {
|
||
// The Grid tab shows ALL photos in the macOS Memories-style mosaic (not just
|
||
// located ones) so it's a rich date-grouped collage, per the design.
|
||
const all = liveMedia(media);
|
||
if (all.length === 0) {
|
||
return (
|
||
<div className="apg-empty">
|
||
<div className="apg-empty__card">
|
||
<div className="apg-empty__title" style={{ fontSize: 22 }}>
|
||
No Photos
|
||
</div>
|
||
<div className="apg-empty__subtitle">Imported photos appear here as a mosaic.</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return <MosaicGrid items={all} groupBy="month" />;
|
||
}
|
||
|
||
return (
|
||
<div className="apg-map">
|
||
<div ref={containerRef} className="apg-map__leaflet" />
|
||
<div className="apg-map__controls">
|
||
<button
|
||
type="button"
|
||
className="apg-iconbtn apg-iconbtn--circle"
|
||
aria-label="Zoom in"
|
||
onClick={() => mapRef.current?.zoomIn()}
|
||
>
|
||
<Icon name="plus" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="apg-iconbtn apg-iconbtn--circle"
|
||
aria-label="Zoom out"
|
||
onClick={() => mapRef.current?.zoomOut()}
|
||
>
|
||
<Icon name="minus" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="apg-iconbtn apg-iconbtn--circle"
|
||
aria-label="Reset north"
|
||
onClick={() => mapRef.current?.setView([20, 0], 2)}
|
||
>
|
||
<Icon name="compass" size={22} />
|
||
</button>
|
||
</div>
|
||
<a className="apg-map__legal" href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer noopener">
|
||
Legal
|
||
</a>
|
||
|
||
{cluster ? (
|
||
<div
|
||
className="apg-map__sheet"
|
||
role="dialog"
|
||
aria-label="Photos at this location"
|
||
style={{ height: `${Math.round(sheetH * 100)}%` }}
|
||
>
|
||
<div
|
||
className="apg-map__grip"
|
||
onPointerDown={onHandleDown}
|
||
onPointerMove={onHandleMove}
|
||
onPointerUp={onHandleUp}
|
||
onPointerCancel={onHandleUp}
|
||
title="Drag to resize"
|
||
aria-hidden
|
||
>
|
||
<span className="apg-map__grip-bar" />
|
||
</div>
|
||
<div className="apg-map__sheet-head">
|
||
<div>
|
||
<div style={{ fontWeight: 700, fontSize: 16 }}>
|
||
{cluster.items[0]?.location?.place ?? 'This location'}
|
||
</div>
|
||
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }} aria-live="polite">
|
||
{filterActive
|
||
? `${filtered.length} of ${cluster.items.length} photo${
|
||
cluster.items.length === 1 ? '' : 's'
|
||
}`
|
||
: `${cluster.items.length} photo${cluster.items.length === 1 ? '' : 's'}`}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="apg-iconbtn"
|
||
aria-label="Close"
|
||
onClick={() => setCluster(null)}
|
||
>
|
||
<Icon name="close" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Search + date range + object chips — all AND-combined. */}
|
||
<div className="apg-map__sheet-filters">
|
||
<div className="apg-mapfilter__row">
|
||
<div className="apg-mapfilter__search">
|
||
<Icon name="search" size={15} />
|
||
<input
|
||
type="search"
|
||
aria-label="Search photos at this location"
|
||
placeholder="Search photos, objects, text…"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
/>
|
||
{filterActive ? (
|
||
<button
|
||
type="button"
|
||
className="apg-mapfilter__clear"
|
||
aria-label="Clear filters"
|
||
title="Clear filters"
|
||
onClick={resetFilters}
|
||
>
|
||
<Icon name="close" size={13} />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="apg-mapfilter__row apg-mapfilter__dates">
|
||
<DateRangeControl
|
||
from={fromDate}
|
||
to={toDate}
|
||
onChange={(f, t) => {
|
||
setFromDate(f);
|
||
setToDate(t);
|
||
}}
|
||
/>
|
||
</div>
|
||
{clusterObjects.length ? (
|
||
<div className="apg-mapfilter__chips" role="group" aria-label="Filter by object">
|
||
{clusterObjects.map(({ label, count }) => {
|
||
const on = objectFilter === label;
|
||
return (
|
||
<button
|
||
key={label}
|
||
type="button"
|
||
className={[
|
||
'apg-mapfilter__chip',
|
||
on ? 'apg-mapfilter__chip--on' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
aria-pressed={on}
|
||
onClick={() => setObjectFilter(on ? null : label)}
|
||
>
|
||
{label}
|
||
<span className="apg-mapfilter__chip-n">{count}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="apg-map__sheet-body apg-scroll">
|
||
{filtered.length === 0 ? (
|
||
<div className="apg-mapfilter__empty">
|
||
<Icon name="search" size={26} />
|
||
<div className="apg-mapfilter__empty-title">No matching photos</div>
|
||
<div className="apg-mapfilter__empty-sub">
|
||
Try a different search, date range, or object.
|
||
</div>
|
||
<button type="button" className="apg-btn" onClick={resetFilters}>
|
||
Clear filters
|
||
</button>
|
||
</div>
|
||
) : (
|
||
groupByTime(filtered, 'day').map((s) => (
|
||
<MediaGrid key={s.key} items={s.items} title={s.title} />
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|