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>
66 lines
2.1 KiB
TypeScript
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>;
|