Files
photos/web/useSelection.ts
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

66 lines
2.1 KiB
TypeScript

import { useCallback, useMemo, useRef, useState } from 'react';
/**
* Multi-select over an ordered list of asset ids.
*
* Shift-click selects a range, which is why the hook needs the ORDER of everything currently on screen and not
* just the set of chosen ids. The order comes from the view, so a timeline that has loaded three months and an
* album that has loaded one page both get the same behaviour without either knowing how the other paginates.
*/
export function useSelection(orderedIds: string[]) {
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const anchor = useRef<string | null>(null);
const order = useMemo(() => new Map(orderedIds.map((id, i) => [id, i])), [orderedIds]);
const toggle = useCallback(
(id: string, ev?: { shiftKey?: boolean; preventDefault?: () => void; stopPropagation?: () => void }) => {
ev?.preventDefault?.();
ev?.stopPropagation?.();
setSelected((prev) => {
const next = new Set(prev);
const from = anchor.current;
if (ev?.shiftKey && from && order.has(from) && order.has(id)) {
const a = order.get(from) ?? 0;
const b = order.get(id) ?? 0;
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) {
const between = orderedIds[i];
if (between) next.add(between);
}
return next;
}
if (next.has(id)) next.delete(id);
else next.add(id);
anchor.current = id;
return next;
});
},
[order, orderedIds],
);
const clear = useCallback(() => {
anchor.current = null;
setSelected(new Set());
}, []);
const selectAll = useCallback(() => setSelected(new Set(orderedIds)), [orderedIds]);
const ids = useMemo(() => orderedIds.filter((id) => selected.has(id)), [orderedIds, selected]);
return {
selected,
/** In document order, so "add to album" preserves what the eye saw rather than insertion order. */
ids,
count: selected.size,
isSelecting: selected.size > 0,
toggle,
clear,
selectAll,
};
}
export type Selection = ReturnType<typeof useSelection>;