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>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
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>;
|
||||
Reference in New Issue
Block a user