feat(backoffice): add reading progress management, series page, and live search
- API: add POST /series/mark-read to batch mark all books in a series - API: add GET /series cross-library endpoint with search, library and status filters - API: add library_id to SeriesItem response - Backoffice: mark book as read/unread button on book detail page - Backoffice: mark series as read/unread button on series cards - Backoffice: new /series top-level page with search and filters - Backoffice: new /libraries/[id]/series/[name] series detail page - Backoffice: opacity on fully read books and series cards - Backoffice: live search with debounce on books and series pages - Backoffice: reading status filter on books and series pages - Fix $2 -> $1 parameter binding in mark-series-read SQL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,10 +61,12 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
const status = readingStatus ?? book.reading_status;
|
||||
const overlay = status ? readingStatusOverlay[status] : null;
|
||||
|
||||
const isRead = status === "read";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/books/${book.id}`}
|
||||
className="group block bg-card rounded-xl border border-border/60 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-200 overflow-hidden"
|
||||
className={`group block bg-card rounded-xl border border-border/60 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-200 overflow-hidden ${isRead ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<BookImage
|
||||
|
||||
128
apps/backoffice/app/components/LiveSearchForm.tsx
Normal file
128
apps/backoffice/app/components/LiveSearchForm.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
interface FieldDef {
|
||||
name: string;
|
||||
type: "text" | "select";
|
||||
placeholder?: string;
|
||||
label: string;
|
||||
options?: { value: string; label: string }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface LiveSearchFormProps {
|
||||
fields: FieldDef[];
|
||||
basePath: string;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const buildUrl = useCallback((): string => {
|
||||
if (!formRef.current) return basePath;
|
||||
const formData = new FormData(formRef.current);
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of formData.entries()) {
|
||||
const str = value.toString().trim();
|
||||
if (str) params.set(key, str);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${basePath}?${qs}` : basePath;
|
||||
}, [basePath]);
|
||||
|
||||
const navigate = useCallback((immediate: boolean) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (immediate) {
|
||||
router.replace(buildUrl() as any);
|
||||
} else {
|
||||
timerRef.current = setTimeout(() => {
|
||||
router.replace(buildUrl() as any);
|
||||
}, debounceMs);
|
||||
}
|
||||
}, [router, buildUrl, debounceMs]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasFilters = fields.some((f) => {
|
||||
const val = searchParams.get(f.name);
|
||||
return val && val.trim() !== "";
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
router.replace(buildUrl() as any);
|
||||
}}
|
||||
className="flex flex-col sm:flex-row gap-3 items-start sm:items-end"
|
||||
>
|
||||
{fields.map((field) =>
|
||||
field.type === "text" ? (
|
||||
<div key={field.name} className={field.className || "flex-1 w-full"}>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
name={field.name}
|
||||
type="text"
|
||||
placeholder={field.placeholder}
|
||||
defaultValue={searchParams.get(field.name) || ""}
|
||||
onChange={() => navigate(false)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div key={field.name} className={field.className || "w-full sm:w-48"}>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{field.label}
|
||||
</label>
|
||||
<select
|
||||
name={field.name}
|
||||
defaultValue={searchParams.get(field.name) || ""}
|
||||
onChange={() => navigate(true)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{hasFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace(basePath as any)}
|
||||
className="
|
||||
inline-flex items-center justify-center
|
||||
h-10 px-4
|
||||
border border-input
|
||||
text-sm font-medium
|
||||
text-muted-foreground
|
||||
bg-background
|
||||
rounded-md
|
||||
hover:bg-accent hover:text-accent-foreground
|
||||
transition-colors duration-200
|
||||
w-full sm:w-auto
|
||||
"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
64
apps/backoffice/app/components/MarkBookReadButton.tsx
Normal file
64
apps/backoffice/app/components/MarkBookReadButton.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "./ui";
|
||||
|
||||
interface MarkBookReadButtonProps {
|
||||
bookId: string;
|
||||
currentStatus: string;
|
||||
}
|
||||
|
||||
export function MarkBookReadButton({ bookId, currentStatus }: MarkBookReadButtonProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const isRead = currentStatus === "read";
|
||||
const targetStatus = isRead ? "unread" : "read";
|
||||
const label = isRead ? "Marquer non lu" : "Marquer comme lu";
|
||||
|
||||
const handleClick = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/books/${bookId}/progress`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: targetStatus }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
console.error("Failed to update reading progress:", body.error);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error("Failed to update reading progress:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={isRead ? "outline" : "primary"}
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : isRead ? (
|
||||
<svg className="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
||||
</svg>
|
||||
)}
|
||||
{!loading && label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
74
apps/backoffice/app/components/MarkSeriesReadButton.tsx
Normal file
74
apps/backoffice/app/components/MarkSeriesReadButton.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface MarkSeriesReadButtonProps {
|
||||
seriesName: string;
|
||||
bookCount: number;
|
||||
booksReadCount: number;
|
||||
}
|
||||
|
||||
export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }: MarkSeriesReadButtonProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const allRead = booksReadCount >= bookCount;
|
||||
const targetStatus = allRead ? "unread" : "read";
|
||||
const label = allRead ? "Marquer non lu" : "Tout marquer lu";
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/series/mark-read", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ series: seriesName, status: targetStatus }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
console.error("Failed to mark series:", body.error);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error("Failed to mark series:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full font-medium transition-colors ${
|
||||
allRead
|
||||
? "bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/25"
|
||||
: "bg-muted/50 text-muted-foreground hover:bg-primary/10 hover:text-primary"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : allRead ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||
</svg>
|
||||
{label}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
||||
</svg>
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import Link from "next/link";
|
||||
import { NavIcon } from "./ui";
|
||||
|
||||
type NavItem = {
|
||||
href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
label: string;
|
||||
icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "settings";
|
||||
icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings";
|
||||
};
|
||||
|
||||
const HamburgerIcon = () => (
|
||||
|
||||
Reference in New Issue
Block a user