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

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,
};
}