diff --git a/apps/api/src/settings.rs b/apps/api/src/settings.rs index 7fb8dee..4272800 100644 --- a/apps/api/src/settings.rs +++ b/apps/api/src/settings.rs @@ -42,6 +42,7 @@ pub fn settings_routes() -> Router { .route("/settings/cache/clear", post(clear_cache)) .route("/settings/cache/stats", get(get_cache_stats)) .route("/settings/thumbnail/stats", get(get_thumbnail_stats)) + .route("/settings/search/resync", post(force_search_resync)) } /// List all settings @@ -324,3 +325,27 @@ pub async fn get_thumbnail_stats(State(_state): State) -> Result, +) -> Result, ApiError> { + sqlx::query("UPDATE sync_metadata SET last_meili_sync = NULL WHERE id = 1") + .execute(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ + "success": true, + "message": "Search resync scheduled. The indexer will perform a full sync on its next cycle." + }))) +} diff --git a/apps/backoffice/app/api/settings/search/resync/route.ts b/apps/backoffice/app/api/settings/search/resync/route.ts new file mode 100644 index 0000000..73da482 --- /dev/null +++ b/apps/backoffice/app/api/settings/search/resync/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { forceSearchResync } from "@/lib/api"; + +export async function POST() { + try { + const data = await forceSearchResync(); + return NextResponse.json(data); + } catch (error) { + return NextResponse.json({ error: "Failed to trigger search resync" }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/books/page.tsx b/apps/backoffice/app/books/page.tsx index 27d320e..00a8759 100644 --- a/apps/backoffice/app/books/page.tsx +++ b/apps/backoffice/app/books/page.tsx @@ -39,8 +39,8 @@ export default async function BooksPage({ library_id: hit.library_id, kind: hit.kind, title: hit.title, - author: hit.author, - authors: [], + author: hit.authors?.[0] ?? null, + authors: hit.authors ?? [], series: hit.series, volume: hit.volume, language: hit.language, diff --git a/apps/backoffice/app/components/EditBookForm.tsx b/apps/backoffice/app/components/EditBookForm.tsx index 1f71b02..48872a5 100644 --- a/apps/backoffice/app/components/EditBookForm.tsx +++ b/apps/backoffice/app/components/EditBookForm.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useTransition } from "react"; +import { useState, useTransition, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { BookDto } from "@/lib/api"; import { FormField, FormLabel, FormInput } from "./ui/Form"; @@ -12,7 +13,7 @@ interface EditBookFormProps { export function EditBookForm({ book }: EditBookFormProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); - const [isEditing, setIsEditing] = useState(false); + const [isOpen, setIsOpen] = useState(false); const [error, setError] = useState(null); const [title, setTitle] = useState(book.title); @@ -43,7 +44,7 @@ export function EditBookForm({ book }: EditBookFormProps) { } }; - const handleCancel = () => { + const handleClose = useCallback(() => { setTitle(book.title); setAuthors(book.authors ?? []); setAuthorInput(""); @@ -51,8 +52,17 @@ export function EditBookForm({ book }: EditBookFormProps) { setVolume(book.volume?.toString() ?? ""); setLanguage(book.language ?? ""); setError(null); - setIsEditing(false); - }; + setIsOpen(false); + }, [book]); + + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isPending) handleClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, isPending, handleClose]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -82,7 +92,7 @@ export function EditBookForm({ book }: EditBookFormProps) { setError(data.error ?? "Erreur lors de la sauvegarde"); return; } - setIsEditing(false); + setIsOpen(false); router.refresh(); } catch { setError("Erreur réseau"); @@ -90,133 +100,163 @@ export function EditBookForm({ book }: EditBookFormProps) { }); }; - if (!isEditing) { - return ( + const modal = isOpen ? createPortal( + <> + {/* Backdrop */} +
!isPending && handleClose()} + /> + + {/* Modal */} +
+
+ {/* Header */} +
+

Modifier les métadonnées

