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>
This commit is contained in:
97
app/admin/components/useVersionedSave.ts
Normal file
97
app/admin/components/useVersionedSave.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
'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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user