Files
photos/web/MapClusterPanel.tsx
T
pastilhasandClaude Opus 5 2dad1907f8 photos, extracted from the platform into its own repository
The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:

  • No reverse dependency. Nothing in src/ imported anything of photos'.
  • Configuration was already right. The URL and API key live in photos_config,
    a per-user registry encrypted at rest and driven from the app. Vault had to
    move VAULTWARDEN_URL out of .env and invent a settings panel; here the
    sidecar already read the database, and its own comment already said why
    nothing reads process.env.
  • The frontend was already panels. The screen was a WorkspaceView around
    exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
    the screen's contents with the wrapper removed.

The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.

Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.

Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:39 +00:00

124 lines
4.8 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { X, ZoomIn } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { thumbUrl } from './shared';
// The contents of one map cluster, listed. Clicking a circle marked "712" used to do nothing but zoom, which
// answers "where are they" and never "what are they" — and for a tight cluster of photos taken in one place,
// zooming never separates them at all.
//
// The id list is SNAPSHOT at click time by the caller, not paged out of the cluster on demand. maplibre's
// cluster ids are assigned per zoom level and stop existing the moment the camera moves, so a panel that
// fetched page 2 lazily would fail exactly when someone scrolled it and then nudged the map. A cluster of
// 12,000 assets is 12,000 short strings — cheap to hold, and it makes the panel independent of the map.
//
// Lazy is therefore about DOM and images, not data: `PAGE` tiles at a time, grown by an IntersectionObserver.
/** What the caller snapshots when a cluster is clicked. */
export type ClusterSelection = {
/** maplibre's cluster id — only valid at the zoom it was read at, so it is used for highlighting and nothing else. */
clusterId: number;
/** Every asset in the cluster, in the order the source returned them. */
ids: string[];
center: [number, number];
/** The zoom that would break this cluster apart, resolved at click time while the id was still live. */
expansionZoom: number;
};
/** How many tiles to add each time the end of the list comes into view. */
const PAGE = 90;
type MapClusterPanelProps = {
cluster: ClusterSelection;
/** Human name for the area, derived from the markers' own city/country. */
place: string | null;
token: string | null;
onOpen: (id: string) => void;
onZoom: () => void;
onClose: () => void;
};
export const MapClusterPanel = ({ cluster, place, token, onOpen, onZoom, onClose }: MapClusterPanelProps) => {
const [shown, setShown] = useState(PAGE);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const total = cluster.ids.length;
useEffect(() => setShown(PAGE), [cluster.ids]);
// The sentinel is always rendered, so the observer never has to be torn down and re-attached as the list
// grows; once everything is shown the callback just clamps to the same value and React bails out.
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) setShown((count) => Math.min(count + PAGE, total));
},
{ rootMargin: '400px' },
);
observer.observe(node);
return () => observer.disconnect();
}, [total]);
// Safe to own Escape: AssetViewer only registers its own handler while an asset is open, and when one is it
// sits above this panel, so the two never both fire.
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
if (ev.key !== 'Escape') return;
onClose();
ev.preventDefault();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<aside className="absolute inset-y-0 left-0 z-10 flex w-[min(21rem,72vw)] flex-col border-r bg-background/95 shadow-xl backdrop-blur">
<header className="flex items-center gap-1 border-b px-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{place ?? 'Selected area'}</p>
<p className="text-xs text-muted-foreground">
{total.toLocaleString()} photo{total === 1 ? '' : 's'}
</p>
</div>
<Button variant="ghost" size="icon" title="Zoom in on this cluster" onClick={onZoom}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" title="Close (Esc)" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
<div className="grid grid-cols-3 gap-1">
{cluster.ids.slice(0, shown).map((id) => (
<button
key={id}
type="button"
title="Open photo"
onClick={() => onOpen(id)}
className="relative aspect-square overflow-hidden rounded-sm bg-muted"
>
<img
src={thumbUrl(id, token)}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition hover:opacity-80"
/>
</button>
))}
</div>
<div ref={sentinelRef} className="h-1" />
{shown < total && (
<p className="py-2 text-center text-xs text-muted-foreground">{(total - shown).toLocaleString()} more…</p>
)}
</div>
</aside>
);
};