fix(web): admin des bibliothèques — UX, gestion d'erreur, styles
- web: AdminPage (admin bibliothèques), client API, styles - web: nginx Refs: #15
This commit is contained in:
@ -26,6 +26,9 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /admin {
|
location ^~ /admin {
|
||||||
|
if ($http_accept ~* "text/html") {
|
||||||
|
rewrite ^ /index.html last;
|
||||||
|
}
|
||||||
proxy_pass http://api:3000;
|
proxy_pass http://api:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@ -165,6 +165,9 @@ export const api = {
|
|||||||
fallback: { id: Date.now(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...input }
|
fallback: { id: Date.now(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...input }
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async deleteLibrary(id: number): Promise<void> {
|
||||||
|
await request<{ ok: true }>(`/admin/libraries/${id}`, { method: "DELETE" });
|
||||||
|
},
|
||||||
async scanLibrary(id: number): Promise<JobDto> {
|
async scanLibrary(id: number): Promise<JobDto> {
|
||||||
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST", fallback: mockJobs[0] });
|
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST", fallback: mockJobs[0] });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { Play, Plus } from "lucide-react";
|
import { Play, Plus, Trash2 } from "lucide-react";
|
||||||
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
|
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
|
||||||
import { api, getApiFallback } from "../api/client";
|
import { api, getApiFallback } from "../api/client";
|
||||||
import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||||||
@ -12,6 +12,7 @@ export function AdminPage() {
|
|||||||
const [name, setName] = useState("Bibliotheque locale");
|
const [name, setName] = useState("Bibliotheque locale");
|
||||||
const [path, setPath] = useState("/library");
|
const [path, setPath] = useState("/library");
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
const [success, setSuccess] = useState<string>();
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -51,9 +52,11 @@ export function AdminPage() {
|
|||||||
async function createLibrary(event: FormEvent) {
|
async function createLibrary(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
setSuccess(undefined);
|
||||||
try {
|
try {
|
||||||
await api.createLibrary({ name, path, enabled: true });
|
await api.createLibrary({ name, path, enabled: true });
|
||||||
await refresh();
|
await refresh();
|
||||||
|
setSuccess(`Bibliothèque "${name}" ajoutée.`);
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
const fallback = getApiFallback<LibraryDto>(createError);
|
const fallback = getApiFallback<LibraryDto>(createError);
|
||||||
if (fallback) setLibraries((current) => [fallback, ...current]);
|
if (fallback) setLibraries((current) => [fallback, ...current]);
|
||||||
@ -63,9 +66,11 @@ export function AdminPage() {
|
|||||||
|
|
||||||
async function scan(id: number) {
|
async function scan(id: number) {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
setSuccess(undefined);
|
||||||
try {
|
try {
|
||||||
await api.scanLibrary(id);
|
await api.scanLibrary(id);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
setSuccess("Scan demandé.");
|
||||||
} catch (scanError) {
|
} catch (scanError) {
|
||||||
const fallback = getApiFallback<JobDto>(scanError);
|
const fallback = getApiFallback<JobDto>(scanError);
|
||||||
if (fallback) setJobs((current) => [fallback, ...current]);
|
if (fallback) setJobs((current) => [fallback, ...current]);
|
||||||
@ -73,6 +78,23 @@ export function AdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteLibrary(library: LibraryDto) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Supprimer la bibliothèque "${library.name}" ?\n\nLes livres restent sur le disque. ReadaBook supprimera seulement cette bibliothèque du catalogue.`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setError(undefined);
|
||||||
|
setSuccess(undefined);
|
||||||
|
try {
|
||||||
|
await api.deleteLibrary(library.id);
|
||||||
|
setLibraries((current) => current.filter((item) => item.id !== library.id));
|
||||||
|
setSuccess(`Bibliothèque "${library.name}" supprimée. Les fichiers disque n'ont pas été supprimés.`);
|
||||||
|
} catch (deleteError) {
|
||||||
|
setError(deleteError instanceof Error ? deleteError.message : "Suppression impossible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<Panel className="span-2">
|
<Panel className="span-2">
|
||||||
@ -81,6 +103,7 @@ export function AdminPage() {
|
|||||||
<span>{loading ? "chargement" : `${users.length} comptes`}</span>
|
<span>{loading ? "chargement" : `${users.length} comptes`}</span>
|
||||||
</div>
|
</div>
|
||||||
<ErrorRibbon message={error} />
|
<ErrorRibbon message={error} />
|
||||||
|
{success && <div className="success-ribbon">{success}</div>}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="retry-row">
|
<div className="retry-row">
|
||||||
<span>Les formulaires restent disponibles.</span>
|
<span>Les formulaires restent disponibles.</span>
|
||||||
@ -91,12 +114,14 @@ export function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
<form className="admin-form" onSubmit={createLibrary}>
|
<form className="admin-form" onSubmit={createLibrary}>
|
||||||
<label>
|
<label>
|
||||||
Nom du rayon
|
Nom de la bibliothèque
|
||||||
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
||||||
|
<small>Nom affiché dans l’application, par exemple : Romans, BD, Documentation.</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Chemin serveur
|
Chemin du dossier
|
||||||
<input value={path} onChange={(event) => setPath(event.target.value)} required />
|
<input value={path} onChange={(event) => setPath(event.target.value)} required />
|
||||||
|
<small>Emplacement réel sur le serveur où ReadaBook lit les livres.</small>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" type="submit">
|
<button className="primary-button" type="submit">
|
||||||
<Plus size={17} />
|
<Plus size={17} />
|
||||||
@ -135,13 +160,17 @@ export function AdminPage() {
|
|||||||
<div key={library.id}>
|
<div key={library.id}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{library.name}</strong>
|
<strong>{library.name}</strong>
|
||||||
<span>{library.path}</span>
|
<span>Chemin : {library.path}</span>
|
||||||
</div>
|
</div>
|
||||||
<span>{library.enabled ? "actif" : "pause"}</span>
|
<span>{library.enabled ? "actif" : "pause"}</span>
|
||||||
<button className="ghost-button" onClick={() => scan(library.id)}>
|
<button className="ghost-button" onClick={() => scan(library.id)}>
|
||||||
<Play size={16} />
|
<Play size={16} />
|
||||||
Scanner
|
Scanner
|
||||||
</button>
|
</button>
|
||||||
|
<button className="ghost-button danger-button" onClick={() => void deleteLibrary(library)}>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -142,6 +142,15 @@ h2 {
|
|||||||
border-color: rgba(213, 168, 77, 0.55);
|
border-color: rgba(213, 168, 77, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
color: #ffd8cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button:hover {
|
||||||
|
border-color: rgba(169, 72, 52, 0.78);
|
||||||
|
background: rgba(169, 72, 52, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
.book-grid {
|
.book-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
@ -271,7 +280,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.library-table > div {
|
.library-table > div {
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,6 +336,11 @@ label {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
label small {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
Reference in New Issue
Block a user