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:
@@ -1,10 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { encodeProofBitmask, decodeProofBitmask } from '@/lib/proofing';
|
||||
import { useDictionary } from './I18nProvider';
|
||||
import { ProofingContext } from './useProofing';
|
||||
|
||||
interface ProofingContextType {
|
||||
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;
|
||||
@@ -18,27 +60,68 @@ interface ProofingContextType {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
const ProofingContext = createContext<ProofingContextType | null>(null);
|
||||
|
||||
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();
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
// 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);
|
||||
|
||||
@@ -48,6 +131,7 @@ export function ProofingProvider({
|
||||
// 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>();
|
||||
@@ -72,14 +156,103 @@ export function ProofingProvider({
|
||||
|
||||
// Mount-time hydration from an external store, not a render cascade:
|
||||
// the state lands in the same commit and never re-triggers this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setFavorites(initialSet);
|
||||
} catch (e) {
|
||||
console.error('Failed to load favorites', e);
|
||||
}
|
||||
}, [storageKey, albumTokens]);
|
||||
}, [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)) {
|
||||
@@ -99,6 +272,10 @@ export function ProofingProvider({
|
||||
const isFavorite = (token: string) => favorites.has(token);
|
||||
|
||||
const clearFavorites = () => {
|
||||
if (sessionInit) {
|
||||
editSelection(() => new Set());
|
||||
return;
|
||||
}
|
||||
setFavorites(new Set());
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
@@ -159,15 +336,24 @@ export function ProofingProvider({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function useProofing() {
|
||||
const context = useContext(ProofingContext);
|
||||
return context;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user