Files
advance-photo-gallery-web-sdk/packages/photo-sdk/src/components/views/MapView.tsx
T
KaushikRK99 7673fc63ea feat: enhance styling and layout for toolbar, map components, and editor
- 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.
2026-07-23 07:34:20 +05:30

734 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}