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.
This commit is contained in:
2026-07-23 07:34:20 +05:30
parent bc8bf2007d
commit 7673fc63ea
35 changed files with 3130 additions and 394 deletions
@@ -1,15 +1,185 @@
'use client';
import { useEffect, useRef, useState } from 'react';
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 } from '../../store/selectors';
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);
@@ -23,6 +193,19 @@ export function MapView() {
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);
@@ -44,16 +227,54 @@ export function MapView() {
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;
@@ -91,16 +312,19 @@ export function MapView() {
// 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;
@@ -108,6 +332,7 @@ export function MapView() {
markersRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const addTiles = () => {
@@ -131,6 +356,8 @@ export function MapView() {
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).
@@ -140,16 +367,19 @@ export function MapView() {
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 with an image preview + caption.
// 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],
@@ -162,8 +392,30 @@ export function MapView() {
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;
if (tipImg) tipImg.src = thumb;
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 ?? '';
@@ -172,9 +424,81 @@ export function MapView() {
}
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);
if (c.items.length < 2) badge.style.display = 'none';
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);
@@ -200,6 +524,36 @@ export function MapView() {
}
}, [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.
@@ -275,8 +629,12 @@ export function MapView() {
<div style={{ fontWeight: 700, fontSize: 16 }}>
{cluster.items[0]?.location?.place ?? 'This location'}
</div>
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }}>
{cluster.items.length} photo{cluster.items.length === 1 ? '' : 's'}
<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
@@ -288,10 +646,85 @@ export function MapView() {
<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">
{groupByTime(cluster.items, 'day').map((s) => (
<MediaGrid key={s.key} items={s.items} title={s.title} />
))}
{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}