-
Style Profiles
+
Style profiles
- Named styles shared across libraries. A library picks one in its Poster Style editor,
+ Named styles shared across libraries. A library picks one in its Poster style editor,
and every library using a profile follows it.
diff --git a/affiche-frontend/src/components/settings/StyleSettings.module.css b/affiche-frontend/src/components/settings/StyleSettings.module.css
index 983cfa9..99e92fb 100644
--- a/affiche-frontend/src/components/settings/StyleSettings.module.css
+++ b/affiche-frontend/src/components/settings/StyleSettings.module.css
@@ -1,21 +1,12 @@
-.spinning {
- animation: spin 1s linear infinite;
-}
-
-@keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-
.backLink {
display: inline-flex;
align-items: center;
gap: 6px;
- font-size: 0.85rem;
+ font-size: var(--text-md);
color: var(--text-secondary);
padding: 6px 10px;
margin: 0 0 12px -10px;
- border-radius: 6px;
+ border-radius: var(--radius-md);
transition: all 0.15s;
}
@@ -25,14 +16,14 @@
}
.subTitle {
- font-size: 1rem;
+ font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
margin: 32px 0 4px;
}
.subDescription {
- font-size: 0.85rem;
+ font-size: var(--text-md);
color: var(--text-muted);
margin: 0 0 16px;
}
@@ -55,7 +46,7 @@
padding: 14px;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
- border-radius: 8px;
+ border-radius: var(--radius-lg);
transition: border-color 0.15s;
}
@@ -64,7 +55,7 @@
}
.fontSample {
- font-size: 1.6rem;
+ font-size: var(--text-3xl);
line-height: 1.2;
color: var(--text-primary);
white-space: nowrap;
@@ -73,7 +64,7 @@
}
.fontName {
- font-size: 0.75rem;
+ font-size: var(--text-xs);
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
@@ -91,16 +82,16 @@
display: inline-flex;
align-items: center;
gap: 4px;
- font-size: 0.72rem;
+ font-size: var(--text-2xs);
font-weight: 500;
color: var(--accent);
}
.setDefaultButton {
- font-size: 0.78rem;
+ font-size: var(--text-xs);
color: var(--text-secondary);
padding: 4px 8px;
- border-radius: 4px;
+ border-radius: var(--radius-sm);
border: 1px solid var(--border);
transition: all 0.15s;
}
@@ -116,13 +107,13 @@
align-items: center;
color: var(--text-muted);
padding: 4px;
- border-radius: 4px;
+ border-radius: var(--radius-sm);
transition: all 0.15s;
}
.deleteButton:hover {
color: var(--error);
- background-color: var(--bg-hover);
+ background-color: var(--error-tint);
}
.editor {
@@ -140,7 +131,7 @@
}
.previewWrapper {
- border-radius: 8px;
+ border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-md);
}
@@ -164,10 +155,10 @@
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-md);
background-color: var(--bg-tertiary);
color: var(--text-primary);
- font-size: 0.85rem;
+ font-size: var(--text-md);
text-align: left;
transition: all 0.15s;
}
@@ -195,7 +186,7 @@
align-items: center;
padding: 8px;
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-md);
background-color: transparent;
color: var(--text-muted);
transition: all 0.15s;
@@ -208,7 +199,7 @@
.subjectNote {
color: var(--text-muted);
- font-size: 0.8rem;
+ font-size: var(--text-sm);
}
.controls {
@@ -227,12 +218,12 @@
}
.savedMsg {
- font-size: 0.85rem;
+ font-size: var(--text-md);
color: var(--success);
}
.errorMsg {
- font-size: 0.85rem;
+ font-size: var(--text-md);
color: var(--error);
}
diff --git a/affiche-frontend/src/components/settings/StyleSettings.tsx b/affiche-frontend/src/components/settings/StyleSettings.tsx
index 32c807a..4eaaec5 100644
--- a/affiche-frontend/src/components/settings/StyleSettings.tsx
+++ b/affiche-frontend/src/components/settings/StyleSettings.tsx
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import { Upload, Trash2, Check, Loader, Save, ArrowLeft, Clapperboard, X } from 'lucide-react';
import { errorMessage, settingsApi, fontsApi } from '../../api';
import { useToast } from '../../context/ToastContext';
-import { useFonts, invalidatePosterConfig, usePreviewSubject } from '../../hooks';
+import { useFonts, invalidatePosterConfig, usePreviewSubject, useUnsavedChangesGuard } from '../../hooks';
import { PosterPreview, PosterStyleControls, fontBaseName } from '../image';
import { ConfirmModal } from '../common';
import { PreviewSubjectModal } from './PreviewSubjectModal';
@@ -183,15 +183,37 @@ export function StyleSettings() {
}
};
+ const hasUnsavedEdits = !!saved && (
+ JSON.stringify(saved.overlay_options) !== JSON.stringify(overlay) ||
+ JSON.stringify(saved.text_options) !== JSON.stringify(text) ||
+ JSON.stringify(saved.generation_options) !== JSON.stringify(gen)
+ );
+
+ const unsavedGuard = useUnsavedChangesGuard(hasUnsavedEdits, (from, to) =>
+ from.pathname === to.pathname
+ && new URLSearchParams(from.search).get('tab') === new URLSearchParams(to.search).get('tab'));
+ const unsavedDialog = unsavedGuard.isAsking && (
+
+ );
+
if (section === 'profiles') {
return (
+ {unsavedDialog}
);
}
@@ -199,10 +221,11 @@ export function StyleSettings() {
if (!overlay || !text || !gen) {
return (
- Style Options
+ Style options
{loadFailed ? 'Could not load the style options.' : 'Loading style options…'}
+ {unsavedDialog}
);
}
@@ -212,7 +235,7 @@ export function StyleSettings() {
);
}
@@ -299,7 +323,7 @@ export function StyleSettings() {
return (
- Style Options
+ Style options
{
}
@@ -307,7 +331,7 @@ export function StyleSettings() {
a style of its own.
- Generation Defaults
+ Generation defaults
These defaults are applied to every poster during generation.
@@ -359,8 +383,8 @@ export function StyleSettings() {
diff --git a/affiche-frontend/src/components/settings/WebhookPanel.module.css b/affiche-frontend/src/components/settings/WebhookPanel.module.css
index 1b184a1..44b00b1 100644
--- a/affiche-frontend/src/components/settings/WebhookPanel.module.css
+++ b/affiche-frontend/src/components/settings/WebhookPanel.module.css
@@ -11,12 +11,12 @@
.title {
font-weight: 500;
- font-size: 0.9rem;
+ font-size: var(--text-base);
}
.description {
margin: 0 0 10px 0;
- font-size: 0.8rem;
+ font-size: var(--text-sm);
color: var(--text-muted);
line-height: 1.5;
}
@@ -44,8 +44,8 @@
padding: 8px 10px;
background: var(--bg-primary);
border: 1px solid var(--border);
- border-radius: 6px;
- font-size: 0.8rem;
+ border-radius: var(--radius-md);
+ font-size: var(--text-sm);
color: var(--text-secondary);
}
diff --git a/affiche-frontend/src/components/settings/mediaServerHelpers.ts b/affiche-frontend/src/components/settings/mediaServerHelpers.ts
index c023278..703417c 100644
--- a/affiche-frontend/src/components/settings/mediaServerHelpers.ts
+++ b/affiche-frontend/src/components/settings/mediaServerHelpers.ts
@@ -11,13 +11,13 @@ export const SERVER_CONFIG: Record void;
+}
+
+export function ConnectServerStep({ onCreated }: ConnectServerStepProps) {
+ const [serverType, setServerType] = useState('PLEX');
+ const [url, setUrl] = useState(SERVER_CONFIG.PLEX.url);
+ const [token, setToken] = useState('');
+ const [found, setFound] = useState(null);
+ const [selected, setSelected] = useState>(new Set());
+ const [status, setStatus] = useState<'idle' | 'connecting' | 'saving'>('idle');
+ const [error, setError] = useState(null);
+
+ const forget = () => {
+ setFound(null);
+ setError(null);
+ };
+
+ const chooseType = (type: MediaServerType) => {
+ if (type === serverType) return;
+ setServerType(type);
+ setUrl(SERVER_CONFIG[type].url);
+ setToken('');
+ forget();
+ };
+
+ const connect = async () => {
+ setStatus('connecting');
+ forget();
+ try {
+ const result = serverType === 'PLEX'
+ ? await mediaServerApi.testPlex(url, token)
+ : await mediaServerApi.testJellyfin(url, token);
+ setFound(result);
+ setSelected(new Set(result.libraries.map((library) => library.id)));
+ } catch (err) {
+ setError(errorMessage(err, `Could not reach ${SERVER_CONFIG[serverType].name} at that address.`));
+ } finally {
+ setStatus('idle');
+ }
+ };
+
+ const save = async () => {
+ if (!found) return;
+ setStatus('saving');
+ setError(null);
+ try {
+ await mediaServerApi.create({
+ name: found.name,
+ type: serverType,
+ url,
+ token,
+ enabled: true,
+ libraries: found.libraries.filter((library) => selected.has(library.id)),
+ });
+ onCreated();
+ } catch (err) {
+ setError(errorMessage(err, 'The server could not be saved.'));
+ setStatus('idle');
+ }
+ };
+
+ const toggle = (id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (!next.delete(id)) next.add(id);
+ return next;
+ });
+ };
+
+ const config = SERVER_CONFIG[serverType];
+ const count = selected.size;
+
+ return (
+ <>
+
+ {(Object.keys(SERVER_CONFIG) as MediaServerType[]).map((type) => (
+
+ ))}
+
+
+
+
+ {serverType === 'PLEX' ? (
+ <>Where to find it: Finding your Plex token.>
+ ) : (
+ 'Create one in Jellyfin under Dashboard → API Keys.'
+ )}
+
+
+ {error && {error}
}
+
+ {found ? (
+ <>
+
+ Connected to “{found.name}” — {found.libraries.length}{' '}
+ {found.libraries.length === 1 ? 'library' : 'libraries'} found
+
+
+ {found.libraries.map((library) => (
+
+ ))}
+
+
+
+
+ >
+ ) : (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/affiche-frontend/src/components/welcome/FirstPostersStep.tsx b/affiche-frontend/src/components/welcome/FirstPostersStep.tsx
new file mode 100644
index 0000000..bee762f
--- /dev/null
+++ b/affiche-frontend/src/components/welcome/FirstPostersStep.tsx
@@ -0,0 +1,79 @@
+import { useState } from 'react';
+import { Loader2 } from 'lucide-react';
+
+import { errorMessage, libraryApi } from '../../api';
+import { useLibraryUploadSetting } from '../../hooks';
+import type { Library } from '../../types';
+import styles from './Welcome.module.css';
+
+interface FirstPostersStepProps {
+ libraries: Library[];
+
+ onStarted: (library: Library) => void;
+}
+
+export function FirstPostersStep({ libraries, onStarted }: FirstPostersStepProps) {
+ const [libraryId, setLibraryId] = useState(libraries[0]?.id);
+ const [status, setStatus] = useState<'idle' | 'generating' | 'syncing'>('idle');
+ const [error, setError] = useState(null);
+
+ const library = libraries.find((l) => l.id === libraryId) ?? libraries[0];
+ const uploads = useLibraryUploadSetting({ library, enabled: !!library });
+
+ const start = async (generate: boolean) => {
+ if (!library) return;
+ setStatus(generate ? 'generating' : 'syncing');
+ setError(null);
+ try {
+ await (generate
+ ? libraryApi.syncLibraryPosters(library.media_server_id, library.id)
+ : libraryApi.syncLibrary(library.media_server_id, library.id));
+ onStarted(library);
+ } catch (err) {
+ setError(errorMessage(err, 'The run could not be started.'));
+ setStatus('idle');
+ }
+ };
+
+ if (!library) {
+ return No library was added. Add one in Settings → Media servers.
;
+ }
+
+ return (
+ <>
+
+
+
+
+ {}
+ {uploads !== undefined && (
+
+ {uploads
+ ? `${library.name} uploads new posters to your media server as they are made, keeping its current artwork for Reset.`
+ : `${library.name} keeps new posters in Affiche until you upload them.`}
+
+ )}
+
+ {error && {error}
}
+
+
+
+
+
+ >
+ );
+}
diff --git a/affiche-frontend/src/components/welcome/PosterSourcesStep.tsx b/affiche-frontend/src/components/welcome/PosterSourcesStep.tsx
new file mode 100644
index 0000000..8f80a72
--- /dev/null
+++ b/affiche-frontend/src/components/welcome/PosterSourcesStep.tsx
@@ -0,0 +1,135 @@
+import { useEffect, useState } from 'react';
+import { Loader2 } from 'lucide-react';
+
+import { configApi, errorMessage, serviceApi } from '../../api';
+import { POSTER_PROVIDER_CARDS, type PosterProvider } from '../../constants/providers';
+import type { Library, ServiceConfiguration } from '../../types';
+import { PROVIDER_ICONS } from '../settings/providerIcons';
+import { missingTmdbWarning } from './welcomeSteps';
+import styles from './Welcome.module.css';
+
+const card = (name: PosterProvider) => POSTER_PROVIDER_CARDS.find((c) => c.serviceName === name)!;
+const TMDB = card('tmdb');
+const TVMAZE = card('tvmaze');
+
+interface PosterSourcesStepProps {
+
+ libraries: Library[];
+
+ onDone: (providers: PosterProvider[]) => void;
+}
+
+export function PosterSourcesStep({ libraries, onDone }: PosterSourcesStepProps) {
+ const [configs, setConfigs] = useState(null);
+ const [tmdbToken, setTmdbToken] = useState('');
+ const [status, setStatus] = useState<'idle' | 'checking' | 'saving'>('idle');
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ configApi
+ .findConfigs('PROVIDER')
+ .then((found) => { if (!cancelled) setConfigs(found); })
+ .catch(() => { if (!cancelled) setConfigs([]); });
+ return () => { cancelled = true; };
+ }, []);
+
+ const stored = (name: string) => configs?.find((config) => config.name === name);
+ const tmdbReady = !!stored('tmdb')?.configured;
+ const warning = missingTmdbWarning(libraries, tmdbReady);
+
+ const checkTmdb = async () => {
+ const token = tmdbToken.trim();
+ setStatus('checking');
+ setError(null);
+ try {
+ await serviceApi.testProvider('tmdb', token, TMDB.defaultUrl);
+ const saved = await configApi.createConfig({
+ name: 'tmdb', type: 'PROVIDER', url: TMDB.defaultUrl, token, enabled: true,
+ });
+ setConfigs((prev) => [...(prev ?? []).filter((c) => c.name !== 'tmdb'), saved]);
+ setTmdbToken('');
+ } catch (err) {
+ setError(errorMessage(err, 'TMDB did not accept that token. Copy the "API Read Access Token", not the API key.'));
+ } finally {
+ setStatus('idle');
+ }
+ };
+
+ const done = async () => {
+ setStatus('saving');
+ setError(null);
+ try {
+ if (!stored('tvmaze')) {
+ await configApi.createConfig({ name: 'tvmaze', type: 'PROVIDER', url: TVMAZE.defaultUrl, enabled: true });
+ }
+ onDone(tmdbReady ? ['tmdb', 'tvmaze'] : ['tvmaze']);
+ } catch (err) {
+ setError(errorMessage(err, 'TVmaze could not be turned on.'));
+ setStatus('idle');
+ }
+ };
+
+ if (configs === null) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
{PROVIDER_ICONS.tvmaze}
+
+
TVmaze
+
Series artwork. No key needed.
+
+
Ready
+
+
+
+
{PROVIDER_ICONS.tmdb}
+
+
TMDB
+
+ The main source for films and series. Free token from{' '}
+ themoviedb.org.
+
+
+
{tmdbReady ? 'Ready' : 'Suggested'}
+ {!tmdbReady && (
+
+ setTmdbToken(e.target.value)}
+ />
+
+
+ )}
+
+
+ TheTVDB, Fanart.tv, MediUX and Shoko can be added in Settings → Poster APIs.
+
+ {error && {error}
}
+
+
+
+ {warning && {warning}}
+
+ >
+ );
+}
diff --git a/affiche-frontend/src/components/welcome/SetupStep.tsx b/affiche-frontend/src/components/welcome/SetupStep.tsx
new file mode 100644
index 0000000..3aac83b
--- /dev/null
+++ b/affiche-frontend/src/components/welcome/SetupStep.tsx
@@ -0,0 +1,43 @@
+import type { ReactNode } from 'react';
+import { Check } from 'lucide-react';
+
+import type { StepState } from './welcomeSteps';
+import styles from './Welcome.module.css';
+
+interface SetupStepProps {
+ number: number;
+ title: string;
+
+ summary: string;
+ state: StepState;
+
+ onChange?: () => void;
+
+ children: ReactNode;
+}
+
+export function SetupStep({ number, title, summary, state, onChange, children }: SetupStepProps) {
+ const headingId = `setup-step-${number}`;
+ return (
+
+
+
+ {state === 'done' ? : number}
+
+
+
+ {title}
+ {state === 'done' && (done)}
+
+
{summary}
+
+ {state === 'done' && onChange && (
+
+ )}
+
+ {state === 'current' && {children}
}
+
+ );
+}
diff --git a/affiche-frontend/src/components/welcome/Welcome.module.css b/affiche-frontend/src/components/welcome/Welcome.module.css
new file mode 100644
index 0000000..73f2b60
--- /dev/null
+++ b/affiche-frontend/src/components/welcome/Welcome.module.css
@@ -0,0 +1,314 @@
+.step {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xl);
+ background-color: var(--bg-secondary);
+}
+
+.stepHead {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 18px;
+}
+
+.stepNumber {
+ flex-shrink: 0;
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 1.5px solid var(--border);
+ color: var(--text-secondary);
+ font-size: var(--text-md);
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.current .stepNumber {
+ border-color: var(--accent-fill);
+ color: var(--accent);
+}
+
+.done .stepNumber {
+ border-color: var(--success-fill);
+ background-color: var(--success-fill);
+ color: var(--text-on-fill);
+}
+
+.stepText {
+ flex: 1;
+ min-width: 0;
+}
+
+.stepTitle {
+ font-size: var(--text-lg);
+ font-weight: 600;
+}
+
+.upcoming .stepTitle {
+ color: var(--text-secondary);
+}
+
+.stepSummary {
+ font-size: var(--text-md);
+ color: var(--text-secondary);
+ overflow-wrap: anywhere;
+}
+
+.stepBody {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 0 18px 18px 60px;
+}
+
+.primary,
+.secondary {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 16px;
+ border-radius: var(--radius-md);
+ font-size: var(--text-base);
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.primary {
+ background-color: var(--accent-fill);
+ color: var(--text-on-fill);
+}
+
+.primary:hover:not(:disabled) {
+ background-color: var(--accent-fill-hover);
+}
+
+.secondary {
+ border: 1px solid var(--border);
+ background-color: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.secondary:hover:not(:disabled) {
+ background-color: var(--bg-hover);
+}
+
+.primary:disabled,
+.secondary:disabled,
+.linkButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.linkButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 8px;
+ border-radius: var(--radius-md);
+ color: var(--accent);
+ font-size: var(--text-base);
+}
+
+.linkButton:hover:not(:disabled) {
+ background-color: var(--bg-tertiary);
+}
+
+.row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px 14px;
+}
+
+.choices {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.choice {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ border: 1.5px solid var(--border);
+ border-radius: var(--radius-lg);
+ background-color: var(--bg-primary);
+ text-align: left;
+ color: var(--text-primary);
+}
+
+.choice[aria-pressed='true'] {
+ border-color: var(--accent-fill);
+}
+
+.choiceName {
+ display: block;
+ font-weight: 600;
+}
+
+.choiceHint {
+ display: block;
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+}
+
+.fields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ max-width: 100%;
+}
+
+.field label {
+ font-size: var(--text-md);
+ color: var(--text-secondary);
+}
+
+.field input,
+.field select,
+.keyRow input {
+ min-width: 0;
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: var(--text-base);
+}
+
+.field select {
+ max-width: 320px;
+}
+
+.hint {
+ font-size: var(--text-md);
+ color: var(--text-secondary);
+}
+
+.warning {
+ font-size: var(--text-md);
+ color: var(--warning);
+}
+
+.error {
+ font-size: var(--text-md);
+ color: var(--error);
+}
+
+.ok {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--text-base);
+ font-weight: 500;
+ color: var(--success);
+}
+
+.libraries {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.library {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 12px;
+ background-color: var(--bg-primary);
+ cursor: pointer;
+}
+
+.library + .library {
+ border-top: 1px solid var(--border);
+}
+
+.libraryCount {
+ margin-left: auto;
+ font-size: var(--text-md);
+ color: var(--text-secondary);
+ font-variant-numeric: tabular-nums;
+}
+
+.providers {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.provider {
+ display: grid;
+ grid-template-columns: 24px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background-color: var(--bg-primary);
+}
+
+.providerIcon {
+ display: flex;
+}
+
+.providerName {
+ font-weight: 600;
+}
+
+.providerText {
+ font-size: var(--text-md);
+ color: var(--text-secondary);
+}
+
+.tag {
+ padding: 2px 8px;
+ border-radius: var(--radius-pill);
+ background-color: var(--bg-tertiary);
+ color: var(--text-secondary);
+ font-size: var(--text-2xs);
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+}
+
+.tagReady {
+ color: var(--success);
+}
+
+.keyRow {
+ grid-column: 2 / 4;
+ display: flex;
+ gap: 8px;
+}
+
+.keyRow input {
+ flex: 1;
+ background-color: var(--bg-secondary);
+}
+
+@media (max-width: 600px) {
+ .stepBody {
+ padding-left: 18px;
+ }
+
+ .choices,
+ .fields {
+ grid-template-columns: 1fr;
+ }
+
+ .keyRow {
+ grid-column: 1 / 4;
+ flex-wrap: wrap;
+ }
+}
diff --git a/affiche-frontend/src/components/welcome/index.ts b/affiche-frontend/src/components/welcome/index.ts
new file mode 100644
index 0000000..512e599
--- /dev/null
+++ b/affiche-frontend/src/components/welcome/index.ts
@@ -0,0 +1,5 @@
+export { SetupStep } from './SetupStep';
+export { ConnectServerStep } from './ConnectServerStep';
+export { PosterSourcesStep } from './PosterSourcesStep';
+export { FirstPostersStep } from './FirstPostersStep';
+export { currentWelcomeStep, stepState, missingTmdbWarning } from './welcomeSteps';
diff --git a/affiche-frontend/src/components/welcome/welcomeSteps.test.ts b/affiche-frontend/src/components/welcome/welcomeSteps.test.ts
new file mode 100644
index 0000000..5eb5016
--- /dev/null
+++ b/affiche-frontend/src/components/welcome/welcomeSteps.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+
+import { currentWelcomeStep, missingTmdbWarning, stepState } from './welcomeSteps';
+import type { Library } from '../../types';
+
+const lib = (name: string, library_type: string) => ({ id: 1, media_server_id: 1, name, library_type }) as Library;
+
+describe('currentWelcomeStep', () => {
+ it('opens on the server until one is stored, however far the sources got', () => {
+ expect(currentWelcomeStep(false, true)).toBe('server');
+ });
+
+ it('asks for sources after a reload with a server, since nothing stored says they were decided', () => {
+ expect(currentWelcomeStep(true, false)).toBe('sources');
+ expect(currentWelcomeStep(true, true)).toBe('posters');
+ });
+
+ it('marks earlier steps done and later ones upcoming', () => {
+ expect(['server', 'sources', 'posters'].map((s) => stepState(s as never, 'sources')))
+ .toEqual(['done', 'current', 'upcoming']);
+ });
+});
+
+describe('missingTmdbWarning', () => {
+ it('names the film libraries that going without TMDB leaves short', () => {
+ expect(missingTmdbWarning([lib('Films', 'movie'), lib('Shows', 'show')], false))
+ .toBe('Without TMDB, Films will get few posters: TVmaze only has series.');
+ });
+
+ it('says nothing when TMDB is ready or there are only series', () => {
+ expect(missingTmdbWarning([lib('Films', 'movie')], true)).toBeNull();
+ expect(missingTmdbWarning([lib('Shows', 'show')], false)).toBeNull();
+ });
+});
diff --git a/affiche-frontend/src/components/welcome/welcomeSteps.ts b/affiche-frontend/src/components/welcome/welcomeSteps.ts
new file mode 100644
index 0000000..a112750
--- /dev/null
+++ b/affiche-frontend/src/components/welcome/welcomeSteps.ts
@@ -0,0 +1,26 @@
+import type { Library } from '../../types';
+
+export type WelcomeStep = 'server' | 'sources' | 'posters';
+
+export const WELCOME_STEPS: WelcomeStep[] = ['server', 'sources', 'posters'];
+
+export type StepState = 'done' | 'current' | 'upcoming';
+
+export function currentWelcomeStep(hasServer: boolean, sourcesConfirmed: boolean): WelcomeStep {
+ if (!hasServer) return 'server';
+ return sourcesConfirmed ? 'posters' : 'sources';
+}
+
+export function stepState(step: WelcomeStep, current: WelcomeStep): StepState {
+ const at = WELCOME_STEPS.indexOf(step);
+ const now = WELCOME_STEPS.indexOf(current);
+ return at < now ? 'done' : at === now ? 'current' : 'upcoming';
+}
+
+export function missingTmdbWarning(libraries: Library[], tmdbReady: boolean): string | null {
+ if (tmdbReady) return null;
+ const films = libraries.filter((library) => library.library_type === 'movie');
+ if (films.length === 0) return null;
+ const names = films.map((library) => library.name).join(', ');
+ return `Without TMDB, ${names} will get few posters: TVmaze only has series.`;
+}
diff --git a/affiche-frontend/src/constants/platform.test.ts b/affiche-frontend/src/constants/platform.test.ts
new file mode 100644
index 0000000..64bfe59
--- /dev/null
+++ b/affiche-frontend/src/constants/platform.test.ts
@@ -0,0 +1,22 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { searchShortcutLabel } from './platform';
+
+afterEach(() => vi.unstubAllGlobals());
+
+const onPlatform = (platform: string) => vi.stubGlobal('navigator', { platform });
+
+describe('searchShortcutLabel', () => {
+ it('writes the shortcut the way the platform does', () => {
+ onPlatform('MacIntel');
+ expect(searchShortcutLabel()).toBe('⌘K');
+
+ onPlatform('Win32');
+ expect(searchShortcutLabel()).toBe('Ctrl K');
+ });
+
+ it('prefers the client-hints platform where the browser offers it', () => {
+ vi.stubGlobal('navigator', { platform: 'Win32', userAgentData: { platform: 'macOS' } });
+ expect(searchShortcutLabel()).toBe('⌘K');
+ });
+});
diff --git a/affiche-frontend/src/constants/platform.ts b/affiche-frontend/src/constants/platform.ts
new file mode 100644
index 0000000..e2ccf01
--- /dev/null
+++ b/affiche-frontend/src/constants/platform.ts
@@ -0,0 +1,7 @@
+export function isApplePlatform(): boolean {
+ const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
+ const platform = nav.userAgentData?.platform ?? nav.platform ?? '';
+ return /mac|iphone|ipad|ipod/i.test(platform);
+}
+
+export const searchShortcutLabel = () => (isApplePlatform() ? '⌘K' : 'Ctrl K');
diff --git a/affiche-frontend/src/context/ToastContext.module.css b/affiche-frontend/src/context/ToastContext.module.css
index cb9da42..cf295ec 100644
--- a/affiche-frontend/src/context/ToastContext.module.css
+++ b/affiche-frontend/src/context/ToastContext.module.css
@@ -2,7 +2,7 @@
position: fixed;
bottom: 20px;
right: 20px;
- z-index: 1000;
+ z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: 10px;
@@ -16,10 +16,9 @@
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
- border-radius: 10px;
+ border-radius: var(--radius-xl);
background-color: var(--bg-secondary);
border: 1px solid var(--border);
- border-left-width: 3px;
box-shadow: var(--shadow-lg);
animation: slideIn 0.18s ease-out;
}
@@ -41,7 +40,8 @@
}
.error {
- border-left-color: var(--error);
+ border-color: var(--notice-error-edge);
+ background: linear-gradient(var(--notice-error-tint), var(--notice-error-tint)), var(--bg-secondary);
}
.error .icon {
@@ -49,7 +49,8 @@
}
.success {
- border-left-color: var(--success);
+ border-color: var(--notice-success-edge);
+ background: linear-gradient(var(--notice-success-tint), var(--notice-success-tint)), var(--bg-secondary);
}
.success .icon {
@@ -57,7 +58,8 @@
}
.info {
- border-left-color: var(--accent);
+ border-color: var(--notice-info-edge);
+ background: linear-gradient(var(--notice-info-tint), var(--notice-info-tint)), var(--bg-secondary);
}
.info .icon {
@@ -70,14 +72,14 @@
}
.title {
- font-size: 0.85rem;
+ font-size: var(--text-md);
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.message {
- font-size: 0.82rem;
+ font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.4;
@@ -93,7 +95,7 @@
align-items: center;
justify-content: center;
padding: 3px;
- border-radius: 5px;
+ border-radius: var(--radius-sm);
color: var(--text-muted);
transition: all 0.15s;
}
@@ -102,3 +104,22 @@
background-color: var(--bg-hover);
color: var(--text-primary);
}
+
+.action {
+ margin-top: 6px;
+ padding: 0;
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--accent);
+}
+
+.action:hover {
+ color: var(--accent-hover);
+ text-decoration: underline;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .toast {
+ animation: none;
+ }
+}
diff --git a/affiche-frontend/src/context/ToastContext.test.tsx b/affiche-frontend/src/context/ToastContext.test.tsx
new file mode 100644
index 0000000..0f752e0
--- /dev/null
+++ b/affiche-frontend/src/context/ToastContext.test.tsx
@@ -0,0 +1,43 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+
+import { ToastProvider, useToast } from './ToastContext';
+
+function Trigger() {
+ const toast = useToast();
+ return ;
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('Toasts', () => {
+ it('stay while hovered, and leave once the pointer does', () => {
+ vi.useFakeTimers();
+ render();
+
+ fireEvent.click(screen.getByText('go'));
+ const toast = screen.getByRole('status');
+ fireEvent.mouseEnter(toast);
+ act(() => { vi.advanceTimersByTime(10_000); });
+ expect(screen.getByText('Posters generated')).toBeInTheDocument();
+
+ fireEvent.mouseLeave(toast);
+ act(() => { vi.advanceTimersByTime(4_000); });
+ expect(screen.queryByText('Posters generated')).not.toBeInTheDocument();
+ });
+
+ it('announce success politely and errors as alerts', () => {
+ function Both() {
+ const toast = useToast();
+ return ;
+ }
+ render();
+
+ fireEvent.click(screen.getByText('go'));
+
+ expect(screen.getByRole('status')).toHaveTextContent('ok');
+ expect(screen.getByRole('alert')).toHaveTextContent('nope');
+ });
+});
diff --git a/affiche-frontend/src/context/ToastContext.tsx b/affiche-frontend/src/context/ToastContext.tsx
index 8c9766d..c1fe13d 100644
--- a/affiche-frontend/src/context/ToastContext.tsx
+++ b/affiche-frontend/src/context/ToastContext.tsx
@@ -2,6 +2,7 @@ import {
createContext,
useCallback,
useContext,
+ useEffect,
useMemo,
useRef,
useState,
@@ -12,17 +13,27 @@ import styles from './ToastContext.module.css';
export type ToastType = 'error' | 'success' | 'info';
+interface ToastAction {
+ label: string;
+ onClick: () => void;
+}
+
interface Toast {
id: number;
type: ToastType;
title?: string;
message: string;
+ action?: ToastAction;
+
+ duration: number;
}
interface ShowToastOptions {
title?: string;
duration?: number;
+
+ action?: ToastAction;
}
interface ToastContextValue {
@@ -56,12 +67,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const show = useCallback((type: ToastType, message: string, options?: ShowToastOptions) => {
const id = nextId.current++;
- setToasts((prev) => [...prev, { id, type, message, title: options?.title }]);
const duration = options?.duration ?? DEFAULT_DURATION[type];
- if (duration > 0) {
- window.setTimeout(() => remove(id), duration);
- }
- }, [remove]);
+ setToasts((prev) => [...prev, { id, type, message, title: options?.title, action: options?.action, duration }]);
+ }, []);
const error = useCallback((m: string, o?: ShowToastOptions) => show('error', m, o), [show]);
const success = useCallback((m: string, o?: ShowToastOptions) => show('success', m, o), [show]);
@@ -76,30 +84,57 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{children}
- {toasts.map((toast) => {
- const Icon = ICONS[toast.type];
- return (
-
-
-
- {toast.title &&
{toast.title}
}
-
{toast.message}
-
-
-
- );
- })}
+ {toasts.map((toast) => (
+
+ ))}
);
}
+function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: number) => void }) {
+ const [paused, setPaused] = useState(false);
+ const Icon = ICONS[toast.type];
+ const onDismiss = () => onRemove(toast.id);
+
+ useEffect(() => {
+ if (paused || toast.duration <= 0) return;
+ const timer = window.setTimeout(() => onRemove(toast.id), toast.duration);
+ return () => window.clearTimeout(timer);
+ }, [paused, toast.duration, toast.id, onRemove]);
+
+ return (
+ setPaused(true)}
+ onMouseLeave={() => setPaused(false)}
+ onFocus={() => setPaused(true)}
+ onBlur={() => setPaused(false)}
+ >
+
+
+ {toast.title &&
{toast.title}
}
+
{toast.message}
+ {toast.action && (
+
+ )}
+
+
+
+ );
+}
+
// eslint-disable-next-line react-refresh/only-export-components
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
diff --git a/affiche-frontend/src/hooks/index.ts b/affiche-frontend/src/hooks/index.ts
index 45db3fd..e7d6d20 100644
--- a/affiche-frontend/src/hooks/index.ts
+++ b/affiche-frontend/src/hooks/index.ts
@@ -9,8 +9,11 @@ export { useLibraryItems, PAGE_SIZE } from './useLibraryItems';
export { useInfiniteScroll } from './useInfiniteScroll';
export { useLibraryListing } from './useLibraryListing';
export { useLibraryItemCounts } from './useLibraryItemCounts';
+export { useLibraryCoverage } from './useLibraryCoverage';
+export { useLibraryUploadSetting } from './useLibraryUploadSetting';
export { useOpenItem } from './useOpenItem';
export { useLibraryRows } from './useLibraryRows';
+export { useStagePoster } from './useStagePoster';
export { useTaskTracking, taskKindFromName } from './useTaskTracking';
export { usePosterBrowser } from './usePosterBrowser';
export { useAlphabetIndex } from './useAlphabetIndex';
@@ -29,3 +32,9 @@ export { usePosterStyleDrafts } from './usePosterStyleDrafts';
export { usePosterTitleDraft } from './usePosterTitleDraft';
export { usePosterBrowseQuery } from './usePosterBrowseQuery';
export { usePopoverDismiss } from './usePopoverDismiss';
+export { useMenuKeyboard } from './useMenuKeyboard';
+export { useMediaQuery } from './useMediaQuery';
+export { useInView } from './useInView';
+export { useItemNeighbours } from './useItemNeighbours';
+export { useUnsavedChangesGuard } from './useUnsavedChangesGuard';
+export { useItemArtwork, resetItemArtworkCache } from './useItemArtwork';
diff --git a/affiche-frontend/src/hooks/useAlphabetIndex.ts b/affiche-frontend/src/hooks/useAlphabetIndex.ts
index 2fa3aab..df50a9f 100644
--- a/affiche-frontend/src/hooks/useAlphabetIndex.ts
+++ b/affiche-frontend/src/hooks/useAlphabetIndex.ts
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react';
import { errorMessage, libraryApi } from '../api';
import { useToast } from '../context/ToastContext';
+import { scrollBehavior } from '../motion';
import { PAGE_SIZE } from './useLibraryItems';
import type {
AlphaIndexEntry,
@@ -88,7 +89,7 @@ export function useAlphabetIndex({
}, [isTrash, hasLibrary, search, listingKey]);
const scrollToLetter = useCallback((letter: string) => {
- document.getElementById(`alpha-anchor-${letter}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ document.getElementById(`alpha-anchor-${letter}`)?.scrollIntoView({ behavior: scrollBehavior(), block: 'start' });
}, []);
const handleLetterClick = useCallback(async (letter: string) => {
diff --git a/affiche-frontend/src/hooks/useInView.ts b/affiche-frontend/src/hooks/useInView.ts
new file mode 100644
index 0000000..22d7381
--- /dev/null
+++ b/affiche-frontend/src/hooks/useInView.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from 'react';
+
+export function useInView(rootMargin = '200px') {
+ const [element, setElement] = useState(null);
+ const [seen, setSeen] = useState(() => typeof IntersectionObserver === 'undefined');
+
+ useEffect(() => {
+ if (!element || seen) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) setSeen(true);
+ },
+ { rootMargin }
+ );
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [element, seen, rootMargin]);
+
+ return [setElement, seen] as const;
+}
diff --git a/affiche-frontend/src/hooks/useItemArtwork.test.tsx b/affiche-frontend/src/hooks/useItemArtwork.test.tsx
new file mode 100644
index 0000000..522c084
--- /dev/null
+++ b/affiche-frontend/src/hooks/useItemArtwork.test.tsx
@@ -0,0 +1,51 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+
+import { resetItemArtworkCache, useItemArtwork } from './useItemArtwork';
+import { postersApi } from '../api';
+
+vi.mock('../api', async (importOriginal) => ({
+ ...(await importOriginal()),
+ postersApi: { getPosters: vi.fn() },
+}));
+
+const getPosters = vi.mocked(postersApi.getPosters);
+const ALIEN = { mediaType: 'movie' as const, tmdbId: 348 };
+const POSTER = { url: 'https://img/a.jpg', provider: 'tmdb', rank: 0, rank_score: 1 };
+
+beforeEach(() => {
+ resetItemArtworkCache();
+ getPosters.mockReset();
+});
+
+describe('useItemArtwork', () => {
+ it('asks nothing while held back', () => {
+ renderHook(() => useItemArtwork(ALIEN, false));
+
+ expect(getPosters).not.toHaveBeenCalled();
+ });
+
+ it('reuses a recent answer for the same title instead of asking the providers again', async () => {
+ getPosters.mockResolvedValue([POSTER]);
+ const first = renderHook(() => useItemArtwork(ALIEN, true));
+ await waitFor(() => expect(first.result.current.posters).toEqual([POSTER]));
+ first.unmount();
+
+ const again = renderHook(() => useItemArtwork(ALIEN, true));
+
+ expect(again.result.current.posters).toEqual([POSTER]);
+ expect(getPosters).toHaveBeenCalledTimes(1);
+ });
+
+ it('tries again after a failure rather than remembering it', async () => {
+ getPosters.mockRejectedValueOnce(new Error('TMDB is down')).mockResolvedValue([POSTER]);
+ const first = renderHook(() => useItemArtwork(ALIEN, true));
+ await waitFor(() => expect(first.result.current.error).toBe('TMDB is down'));
+ first.unmount();
+
+ const again = renderHook(() => useItemArtwork(ALIEN, true));
+
+ await waitFor(() => expect(again.result.current.posters).toEqual([POSTER]));
+ expect(getPosters).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/affiche-frontend/src/hooks/useItemArtwork.ts b/affiche-frontend/src/hooks/useItemArtwork.ts
new file mode 100644
index 0000000..91842b3
--- /dev/null
+++ b/affiche-frontend/src/hooks/useItemArtwork.ts
@@ -0,0 +1,62 @@
+import { useEffect, useSyncExternalStore } from 'react';
+
+import { errorMessage, postersApi } from '../api';
+import type { PosterCandidate } from '../types';
+
+const FRESH_FOR_MS = 10 * 60 * 1000;
+
+type Entry =
+ | { status: 'loading' }
+ | { status: 'ready'; posters: PosterCandidate[]; fetchedAt: number }
+ | { status: 'error'; error: string };
+
+const cache = new Map();
+const listeners = new Set<() => void>();
+const emit = () => listeners.forEach((listener) => listener());
+
+const subscribe = (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+};
+
+export function resetItemArtworkCache() {
+ cache.clear();
+}
+
+interface ArtworkTarget {
+ mediaType: 'movie' | 'show';
+ tmdbId?: number;
+ tvdbId?: number;
+}
+
+function load(key: string, { mediaType, tmdbId, tvdbId }: ArtworkTarget) {
+ const entry = cache.get(key);
+ if (entry?.status === 'loading') return;
+ if (entry?.status === 'ready' && Date.now() - entry.fetchedAt < FRESH_FOR_MS) return;
+
+ cache.set(key, { status: 'loading' });
+ emit();
+ postersApi
+ .getPosters({ media_type: mediaType, tmdb_id: tmdbId, tvdb_id: tvdbId })
+ .then((posters) => cache.set(key, { status: 'ready', posters, fetchedAt: Date.now() }))
+ .catch((err) => cache.set(key, { status: 'error', error: errorMessage(err, 'Failed to fetch posters') }))
+ .finally(emit);
+}
+
+export function useItemArtwork(target: ArtworkTarget, enabled: boolean) {
+ const { mediaType, tmdbId, tvdbId } = target;
+ const canBrowse = tmdbId !== undefined || tvdbId !== undefined;
+ const key = `${mediaType}:${tmdbId ?? ''}:${tvdbId ?? ''}`;
+ const entry = useSyncExternalStore(subscribe, () => cache.get(key));
+
+ useEffect(() => {
+ if (enabled && canBrowse) load(key, { mediaType, tmdbId, tvdbId });
+ }, [enabled, canBrowse, key, mediaType, tmdbId, tvdbId]);
+
+ if (!canBrowse) return { posters: [], isLoading: false, error: null };
+ return {
+ posters: entry?.status === 'ready' ? entry.posters : [],
+ isLoading: entry === undefined || entry.status === 'loading',
+ error: entry?.status === 'error' ? entry.error : null,
+ };
+}
diff --git a/affiche-frontend/src/hooks/useItemLock.test.tsx b/affiche-frontend/src/hooks/useItemLock.test.tsx
new file mode 100644
index 0000000..0c0313c
--- /dev/null
+++ b/affiche-frontend/src/hooks/useItemLock.test.tsx
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi } from 'vitest';
+import { act, renderHook, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+
+import { useItemLock } from './useItemLock';
+import { libraryApi } from '../api';
+import { ToastProvider } from '../context/ToastContext';
+import type { Library, LibraryItem } from '../types';
+
+vi.mock('../api', async (importOriginal) => ({
+ ...(await importOriginal()),
+ libraryApi: { setItemLock: vi.fn() },
+}));
+
+const ITEM = { id: 1, library_id: 2, title: 'Alien', locked: false } as LibraryItem;
+const LIBRARIES = [{ id: 2, media_server_id: 9 } as Library];
+
+const wrapper = ({ children }: { children: ReactNode }) => {children};
+
+describe('useItemLock', () => {
+ it('says what a lock does once it is set, and what unlocking gives back', async () => {
+ const setLock = vi.mocked(libraryApi.setItemLock);
+ const { result } = renderHook(
+ () => useItemLock({ allLibraries: LIBRARIES, setItems: vi.fn() }),
+ { wrapper }
+ );
+
+ setLock.mockResolvedValueOnce({ ...ITEM, locked: true });
+ await act(() => result.current.toggle(ITEM));
+ expect(screen.getByText('Poster generation will skip "Alien" until it is unlocked.')).toBeInTheDocument();
+
+ setLock.mockResolvedValueOnce({ ...ITEM, locked: false });
+ await act(() => result.current.toggle({ ...ITEM, locked: true }));
+ expect(screen.getByText('Poster generation can replace the poster for "Alien" again.')).toBeInTheDocument();
+ });
+});
diff --git a/affiche-frontend/src/hooks/useItemLock.ts b/affiche-frontend/src/hooks/useItemLock.ts
index e30fd22..4732c13 100644
--- a/affiche-frontend/src/hooks/useItemLock.ts
+++ b/affiche-frontend/src/hooks/useItemLock.ts
@@ -43,6 +43,13 @@ export function useItemLock({ allLibraries, setItems, onLockChanged }: UseItemLo
)
);
onLockChanged?.();
+
+ toast.info(
+ updated.locked
+ ? `Poster generation will skip "${item.title}" until it is unlocked.`
+ : `Poster generation can replace the poster for "${item.title}" again.`,
+ { title: updated.locked ? 'Locked' : 'Unlocked' }
+ );
} catch (error) {
toast.error(errorMessage(error, 'Failed to change the lock on this item.'), {
title: 'Lock failed',
diff --git a/affiche-frontend/src/hooks/useItemNeighbours.test.tsx b/affiche-frontend/src/hooks/useItemNeighbours.test.tsx
new file mode 100644
index 0000000..8e2ab5f
--- /dev/null
+++ b/affiche-frontend/src/hooks/useItemNeighbours.test.tsx
@@ -0,0 +1,59 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+
+import { useItemNeighbours } from './useItemNeighbours';
+import { libraryApi } from '../api';
+import type { Library, LibraryItem } from '../types';
+
+vi.mock('../api', async (importOriginal) => ({
+ ...(await importOriginal()),
+ libraryApi: { getLibraryItemIds: vi.fn(), getItem: vi.fn() },
+}));
+
+const FILMS = { id: 2, media_server_id: 1, name: 'Films', library_type: 'movie' } as Library;
+const film = (id: number, title: string) => ({ id, library_id: 2, title }) as LibraryItem;
+const [ALIEN, BRAZIL, HEAT, ZODIAC] = [film(1, 'Alien'), film(2, 'Brazil'), film(3, 'Heat'), film(4, 'Zodiac')];
+const LISTING = { search: 'a', sort: { by: 'year', dir: 'desc' as const } };
+
+const ids = vi.mocked(libraryApi.getLibraryItemIds);
+const getItem = vi.mocked(libraryApi.getItem);
+
+beforeEach(() => {
+ ids.mockReset().mockResolvedValue([1, 2, 3, 4]);
+ getItem.mockReset().mockImplementation(async (_s, _l, id) => [ALIEN, BRAZIL, HEAT, ZODIAC][id - 1]);
+});
+
+const run = (item: LibraryItem, items: LibraryItem[], hasMore: boolean, library: Library | null = FILMS) =>
+ renderHook(() => useItemNeighbours({
+ library: library ?? undefined, item, items, hasMore, listing: LISTING, enabled: true,
+ }));
+
+describe('useItemNeighbours', () => {
+ it('answers from the loaded items when they hold both neighbours', () => {
+ const { result } = run(BRAZIL, [ALIEN, BRAZIL, HEAT], true);
+
+ expect(result.current).toEqual({ previous: ALIEN, next: HEAT });
+ expect(ids).not.toHaveBeenCalled();
+ });
+
+ it('finds the next item beyond the last loaded one, in the listing order', async () => {
+ const { result } = run(HEAT, [ALIEN, BRAZIL, HEAT], true);
+
+ await waitFor(() => expect(result.current.next).toEqual(ZODIAC));
+ expect(result.current.previous).toEqual(BRAZIL);
+ expect(ids).toHaveBeenCalledWith(1, 2, expect.objectContaining({ search: 'a', sortBy: 'year', sortDir: 'desc' }));
+ });
+
+ it('gives a deep-linked item both neighbours', async () => {
+ const { result } = run(HEAT, [], true);
+
+ await waitFor(() => expect(result.current).toEqual({ previous: BRAZIL, next: ZODIAC }));
+ });
+
+ it('keeps to the loaded items in All libraries, which has no single listing to ask', () => {
+ const { result } = run(HEAT, [ALIEN, BRAZIL, HEAT], true, null);
+
+ expect(result.current).toEqual({ previous: BRAZIL, next: undefined });
+ expect(ids).not.toHaveBeenCalled();
+ });
+});
diff --git a/affiche-frontend/src/hooks/useItemNeighbours.ts b/affiche-frontend/src/hooks/useItemNeighbours.ts
new file mode 100644
index 0000000..eab3c31
--- /dev/null
+++ b/affiche-frontend/src/hooks/useItemNeighbours.ts
@@ -0,0 +1,63 @@
+import { useEffect, useEffectEvent, useState } from 'react';
+
+import { libraryApi } from '../api';
+import { neighbourIds, neighbours } from '../components/library/selection';
+import type { ItemStatusFilter, Library, LibraryItem, SortState } from '../types';
+
+interface UseItemNeighboursOptions {
+ library?: Library;
+ item: LibraryItem | null;
+ items: LibraryItem[];
+ hasMore: boolean;
+ listing: { search?: string; status?: ItemStatusFilter; provider?: string; sort: SortState };
+ enabled: boolean;
+}
+
+interface Resolved {
+
+ key: string;
+ previous?: LibraryItem;
+ next?: LibraryItem;
+}
+
+export function useItemNeighbours({ library, item, items, hasMore, listing, enabled }: UseItemNeighboursOptions) {
+ const [resolved, setResolved] = useState(null);
+
+ const local = enabled && item ? neighbours(items, item) : {};
+ const isLoaded = !!item && items.some((i) => i.id === item.id && i.library_id === item.library_id);
+ const needsListing = enabled && !!item && !!library && (!isLoaded || (!local.next && hasMore));
+
+ const listingKey = `${listing.search ?? ''}|${listing.status ?? ''}|${listing.provider ?? ''}|${listing.sort.by}|${listing.sort.dir}`;
+
+ const key = `${item?.library_id}|${item?.id}|${listingKey}`;
+
+ const resolve = useEffectEvent(async () => {
+ const lib = library;
+ const open = item;
+ if (!lib || !open) return;
+ const requestKey = key;
+ try {
+ const ids = await libraryApi.getLibraryItemIds(lib.media_server_id, lib.id, {
+ search: listing.search, status: listing.status, provider: listing.provider,
+ sortBy: listing.sort.by, sortDir: listing.sort.dir,
+ });
+ const around = neighbourIds(ids, open.id);
+ const fetchOne = async (id?: number) => {
+ if (id === undefined) return undefined;
+ return items.find((i) => i.id === id && i.library_id === lib.id)
+ ?? await libraryApi.getItem(lib.media_server_id, lib.id, id);
+ };
+ const [previous, next] = await Promise.all([fetchOne(around.previous), fetchOne(around.next)]);
+ setResolved({ key: requestKey, previous, next });
+ } catch {}
+ });
+
+ useEffect(() => {
+ if (needsListing) void resolve();
+ }, [needsListing, key]);
+
+ if (needsListing && resolved?.key === key) {
+ return { previous: resolved.previous ?? local.previous, next: resolved.next ?? local.next };
+ }
+ return local;
+}
diff --git a/affiche-frontend/src/hooks/useItemSelection.ts b/affiche-frontend/src/hooks/useItemSelection.ts
index e893a51..2dd3be5 100644
--- a/affiche-frontend/src/hooks/useItemSelection.ts
+++ b/affiche-frontend/src/hooks/useItemSelection.ts
@@ -1,8 +1,8 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { errorMessage, libraryApi } from '../api';
import { useToast } from '../context/ToastContext';
-import { emptySelection, pruneSelection, toggleAll, toggleId } from '../components/library/selection';
+import { emptySelection, pruneSelection, selectRange, toggleAll, toggleId } from '../components/library/selection';
import type { LibraryItem } from '../types';
interface UseItemSelectionOptions {
@@ -14,6 +14,8 @@ interface UseItemSelectionOptions {
onTaskStarted: (taskId: string) => void;
refreshListing: (silent?: boolean) => void;
+
+ listingKey: string;
}
export function useItemSelection({
@@ -21,17 +23,28 @@ export function useItemSelection({
mediaServerId,
onTaskStarted,
refreshListing,
+ listingKey,
}: UseItemSelectionOptions) {
const toast = useToast();
const [selected, setSelected] = useState>(emptySelection);
const [isSelectMode, setIsSelectMode] = useState(false);
const [isBusy, setIsBusy] = useState(false);
+ const [allMatching, setAllMatching] = useState<{ key: string; ids: ReadonlySet } | null>(null);
+ const anchor = useRef(null);
useEffect(() => {
setSelected((prev) => pruneSelection(prev, items));
}, [items]);
- const ids = [...selected];
+ const matching = allMatching?.key === listingKey ? allMatching.ids : null;
+ const current = matching ?? selected;
+ const ids = [...current];
+
+ const edit = (change: (prev: ReadonlySet) => ReadonlySet) => {
+ const base = matching ?? selected;
+ setAllMatching(null);
+ setSelected(change(base));
+ };
const run = async (action: (itemIds: number[]) => Promise) => {
if (!mediaServerId || ids.length === 0) return;
@@ -40,6 +53,7 @@ export function useItemSelection({
await action(ids);
setSelected(emptySelection());
+ setAllMatching(null);
setIsSelectMode(false);
} finally {
setIsBusy(false);
@@ -77,24 +91,35 @@ export function useItemSelection({
});
return {
- selected,
- count: selected.size,
+ selected: current,
+ count: current.size,
+ isAllMatching: matching !== null,
isBusy,
isSelectMode,
- isSelected: (id: number) => selected.has(id),
- toggle: (id: number) => {
- setSelected((prev) => toggleId(prev, id));
+ isSelected: (id: number) => current.has(id),
+
+ toggle: (id: number, extend = false) => {
+ edit((prev) => (extend ? selectRange(prev, items, anchor.current, id) : toggleId(prev, id)));
+ anchor.current = id;
+ setIsSelectMode(true);
+ },
+ toggleAll: () => edit((prev) => toggleAll(prev, items)),
+ selectAllMatching: (matchingIds: number[]) => {
+ setAllMatching({ key: listingKey, ids: new Set(matchingIds) });
setIsSelectMode(true);
},
- toggleAll: () => setSelected((prev) => toggleAll(prev, items)),
toggleSelectMode: () =>
setIsSelectMode((prev) => {
- if (prev) setSelected(emptySelection());
+ if (prev) {
+ setSelected(emptySelection());
+ setAllMatching(null);
+ }
return !prev;
}),
clear: () => {
setSelected(emptySelection());
+ setAllMatching(null);
setIsSelectMode(false);
},
generate: () =>
diff --git a/affiche-frontend/src/hooks/useLibraryCoverage.ts b/affiche-frontend/src/hooks/useLibraryCoverage.ts
new file mode 100644
index 0000000..b7d4c99
--- /dev/null
+++ b/affiche-frontend/src/hooks/useLibraryCoverage.ts
@@ -0,0 +1,33 @@
+import { useEffect, useEffectEvent, useRef, useState } from 'react';
+
+import { dashboardApi } from '../api';
+import type { ItemStats } from '../types';
+
+interface UseLibraryCoverageOptions {
+ libraryId?: number;
+ enabled: boolean;
+
+ refreshKey?: number;
+}
+
+export function useLibraryCoverage({ libraryId, enabled, refreshKey = 0 }: UseLibraryCoverageOptions) {
+ const [loaded, setLoaded] = useState<{ libraryId: number; stats: ItemStats } | null>(null);
+ const latest = useRef(0);
+
+ const load = useEffectEvent(async (id: number) => {
+ const request = ++latest.current;
+ try {
+ const summary = await dashboardApi.getSummary();
+ if (request !== latest.current) return;
+ const row = summary.libraries.find((library) => library.library_id === id);
+ setLoaded(row ? { libraryId: id, stats: row.stats } : null);
+ } catch {}
+ });
+
+ useEffect(() => {
+ if (!enabled || libraryId === undefined) return;
+ void load(libraryId);
+ }, [enabled, libraryId, refreshKey]);
+
+ return loaded && loaded.libraryId === libraryId ? loaded.stats : undefined;
+}
diff --git a/affiche-frontend/src/hooks/useLibraryItemCounts.ts b/affiche-frontend/src/hooks/useLibraryItemCounts.ts
index 072144f..5f3b02c 100644
--- a/affiche-frontend/src/hooks/useLibraryItemCounts.ts
+++ b/affiche-frontend/src/hooks/useLibraryItemCounts.ts
@@ -43,6 +43,8 @@ function sum(all: LibraryItemCounts[]): LibraryItemCounts {
unprocessed: acc.unprocessed + c.unprocessed,
errors: acc.errors + c.errors,
locked: acc.locked + c.locked,
+ ready: (acc.ready ?? 0) + (c.ready ?? 0),
+ uploaded: (acc.uploaded ?? 0) + (c.uploaded ?? 0),
providers: acc.providers,
}),
{ ...ZERO, providers: sumProviders(all) }
diff --git a/affiche-frontend/src/hooks/useLibraryRows.ts b/affiche-frontend/src/hooks/useLibraryRows.ts
index eb92e6c..7432250 100644
--- a/affiche-frontend/src/hooks/useLibraryRows.ts
+++ b/affiche-frontend/src/hooks/useLibraryRows.ts
@@ -1,8 +1,8 @@
import { useEffect, useEffectEvent, useRef, useState } from 'react';
-import { errorMessage, libraryApi } from '../api';
+import { dashboardApi, errorMessage, libraryApi } from '../api';
import { useToast } from '../context/ToastContext';
-import type { Library, LibraryItem } from '../types';
+import type { ItemStats, Library, LibraryItem } from '../types';
export const ROW_SIZE = 20;
@@ -11,6 +11,8 @@ export interface LibraryRow {
items: LibraryItem[];
total: number;
+
+ stats?: ItemStats;
}
interface UseLibraryRowsOptions {
@@ -37,16 +39,22 @@ export function useLibraryRows({
const mine = ++request.current;
setIsLoading(true);
try {
- const pages = await Promise.all(libraries.map((library) =>
- libraryApi.getLibraryItems(library.media_server_id, library.id, {
- page: 0, pageSize: ROW_SIZE, sortBy: 'added_at', sortDir: 'desc',
- })));
+
+ const [pages, summary] = await Promise.all([
+ Promise.all(libraries.map((library) =>
+ libraryApi.getLibraryItems(library.media_server_id, library.id, {
+ page: 0, pageSize: ROW_SIZE, sortBy: 'added_at', sortDir: 'desc',
+ }))),
+ dashboardApi.getSummary().catch(() => null),
+ ]);
if (mine !== request.current) return;
+ const statsByLibrary = new Map(summary?.libraries.map((row) => [row.library_id, row.stats]));
setRows(libraries.map((library, index) => ({
library,
items: pages[index].items,
total: pages[index].total,
+ stats: statsByLibrary.get(library.id),
})));
} catch (error) {
if (mine !== request.current) return;
diff --git a/affiche-frontend/src/hooks/useLibraryUploadSetting.ts b/affiche-frontend/src/hooks/useLibraryUploadSetting.ts
new file mode 100644
index 0000000..f13c343
--- /dev/null
+++ b/affiche-frontend/src/hooks/useLibraryUploadSetting.ts
@@ -0,0 +1,33 @@
+import { useEffect, useEffectEvent, useRef, useState } from 'react';
+
+import { libraryApi } from '../api';
+import type { Library } from '../types';
+
+interface UseLibraryUploadSettingOptions {
+ library?: Library;
+ enabled: boolean;
+}
+
+export function useLibraryUploadSetting({ library, enabled }: UseLibraryUploadSettingOptions) {
+ const [loaded, setLoaded] = useState<{ libraryId: number; uploads: boolean } | null>(null);
+ const latest = useRef(0);
+
+ const libraryId = library?.id;
+ const mediaServerId = library?.media_server_id;
+
+ const load = useEffectEvent(async (serverId: number, id: number) => {
+ const request = ++latest.current;
+ try {
+ const settings = await libraryApi.getLibrarySettings(serverId, id);
+ if (request !== latest.current) return;
+ setLoaded({ libraryId: id, uploads: !!settings.upload_enabled });
+ } catch {}
+ });
+
+ useEffect(() => {
+ if (!enabled || libraryId === undefined || mediaServerId === undefined) return;
+ void load(mediaServerId, libraryId);
+ }, [enabled, libraryId, mediaServerId]);
+
+ return loaded && loaded.libraryId === libraryId ? loaded.uploads : undefined;
+}
diff --git a/affiche-frontend/src/hooks/useMediaQuery.ts b/affiche-frontend/src/hooks/useMediaQuery.ts
new file mode 100644
index 0000000..e20dc07
--- /dev/null
+++ b/affiche-frontend/src/hooks/useMediaQuery.ts
@@ -0,0 +1,12 @@
+import { useSyncExternalStore } from 'react';
+
+export function useMediaQuery(query: string): boolean {
+ return useSyncExternalStore(
+ (onChange) => {
+ const list = window.matchMedia?.(query);
+ list?.addEventListener('change', onChange);
+ return () => list?.removeEventListener('change', onChange);
+ },
+ () => window.matchMedia?.(query).matches ?? false,
+ );
+}
diff --git a/affiche-frontend/src/hooks/useMenuKeyboard.ts b/affiche-frontend/src/hooks/useMenuKeyboard.ts
new file mode 100644
index 0000000..c5547bc
--- /dev/null
+++ b/affiche-frontend/src/hooks/useMenuKeyboard.ts
@@ -0,0 +1,37 @@
+import { useEffect, useRef, type KeyboardEvent } from 'react';
+
+import { menuFocusIndex } from '../components/common/menuKeys';
+
+const ITEM_SELECTOR = '[role="menuitem"]';
+
+export function useMenuKeyboard(
+ isOpen: boolean,
+ close: () => void
+) {
+ const menuRef = useRef(null);
+ const triggerRef = useRef(null);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ const first = [...(menuRef.current?.querySelectorAll(ITEM_SELECTOR) ?? [])]
+ .find((item) => !item.disabled);
+ first?.focus();
+ }, [isOpen]);
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ const items = [...(menuRef.current?.querySelectorAll(ITEM_SELECTOR) ?? [])];
+ if (event.key === 'Escape' || event.key === 'Tab') {
+ if (event.key === 'Escape') event.preventDefault();
+ close();
+ triggerRef.current?.focus();
+ return;
+ }
+ const current = items.findIndex((item) => item === document.activeElement);
+ const next = menuFocusIndex(event.key, current, items.map((item) => !item.disabled));
+ if (next === null) return;
+ event.preventDefault();
+ items[next].focus();
+ };
+
+ return { menuRef, triggerRef, onKeyDown };
+}
diff --git a/affiche-frontend/src/hooks/usePosterBrowser.ts b/affiche-frontend/src/hooks/usePosterBrowser.ts
index e542d35..7f78d77 100644
--- a/affiche-frontend/src/hooks/usePosterBrowser.ts
+++ b/affiche-frontend/src/hooks/usePosterBrowser.ts
@@ -26,6 +26,7 @@ export function usePosterBrowser({ item, mediaServerId, onApplied }: UsePosterBr
const [season, setSeason] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [uploadDefault, setUploadDefault] = useState(false);
+ const [initialPoster, setInitialPoster] = useState(undefined);
const loadUploadDefault = useCallback(async (libraryId: number) => {
if (mediaServerId == null) {
@@ -40,9 +41,10 @@ export function usePosterBrowser({ item, mediaServerId, onApplied }: UsePosterBr
}
}, [mediaServerId]);
- const open = useCallback(async (forSeason: ItemSeason | null) => {
+ const open = useCallback(async (forSeason: ItemSeason | null, poster?: string) => {
if (item) await loadUploadDefault(item.library_id);
setSeason(forSeason);
+ setInitialPoster(poster);
setIsOpen(true);
}, [item, loadUploadDefault]);
@@ -83,5 +85,5 @@ export function usePosterBrowser({ item, mediaServerId, onApplied }: UsePosterBr
}
}, [item, mediaServerId, season, onApplied, toast]);
- return { isOpen, season, isSaving, uploadDefault, open, close, save };
+ return { isOpen, season, isSaving, uploadDefault, initialPoster, open, close, save };
}
diff --git a/affiche-frontend/src/hooks/usePosterStyleDrafts.test.ts b/affiche-frontend/src/hooks/usePosterStyleDrafts.test.ts
index f6ab670..762fc8e 100644
--- a/affiche-frontend/src/hooks/usePosterStyleDrafts.test.ts
+++ b/affiche-frontend/src/hooks/usePosterStyleDrafts.test.ts
@@ -104,4 +104,28 @@ describe('usePosterStyleDrafts', () => {
expect(result.current.overlayOptions).toEqual({ border_enabled: true, blur_amount: 99 });
expect(result.current.quality).toBe(85);
});
+
+ it('puts every edit back when a reset is undone', () => {
+ const { result } = renderHook(() => usePosterStyleDrafts(config()));
+ act(() => result.current.changeOverlay({ blur_amount: 20 }));
+ act(() => result.current.changeQuality(50));
+
+ let undo: (() => void) | null = null;
+ act(() => { undo = result.current.reset(); });
+ act(() => undo!());
+
+ expect(result.current.overlayOptions).toEqual({ border_enabled: true, blur_amount: 20 });
+ expect(result.current.textOptions).toEqual({ font_name: 'Serif.ttf', all_caps: false });
+ expect(result.current.quality).toBe(50);
+ });
+
+ it('has nothing to undo when the style was never edited', () => {
+
+ const { result } = renderHook(() => usePosterStyleDrafts(config()));
+
+ let undo: (() => void) | null = () => {};
+ act(() => { undo = result.current.reset(); });
+
+ expect(undo).toBeNull();
+ });
});
diff --git a/affiche-frontend/src/hooks/usePosterStyleDrafts.ts b/affiche-frontend/src/hooks/usePosterStyleDrafts.ts
index fe28834..0a3e471 100644
--- a/affiche-frontend/src/hooks/usePosterStyleDrafts.ts
+++ b/affiche-frontend/src/hooks/usePosterStyleDrafts.ts
@@ -27,10 +27,16 @@ export function usePosterStyleDrafts(posterConfig: PosterConfig | null) {
},
changeQuality: setQualityDraft,
- reset: () => {
+ reset: (): (() => void) | null => {
+ if (overlayDraft === null && textDraft === null && qualityDraft === null) return null;
setOverlayDraft(null);
setTextDraft(null);
setQualityDraft(null);
+ return () => {
+ setOverlayDraft(overlayDraft);
+ setTextDraft(textDraft);
+ setQualityDraft(qualityDraft);
+ };
},
};
}
diff --git a/affiche-frontend/src/hooks/useStagePoster.ts b/affiche-frontend/src/hooks/useStagePoster.ts
new file mode 100644
index 0000000..02c600b
--- /dev/null
+++ b/affiche-frontend/src/hooks/useStagePoster.ts
@@ -0,0 +1,76 @@
+import { useEffect, useEffectEvent, useRef, useState } from 'react';
+
+import { libraryApi } from '../api';
+import { isNewArrival, newestWithPoster, type StagePoster } from '../components/library/stagePoster';
+import type { Library, LibraryItem } from '../types';
+
+const DWELL_MS = 4000;
+const NEWEST_PAGE = 12;
+
+interface UseStagePosterOptions {
+
+ library?: Library;
+
+ items: LibraryItem[];
+ enabled: boolean;
+}
+
+export function useStagePoster({ library, items, enabled }: UseStagePosterOptions) {
+ const [staged, setStaged] = useState(null);
+ const offer = useRef | null>(null);
+
+ const libraryId = library?.id;
+ const mediaServerId = library?.media_server_id;
+
+ useEffect(() => {
+ if (!enabled || libraryId === undefined || mediaServerId === undefined) return;
+ let cancelled = false;
+ const load = async () => {
+ try {
+ const page = await libraryApi.getLibraryItems(mediaServerId, libraryId, {
+
+ pageSize: NEWEST_PAGE, sortBy: 'poster_generated_at', sortDir: 'desc',
+ });
+ if (cancelled) return;
+
+ setStaged((prev) => (prev?.reason === 'generated' && prev.libraryId === libraryId
+ ? prev
+ : newestWithPoster(page.items)));
+ } catch {}
+ };
+ void load();
+ return () => { cancelled = true; };
+ }, [enabled, libraryId, mediaServerId]);
+
+ const takeOffer = useEffectEvent(async () => {
+ const next = offer.current;
+ offer.current = null;
+ if (!next || mediaServerId === undefined || !isNewArrival(staged, next)) return;
+
+ const listed = items.find((item) => item.id === next.itemId && item.library_id === next.libraryId);
+ let title = listed?.title;
+ if (!title) {
+ try {
+ title = (await libraryApi.getItem(mediaServerId, next.libraryId, next.itemId)).title;
+ } catch {
+ return;
+ }
+ }
+ setStaged({ ...next, title });
+ });
+
+ useEffect(() => {
+ if (!enabled) return;
+ const timer = window.setInterval(() => { void takeOffer(); }, DWELL_MS);
+ return () => window.clearInterval(timer);
+ }, [enabled]);
+
+ return {
+
+ poster: staged?.libraryId === libraryId ? staged : null,
+ offerGenerated: (eventLibraryId: number, itemId: number, posterVersion?: string | null) => {
+ if (eventLibraryId !== libraryId || posterVersion == null) return;
+ offer.current = { libraryId: eventLibraryId, itemId, posterVersion, reason: 'generated' };
+ },
+ };
+}
diff --git a/affiche-frontend/src/hooks/useTaskTracking.test.ts b/affiche-frontend/src/hooks/useTaskTracking.test.ts
index b5e35a8..1411363 100644
--- a/affiche-frontend/src/hooks/useTaskTracking.test.ts
+++ b/affiche-frontend/src/hooks/useTaskTracking.test.ts
@@ -24,7 +24,7 @@ describe('taskKindFromName', () => {
});
it('falls back to a bar-less kind for anything else', () => {
- expect(taskKindFromName('poster_upload_2')).toBe('other');
+ expect(taskKindFromName('poster_upload_2')).toBe('upload');
expect(taskKindFromName('something_new')).toBe('other');
expect(taskKindFromName(undefined)).toBe('other');
});
diff --git a/affiche-frontend/src/hooks/useTaskTracking.ts b/affiche-frontend/src/hooks/useTaskTracking.ts
index 5a65a41..bfb4431 100644
--- a/affiche-frontend/src/hooks/useTaskTracking.ts
+++ b/affiche-frontend/src/hooks/useTaskTracking.ts
@@ -6,13 +6,19 @@ import type { TaskKind, TaskProgressState } from '../types';
export function taskKindFromName(name?: string): TaskKind {
if (name?.startsWith('library_sync')) return 'sync';
if (name?.startsWith('poster_sync')) return 'generate';
+ if (name?.startsWith('poster_upload')) return 'upload';
if (name?.startsWith('poster_reset')) return 'reset';
return 'other';
}
+export interface TaskOutcome {
+ status: 'completed' | 'failed' | 'cancelled';
+ kind: TaskKind;
+}
+
interface UseTaskTrackingOptions {
- onTaskFinished: () => void;
+ onTaskFinished: (outcome: TaskOutcome) => void;
}
export function useTaskTracking({ onTaskFinished }: UseTaskTrackingOptions) {
@@ -27,7 +33,7 @@ export function useTaskTracking({ onTaskFinished }: UseTaskTrackingOptions) {
const startTaskTracking = useCallback((taskId: string, message?: string, kind: TaskKind = 'other') => {
currentTaskIdRef.current = taskId;
setIsActionLoading(true);
- setTaskMessage(message || 'Starting task...');
+ setTaskMessage(message || 'Starting task…');
setTaskKind(kind);
setTaskProgress(null);
}, []);
@@ -63,7 +69,7 @@ export function useTaskTracking({ onTaskFinished }: UseTaskTrackingOptions) {
(taskId: string, status: string, taskName: string, message?: string, error?: string) => {
if (currentTaskIdRef.current !== taskId) return;
- setTaskMessage(message || `Task ${status}...`);
+ setTaskMessage(message || `Task ${status}…`);
setTaskKind((prev) => prev ?? taskKindFromName(taskName));
if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') return;
@@ -79,7 +85,7 @@ export function useTaskTracking({ onTaskFinished }: UseTaskTrackingOptions) {
toast.error(error || 'Unknown error', { title: 'Task failed' });
}
- onTaskFinished();
+ onTaskFinished({ status: status as TaskOutcome['status'], kind: taskKindFromName(taskName) });
},
[onTaskFinished, toast]
);
diff --git a/affiche-frontend/src/hooks/useUnsavedChangesGuard.test.tsx b/affiche-frontend/src/hooks/useUnsavedChangesGuard.test.tsx
new file mode 100644
index 0000000..e672e84
--- /dev/null
+++ b/affiche-frontend/src/hooks/useUnsavedChangesGuard.test.tsx
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+import { createMemoryRouter, RouterProvider, useNavigate } from 'react-router-dom';
+
+import { useUnsavedChangesGuard } from './useUnsavedChangesGuard';
+
+function Editor({ dirty }: { dirty: boolean }) {
+ const navigate = useNavigate();
+ const guard = useUnsavedChangesGuard(dirty, (from, to) => from.pathname === to.pathname);
+ return (
+ <>
+
+
+ {guard.isAsking && (
+ <>
+ Leave without saving?
+
+
+ >
+ )}
+ >
+ );
+}
+
+function renderAt(dirty: boolean) {
+ const router = createMemoryRouter(
+ [
+ { path: '/settings', element: },
+ { path: '/dashboard', element: Dashboard page
},
+ ],
+ { initialEntries: ['/settings'] },
+ );
+ render();
+ return router;
+}
+
+const click = (name: string) => act(() => screen.getByRole('button', { name }).click());
+
+describe('useUnsavedChangesGuard', () => {
+ it('lets a clean editor go anywhere without asking', async () => {
+ const router = renderAt(false);
+
+ await click('Dashboard');
+
+ expect(router.state.location.pathname).toBe('/dashboard');
+ });
+
+ it('asks before leaving with unsaved edits, and stays when told to', async () => {
+ const router = renderAt(true);
+
+ await click('Dashboard');
+ expect(screen.getByText('Leave without saving?')).toBeInTheDocument();
+
+ await click('Keep editing');
+ expect(router.state.location.pathname).toBe('/settings');
+ expect(screen.queryByText('Leave without saving?')).not.toBeInTheDocument();
+ });
+
+ it('leaves when the edits are discarded', async () => {
+ const router = renderAt(true);
+
+ await click('Dashboard');
+ await click('Discard changes');
+
+ expect(router.state.location.pathname).toBe('/dashboard');
+ });
+
+ it('does not ask for a move that keeps the editor mounted', async () => {
+ const router = renderAt(true);
+
+ await click('Fonts');
+
+ expect(router.state.location.search).toBe('?section=fonts');
+ expect(screen.queryByText('Leave without saving?')).not.toBeInTheDocument();
+ });
+});
diff --git a/affiche-frontend/src/hooks/useUnsavedChangesGuard.ts b/affiche-frontend/src/hooks/useUnsavedChangesGuard.ts
new file mode 100644
index 0000000..5bd52e8
--- /dev/null
+++ b/affiche-frontend/src/hooks/useUnsavedChangesGuard.ts
@@ -0,0 +1,23 @@
+import { useEffect } from 'react';
+import { useBlocker, type Location } from 'react-router-dom';
+
+export function useUnsavedChangesGuard(
+ hasUnsavedChanges: boolean,
+ isSameScreen: (from: Location, to: Location) => boolean = () => false,
+) {
+ const blocker = useBlocker(({ currentLocation, nextLocation }) =>
+ hasUnsavedChanges && !isSameScreen(currentLocation, nextLocation));
+
+ useEffect(() => {
+ if (!hasUnsavedChanges) return;
+ const warn = (event: BeforeUnloadEvent) => event.preventDefault();
+ window.addEventListener('beforeunload', warn);
+ return () => window.removeEventListener('beforeunload', warn);
+ }, [hasUnsavedChanges]);
+
+ return {
+ isAsking: blocker.state === 'blocked',
+ stay: () => blocker.reset?.(),
+ leave: () => blocker.proceed?.(),
+ };
+}
diff --git a/affiche-frontend/src/index.css b/affiche-frontend/src/index.css
index f69d7a5..c7c2fee 100644
--- a/affiche-frontend/src/index.css
+++ b/affiche-frontend/src/index.css
@@ -2,6 +2,42 @@
--sidebar-width: 240px;
--header-height: 56px;
+
+ --text-micro: 0.6rem;
+ --text-2xs: 0.7rem;
+ --text-xs: 0.75rem;
+ --text-sm: 0.8rem;
+ --text-md: 0.85rem;
+ --text-base: 0.9rem;
+ --text-lg: 1rem;
+ --text-xl: 1.1rem;
+ --text-2xl: 1.25rem;
+ --text-3xl: 1.5rem;
+
+ --radius-xs: 3px;
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 10px;
+ --radius-2xl: 12px;
+ --radius-pill: 999px;
+
+ --z-rail: 5;
+ --z-sticky: 10;
+ --z-popover: 20;
+ --z-sidebar-rail: 99;
+ --z-sidebar: 100;
+ --z-sidebar-backdrop: 101;
+ --z-sidebar-overlay: 102;
+ --z-modal: 200;
+ --z-modal-elevated: 300;
+ --z-toast: 1000;
+}
+
+@media (max-width: 699px) {
+ :root {
+ --header-height: 104px;
+ }
}
:root,
@@ -20,21 +56,40 @@
--bg-hover: #3d3d3d;
--bg-card: #2a2a2a;
--text-primary: #f0f0f0;
- --text-secondary: #9a9a9a;
- --text-muted: #666666;
- --accent: #5c9ded;
+
+ --text-secondary: #ababab;
+ --text-muted: #9c9c9c;
+ --accent: #5f9fed;
--accent-hover: #7ab3f5;
- --success: #5cb85c;
+ --success: #65bc65;
--warning: #f0ad4e;
- --warning-hover: #d9932c;
- --error: #d9534f;
- --error-hover: #c33c38;
+ --error: #ec7b76;
--border: #3a3a3a;
+ --accent-fill: #2f6fd0;
+ --accent-fill-hover: #245bb0;
+ --success-fill: #2e8540;
+ --success-fill-hover: #256b34;
+ --warning-fill: #a86400;
+ --warning-fill-hover: #874f00;
+ --error-fill: #c9443f;
+ --error-fill-hover: #a52020;
+
+ --focus-ring: #7ab3f5;
+
--text-on-fill: #ffffff;
--error-tint: rgba(217, 83, 79, 0.12);
+ --notice-error-edge: rgba(236, 123, 118, 0.5);
+ --notice-error-tint: rgba(236, 123, 118, 0.1);
+ --notice-warning-edge: rgba(240, 173, 78, 0.5);
+ --notice-warning-tint: rgba(240, 173, 78, 0.1);
+ --notice-success-edge: rgba(101, 188, 101, 0.5);
+ --notice-success-tint: rgba(101, 188, 101, 0.1);
+ --notice-info-edge: rgba(95, 159, 237, 0.5);
+ --notice-info-tint: rgba(95, 159, 237, 0.1);
+
--overlay-scrim: rgba(0, 0, 0, 0.5);
--backdrop: rgba(0, 0, 0, 0.7);
@@ -68,20 +123,37 @@
--bg-card: #ffffff;
--text-primary: #1b1d21;
--text-secondary: #5c6370;
- --text-muted: #848b98;
-
- --accent: #2f6fd0;
+ --text-muted: #5f6672;
+ --accent: #2a64bb;
--accent-hover: #245bb0;
- --success: #2e7d32;
- --warning: #a05f00;
- --warning-hover: #874f00;
- --error: #c62828;
- --error-hover: #a52020;
+ --success: #2b742e;
+ --warning: #935700;
+ --error: #c32727;
--border: #dcdfe4;
+ --accent-fill: #2f6fd0;
+ --accent-fill-hover: #245bb0;
+ --success-fill: #2e7d32;
+ --success-fill-hover: #256b34;
+ --warning-fill: #a05f00;
+ --warning-fill-hover: #874f00;
+ --error-fill: #c62828;
+ --error-fill-hover: #a52020;
+
+ --focus-ring: #2f6fd0;
+
--text-on-fill: #ffffff;
--error-tint: rgba(198, 40, 40, 0.1);
+ --notice-error-edge: rgba(198, 40, 40, 0.45);
+ --notice-error-tint: rgba(198, 40, 40, 0.07);
+ --notice-warning-edge: rgba(160, 95, 0, 0.45);
+ --notice-warning-tint: rgba(160, 95, 0, 0.08);
+ --notice-success-edge: rgba(46, 125, 50, 0.45);
+ --notice-success-tint: rgba(46, 125, 50, 0.07);
+ --notice-info-edge: rgba(47, 111, 208, 0.45);
+ --notice-info-tint: rgba(47, 111, 208, 0.07);
+
--overlay-scrim: rgba(0, 0, 0, 0.5);
--backdrop: rgba(15, 18, 22, 0.45);
--backdrop-elevated: rgba(15, 18, 22, 0.6);
@@ -149,6 +221,20 @@ input, select {
font-size: inherit;
}
+:focus-visible {
+ outline: 2px solid var(--focus-ring);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
::-webkit-scrollbar {
width: 8px;
height: 8px;
@@ -160,7 +246,7 @@ input, select {
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
- border-radius: 4px;
+ border-radius: var(--radius-sm);
}
::-webkit-scrollbar-thumb:hover {
@@ -187,6 +273,15 @@ input, select {
color: var(--error);
}
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
.spin {
animation: spin 0.8s linear infinite;
}
diff --git a/affiche-frontend/src/motion.ts b/affiche-frontend/src/motion.ts
new file mode 100644
index 0000000..69a4ebe
--- /dev/null
+++ b/affiche-frontend/src/motion.ts
@@ -0,0 +1,3 @@
+export function scrollBehavior(): ScrollBehavior {
+ return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
+}
diff --git a/affiche-frontend/src/pages/AuthPage.module.css b/affiche-frontend/src/pages/AuthPage.module.css
index 4412bfd..ed994f2 100644
--- a/affiche-frontend/src/pages/AuthPage.module.css
+++ b/affiche-frontend/src/pages/AuthPage.module.css
@@ -12,7 +12,7 @@
max-width: 380px;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
- border-radius: 12px;
+ border-radius: var(--radius-2xl);
padding: 32px;
box-shadow: var(--shadow-xl);
}
@@ -27,7 +27,7 @@
}
.brandText {
- font-size: 1.25rem;
+ font-size: var(--text-2xl);
font-weight: 600;
letter-spacing: 0.26em;
text-transform: uppercase;
@@ -45,7 +45,7 @@
.subtitle {
text-align: center;
color: var(--text-secondary);
- font-size: 0.9rem;
+ font-size: var(--text-base);
margin-bottom: 24px;
}
@@ -62,7 +62,7 @@
}
.label {
- font-size: 0.8rem;
+ font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
}
@@ -70,11 +70,11 @@
.input {
width: 100%;
padding: 10px 12px;
- border-radius: 8px;
+ border-radius: var(--radius-lg);
border: 1px solid var(--border);
background-color: var(--bg-tertiary);
color: var(--text-primary);
- font-size: 0.9rem;
+ font-size: var(--text-base);
transition: border-color 0.15s;
}
@@ -86,16 +86,16 @@
.submit {
margin-top: 4px;
padding: 11px 14px;
- border-radius: 8px;
- background-color: var(--accent);
+ border-radius: var(--radius-lg);
+ background-color: var(--accent-fill);
color: var(--text-on-fill);
- font-size: 0.9rem;
+ font-size: var(--text-base);
font-weight: 600;
transition: background-color 0.15s;
}
.submit:hover:not(:disabled) {
- background-color: var(--accent-hover);
+ background-color: var(--accent-fill-hover);
}
.submit:disabled {
@@ -104,19 +104,20 @@
}
.error {
- background-color: var(--error-tint);
- border: 1px solid var(--error);
+ background-color: var(--notice-error-tint);
+ border: 1px solid var(--notice-error-edge);
color: var(--error);
- border-radius: 8px;
+ border-radius: var(--radius-lg);
padding: 10px 12px;
- font-size: 0.82rem;
+ font-size: var(--text-sm);
}
.notice {
- border: 1px solid var(--warning);
+ border: 1px solid var(--notice-warning-edge);
+ background-color: var(--notice-warning-tint);
color: var(--text-secondary);
- border-radius: 8px;
+ border-radius: var(--radius-lg);
padding: 10px 12px;
- font-size: 0.82rem;
+ font-size: var(--text-sm);
line-height: 1.5;
}
diff --git a/affiche-frontend/src/pages/ChangePasswordPage.test.tsx b/affiche-frontend/src/pages/ChangePasswordPage.test.tsx
index 92b669b..0e6d440 100644
--- a/affiche-frontend/src/pages/ChangePasswordPage.test.tsx
+++ b/affiche-frontend/src/pages/ChangePasswordPage.test.tsx
@@ -70,13 +70,24 @@ describe('ChangePasswordPage', () => {
await waitFor(() => expect(changePassword).toHaveBeenCalledWith(TEMPORARY, CHOSEN));
});
- it('refuses a mistyped confirmation without asking the backend', async () => {
+ it('refuses a mistyped confirmation without asking the backend, and says so while typing', async () => {
const user = userEvent.setup();
renderPage();
await fill(user, { confirm: 'something-else' });
- expect(await screen.findByText('Passwords do not match')).toBeInTheDocument();
+ expect(screen.getByText('Both passwords match').closest('li')).toHaveTextContent('(not met yet)');
+ expect(screen.getByRole('button', { name: 'Set password' })).toBeDisabled();
+ expect(changePassword).not.toHaveBeenCalled();
+ });
+
+ it('holds a new password shorter than the minimum back before submitting', async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await fill(user, { next: 'short', confirm: 'short' });
+
+ expect(screen.getByText('At least 8 characters').closest('li')).toHaveTextContent('(not met yet)');
expect(changePassword).not.toHaveBeenCalled();
});
diff --git a/affiche-frontend/src/pages/ChangePasswordPage.tsx b/affiche-frontend/src/pages/ChangePasswordPage.tsx
index 65bbf89..3182ab9 100644
--- a/affiche-frontend/src/pages/ChangePasswordPage.tsx
+++ b/affiche-frontend/src/pages/ChangePasswordPage.tsx
@@ -1,7 +1,7 @@
import { useState, type FormEvent } from 'react';
import { errorMessage } from '../api';
import { useAuth } from '../context/AuthContext';
-import { AfficheLogo } from '../components/common';
+import { AfficheLogo, PasswordRules, passwordRules } from '../components/common';
import styles from './AuthPage.module.css';
export function ChangePasswordPage() {
@@ -11,6 +11,7 @@ export function ChangePasswordPage() {
const [confirm, setConfirm] = useState('');
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
+ const rules = passwordRules(password, confirm);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -85,7 +86,8 @@ export function ChangePasswordPage() {
required
/>