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:
@@ -42,6 +42,7 @@ pub fn settings_routes() -> Router<AppState> {
|
|||||||
.route("/settings/cache/clear", post(clear_cache))
|
.route("/settings/cache/clear", post(clear_cache))
|
||||||
.route("/settings/cache/stats", get(get_cache_stats))
|
.route("/settings/cache/stats", get(get_cache_stats))
|
||||||
.route("/settings/thumbnail/stats", get(get_thumbnail_stats))
|
.route("/settings/thumbnail/stats", get(get_thumbnail_stats))
|
||||||
|
.route("/settings/search/resync", post(force_search_resync))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all settings
|
/// List all settings
|
||||||
@@ -324,3 +325,27 @@ pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<
|
|||||||
|
|
||||||
Ok(Json(stats))
|
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."
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
11
apps/backoffice/app/api/settings/search/resync/route.ts
Normal file
11
apps/backoffice/app/api/settings/search/resync/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,8 @@ export default async function BooksPage({
|
|||||||
library_id: hit.library_id,
|
library_id: hit.library_id,
|
||||||
kind: hit.kind,
|
kind: hit.kind,
|
||||||
title: hit.title,
|
title: hit.title,
|
||||||
author: hit.author,
|
author: hit.authors?.[0] ?? null,
|
||||||
authors: [],
|
authors: hit.authors ?? [],
|
||||||
series: hit.series,
|
series: hit.series,
|
||||||
volume: hit.volume,
|
volume: hit.volume,
|
||||||
language: hit.language,
|
language: hit.language,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { useRouter } from "next/navigation";
|
||||||
import { BookDto } from "@/lib/api";
|
import { BookDto } from "@/lib/api";
|
||||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||||
@@ -12,7 +13,7 @@ interface EditBookFormProps {
|
|||||||
export function EditBookForm({ book }: EditBookFormProps) {
|
export function EditBookForm({ book }: EditBookFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [title, setTitle] = useState(book.title);
|
const [title, setTitle] = useState(book.title);
|
||||||
@@ -43,7 +44,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleClose = useCallback(() => {
|
||||||
setTitle(book.title);
|
setTitle(book.title);
|
||||||
setAuthors(book.authors ?? []);
|
setAuthors(book.authors ?? []);
|
||||||
setAuthorInput("");
|
setAuthorInput("");
|
||||||
@@ -51,8 +52,17 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
setVolume(book.volume?.toString() ?? "");
|
setVolume(book.volume?.toString() ?? "");
|
||||||
setLanguage(book.language ?? "");
|
setLanguage(book.language ?? "");
|
||||||
setError(null);
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -82,7 +92,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
setError(data.error ?? "Erreur lors de la sauvegarde");
|
setError(data.error ?? "Erreur lors de la sauvegarde");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
setIsOpen(false);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Erreur réseau");
|
setError("Erreur réseau");
|
||||||
@@ -90,21 +100,34 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isEditing) {
|
const modal = isOpen ? createPortal(
|
||||||
return (
|
<>
|
||||||
|
{/* 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
|
<button
|
||||||
onClick={() => setIsEditing(true)}
|
type="button"
|
||||||
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"
|
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>
|
</button>
|
||||||
);
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel required>Titre</FormLabel>
|
<FormLabel required>Titre</FormLabel>
|
||||||
@@ -200,7 +223,16 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
<p className="text-xs text-destructive">{error}</p>
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || !title.trim()}
|
disabled={isPending || !title.trim()}
|
||||||
@@ -208,15 +240,23 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
>
|
>
|
||||||
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { useRouter } from "next/navigation";
|
||||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export function EditSeriesForm({
|
|||||||
}: EditSeriesFormProps) {
|
}: EditSeriesFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Champs propres à la série
|
// Champs propres à la série
|
||||||
@@ -86,7 +87,7 @@ export function EditSeriesForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleClose = useCallback(() => {
|
||||||
setNewName(seriesName === "unclassified" ? "" : seriesName);
|
setNewName(seriesName === "unclassified" ? "" : seriesName);
|
||||||
setAuthors(currentAuthors);
|
setAuthors(currentAuthors);
|
||||||
setAuthorInput("");
|
setAuthorInput("");
|
||||||
@@ -98,8 +99,17 @@ export function EditSeriesForm({
|
|||||||
setBookAuthor(currentBookAuthor ?? "");
|
setBookAuthor(currentBookAuthor ?? "");
|
||||||
setBookLanguage(currentBookLanguage ?? "");
|
setBookLanguage(currentBookLanguage ?? "");
|
||||||
setError(null);
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -142,7 +152,7 @@ export function EditSeriesForm({
|
|||||||
setError(data.error ?? "Erreur lors de la sauvegarde");
|
setError(data.error ?? "Erreur lors de la sauvegarde");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
setIsOpen(false);
|
||||||
|
|
||||||
if (effectiveName !== seriesName) {
|
if (effectiveName !== seriesName) {
|
||||||
router.push(`/libraries/${libraryId}/series/${encodeURIComponent(effectiveName)}` as any);
|
router.push(`/libraries/${libraryId}/series/${encodeURIComponent(effectiveName)}` as any);
|
||||||
@@ -155,21 +165,34 @@ export function EditSeriesForm({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isEditing) {
|
const modal = isOpen ? createPortal(
|
||||||
return (
|
<>
|
||||||
|
{/* 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
|
<button
|
||||||
onClick={() => setIsEditing(true)}
|
type="button"
|
||||||
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"
|
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>
|
</button>
|
||||||
);
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel required>Nom</FormLabel>
|
<FormLabel required>Nom</FormLabel>
|
||||||
@@ -339,7 +362,16 @@ export function EditSeriesForm({
|
|||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || (!newName.trim() && seriesName !== "unclassified")}
|
disabled={isPending || (!newName.trim() && seriesName !== "unclassified")}
|
||||||
@@ -347,15 +379,23 @@ export function EditSeriesForm({
|
|||||||
>
|
>
|
||||||
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
const [clearResult, setClearResult] = useState<ClearCacheResponse | null>(null);
|
const [clearResult, setClearResult] = useState<ClearCacheResponse | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
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
|
// Komga sync state — URL and username are persisted in settings
|
||||||
const [komgaUrl, setKomgaUrl] = useState("");
|
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 () => {
|
const fetchReports = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/komga/reports");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -156,6 +179,24 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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 && (
|
{saveMessage && (
|
||||||
<Card className="mb-6 border-success/50 bg-success/5">
|
<Card className="mb-6 border-success/50 bg-success/5">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
@@ -164,6 +205,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "general" && (<>
|
||||||
{/* Image Processing Settings */}
|
{/* Image Processing Settings */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -323,6 +365,43 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Limits Settings */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -522,6 +601,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{activeTab === "integrations" && (<>
|
||||||
{/* Komga Sync */}
|
{/* Komga Sync */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -761,6 +843,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export type SearchHitDto = {
|
|||||||
id: string;
|
id: string;
|
||||||
library_id: string;
|
library_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string | null;
|
authors: string[];
|
||||||
series: string | null;
|
series: string | null;
|
||||||
volume: number | null;
|
volume: number | null;
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -406,6 +406,12 @@ export async function getThumbnailStats() {
|
|||||||
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
|
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) {
|
export async function convertBook(bookId: string) {
|
||||||
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
|
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ struct SearchDoc {
|
|||||||
library_id: String,
|
library_id: String,
|
||||||
kind: String,
|
kind: String,
|
||||||
title: String,
|
title: String,
|
||||||
author: Option<String>,
|
authors: Vec<String>,
|
||||||
series: Option<String>,
|
series: Option<String>,
|
||||||
volume: Option<i32>,
|
volume: Option<i32>,
|
||||||
language: Option<String>,
|
language: Option<String>,
|
||||||
@@ -37,6 +37,13 @@ pub async fn sync_meili(pool: &PgPool, meili_url: &str, meili_master_key: &str)
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.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
|
// Get last sync timestamp
|
||||||
let last_sync: Option<DateTime<Utc>> = sqlx::query_scalar(
|
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"
|
"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
|
// If no previous sync, do a full sync
|
||||||
let is_full_sync = last_sync.is_none();
|
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 {
|
let rows = if is_full_sync {
|
||||||
info!("[MEILI] Performing full sync");
|
info!("[MEILI] Performing full sync");
|
||||||
sqlx::query(
|
sqlx::query(books_query)
|
||||||
"SELECT id, library_id, kind, title, author, series, volume, language, updated_at FROM books",
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
let since = last_sync.unwrap();
|
let since = last_sync.unwrap();
|
||||||
info!("[MEILI] Performing incremental sync since {}", since);
|
info!("[MEILI] Performing incremental sync since {}", since);
|
||||||
|
|
||||||
// Also get deleted book IDs to remove from MeiliSearch
|
// Include books that changed OR whose series_metadata changed
|
||||||
// For now, we'll do a diff approach: get all book IDs from DB and compare with Meili
|
sqlx::query(&format!(
|
||||||
sqlx::query(
|
"{books_query} WHERE b.updated_at > $1 OR sm.updated_at > $1"
|
||||||
"SELECT id, library_id, kind, title, author, series, volume, language, updated_at FROM books WHERE updated_at > $1",
|
))
|
||||||
)
|
|
||||||
.bind(since)
|
.bind(since)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?
|
.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(),
|
library_id: row.get::<Uuid, _>("library_id").to_string(),
|
||||||
kind: row.get("kind"),
|
kind: row.get("kind"),
|
||||||
title: row.get("title"),
|
title: row.get("title"),
|
||||||
author: row.get("author"),
|
authors: row.get::<Vec<String>, _>("authors"),
|
||||||
series: row.get("series"),
|
series: row.get("series"),
|
||||||
volume: row.get("volume"),
|
volume: row.get("volume"),
|
||||||
language: row.get("language"),
|
language: row.get("language"),
|
||||||
|
|||||||
Reference in New Issue
Block a user