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/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."
|
||||
})))
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
|
||||
@@ -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,133 +100,163 @@ 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
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
<FormInput
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Titre du livre"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Auteurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel>Auteur(s)</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{authors.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{authors.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium"
|
||||
>
|
||||
{a}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAuthor(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Supprimer ${a}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={setAuthorInputEl}
|
||||
value={authorInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAuthor}
|
||||
disabled={isPending || !authorInput.trim()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel>Langue</FormLabel>
|
||||
<FormInput
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : fr, en, jp"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel>Série</FormLabel>
|
||||
<FormInput
|
||||
value={series}
|
||||
onChange={(e) => setSeries(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Nom de la série"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel>Volume</FormLabel>
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Numéro de volume"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* 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()}
|
||||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel required>Titre</FormLabel>
|
||||
<FormInput
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Titre du livre"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Auteurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel>Auteur(s)</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{authors.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{authors.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium"
|
||||
>
|
||||
{a}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAuthor(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Supprimer ${a}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={setAuthorInputEl}
|
||||
value={authorInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAuthor}
|
||||
disabled={isPending || !authorInput.trim()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel>Langue</FormLabel>
|
||||
<FormInput
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : fr, en, jp"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel>Série</FormLabel>
|
||||
<FormInput
|
||||
value={series}
|
||||
onChange={(e) => setSeries(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Nom de la série"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel>Volume</FormLabel>
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Numéro de volume"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || !title.trim()}
|
||||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{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>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,207 +165,237 @@ 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
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
<FormInput
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Nom de la série"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel>Année de début</FormLabel>
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
value={startYear}
|
||||
onChange={(e) => setStartYear(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : 1990"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Auteurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel>Auteur(s)</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{authors.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{authors.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium"
|
||||
>
|
||||
{a}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAuthor(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Supprimer ${a}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={setAuthorInputEl}
|
||||
value={authorInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAuthor}
|
||||
disabled={isPending || !authorInput.trim()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApplyToBooks(!showApplyToBooks)}
|
||||
disabled={isPending}
|
||||
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors ${
|
||||
showApplyToBooks
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
title="Appliquer auteur et langue à tous les livres de la série"
|
||||
>
|
||||
→ livres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{showApplyToBooks && (
|
||||
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3 pl-4 border-l-2 border-primary/30">
|
||||
<FormField>
|
||||
<FormLabel>Auteur (livres)</FormLabel>
|
||||
<FormInput
|
||||
value={bookAuthor}
|
||||
onChange={(e) => setBookAuthor(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Écrase le champ auteur de chaque livre"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<FormLabel>Langue (livres)</FormLabel>
|
||||
<FormInput
|
||||
value={bookLanguage}
|
||||
onChange={(e) => setBookLanguage(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : fr, en, jp"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Éditeurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel>Éditeur(s)</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{publishers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{publishers.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-secondary/50 text-secondary-foreground text-xs font-medium"
|
||||
>
|
||||
{p}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePublisher(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Supprimer ${p}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={setPublisherInputEl}
|
||||
value={publisherInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPublisher}
|
||||
disabled={isPending || !publisherInput.trim()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isPending}
|
||||
rows={3}
|
||||
placeholder="Synopsis ou description de la série…"
|
||||
className="flex w-full 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 resize-none"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
{/* 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")}
|
||||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField>
|
||||
<FormLabel required>Nom</FormLabel>
|
||||
<FormInput
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Nom de la série"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel>Année de début</FormLabel>
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
value={startYear}
|
||||
onChange={(e) => setStartYear(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : 1990"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Auteurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel>Auteur(s)</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{authors.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{authors.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium"
|
||||
>
|
||||
{a}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAuthor(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Supprimer ${a}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={setAuthorInputEl}
|
||||
value={authorInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAuthor}
|
||||
disabled={isPending || !authorInput.trim()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApplyToBooks(!showApplyToBooks)}
|
||||
disabled={isPending}
|
||||
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors ${
|
||||
showApplyToBooks
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
title="Appliquer auteur et langue à tous les livres de la série"
|
||||
>
|
||||
→ livres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{showApplyToBooks && (
|
||||
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3 pl-4 border-l-2 border-primary/30">
|
||||
<FormField>
|
||||
<FormLabel>Auteur (livres)</FormLabel>
|
||||
<FormInput
|
||||
value={bookAuthor}
|
||||
onChange={(e) => setBookAuthor(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Écrase le champ auteur de chaque livre"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<FormLabel>Langue (livres)</FormLabel>
|
||||
<FormInput
|
||||
value={bookLanguage}
|
||||
onChange={(e) => setBookLanguage(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : fr, en, jp"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Éditeurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel>Éditeur(s)</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{publishers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{publishers.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-secondary/50 text-secondary-foreground text-xs font-medium"
|
||||
>
|
||||
{p}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePublisher(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Supprimer ${p}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={setPublisherInputEl}
|
||||
value={publisherInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPublisher}
|
||||
disabled={isPending || !publisherInput.trim()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isPending}
|
||||
rows={3}
|
||||
placeholder="Synopsis ou description de la série…"
|
||||
className="flex w-full 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 resize-none"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || (!newName.trim() && seriesName !== "unclassified")}
|
||||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{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>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user