feat: fix author search, add edit modals, settings tabs & search resync

- Fix Meilisearch indexing to use authors[] array instead of scalar author field
- Join series_metadata to include series-level authors in search documents
- Configure searchable attributes (title, authors, series) in Meilisearch
- Convert EditSeriesForm and EditBookForm from inline forms to modals
- Add tabbed navigation (General / Integrations) to Settings page
- Add Force Search Resync button (POST /settings/search/resync)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 10:45:36 +01:00
parent a675dcd2a4
commit 4be8177683
8 changed files with 571 additions and 347 deletions

View File

@@ -42,6 +42,7 @@ pub fn settings_routes() -> Router<AppState> {
.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<AppState>) -> Result<Json<
Ok(Json(stats))
}
/// Force a full Meilisearch resync by resetting the sync timestamp
#[utoipa::path(
post,
path = "/settings/search/resync",
tag = "settings",
responses(
(status = 200, description = "Resync scheduled"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn force_search_resync(
State(state): State<AppState>,
) -> Result<Json<Value>, 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."
})))
}

View File

@@ -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 });
}
}

View File

@@ -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,

View File

@@ -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<string | null>(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,21 +100,34 @@ export function EditBookForm({ book }: EditBookFormProps) {
});
};
if (!isEditing) {
return (
const modal = isOpen ? createPortal(
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={() => !isPending && handleClose()}
/>
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">Modifier les métadonnées</h3>
<button
onClick={() => setIsEditing(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
type="button"
onClick={handleClose}
disabled={isPending}
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
>
<span></span> Modifier
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
);
}
return (
<form onSubmit={handleSubmit} className="mt-4 p-4 border border-border rounded-xl bg-muted/30 space-y-4">
<h3 className="text-sm font-semibold text-foreground">Modifier les métadonnées</h3>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="p-5 space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField className="sm:col-span-2">
<FormLabel required>Titre</FormLabel>
@@ -200,7 +223,16 @@ export function EditBookForm({ book }: EditBookFormProps) {
<p className="text-xs text-destructive">{error}</p>
)}
<div className="flex items-center gap-2">
{/* Footer */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border/50">
<button
type="button"
onClick={handleClose}
disabled={isPending}
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={isPending || !title.trim()}
@@ -208,15 +240,23 @@ export function EditBookForm({ book }: EditBookFormProps) {
>
{isPending ? "Sauvegarde…" : "Sauvegarder"}
</button>
<button
type="button"
onClick={handleCancel}
disabled={isPending}
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Annuler
</button>
</div>
</form>
</div>
</div>
</>,
document.body
) : null;
return (
<>
<button
onClick={() => setIsOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
<span></span> Modifier
</button>
{modal}
</>
);
}

View File

@@ -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<string | null>(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,21 +165,34 @@ export function EditSeriesForm({
});
};
if (!isEditing) {
return (
const modal = isOpen ? createPortal(
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={() => !isPending && handleClose()}
/>
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">Modifier la série</h3>
<button
onClick={() => setIsEditing(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
type="button"
onClick={handleClose}
disabled={isPending}
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
>
<span></span> Modifier la série
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
);
}
return (
<form onSubmit={handleSubmit} className="w-full p-4 border border-border rounded-xl bg-muted/30 space-y-5">
<h3 className="text-sm font-semibold text-foreground">Modifier les métadonnées de la série</h3>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="p-5 space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField>
<FormLabel required>Nom</FormLabel>
@@ -339,7 +362,16 @@ export function EditSeriesForm({
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center gap-2">
{/* Footer */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border/50">
<button
type="button"
onClick={handleClose}
disabled={isPending}
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={isPending || (!newName.trim() && seriesName !== "unclassified")}
@@ -347,15 +379,23 @@ export function EditSeriesForm({
>
{isPending ? "Sauvegarde…" : "Sauvegarder"}
</button>
<button
type="button"
onClick={handleCancel}
disabled={isPending}
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Annuler
</button>
</div>
</form>
</div>
</div>
</>,
document.body
) : null;
return (
<>
<button
onClick={() => setIsOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
<span></span> Modifier la série
</button>
{modal}
</>
);
}

View File

@@ -21,6 +21,8 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
const [clearResult, setClearResult] = useState<ClearCacheResponse | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const [isResyncing, setIsResyncing] = useState(false);
const [resyncResult, setResyncResult] = useState<{ success: boolean; message: string } | null>(null);
// Komga sync state — URL and username are persisted in settings
const [komgaUrl, setKomgaUrl] = useState("");
@@ -87,6 +89,20 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
}
}
async function handleSearchResync() {
setIsResyncing(true);
setResyncResult(null);
try {
const response = await fetch("/api/settings/search/resync", { method: "POST" });
const result = await response.json();
setResyncResult(result);
} catch {
setResyncResult({ success: false, message: "Failed to trigger search resync" });
} finally {
setIsResyncing(false);
}
}
const fetchReports = useCallback(async () => {
try {
const resp = await fetch("/api/komga/reports");
@@ -147,6 +163,13 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
}
}
const [activeTab, setActiveTab] = useState<"general" | "integrations">("general");
const tabs = [
{ id: "general" as const, label: "General", icon: "settings" as const },
{ id: "integrations" as const, label: "Integrations", icon: "refresh" as const },
];
return (
<>
<div className="mb-6">
@@ -156,6 +179,24 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</h1>
</div>
{/* Tab Navigation */}
<div className="flex gap-1 mb-6 border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
<Icon name={tab.icon} size="sm" />
{tab.label}
</button>
))}
</div>
{saveMessage && (
<Card className="mb-6 border-success/50 bg-success/5">
<CardContent className="pt-6">
@@ -164,6 +205,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</Card>
)}
{activeTab === "general" && (<>
{/* Image Processing Settings */}
<Card className="mb-6">
<CardHeader>
@@ -323,6 +365,43 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</CardContent>
</Card>
{/* Search Index */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="search" size="md" />
Search Index
</CardTitle>
<CardDescription>Force a full resync of the Meilisearch index. This will re-index all books on the next indexer cycle.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{resyncResult && (
<div className={`p-3 rounded-lg ${resyncResult.success ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive'}`}>
{resyncResult.message}
</div>
)}
<Button
onClick={handleSearchResync}
disabled={isResyncing}
>
{isResyncing ? (
<>
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
Scheduling...
</>
) : (
<>
<Icon name="refresh" size="sm" className="mr-2" />
Force Search Resync
</>
)}
</Button>
</div>
</CardContent>
</Card>
{/* Limits Settings */}
<Card className="mb-6">
<CardHeader>
@@ -522,6 +601,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</CardContent>
</Card>
</>)}
{activeTab === "integrations" && (<>
{/* Komga Sync */}
<Card className="mb-6">
<CardHeader>
@@ -761,6 +843,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</div>
</CardContent>
</Card>
</>)}
</>
);
}

View File

@@ -87,7 +87,7 @@ export type SearchHitDto = {
id: string;
library_id: string;
title: string;
author: string | null;
authors: string[];
series: string | null;
volume: number | null;
kind: string;
@@ -406,6 +406,12 @@ export async function getThumbnailStats() {
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
}
export async function forceSearchResync() {
return apiFetch<{ success: boolean; message: string }>("/settings/search/resync", {
method: "POST",
});
}
export async function convertBook(bookId: string) {
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
}

View File

@@ -12,7 +12,7 @@ struct SearchDoc {
library_id: String,
kind: String,
title: String,
author: Option<String>,
authors: Vec<String>,
series: Option<String>,
volume: Option<i32>,
language: Option<String>,
@@ -37,6 +37,13 @@ pub async fn sync_meili(pool: &PgPool, meili_url: &str, meili_master_key: &str)
.send()
.await;
let _ = client
.put(format!("{base}/indexes/books/settings/searchable-attributes"))
.header("Authorization", format!("Bearer {meili_master_key}"))
.json(&serde_json::json!(["title", "authors", "series"]))
.send()
.await;
// Get last sync timestamp
let last_sync: Option<DateTime<Utc>> = sqlx::query_scalar(
"SELECT last_meili_sync FROM sync_metadata WHERE id = 1 AND last_meili_sync IS NOT NULL"
@@ -47,23 +54,35 @@ pub async fn sync_meili(pool: &PgPool, meili_url: &str, meili_master_key: &str)
// If no previous sync, do a full sync
let is_full_sync = last_sync.is_none();
// Get books to sync: all if full sync, only modified since last sync otherwise
// Get books to sync: all if full sync, only modified since last sync otherwise.
// Join series_metadata to merge series-level authors into the search document.
let books_query = r#"
SELECT b.id, b.library_id, b.kind, b.title, b.series, b.volume, b.language, b.updated_at,
ARRAY(
SELECT DISTINCT unnest(
COALESCE(b.authors, CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)
|| COALESCE(sm.authors, ARRAY[]::text[])
)
) as authors
FROM books b
LEFT JOIN series_metadata sm
ON sm.library_id = b.library_id
AND sm.name = COALESCE(NULLIF(b.series, ''), 'unclassified')
"#;
let rows = if is_full_sync {
info!("[MEILI] Performing full sync");
sqlx::query(
"SELECT id, library_id, kind, title, author, series, volume, language, updated_at FROM books",
)
sqlx::query(books_query)
.fetch_all(pool)
.await?
} else {
let since = last_sync.unwrap();
info!("[MEILI] Performing incremental sync since {}", since);
// Also get deleted book IDs to remove from MeiliSearch
// For now, we'll do a diff approach: get all book IDs from DB and compare with Meili
sqlx::query(
"SELECT id, library_id, kind, title, author, series, volume, language, updated_at FROM books WHERE updated_at > $1",
)
// Include books that changed OR whose series_metadata changed
sqlx::query(&format!(
"{books_query} WHERE b.updated_at > $1 OR sm.updated_at > $1"
))
.bind(since)
.fetch_all(pool)
.await?
@@ -87,7 +106,7 @@ pub async fn sync_meili(pool: &PgPool, meili_url: &str, meili_master_key: &str)
library_id: row.get::<Uuid, _>("library_id").to_string(),
kind: row.get("kind"),
title: row.get("title"),
author: row.get("author"),
authors: row.get::<Vec<String>, _>("authors"),
series: row.get("series"),
volume: row.get("volume"),
language: row.get("language"),