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>
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { encodeProofBitmask, decodeProofBitmask } from '@/lib/proofing';
|
|
import { useDictionary } from './I18nProvider';
|
|
import { ProofingContext } from './useProofing';
|
|
|
|
export { useProofing } from './useProofing';
|
|
|
|
/** What the server tells a client proofing page about its session. */
|
|
export interface ProofSessionInit {
|
|
/** The link token — the capability for this session's API routes. */
|
|
token: string;
|
|
/** Asset tokens already selected, from the server. */
|
|
selected: string[];
|
|
submitted: boolean;
|
|
download: 'none' | 'selection' | 'album';
|
|
/** ZIP downloads left, or null for unlimited. */
|
|
downloadsRemaining: number | null;
|
|
}
|
|
|
|
export type ProofSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|
|
|
/**
|
|
* Session mode: a client proofing link (/proof/<token>). The selection lives on
|
|
* the server instead of localStorage and the URL, and can be submitted once.
|
|
*/
|
|
export interface ProofSessionState {
|
|
saveState: ProofSaveState;
|
|
/**
|
|
* Hearts the server has not confirmed yet — including those still waiting
|
|
* out the autosave debounce, while `saveState` reads 'idle' or 'saved'.
|
|
*/
|
|
unsaved: boolean;
|
|
submitted: boolean;
|
|
/** Save anything pending, then lock the selection. Resolves to success. */
|
|
submit: () => Promise<boolean>;
|
|
download: ProofSessionInit['download'];
|
|
downloadsRemaining: number | null;
|
|
/** Count a download that was just started against `downloadsRemaining`. */
|
|
countDownload: () => void;
|
|
/** The ZIP route for this session. */
|
|
archiveUrl: string;
|
|
}
|
|
|
|
/** How long the selection may sit unsaved after the last heart. */
|
|
const SAVE_DEBOUNCE_MS = 600;
|
|
|
|
export interface ProofingContextType {
|
|
favorites: Set<string>;
|
|
toggleFavorite: (token: string) => void;
|
|
isFavorite: (token: string) => boolean;
|
|
clearFavorites: () => void;
|
|
isFilterActive: boolean;
|
|
setIsFilterActive: React.Dispatch<React.SetStateAction<boolean>>;
|
|
isModalOpen: boolean;
|
|
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
getProofingUrl: () => string;
|
|
getFormattedList: () => string;
|
|
/** Selected asset tokens in album order — the payload for a ZIP download. */
|
|
getSelectedTokens: () => string[];
|
|
allowMailto: boolean;
|
|
/**
|
|
* `encodeEmail()` of the "email to photographer" recipient, when one is
|
|
* configured (#736). The modal decodes it in the browser; without it the
|
|
* button is hidden.
|
|
*/
|
|
encodedMailto?: string;
|
|
/** The ZIP endpoint when the album offers downloads; undefined otherwise. */
|
|
downloadArchiveUrl?: string;
|
|
/** Present on a client proofing link only. */
|
|
session?: ProofSessionState;
|
|
}
|
|
|
|
export function ProofingProvider({
|
|
children,
|
|
albumTokens = [],
|
|
albumName = 'Gallery',
|
|
allowMailto = true,
|
|
encodedMailto,
|
|
downloadArchiveUrl,
|
|
session: sessionInit,
|
|
}: {
|
|
children: React.ReactNode;
|
|
albumTokens?: string[];
|
|
albumName?: string;
|
|
allowMailto?: boolean;
|
|
encodedMailto?: string;
|
|
downloadArchiveUrl?: string;
|
|
session?: ProofSessionInit;
|
|
}) {
|
|
const t = useDictionary();
|
|
// A session's selection comes from the server, so it can be rendered on the
|
|
// first pass; the anonymous mode hydrates from the browser in an effect below.
|
|
const [favorites, setFavorites] = useState<Set<string>>(
|
|
() => new Set(sessionInit?.selected ?? []),
|
|
);
|
|
const [saveState, setSaveState] = useState<ProofSaveState>('idle');
|
|
const [submitted, setSubmitted] = useState(Boolean(sessionInit?.submitted));
|
|
/**
|
|
* Edits made vs. edits the server has confirmed. A counter rather than a
|
|
* dirty flag, so a heart clicked while a save is in flight is not marked
|
|
* saved by that earlier request.
|
|
*/
|
|
const [edits, setEdits] = useState(0);
|
|
const editsRef = useRef(0);
|
|
const savedEdits = useRef(0);
|
|
/** `savedEdits` for rendering: what makes `unsaved` clear once a save lands. */
|
|
const [savedEditsShown, setSavedEditsShown] = useState(0);
|
|
/**
|
|
* Starts from the page load and counts down with each download started here,
|
|
* so the dialog does not keep offering a download the limit has used up.
|
|
*/
|
|
const [downloadsRemaining, setDownloadsRemaining] = useState(
|
|
sessionInit?.downloadsRemaining ?? null,
|
|
);
|
|
useEffect(() => {
|
|
editsRef.current = edits;
|
|
}, [edits]);
|
|
/** The selection as the save callbacks see it; kept current by the effect below. */
|
|
const favoritesRef = useRef(favorites);
|
|
useEffect(() => {
|
|
favoritesRef.current = favorites;
|
|
}, [favorites]);
|
|
const [isFilterActive, setIsFilterActive] = useState(false);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
const storageKey = `folio_fav_${albumName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
|
|
|
// Hydrate from localStorage and URL query params on mount. This has to stay in
|
|
// an effect: both sources are browser-only, so reading them during render would
|
|
// break SSR and produce a hydration mismatch.
|
|
useEffect(() => {
|
|
if (sessionInit) return;
|
|
try {
|
|
const saved = localStorage.getItem(storageKey);
|
|
const initialSet = new Set<string>();
|
|
if (saved) {
|
|
const parsed = JSON.parse(saved);
|
|
if (Array.isArray(parsed)) {
|
|
parsed.forEach((t) => initialSet.add(t));
|
|
}
|
|
}
|
|
|
|
// Check URL query param ?proof=...
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const proofCode = urlParams.get('proof');
|
|
if (proofCode && albumTokens.length > 0) {
|
|
const decodedIndices = decodeProofBitmask(proofCode, albumTokens.length);
|
|
decodedIndices.forEach((idx) => {
|
|
if (albumTokens[idx]) {
|
|
initialSet.add(albumTokens[idx]);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Mount-time hydration from an external store, not a render cascade:
|
|
// the state lands in the same commit and never re-triggers this effect.
|
|
setFavorites(initialSet);
|
|
} catch (e) {
|
|
console.error('Failed to load favorites', e);
|
|
}
|
|
}, [storageKey, albumTokens, sessionInit]);
|
|
|
|
const sessionToken = sessionInit?.token;
|
|
|
|
/** Send the current selection, in album order. Resolves to success. */
|
|
const saveNow = useCallback(async (): Promise<boolean> => {
|
|
if (!sessionToken) return true;
|
|
const version = editsRef.current;
|
|
setSaveState('saving');
|
|
const selected = albumTokens.filter((token) => favoritesRef.current.has(token));
|
|
try {
|
|
const res = await fetch(`/api/proof/${encodeURIComponent(sessionToken)}/selection`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ assets: selected }),
|
|
});
|
|
if (res.status === 409) {
|
|
// Submitted from another tab or device: show the locked state here too.
|
|
setSubmitted(true);
|
|
setSaveState('saved');
|
|
return false;
|
|
}
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
savedEdits.current = Math.max(savedEdits.current, version);
|
|
setSavedEditsShown(savedEdits.current);
|
|
// A heart clicked while this request was in flight has its own save queued.
|
|
if (editsRef.current <= savedEdits.current) setSaveState('saved');
|
|
return true;
|
|
} catch {
|
|
setSaveState('error');
|
|
return false;
|
|
}
|
|
}, [sessionToken, albumTokens]);
|
|
|
|
// Debounced autosave: a burst of hearts becomes one request.
|
|
useEffect(() => {
|
|
if (!sessionToken || edits <= savedEdits.current) return;
|
|
const timer = setTimeout(() => void saveNow(), SAVE_DEBOUNCE_MS);
|
|
return () => clearTimeout(timer);
|
|
}, [edits, sessionToken, saveNow]);
|
|
|
|
// Leaving with an unsaved heart: send it anyway. keepalive lets the request
|
|
// outlive the page, which a normal fetch in `pagehide` would not.
|
|
useEffect(() => {
|
|
if (!sessionToken) return;
|
|
const flush = () => {
|
|
if (editsRef.current <= savedEdits.current) return;
|
|
const selected = albumTokens.filter((token) => favoritesRef.current.has(token));
|
|
void fetch(`/api/proof/${encodeURIComponent(sessionToken)}/selection`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ assets: selected }),
|
|
keepalive: true,
|
|
}).catch(() => {});
|
|
};
|
|
window.addEventListener('pagehide', flush);
|
|
return () => window.removeEventListener('pagehide', flush);
|
|
}, [sessionToken, albumTokens]);
|
|
|
|
const submit = useCallback(async (): Promise<boolean> => {
|
|
if (!sessionToken) return false;
|
|
if (editsRef.current > savedEdits.current && !(await saveNow())) return false;
|
|
try {
|
|
const res = await fetch(`/api/proof/${encodeURIComponent(sessionToken)}/submit`, {
|
|
method: 'POST',
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
setSubmitted(true);
|
|
return true;
|
|
} catch {
|
|
setSaveState('error');
|
|
return false;
|
|
}
|
|
}, [sessionToken, saveNow]);
|
|
|
|
/** A session edit: counted for autosave, refused once submitted. */
|
|
const editSelection = (update: (prev: Set<string>) => Set<string>) => {
|
|
// A submitted selection is the photographer's to reopen, not the client's.
|
|
if (submitted) return;
|
|
setEdits((n) => n + 1);
|
|
setFavorites(update);
|
|
};
|
|
|
|
const toggleFavorite = (token: string) => {
|
|
if (sessionInit) {
|
|
editSelection((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(token)) next.delete(token);
|
|
else next.add(token);
|
|
return next;
|
|
});
|
|
return;
|
|
}
|
|
setFavorites((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(token)) {
|
|
next.delete(token);
|
|
} else {
|
|
next.add(token);
|
|
}
|
|
try {
|
|
localStorage.setItem(storageKey, JSON.stringify(Array.from(next)));
|
|
} catch (e) {
|
|
console.error('Failed to save favorites', e);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const isFavorite = (token: string) => favorites.has(token);
|
|
|
|
const clearFavorites = () => {
|
|
if (sessionInit) {
|
|
editSelection(() => new Set());
|
|
return;
|
|
}
|
|
setFavorites(new Set());
|
|
try {
|
|
localStorage.removeItem(storageKey);
|
|
} catch (e) {
|
|
console.error('Failed to clear favorites', e);
|
|
}
|
|
};
|
|
|
|
const getProofingUrl = () => {
|
|
if (albumTokens.length === 0 || favorites.size === 0) {
|
|
return typeof window !== 'undefined' ? window.location.href : '';
|
|
}
|
|
const indices: number[] = [];
|
|
albumTokens.forEach((token, index) => {
|
|
if (favorites.has(token)) {
|
|
indices.push(index);
|
|
}
|
|
});
|
|
const code = encodeProofBitmask(indices);
|
|
const url = new URL(typeof window !== 'undefined' ? window.location.href : 'http://localhost');
|
|
if (code) {
|
|
url.searchParams.set('proof', code);
|
|
} else {
|
|
url.searchParams.delete('proof');
|
|
}
|
|
return url.toString();
|
|
};
|
|
|
|
const getSelectedTokens = () => albumTokens.filter((token) => favorites.has(token));
|
|
|
|
const getFormattedList = () => {
|
|
const selectedIndices: number[] = [];
|
|
albumTokens.forEach((token, index) => {
|
|
if (favorites.has(token)) {
|
|
selectedIndices.push(index + 1);
|
|
}
|
|
});
|
|
if (selectedIndices.length === 0) return t.proofing.listEmpty(albumName);
|
|
return t.proofing.listSummary(
|
|
albumName,
|
|
selectedIndices.length,
|
|
`#${selectedIndices.join(', #')}`,
|
|
);
|
|
};
|
|
|
|
return (
|
|
<ProofingContext.Provider
|
|
value={{
|
|
favorites,
|
|
toggleFavorite,
|
|
isFavorite,
|
|
clearFavorites,
|
|
isFilterActive,
|
|
setIsFilterActive,
|
|
isModalOpen,
|
|
setIsModalOpen,
|
|
getProofingUrl,
|
|
getFormattedList,
|
|
getSelectedTokens,
|
|
allowMailto,
|
|
encodedMailto,
|
|
downloadArchiveUrl,
|
|
session: sessionInit
|
|
? {
|
|
saveState,
|
|
unsaved: edits > savedEditsShown,
|
|
submitted,
|
|
submit,
|
|
download: sessionInit.download,
|
|
downloadsRemaining,
|
|
countDownload: () =>
|
|
setDownloadsRemaining((n) => (n === null ? null : Math.max(0, n - 1))),
|
|
archiveUrl: `/api/proof/${encodeURIComponent(sessionInit.token)}/archive`,
|
|
}
|
|
: undefined,
|
|
}}
|
|
>
|
|
{children}
|
|
</ProofingContext.Provider>
|
|
);
|
|
}
|