feat(web): page admin des sources de métadonnées et de l'automatisation
Configuration des providers (activation, priorité, clé API), réglages d'automatisation (watch des bibliothèques, auto-enrichissement, planifications scan/enrichissement) et déclenchement manuel, avec client API, mocks et styles associés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -3,6 +3,7 @@ import type { Session } from "./api/types";
|
||||
import { api } from "./api/client";
|
||||
import { isPrivateRoute } from "./auth/routing";
|
||||
import { AppShell } from "./layout/AppShell";
|
||||
import { AdminAutomationPage } from "./pages/AdminAutomationPage";
|
||||
import { AdminPage } from "./pages/AdminPage";
|
||||
import { BookPage } from "./pages/BookPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
@ -31,6 +32,8 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
|
||||
<SearchPage />
|
||||
) : route.name === "me" ? (
|
||||
<ProfilePage session={session} onSessionChange={refreshSession} />
|
||||
) : route.name === "admin" && route.section === "automation" ? (
|
||||
<AdminAutomationPage />
|
||||
) : (
|
||||
<AdminPage />
|
||||
);
|
||||
|
||||
@ -30,4 +30,58 @@ describe("api fallback helpers", () => {
|
||||
expect(init.method).toBe("DELETE");
|
||||
expect(headers.has("Content-Type")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends metadata source updates to the admin endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.updateMetadataSources({
|
||||
isbnPriorityEnabled: false,
|
||||
sources: [{ provider: "openlibrary", enabled: true, priority: 1 }]
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/admin/metadata-sources");
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
isbnPriorityEnabled: false,
|
||||
sources: [{ provider: "openlibrary", enabled: true, priority: 1 }]
|
||||
});
|
||||
});
|
||||
|
||||
it("sends automation settings to the admin endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
watchLibraries: true,
|
||||
autoEnrichNewBooks: true,
|
||||
scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 },
|
||||
enrichSchedule: { frequency: "disabled", time: "04:00", dayOfWeek: 1 }
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
)
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.updateAutomationSettings({
|
||||
watchLibraries: true,
|
||||
scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 }
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/admin/automation");
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
watchLibraries: true,
|
||||
scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,17 +2,30 @@ import type {
|
||||
BookDto,
|
||||
BookQueryDto,
|
||||
AuthStatusDto,
|
||||
AutomationSettingsDto,
|
||||
BootstrapAdminDto,
|
||||
CreateLibraryDto,
|
||||
JobDto,
|
||||
LibraryDto,
|
||||
LoginDto,
|
||||
MetadataSourcesConfigDto,
|
||||
ProgressDto,
|
||||
UpdateAutomationSettingsDto,
|
||||
UpdateAccountDto,
|
||||
UpdateMetadataSourcesConfigDto,
|
||||
UpdateProgressDto,
|
||||
UserDto
|
||||
} from "@readabook/shared";
|
||||
import { mockBooks, mockContinue, mockJobs, mockLibraries, mockProgress, mockUser } from "./mockData";
|
||||
import {
|
||||
mockAutomationSettings,
|
||||
mockBooks,
|
||||
mockContinue,
|
||||
mockJobs,
|
||||
mockLibraries,
|
||||
mockMetadataSources,
|
||||
mockProgress,
|
||||
mockUser
|
||||
} from "./mockData";
|
||||
import type { CbzPagesDto, ContinueItem, Session } from "./types";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
@ -187,5 +200,31 @@ export const api = {
|
||||
},
|
||||
async users(): Promise<UserDto[]> {
|
||||
return request<UserDto[]>("/admin/users", { fallback: [mockUser] });
|
||||
},
|
||||
async metadataSources(): Promise<MetadataSourcesConfigDto> {
|
||||
return request<MetadataSourcesConfigDto>("/admin/metadata-sources", { fallback: mockMetadataSources });
|
||||
},
|
||||
async updateMetadataSources(input: UpdateMetadataSourcesConfigDto): Promise<MetadataSourcesConfigDto> {
|
||||
return request<MetadataSourcesConfigDto>("/admin/metadata-sources", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
fallback: mockMetadataSources
|
||||
});
|
||||
},
|
||||
async automationSettings(): Promise<AutomationSettingsDto> {
|
||||
return request<AutomationSettingsDto>("/admin/automation", { fallback: mockAutomationSettings });
|
||||
},
|
||||
async updateAutomationSettings(input: UpdateAutomationSettingsDto): Promise<AutomationSettingsDto> {
|
||||
return request<AutomationSettingsDto>("/admin/automation", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
fallback: mockAutomationSettings
|
||||
});
|
||||
},
|
||||
async runAutomationScan(): Promise<JobDto> {
|
||||
return request<JobDto>("/admin/automation/run-scan", { method: "POST", fallback: mockJobs[0] });
|
||||
},
|
||||
async runAutomationEnrich(): Promise<JobDto> {
|
||||
return request<JobDto>("/admin/automation/run-enrich", { method: "POST", fallback: mockJobs[0] });
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { BookDto, JobDto, LibraryDto, ProgressDto, UserDto } from "@readabook/shared";
|
||||
import type { AutomationSettingsDto, BookDto, JobDto, LibraryDto, MetadataSourcesConfigDto, ProgressDto, UserDto } from "@readabook/shared";
|
||||
import type { ContinueItem } from "./types";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@ -24,6 +24,7 @@ export const mockBooks: BookDto[] = [
|
||||
author: "M. Valrose",
|
||||
description: "Fragments, croquis et notes rassemblees autour d'automates introuvables.",
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: "fr",
|
||||
publisher: "Cabinet ReadaBook",
|
||||
publishedDate: "1908",
|
||||
@ -42,6 +43,7 @@ export const mockBooks: BookDto[] = [
|
||||
author: "I. Nadir",
|
||||
description: "Un atlas annote ou chaque page devient une vitrine de lecture.",
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: "fr",
|
||||
publisher: "ReadaBook",
|
||||
publishedDate: "1921",
|
||||
@ -60,6 +62,7 @@ export const mockBooks: BookDto[] = [
|
||||
author: "A. Muze",
|
||||
description: "Un recit graphique indexe comme archive CBZ.",
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: "fr",
|
||||
publisher: "ReadaBook",
|
||||
publishedDate: "1934",
|
||||
@ -78,6 +81,7 @@ export const mockBooks: BookDto[] = [
|
||||
author: "L. Rar",
|
||||
description: "Archive CBR lue avec le même parcours paginé que les comics CBZ.",
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: "fr",
|
||||
publisher: "ReadaBook",
|
||||
publishedDate: "1937",
|
||||
@ -106,3 +110,20 @@ export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
|
||||
export const mockJobs: JobDto[] = [
|
||||
{ id: 1, type: "scan-library", status: "succeeded", detail: "2 ouvrages indexes", error: null, createdAt: now, updatedAt: now }
|
||||
];
|
||||
|
||||
export const mockMetadataSources: MetadataSourcesConfigDto = {
|
||||
isbnPriorityEnabled: true,
|
||||
sources: [
|
||||
{ provider: "local", enabled: true, priority: 0, hasApiKey: false },
|
||||
{ provider: "openlibrary", enabled: true, priority: 1, hasApiKey: false },
|
||||
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false },
|
||||
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
|
||||
]
|
||||
};
|
||||
|
||||
export const mockAutomationSettings: AutomationSettingsDto = {
|
||||
watchLibraries: false,
|
||||
autoEnrichNewBooks: true,
|
||||
scanSchedule: { frequency: "disabled", time: "03:00", dayOfWeek: 1 },
|
||||
enrichSchedule: { frequency: "weekly", time: "04:00", dayOfWeek: 1 }
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Archive, Home, Search, Settings, UserRound } from "lucide-react";
|
||||
import { Archive, Home, Search, Settings, SlidersHorizontal, UserRound } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Session } from "../api/types";
|
||||
import { navigate } from "../router";
|
||||
@ -7,6 +7,7 @@ const navItems = [
|
||||
{ href: "/home", label: "Accueil", icon: Home },
|
||||
{ href: "/search", label: "Recherche", icon: Search },
|
||||
{ href: "/admin/libraries", label: "Admin", icon: Settings },
|
||||
{ href: "/admin/automation", label: "Automatisation", icon: SlidersHorizontal },
|
||||
{ href: "/me", label: "Profil", icon: UserRound }
|
||||
];
|
||||
|
||||
|
||||
506
apps/web/src/pages/AdminAutomationPage.tsx
Normal file
506
apps/web/src/pages/AdminAutomationPage.tsx
Normal file
@ -0,0 +1,506 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Play, Save } from "lucide-react";
|
||||
import type {
|
||||
AutomationFrequency,
|
||||
AutomationScheduleDto,
|
||||
AutomationSettingsDto,
|
||||
MetadataProviderId,
|
||||
MetadataSourcesConfigDto
|
||||
} from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||||
import {
|
||||
metadataSourcesPayload,
|
||||
moveSource,
|
||||
normalizeMetadataSources,
|
||||
providerLabels,
|
||||
scheduleDays,
|
||||
scheduleSummary
|
||||
} from "./adminAutomation";
|
||||
|
||||
type AdminAutomationTab = "sources" | "automation";
|
||||
type ApiState<T> = {
|
||||
initial: T | null;
|
||||
draft: T | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
|
||||
const defaultMetadataConfig: MetadataSourcesConfigDto = {
|
||||
isbnPriorityEnabled: true,
|
||||
sources: [
|
||||
{ provider: "local", enabled: true, priority: 0, hasApiKey: false },
|
||||
{ provider: "openlibrary", enabled: false, priority: 1, hasApiKey: false },
|
||||
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false },
|
||||
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
|
||||
]
|
||||
};
|
||||
|
||||
const defaultAutomationSettings: AutomationSettingsDto = {
|
||||
watchLibraries: false,
|
||||
autoEnrichNewBooks: false,
|
||||
scanSchedule: { frequency: "disabled", time: "03:00", dayOfWeek: 1 },
|
||||
enrichSchedule: { frequency: "disabled", time: "04:00", dayOfWeek: 1 }
|
||||
};
|
||||
|
||||
export function AdminAutomationPage() {
|
||||
const [tab, setTab] = useState<AdminAutomationTab>("sources");
|
||||
const [metadataState, setMetadataState] = useState<ApiState<MetadataSourcesConfigDto>>({
|
||||
initial: null,
|
||||
draft: null,
|
||||
loading: true,
|
||||
saving: false
|
||||
});
|
||||
const [automationState, setAutomationState] = useState<ApiState<AutomationSettingsDto>>({
|
||||
initial: null,
|
||||
draft: null,
|
||||
loading: true,
|
||||
saving: false
|
||||
});
|
||||
const [apiKeys, setApiKeys] = useState<Partial<Record<MetadataProviderId, string>>>({});
|
||||
|
||||
const metadataDirty = useMemo(
|
||||
() => Boolean(metadataState.initial && metadataState.draft && JSON.stringify(metadataState.initial) !== JSON.stringify(metadataState.draft)),
|
||||
[metadataState.initial, metadataState.draft]
|
||||
);
|
||||
const automationDirty = useMemo(
|
||||
() =>
|
||||
Boolean(automationState.initial && automationState.draft && JSON.stringify(automationState.initial) !== JSON.stringify(automationState.draft)),
|
||||
[automationState.initial, automationState.draft]
|
||||
);
|
||||
|
||||
async function refreshMetadata() {
|
||||
setMetadataState((current) => ({ ...current, loading: true, error: undefined, success: undefined }));
|
||||
try {
|
||||
const next = normalizeMetadataSources(await api.metadataSources());
|
||||
setMetadataState({ initial: next, draft: next, loading: false, saving: false });
|
||||
setApiKeys({});
|
||||
} catch (error) {
|
||||
const fallback = getApiFallback<MetadataSourcesConfigDto>(error);
|
||||
const next = normalizeMetadataSources(fallback ?? defaultMetadataConfig);
|
||||
setMetadataState({
|
||||
initial: next,
|
||||
draft: next,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: fallback ? "Sources chargees en mode degrade." : "Lecture des sources impossible."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAutomation() {
|
||||
setAutomationState((current) => ({ ...current, loading: true, error: undefined, success: undefined }));
|
||||
try {
|
||||
const next = await api.automationSettings();
|
||||
setAutomationState({ initial: next, draft: next, loading: false, saving: false });
|
||||
} catch (error) {
|
||||
const fallback = getApiFallback<AutomationSettingsDto>(error);
|
||||
const next = fallback ?? defaultAutomationSettings;
|
||||
setAutomationState({
|
||||
initial: next,
|
||||
draft: next,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: fallback ? "Automatisation chargee en mode degrade." : "Lecture de l'automatisation impossible."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMetadata();
|
||||
void refreshAutomation();
|
||||
}, []);
|
||||
|
||||
async function saveMetadata(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!metadataState.draft) return;
|
||||
setMetadataState((current) => ({ ...current, saving: true, error: undefined, success: undefined }));
|
||||
try {
|
||||
const payload = metadataSourcesPayload(metadataState.draft);
|
||||
payload.sources = payload.sources?.map((source) => {
|
||||
const apiKey = apiKeys[source.provider]?.trim();
|
||||
return apiKey ? { ...source, apiKey } : source;
|
||||
});
|
||||
const next = normalizeMetadataSources(await api.updateMetadataSources(payload));
|
||||
setMetadataState({ initial: next, draft: next, loading: false, saving: false, success: "Sources enregistrees." });
|
||||
setApiKeys({});
|
||||
} catch (error) {
|
||||
setMetadataState((current) => ({
|
||||
...current,
|
||||
saving: false,
|
||||
error: error instanceof Error ? error.message : "Enregistrement des sources impossible."
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAutomation(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!automationState.draft) return;
|
||||
setAutomationState((current) => ({ ...current, saving: true, error: undefined, success: undefined }));
|
||||
try {
|
||||
const next = await api.updateAutomationSettings(automationState.draft);
|
||||
setAutomationState({ initial: next, draft: next, loading: false, saving: false, success: "Automatisation enregistree." });
|
||||
} catch (error) {
|
||||
setAutomationState((current) => ({
|
||||
...current,
|
||||
saving: false,
|
||||
error: error instanceof Error ? error.message : "Enregistrement de l'automatisation impossible."
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow(kind: "scan" | "enrich") {
|
||||
setAutomationState((current) => ({ ...current, error: undefined, success: undefined }));
|
||||
try {
|
||||
if (kind === "scan") await api.runAutomationScan();
|
||||
else await api.runAutomationEnrich();
|
||||
setAutomationState((current) => ({
|
||||
...current,
|
||||
success: kind === "scan" ? "Scan planifie demande." : "Enrichissement planifie demande."
|
||||
}));
|
||||
} catch (error) {
|
||||
setAutomationState((current) => ({
|
||||
...current,
|
||||
error: error instanceof Error ? error.message : "Demande impossible."
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Panel className="span-3">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h1>Automatisation & enrichissement</h1>
|
||||
<p className="muted-copy">Sources, priorites et taches recurrentes.</p>
|
||||
</div>
|
||||
<span>{metadataDirty || automationDirty ? "modifications non enregistrees" : "a jour"}</span>
|
||||
</div>
|
||||
<div className="admin-tabs" role="tablist" aria-label="Automatisation admin">
|
||||
<button className={tab === "sources" ? "active" : ""} onClick={() => setTab("sources")} role="tab" aria-selected={tab === "sources"}>
|
||||
Sources de métadonnées
|
||||
</button>
|
||||
<button
|
||||
className={tab === "automation" ? "active" : ""}
|
||||
onClick={() => setTab("automation")}
|
||||
role="tab"
|
||||
aria-selected={tab === "automation"}
|
||||
>
|
||||
Automatisation
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{tab === "sources" ? (
|
||||
<MetadataSourcesPanel
|
||||
state={metadataState}
|
||||
dirty={metadataDirty}
|
||||
apiKeys={apiKeys}
|
||||
setApiKeys={setApiKeys}
|
||||
onChange={(draft) => setMetadataState((current) => ({ ...current, draft, success: undefined }))}
|
||||
onSubmit={saveMetadata}
|
||||
onRefresh={refreshMetadata}
|
||||
/>
|
||||
) : (
|
||||
<AutomationPanel
|
||||
state={automationState}
|
||||
dirty={automationDirty}
|
||||
onChange={(draft) => setAutomationState((current) => ({ ...current, draft, success: undefined }))}
|
||||
onSubmit={saveAutomation}
|
||||
onRefresh={refreshAutomation}
|
||||
onRunNow={runNow}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataSourcesPanel({
|
||||
state,
|
||||
dirty,
|
||||
apiKeys,
|
||||
setApiKeys,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onRefresh
|
||||
}: {
|
||||
state: ApiState<MetadataSourcesConfigDto>;
|
||||
dirty: boolean;
|
||||
apiKeys: Partial<Record<MetadataProviderId, string>>;
|
||||
setApiKeys: (next: Partial<Record<MetadataProviderId, string>>) => void;
|
||||
onChange: (draft: MetadataSourcesConfigDto) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const draft = state.draft;
|
||||
if (state.loading && !draft) return <LoadingPanel label="Lecture des sources" />;
|
||||
if (!draft) return null;
|
||||
|
||||
const local = draft.sources.find((source) => source.provider === "local");
|
||||
const external = draft.sources.filter((source) => source.provider !== "local");
|
||||
|
||||
return (
|
||||
<form className="span-3 automation-grid" onSubmit={onSubmit}>
|
||||
<Panel className="span-3">
|
||||
<div className="section-heading compact-heading">
|
||||
<div>
|
||||
<h2>Sources de métadonnées</h2>
|
||||
<p className="muted-copy">La source locale reste active en premier passage.</p>
|
||||
</div>
|
||||
<StatusText dirty={dirty} loading={state.loading} />
|
||||
</div>
|
||||
<ErrorRibbon message={state.error} />
|
||||
{state.success && <div className="success-ribbon">{state.success}</div>}
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.isbnPriorityEnabled}
|
||||
onChange={(event) => onChange({ ...draft, isbnPriorityEnabled: event.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
<strong>Priorite ISBN globale</strong>
|
||||
<small>Les correspondances ISBN passent avant les rapprochements titre/auteur.</small>
|
||||
</span>
|
||||
</label>
|
||||
</Panel>
|
||||
|
||||
<Panel className="span-3">
|
||||
<div className="provider-list">
|
||||
{local && (
|
||||
<div className="provider-row provider-local">
|
||||
<div>
|
||||
<strong>{providerLabels.local}</strong>
|
||||
<span>Source locale</span>
|
||||
</div>
|
||||
<span className="status-pill active">toujours active</span>
|
||||
</div>
|
||||
)}
|
||||
{external.map((source, index) => (
|
||||
<div className="provider-row" key={source.provider}>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={source.enabled}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...draft,
|
||||
sources: draft.sources.map((item) =>
|
||||
item.provider === source.provider ? { ...item, enabled: event.target.checked } : item
|
||||
)
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{providerLabels[source.provider]}</strong>
|
||||
<small>{source.enabled ? "active" : "inactive"}</small>
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
Cle API
|
||||
<input
|
||||
value={apiKeys[source.provider] ?? ""}
|
||||
onChange={(event) => setApiKeys({ ...apiKeys, [source.provider]: event.target.value })}
|
||||
placeholder={source.hasApiKey ? "cle conservee" : "optionnelle"}
|
||||
/>
|
||||
</label>
|
||||
<div className="provider-actions">
|
||||
<button
|
||||
className="ghost-button icon-button"
|
||||
type="button"
|
||||
title="Monter"
|
||||
disabled={index === 0}
|
||||
onClick={() => onChange({ ...draft, sources: moveSource(draft.sources, source.provider, -1) })}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button icon-button"
|
||||
type="button"
|
||||
title="Descendre"
|
||||
disabled={index === external.length - 1}
|
||||
onClick={() => onChange({ ...draft, sources: moveSource(draft.sources, source.provider, 1) })}
|
||||
>
|
||||
<ArrowDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<SaveBar dirty={dirty} saving={state.saving} onRefresh={onRefresh} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationPanel({
|
||||
state,
|
||||
dirty,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onRefresh,
|
||||
onRunNow
|
||||
}: {
|
||||
state: ApiState<AutomationSettingsDto>;
|
||||
dirty: boolean;
|
||||
onChange: (draft: AutomationSettingsDto) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
onRunNow: (kind: "scan" | "enrich") => Promise<void>;
|
||||
}) {
|
||||
const draft = state.draft;
|
||||
if (state.loading && !draft) return <LoadingPanel label="Lecture de l'automatisation" />;
|
||||
if (!draft) return null;
|
||||
|
||||
return (
|
||||
<form className="span-3 automation-grid" onSubmit={onSubmit}>
|
||||
<Panel className="span-3">
|
||||
<div className="section-heading compact-heading">
|
||||
<div>
|
||||
<h2>Automatisation</h2>
|
||||
<p className="muted-copy">Surveillance des dossiers et traitements planifies.</p>
|
||||
</div>
|
||||
<StatusText dirty={dirty} loading={state.loading} />
|
||||
</div>
|
||||
<ErrorRibbon message={state.error} />
|
||||
{state.success && <div className="success-ribbon">{state.success}</div>}
|
||||
<div className="toggle-stack">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.watchLibraries}
|
||||
onChange={(event) => onChange({ ...draft, watchLibraries: event.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
<strong>Watch auto</strong>
|
||||
<small>Les bibliotheques actives declenchent un scan quand un fichier change.</small>
|
||||
</span>
|
||||
</label>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.autoEnrichNewBooks}
|
||||
onChange={(event) => onChange({ ...draft, autoEnrichNewBooks: event.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
<strong>Auto enrich new files</strong>
|
||||
<small>Les nouveaux livres passent par la chaine d'enrichissement active.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<SchedulePanel
|
||||
title="Scan planifié"
|
||||
schedule={draft.scanSchedule}
|
||||
summary={scheduleSummary(draft.scanSchedule, "Scan")}
|
||||
runLabel="Lancer un scan"
|
||||
onRun={() => onRunNow("scan")}
|
||||
onChange={(scanSchedule) => onChange({ ...draft, scanSchedule })}
|
||||
/>
|
||||
<SchedulePanel
|
||||
title="Enrichissement planifié"
|
||||
schedule={draft.enrichSchedule}
|
||||
summary={scheduleSummary(draft.enrichSchedule, "Enrichissement")}
|
||||
runLabel="Lancer l'enrichissement"
|
||||
onRun={() => onRunNow("enrich")}
|
||||
onChange={(enrichSchedule) => onChange({ ...draft, enrichSchedule })}
|
||||
/>
|
||||
|
||||
<SaveBar dirty={dirty} saving={state.saving} onRefresh={onRefresh} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulePanel({
|
||||
title,
|
||||
schedule,
|
||||
summary,
|
||||
runLabel,
|
||||
onRun,
|
||||
onChange
|
||||
}: {
|
||||
title: string;
|
||||
schedule: AutomationScheduleDto;
|
||||
summary: string;
|
||||
runLabel: string;
|
||||
onRun: () => Promise<void>;
|
||||
onChange: (schedule: AutomationScheduleDto) => void;
|
||||
}) {
|
||||
function patch(next: Partial<AutomationScheduleDto>) {
|
||||
onChange({ ...schedule, ...next });
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<div className="section-heading compact-heading">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p className="muted-copy">{summary}</p>
|
||||
</div>
|
||||
<button className="ghost-button icon-text-button" type="button" onClick={() => void onRun()}>
|
||||
<Play size={16} />
|
||||
{runLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div className="schedule-controls">
|
||||
<label>
|
||||
Frequence
|
||||
<select value={schedule.frequency} onChange={(event) => patch({ frequency: event.target.value as AutomationFrequency })}>
|
||||
<option value="disabled">Desactive</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Heure
|
||||
<input type="time" value={schedule.time} onChange={(event) => patch({ time: event.target.value })} />
|
||||
</label>
|
||||
{schedule.frequency === "weekly" && (
|
||||
<label>
|
||||
Jour
|
||||
<select value={schedule.dayOfWeek} onChange={(event) => patch({ dayOfWeek: Number(event.target.value) })}>
|
||||
{scheduleDays.map((day) => (
|
||||
<option value={day.value} key={day.value}>
|
||||
{day.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveBar({ dirty, saving, onRefresh }: { dirty: boolean; saving: boolean; onRefresh: () => Promise<void> }) {
|
||||
return (
|
||||
<Panel className="span-3 save-bar">
|
||||
<span>{dirty ? "Modifications en attente." : "Aucune modification en attente."}</span>
|
||||
<div>
|
||||
<button className="ghost-button" type="button" onClick={() => void onRefresh()} disabled={saving}>
|
||||
Recharger
|
||||
</button>
|
||||
<button className="primary-button" type="submit" disabled={!dirty || saving}>
|
||||
<Save size={16} />
|
||||
{saving ? "Enregistrement" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusText({ dirty, loading }: { dirty: boolean; loading: boolean }) {
|
||||
if (loading) return <span>chargement</span>;
|
||||
return <span>{dirty ? "non enregistre" : "synchronise"}</span>;
|
||||
}
|
||||
|
||||
function LoadingPanel({ label }: { label: string }) {
|
||||
return (
|
||||
<Panel className="span-3">
|
||||
<LoadingState label={label} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/pages/adminAutomation.test.ts
Normal file
43
apps/web/src/pages/adminAutomation.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MetadataSourcesConfigDto } from "@readabook/shared";
|
||||
import { metadataSourcesPayload, moveSource, normalizeMetadataSources, scheduleSummary } from "./adminAutomation";
|
||||
|
||||
const config: MetadataSourcesConfigDto = {
|
||||
isbnPriorityEnabled: true,
|
||||
sources: [
|
||||
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: true },
|
||||
{ provider: "local", enabled: false, priority: 99, hasApiKey: false },
|
||||
{ provider: "openlibrary", enabled: true, priority: 1, hasApiKey: false },
|
||||
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
|
||||
]
|
||||
};
|
||||
|
||||
describe("admin automation helpers", () => {
|
||||
it("keeps the local metadata source active and first", () => {
|
||||
expect(normalizeMetadataSources(config).sources[0]).toMatchObject({
|
||||
provider: "local",
|
||||
enabled: true,
|
||||
priority: 0
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes the local source from the update payload", () => {
|
||||
expect(metadataSourcesPayload(normalizeMetadataSources(config))).toEqual({
|
||||
isbnPriorityEnabled: true,
|
||||
sources: [
|
||||
{ provider: "openlibrary", enabled: true, priority: 1 },
|
||||
{ provider: "googlebooks", enabled: false, priority: 2 },
|
||||
{ provider: "bnf", enabled: false, priority: 3 }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("moves only external providers", () => {
|
||||
const moved = moveSource(normalizeMetadataSources(config).sources, "bnf", -1);
|
||||
expect(moved.map((source) => source.provider)).toEqual(["local", "openlibrary", "bnf", "googlebooks"]);
|
||||
});
|
||||
|
||||
it("summarizes weekly schedules", () => {
|
||||
expect(scheduleSummary({ frequency: "weekly", time: "04:30", dayOfWeek: 1 }, "Scan")).toBe("Scan chaque lundi a 04:30.");
|
||||
});
|
||||
});
|
||||
65
apps/web/src/pages/adminAutomation.ts
Normal file
65
apps/web/src/pages/adminAutomation.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type {
|
||||
AutomationScheduleDto,
|
||||
MetadataProviderId,
|
||||
MetadataSourceConfigDto,
|
||||
MetadataSourcesConfigDto,
|
||||
UpdateMetadataSourcesConfigDto
|
||||
} from "@readabook/shared";
|
||||
|
||||
export const providerLabels: Record<MetadataProviderId, string> = {
|
||||
local: "Fichier local",
|
||||
openlibrary: "OpenLibrary",
|
||||
googlebooks: "Google Books",
|
||||
bnf: "BnF"
|
||||
};
|
||||
|
||||
const weekdays = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"];
|
||||
|
||||
export function normalizeMetadataSources(config: MetadataSourcesConfigDto): MetadataSourcesConfigDto {
|
||||
const sorted = [...config.sources].sort((left, right) => left.priority - right.priority);
|
||||
const local = sorted.find((source) => source.provider === "local") ?? { provider: "local", enabled: true, priority: 0, hasApiKey: false };
|
||||
const external = sorted.filter((source) => source.provider !== "local");
|
||||
return {
|
||||
isbnPriorityEnabled: config.isbnPriorityEnabled,
|
||||
sources: [
|
||||
{ ...local, enabled: true, priority: 0 },
|
||||
...external.map((source, index) => ({ ...source, priority: index + 1 }))
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function metadataSourcesPayload(config: MetadataSourcesConfigDto): UpdateMetadataSourcesConfigDto {
|
||||
const sources: NonNullable<UpdateMetadataSourcesConfigDto["sources"]> = [];
|
||||
config.sources.forEach((source) => {
|
||||
if (source.provider === "local") return;
|
||||
sources.push({
|
||||
provider: source.provider,
|
||||
enabled: source.enabled,
|
||||
priority: sources.length + 1
|
||||
});
|
||||
});
|
||||
return {
|
||||
isbnPriorityEnabled: config.isbnPriorityEnabled,
|
||||
sources
|
||||
};
|
||||
}
|
||||
|
||||
export function moveSource(sources: MetadataSourceConfigDto[], provider: MetadataProviderId, direction: -1 | 1): MetadataSourceConfigDto[] {
|
||||
const external = sources.filter((source) => source.provider !== "local");
|
||||
const index = external.findIndex((source) => source.provider === provider);
|
||||
const nextIndex = index + direction;
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= external.length) return sources;
|
||||
|
||||
const nextExternal = [...external];
|
||||
[nextExternal[index], nextExternal[nextIndex]] = [nextExternal[nextIndex], nextExternal[index]];
|
||||
const local = sources.find((source) => source.provider === "local") ?? { provider: "local", enabled: true, priority: 0, hasApiKey: false };
|
||||
return [local, ...nextExternal].map((source, priority) => ({ ...source, priority: source.provider === "local" ? 0 : priority }));
|
||||
}
|
||||
|
||||
export function scheduleSummary(schedule: AutomationScheduleDto, subject: string): string {
|
||||
if (schedule.frequency === "disabled") return `${subject} desactive.`;
|
||||
if (schedule.frequency === "daily") return `${subject} tous les jours a ${schedule.time}.`;
|
||||
return `${subject} chaque ${weekdays[schedule.dayOfWeek]} a ${schedule.time}.`;
|
||||
}
|
||||
|
||||
export const scheduleDays = weekdays.map((label, value) => ({ label, value }));
|
||||
@ -23,6 +23,7 @@
|
||||
|
||||
.brand-button,
|
||||
.side-rail nav button,
|
||||
.admin-tabs button,
|
||||
.ghost-button,
|
||||
.primary-button {
|
||||
display: inline-flex;
|
||||
@ -138,7 +139,8 @@ h2 {
|
||||
|
||||
.ghost-button:hover,
|
||||
.side-rail nav button:hover,
|
||||
.brand-button:hover {
|
||||
.brand-button:hover,
|
||||
.admin-tabs button:hover {
|
||||
border-color: rgba(213, 168, 77, 0.55);
|
||||
}
|
||||
|
||||
@ -380,6 +382,15 @@ input {
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
select {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 34px 0 12px;
|
||||
color: var(--ink);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
@ -442,6 +453,138 @@ input {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.admin-tabs button {
|
||||
min-width: 170px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.admin-tabs button.active {
|
||||
border-color: rgba(213, 168, 77, 0.72);
|
||||
background: rgba(213, 168, 77, 0.16);
|
||||
color: var(--brass);
|
||||
}
|
||||
|
||||
.automation-grid {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.compact-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.compact-heading h2 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.toggle-stack,
|
||||
.provider-list,
|
||||
.schedule-controls {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.toggle-row input[type="checkbox"] {
|
||||
width: 20px;
|
||||
min-height: 20px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--brass);
|
||||
}
|
||||
|
||||
.toggle-row span,
|
||||
.provider-row > div:first-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggle-row strong,
|
||||
.provider-row strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.provider-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 1fr) minmax(190px, 280px) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.provider-local {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.provider-row span,
|
||||
.provider-row small {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.provider-actions,
|
||||
.save-bar,
|
||||
.save-bar div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-actions {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 42px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon-text-button {
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-pill.active {
|
||||
border-color: rgba(45, 111, 99, 0.72);
|
||||
color: #d8fff5;
|
||||
background: rgba(45, 111, 99, 0.18);
|
||||
}
|
||||
|
||||
.schedule-controls {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
justify-content: space-between;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state {
|
||||
display: grid;
|
||||
@ -592,7 +735,7 @@ input {
|
||||
}
|
||||
|
||||
.side-rail nav {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@ -638,7 +781,24 @@ input {
|
||||
}
|
||||
|
||||
.library-table > div,
|
||||
.search-form {
|
||||
.search-form,
|
||||
.automation-grid,
|
||||
.provider-row,
|
||||
.provider-local,
|
||||
.schedule-controls,
|
||||
.save-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.save-bar,
|
||||
.save-bar div,
|
||||
.provider-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.save-bar div,
|
||||
.provider-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,10 +33,16 @@ body {
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user