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>
124 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
};
|