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>
137 lines
4.6 KiB
TypeScript
137 lines
4.6 KiB
TypeScript
import type { Album } from './shared';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { toast } from 'sonner';
|
|
|
|
// Every mutation the /photos panels can perform, in one place.
|
|
//
|
|
// All of them are bulk-shaped even when the UI is acting on a single tile: Immich's own API is
|
|
// `PUT /assets {ids, …}` / `DELETE /assets {ids}`, and having one code path means the selection bar and the
|
|
// lightbox's single-asset buttons cannot drift into behaving differently.
|
|
//
|
|
// Invalidation is broad — the whole ['photos'] key. Favoriting an asset changes the favorites bucket index,
|
|
// the timeline tile, the album cover and the asset detail; the queries are cheap and correctness is worth
|
|
// more than a surgical cache patch.
|
|
|
|
export function useAssetActions() {
|
|
const { put, post, patch, delete: del } = useClient();
|
|
const qc = useQueryClient();
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['photos'] });
|
|
|
|
const fail = (verb: string) => (err: unknown) =>
|
|
toast.error(`Could not ${verb}: ${err instanceof Error ? err.message : String(err)}`);
|
|
|
|
const setFavorite = useMutation({
|
|
mutationFn: ({ ids, isFavorite }: { ids: string[]; isFavorite: boolean }) =>
|
|
put('/photos/_officer/assets', { ids, isFavorite }),
|
|
onSuccess: invalidate,
|
|
onError: fail('update favorites'),
|
|
});
|
|
|
|
const setArchived = useMutation({
|
|
mutationFn: ({ ids, archived }: { ids: string[]; archived: boolean }) =>
|
|
put('/photos/_officer/assets', { ids, visibility: archived ? 'archive' : 'timeline' }),
|
|
onSuccess: () => {
|
|
invalidate();
|
|
toast.success('Moved');
|
|
},
|
|
onError: fail('archive'),
|
|
});
|
|
|
|
// Immich's delete is a move to trash unless `force`. Keep those as two named actions rather than one with a
|
|
// boolean at the call site — "delete" and "delete permanently" should not look alike in the code either.
|
|
const trash = useMutation({
|
|
mutationFn: (ids: string[]) => del('/photos/_officer/assets', { ids }),
|
|
onSuccess: (_data, ids) => {
|
|
invalidate();
|
|
toast.success(`${ids.length} moved to trash`);
|
|
},
|
|
onError: fail('delete'),
|
|
});
|
|
|
|
const deleteForever = useMutation({
|
|
mutationFn: (ids: string[]) => del('/photos/_officer/assets', { ids, force: true }),
|
|
onSuccess: (_data, ids) => {
|
|
invalidate();
|
|
toast.success(`${ids.length} permanently deleted`);
|
|
},
|
|
onError: fail('delete permanently'),
|
|
});
|
|
|
|
const restore = useMutation({
|
|
mutationFn: (ids: string[]) => post('/photos/_officer/trash/restore/assets', { ids }),
|
|
onSuccess: (_data, ids) => {
|
|
invalidate();
|
|
toast.success(`${ids.length} restored`);
|
|
},
|
|
onError: fail('restore'),
|
|
});
|
|
|
|
const emptyTrash = useMutation({
|
|
mutationFn: () => post('/photos/_officer/trash/empty'),
|
|
onSuccess: () => {
|
|
invalidate();
|
|
toast.success('Trash emptied');
|
|
},
|
|
onError: fail('empty the trash'),
|
|
});
|
|
|
|
const addToAlbum = useMutation({
|
|
mutationFn: ({ albumId, ids }: { albumId: string; ids: string[] }) =>
|
|
put(`/photos/_officer/albums/${albumId}/assets`, { ids }),
|
|
onSuccess: (_data, { ids }) => {
|
|
invalidate();
|
|
toast.success(`${ids.length} added to album`);
|
|
},
|
|
onError: fail('add to the album'),
|
|
});
|
|
|
|
const removeFromAlbum = useMutation({
|
|
mutationFn: ({ albumId, ids }: { albumId: string; ids: string[] }) =>
|
|
del(`/photos/_officer/albums/${albumId}/assets`, { ids }),
|
|
onSuccess: () => {
|
|
invalidate();
|
|
toast.success('Removed from album');
|
|
},
|
|
onError: fail('remove from the album'),
|
|
});
|
|
|
|
const createAlbum = useMutation({
|
|
mutationFn: ({ albumName, assetIds }: { albumName: string; assetIds?: string[] }) =>
|
|
post<Album>('/photos/_officer/albums', { albumName, assetIds: assetIds ?? [] }),
|
|
onSuccess: invalidate,
|
|
onError: fail('create the album'),
|
|
});
|
|
|
|
const deleteAlbum = useMutation({
|
|
mutationFn: (albumId: string) => del(`/photos/_officer/albums/${albumId}`),
|
|
onSuccess: () => {
|
|
invalidate();
|
|
toast.success('Album deleted');
|
|
},
|
|
onError: fail('delete the album'),
|
|
});
|
|
|
|
// PATCH, not PUT: on Immich, PUT /albums/{id}/assets means "add assets" — the album itself is patched.
|
|
const renameAlbum = useMutation({
|
|
mutationFn: ({ albumId, albumName }: { albumId: string; albumName: string }) =>
|
|
patch(`/photos/_officer/albums/${albumId}`, { albumName }),
|
|
onSuccess: invalidate,
|
|
onError: fail('rename the album'),
|
|
});
|
|
|
|
return {
|
|
setFavorite,
|
|
setArchived,
|
|
trash,
|
|
deleteForever,
|
|
restore,
|
|
emptyTrash,
|
|
addToAlbum,
|
|
removeFromAlbum,
|
|
createAlbum,
|
|
deleteAlbum,
|
|
renameAlbum,
|
|
};
|
|
}
|