Files
immich-folio/app/admin/components/useVersionedSave.ts
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

98 lines
3.4 KiB
TypeScript

'use client';
/**
* Saving a content file without clobbering a change made elsewhere (#601).
*
* Each editor keeps the `version` its GET returned in a ref and saves through
* this hook, which sends it as `If-Match`. When the server answers 409 with
* `conflict: true` — the file was saved from another tab or device, edited by
* hand, or restored from a backup since it was loaded — the operator picks:
*
* - Reload: throw away this editor's edits and load the file as it is now.
* - Overwrite anyway: save again against the new version, replacing that change.
* - Keep editing (cancel): nothing happens; the edits and their draft stay.
*
* Other 409s (a rename onto a taken slug) carry no `conflict` flag and are
* returned like any other failure.
*/
import { useCallback, type MutableRefObject } from 'react';
import { useChoice } from './ConfirmDialog';
/** The If-Match header for a version, or none before the first load. */
export function ifMatch(version: string | null): Record<string, string> {
return version ? { 'If-Match': `"${version}"` } : {};
}
/** The body of any JSON answer; routes return objects. */
export type SaveData = Record<string, unknown> & { error?: string };
export type VersionedSaveResult =
| { kind: 'saved'; res: Response; data: SaveData }
| { kind: 'failed'; res: Response; data: SaveData | null }
| { kind: 'reload' }
| { kind: 'keep' };
export function isEditConflict(res: Response, data: unknown): data is { version: string } {
return (
res.status === 409 &&
!!data &&
typeof data === 'object' &&
(data as { conflict?: unknown }).conflict === true &&
typeof (data as { version?: unknown }).version === 'string'
);
}
type ConflictChoice = 'overwrite' | 'reload';
/**
* `what` names the file in the dialog ("The settings", "This entry").
* A network failure throws, as a plain fetch would.
*/
export function useVersionedSave() {
const choose = useChoice();
return useCallback(
async (
url: string,
body: unknown,
versionRef: MutableRefObject<string | null>,
what: string,
): Promise<VersionedSaveResult> => {
let version = versionRef.current;
for (;;) {
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...ifMatch(version) },
body: JSON.stringify(body),
});
const data = (await res.json().catch(() => null)) as SaveData | null;
if (isEditConflict(res, data)) {
const choice = await choose<ConflictChoice>({
title: 'Changed elsewhere',
message: `${what} was changed since you opened it — in another tab, on another device, by hand or by a backup restore. Saving now would replace that change.`,
actions: [
{ id: 'overwrite', label: 'Overwrite anyway' },
{ id: 'reload', label: 'Reload (discard mine)' },
],
cancelLabel: 'Keep editing',
});
if (choice === 'overwrite') {
version = data.version;
continue;
}
return { kind: choice === 'reload' ? 'reload' : 'keep' };
}
if (res.ok && data) {
if (typeof data.version === 'string') versionRef.current = data.version;
return { kind: 'saved', res, data };
}
return { kind: 'failed', res, data };
}
},
[choose],
);
}