Files
photos/web/useAssetActions.ts
pastilhasandClaude Opus 5 70fc2b08c6 a shared link is a URL, not a key
Create, edit and revoke shared links from the album header, the selection
bar and the Sharing panel. Password, expiry, and the three flags that
decide what the person opening it can do.

The panel showed a bare key because this plugin never learned the
instance's public address. It does not need to store one: Immich has
`externalDomain` in its own admin settings, `/server/config` already
returns it, and it is the same field Immich's own copy-link button reads,
so the two cannot disagree. Empty is a real state and the UI says so
rather than inventing a host.

Payload shapes came from the live instance's spec.json, not from memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:16:02 +01:00

173 lines
6.0 KiB
TypeScript

import type { Album, SharedLink, SharedLinkCreate, SharedLinkEdit } 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'),
});
// ── Shared links ──
//
// The only thing in this file that produces a PUBLIC address, which is why the create dialog sends every
// flag explicitly instead of letting Immich's defaults apply. Immich defaults `allowDownload` and
// `showMetadata` to true and the expiry to never; a form that silently inherits those is a form that
// shares more than it appears to.
const createSharedLink = useMutation({
mutationFn: (body: SharedLinkCreate) => post<SharedLink>('/photos/_officer/shared-links', body),
onSuccess: invalidate,
onError: fail('create the link'),
});
const updateSharedLink = useMutation({
mutationFn: ({ id, ...body }: SharedLinkEdit & { id: string }) =>
patch<SharedLink>(`/photos/_officer/shared-links/${id}`, body),
onSuccess: () => {
invalidate();
toast.success('Link updated');
},
onError: fail('update the link'),
});
// Revoke, not delete: the link stops working for everybody holding it, and nothing about the photos changes.
const deleteSharedLink = useMutation({
mutationFn: (id: string) => del(`/photos/_officer/shared-links/${id}`),
onSuccess: () => {
invalidate();
toast.success('Link revoked');
},
onError: fail('revoke the link'),
});
return {
setFavorite,
setArchived,
trash,
deleteForever,
restore,
emptyTrash,
addToAlbum,
removeFromAlbum,
createAlbum,
deleteAlbum,
renameAlbum,
createSharedLink,
updateSharedLink,
deleteSharedLink,
};
}