diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index be34fb9..0831671 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ) : route.name === "me" ? ( + ) : route.name === "admin" && route.section === "automation" ? ( + ) : ( ); diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts index 1f8ccf8..aab4b8c 100644 --- a/apps/web/src/api/client.test.ts +++ b/apps/web/src/api/client.test.ts @@ -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 } + }); + }); }); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 7420756..489f910 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 { return request("/admin/users", { fallback: [mockUser] }); + }, + async metadataSources(): Promise { + return request("/admin/metadata-sources", { fallback: mockMetadataSources }); + }, + async updateMetadataSources(input: UpdateMetadataSourcesConfigDto): Promise { + return request("/admin/metadata-sources", { + method: "PUT", + body: JSON.stringify(input), + fallback: mockMetadataSources + }); + }, + async automationSettings(): Promise { + return request("/admin/automation", { fallback: mockAutomationSettings }); + }, + async updateAutomationSettings(input: UpdateAutomationSettingsDto): Promise { + return request("/admin/automation", { + method: "PUT", + body: JSON.stringify(input), + fallback: mockAutomationSettings + }); + }, + async runAutomationScan(): Promise { + return request("/admin/automation/run-scan", { method: "POST", fallback: mockJobs[0] }); + }, + async runAutomationEnrich(): Promise { + return request("/admin/automation/run-enrich", { method: "POST", fallback: mockJobs[0] }); } }; diff --git a/apps/web/src/api/mockData.ts b/apps/web/src/api/mockData.ts index 0db5833..23acb66 100644 --- a/apps/web/src/api/mockData.ts +++ b/apps/web/src/api/mockData.ts @@ -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 } +}; diff --git a/apps/web/src/layout/AppShell.tsx b/apps/web/src/layout/AppShell.tsx index 0b84200..4f695bf 100644 --- a/apps/web/src/layout/AppShell.tsx +++ b/apps/web/src/layout/AppShell.tsx @@ -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 } ]; diff --git a/apps/web/src/pages/AdminAutomationPage.tsx b/apps/web/src/pages/AdminAutomationPage.tsx new file mode 100644 index 0000000..a570abf --- /dev/null +++ b/apps/web/src/pages/AdminAutomationPage.tsx @@ -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 = { + 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("sources"); + const [metadataState, setMetadataState] = useState>({ + initial: null, + draft: null, + loading: true, + saving: false + }); + const [automationState, setAutomationState] = useState>({ + initial: null, + draft: null, + loading: true, + saving: false + }); + const [apiKeys, setApiKeys] = useState>>({}); + + 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(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(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 ( +
+ +
+
+

Automatisation & enrichissement

+

Sources, priorites et taches recurrentes.

+
+ {metadataDirty || automationDirty ? "modifications non enregistrees" : "a jour"} +
+
+ + +
+
+ + {tab === "sources" ? ( + setMetadataState((current) => ({ ...current, draft, success: undefined }))} + onSubmit={saveMetadata} + onRefresh={refreshMetadata} + /> + ) : ( + setAutomationState((current) => ({ ...current, draft, success: undefined }))} + onSubmit={saveAutomation} + onRefresh={refreshAutomation} + onRunNow={runNow} + /> + )} +
+ ); +} + +function MetadataSourcesPanel({ + state, + dirty, + apiKeys, + setApiKeys, + onChange, + onSubmit, + onRefresh +}: { + state: ApiState; + dirty: boolean; + apiKeys: Partial>; + setApiKeys: (next: Partial>) => void; + onChange: (draft: MetadataSourcesConfigDto) => void; + onSubmit: (event: FormEvent) => void; + onRefresh: () => Promise; +}) { + const draft = state.draft; + if (state.loading && !draft) return ; + if (!draft) return null; + + const local = draft.sources.find((source) => source.provider === "local"); + const external = draft.sources.filter((source) => source.provider !== "local"); + + return ( +
+ +
+
+

Sources de métadonnées

+

La source locale reste active en premier passage.

+
+ +
+ + {state.success &&
{state.success}
} + +
+ + +
+ {local && ( +
+
+ {providerLabels.local} + Source locale +
+ toujours active +
+ )} + {external.map((source, index) => ( +
+ + +
+ + +
+
+ ))} +
+
+ + + + ); +} + +function AutomationPanel({ + state, + dirty, + onChange, + onSubmit, + onRefresh, + onRunNow +}: { + state: ApiState; + dirty: boolean; + onChange: (draft: AutomationSettingsDto) => void; + onSubmit: (event: FormEvent) => void; + onRefresh: () => Promise; + onRunNow: (kind: "scan" | "enrich") => Promise; +}) { + const draft = state.draft; + if (state.loading && !draft) return ; + if (!draft) return null; + + return ( +
+ +
+
+

Automatisation

+

Surveillance des dossiers et traitements planifies.

+
+ +
+ + {state.success &&
{state.success}
} +
+ + +
+
+ + onRunNow("scan")} + onChange={(scanSchedule) => onChange({ ...draft, scanSchedule })} + /> + onRunNow("enrich")} + onChange={(enrichSchedule) => onChange({ ...draft, enrichSchedule })} + /> + + + + ); +} + +function SchedulePanel({ + title, + schedule, + summary, + runLabel, + onRun, + onChange +}: { + title: string; + schedule: AutomationScheduleDto; + summary: string; + runLabel: string; + onRun: () => Promise; + onChange: (schedule: AutomationScheduleDto) => void; +}) { + function patch(next: Partial) { + onChange({ ...schedule, ...next }); + } + + return ( + +
+
+

{title}

+

{summary}

+
+ +
+
+ + + {schedule.frequency === "weekly" && ( + + )} +
+
+ ); +} + +function SaveBar({ dirty, saving, onRefresh }: { dirty: boolean; saving: boolean; onRefresh: () => Promise }) { + return ( + + {dirty ? "Modifications en attente." : "Aucune modification en attente."} +
+ + +
+
+ ); +} + +function StatusText({ dirty, loading }: { dirty: boolean; loading: boolean }) { + if (loading) return chargement; + return {dirty ? "non enregistre" : "synchronise"}; +} + +function LoadingPanel({ label }: { label: string }) { + return ( + + + + ); +} diff --git a/apps/web/src/pages/adminAutomation.test.ts b/apps/web/src/pages/adminAutomation.test.ts new file mode 100644 index 0000000..46d961e --- /dev/null +++ b/apps/web/src/pages/adminAutomation.test.ts @@ -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."); + }); +}); diff --git a/apps/web/src/pages/adminAutomation.ts b/apps/web/src/pages/adminAutomation.ts new file mode 100644 index 0000000..ba31007 --- /dev/null +++ b/apps/web/src/pages/adminAutomation.ts @@ -0,0 +1,65 @@ +import type { + AutomationScheduleDto, + MetadataProviderId, + MetadataSourceConfigDto, + MetadataSourcesConfigDto, + UpdateMetadataSourcesConfigDto +} from "@readabook/shared"; + +export const providerLabels: Record = { + 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 = []; + 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 })); diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 4ca26f0..7fc8fe0 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -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; + } } diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css index 2d438d5..92e8d9a 100644 --- a/apps/web/src/styles/tokens.css +++ b/apps/web/src/styles/tokens.css @@ -33,10 +33,16 @@ body { } button, -input { +input, +select { font: inherit; } button { cursor: pointer; } + +button:disabled { + cursor: not-allowed; + opacity: 0.52; +}