+ +
+ + {/* Body */} +
+
+ + Titre + setTitle(e.target.value)} + disabled={isPending} + placeholder="Titre du livre" + /> + + + {/* Auteurs — multi-valeur */} + + Auteur(s) +
+ {authors.length > 0 && ( +
+ {authors.map((a, i) => ( + + {a} + + + ))} +
+ )} +
+ setAuthorInput(e.target.value)} + onKeyDown={handleAuthorKeyDown} + disabled={isPending} + placeholder="Ajouter un auteur (Entrée pour valider)" + className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> + +
+
+
+ + + Langue + setLanguage(e.target.value)} + disabled={isPending} + placeholder="ex : fr, en, jp" + /> + + + + Série + setSeries(e.target.value)} + disabled={isPending} + placeholder="Nom de la série" + /> + + + + Volume + setVolume(e.target.value)} + disabled={isPending} + placeholder="Numéro de volume" + /> + +
+ + {error && ( +

{error}

+ )} + + {/* Footer */} +
+ + +
+
+
+
+ , + document.body + ) : null; + + return ( + <> - ); - } - - return ( -
-

Modifier les métadonnées

- -
- - Titre - setTitle(e.target.value)} - disabled={isPending} - placeholder="Titre du livre" - /> - - - {/* Auteurs — multi-valeur */} - - Auteur(s) -
- {authors.length > 0 && ( -
- {authors.map((a, i) => ( - - {a} - - - ))} -
- )} -
- setAuthorInput(e.target.value)} - onKeyDown={handleAuthorKeyDown} - disabled={isPending} - placeholder="Ajouter un auteur (Entrée pour valider)" - className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" - /> - -
-
-
- - - Langue - setLanguage(e.target.value)} - disabled={isPending} - placeholder="ex : fr, en, jp" - /> - - - - Série - setSeries(e.target.value)} - disabled={isPending} - placeholder="Nom de la série" - /> - - - - Volume - setVolume(e.target.value)} - disabled={isPending} - placeholder="Numéro de volume" - /> - -
- - {error && ( -

{error}

- )} - -
- - -
-
+ {modal} + ); } diff --git a/apps/backoffice/app/components/EditSeriesForm.tsx b/apps/backoffice/app/components/EditSeriesForm.tsx index bd3f4de..b520f2f 100644 --- a/apps/backoffice/app/components/EditSeriesForm.tsx +++ b/apps/backoffice/app/components/EditSeriesForm.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useTransition } from "react"; +import { useState, useTransition, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { FormField, FormLabel, FormInput } from "./ui/Form"; @@ -27,7 +28,7 @@ export function EditSeriesForm({ }: EditSeriesFormProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); - const [isEditing, setIsEditing] = useState(false); + const [isOpen, setIsOpen] = useState(false); const [error, setError] = useState(null); // Champs propres à la série @@ -86,7 +87,7 @@ export function EditSeriesForm({ } }; - const handleCancel = () => { + const handleClose = useCallback(() => { setNewName(seriesName === "unclassified" ? "" : seriesName); setAuthors(currentAuthors); setAuthorInput(""); @@ -98,8 +99,17 @@ export function EditSeriesForm({ setBookAuthor(currentBookAuthor ?? ""); setBookLanguage(currentBookLanguage ?? ""); setError(null); - setIsEditing(false); - }; + setIsOpen(false); + }, [seriesName, currentAuthors, currentPublishers, currentDescription, currentStartYear, currentBookAuthor, currentBookLanguage]); + + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isPending) handleClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, isPending, handleClose]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -142,7 +152,7 @@ export function EditSeriesForm({ setError(data.error ?? "Erreur lors de la sauvegarde"); return; } - setIsEditing(false); + setIsOpen(false); if (effectiveName !== seriesName) { router.push(`/libraries/${libraryId}/series/${encodeURIComponent(effectiveName)}` as any); @@ -155,207 +165,237 @@ export function EditSeriesForm({ }); }; - if (!isEditing) { - return ( + const modal = isOpen ? createPortal( + <> + {/* Backdrop */} +
!isPending && handleClose()} + /> + + {/* Modal */} +
+
+ {/* Header */} +
+

Modifier la série

+ +
+ + {/* Body */} +
+
+ + Nom + setNewName(e.target.value)} + disabled={isPending} + placeholder="Nom de la série" + /> + + + + Année de début + setStartYear(e.target.value)} + disabled={isPending} + placeholder="ex : 1990" + /> + + + {/* Auteurs — multi-valeur */} + + Auteur(s) +
+ {authors.length > 0 && ( +
+ {authors.map((a, i) => ( + + {a} + + + ))} +
+ )} +
+ setAuthorInput(e.target.value)} + onKeyDown={handleAuthorKeyDown} + disabled={isPending} + placeholder="Ajouter un auteur (Entrée pour valider)" + className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> + + +
+
+
+ + {showApplyToBooks && ( +
+ + Auteur (livres) + setBookAuthor(e.target.value)} + disabled={isPending} + placeholder="Écrase le champ auteur de chaque livre" + /> + + + Langue (livres) + setBookLanguage(e.target.value)} + disabled={isPending} + placeholder="ex : fr, en, jp" + /> + +
+ )} + + {/* Éditeurs — multi-valeur */} + + Éditeur(s) +
+ {publishers.length > 0 && ( +
+ {publishers.map((p, i) => ( + + {p} + + + ))} +
+ )} +
+ setPublisherInput(e.target.value)} + onKeyDown={handlePublisherKeyDown} + disabled={isPending} + placeholder="Ajouter un éditeur (Entrée pour valider)" + className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> + +
+
+
+ + + Description +