feat(komga): add Komga read-status sync with reports and history
Adds Komga sync feature to import read status from a Komga server. Books are matched by title (case-insensitive) with series+title primary match and title-only fallback. Sync reports are persisted with matched, newly marked, and unmatched book lists. UI shows check icon for newly marked books, sorted to top. Credentials (URL+username) are saved between sessions. Uses HashSet for O(1) lookups to handle large libraries. Closes #2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
apps/backoffice/app/api/komga/reports/[id]/route.ts
Normal file
16
apps/backoffice/app/api/komga/reports/[id]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { getKomgaReport } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const data = await getKomgaReport(id);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch report";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/komga/reports/route.ts
Normal file
12
apps/backoffice/app/api/komga/reports/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listKomgaReports } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await listKomgaReports();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch reports";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/komga/sync/route.ts
Normal file
16
apps/backoffice/app/api/komga/sync/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch("/komga/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to sync with Komga";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats } from "../../lib/api";
|
||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary } from "../../lib/api";
|
||||
|
||||
interface SettingsPageProps {
|
||||
initialSettings: Settings;
|
||||
@@ -22,6 +22,29 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
|
||||
// Komga sync state — URL and username are persisted in settings
|
||||
const [komgaUrl, setKomgaUrl] = useState("");
|
||||
const [komgaUsername, setKomgaUsername] = useState("");
|
||||
const [komgaPassword, setKomgaPassword] = useState("");
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
const [showUnmatched, setShowUnmatched] = useState(false);
|
||||
const [reports, setReports] = useState<KomgaSyncReportSummary[]>([]);
|
||||
const [selectedReport, setSelectedReport] = useState<KomgaSyncResponse | null>(null);
|
||||
const [showReportUnmatched, setShowReportUnmatched] = useState(false);
|
||||
const [showMatchedBooks, setShowMatchedBooks] = useState(false);
|
||||
const [showReportMatchedBooks, setShowReportMatchedBooks] = useState(false);
|
||||
|
||||
const syncNewlyMarkedSet = useMemo(
|
||||
() => new Set(syncResult?.newly_marked_books ?? []),
|
||||
[syncResult?.newly_marked_books],
|
||||
);
|
||||
const reportNewlyMarkedSet = useMemo(
|
||||
() => new Set(selectedReport?.newly_marked_books ?? []),
|
||||
[selectedReport?.newly_marked_books],
|
||||
);
|
||||
|
||||
async function handleUpdateSetting(key: string, value: unknown) {
|
||||
setIsSaving(true);
|
||||
setSaveMessage(null);
|
||||
@@ -64,6 +87,66 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
}
|
||||
}
|
||||
|
||||
const fetchReports = useCallback(async () => {
|
||||
try {
|
||||
const resp = await fetch("/api/komga/reports");
|
||||
if (resp.ok) setReports(await resp.json());
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
// Load saved Komga credentials (URL + username only)
|
||||
fetch("/api/settings/komga").then(r => r.ok ? r.json() : null).then(data => {
|
||||
if (data) {
|
||||
if (data.url) setKomgaUrl(data.url);
|
||||
if (data.username) setKomgaUsername(data.username);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [fetchReports]);
|
||||
|
||||
async function handleViewReport(id: string) {
|
||||
setSelectedReport(null);
|
||||
setShowReportUnmatched(false);
|
||||
setShowReportMatchedBooks(false);
|
||||
try {
|
||||
const resp = await fetch(`/api/komga/reports/${id}`);
|
||||
if (resp.ok) setSelectedReport(await resp.json());
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleKomgaSync() {
|
||||
setIsSyncing(true);
|
||||
setSyncResult(null);
|
||||
setSyncError(null);
|
||||
setShowUnmatched(false);
|
||||
setShowMatchedBooks(false);
|
||||
try {
|
||||
const response = await fetch("/api/komga/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
setSyncError(data.error || "Sync failed");
|
||||
} else {
|
||||
setSyncResult(data);
|
||||
fetchReports();
|
||||
// Persist URL and username (not password)
|
||||
fetch("/api/settings/komga", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername } }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
setSyncError("Failed to connect to sync endpoint");
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
@@ -438,6 +521,246 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Komga Sync */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="refresh" size="md" />
|
||||
Komga Sync
|
||||
</CardTitle>
|
||||
<CardDescription>Import read status from a Komga server. Books are matched by title (case-insensitive). Credentials are not stored.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Komga URL</label>
|
||||
<FormInput
|
||||
type="url"
|
||||
placeholder="https://komga.example.com"
|
||||
value={komgaUrl}
|
||||
onChange={(e) => setKomgaUrl(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Username</label>
|
||||
<FormInput
|
||||
value={komgaUsername}
|
||||
onChange={(e) => setKomgaUsername(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Password</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
value={komgaPassword}
|
||||
onChange={(e) => setKomgaPassword(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
|
||||
<Button
|
||||
onClick={handleKomgaSync}
|
||||
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
|
||||
Syncing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="refresh" size="sm" className="mr-2" />
|
||||
Sync Read Books
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{syncError && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive">
|
||||
{syncError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Komga read</p>
|
||||
<p className="text-2xl font-semibold">{syncResult.total_komga_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Matched</p>
|
||||
<p className="text-2xl font-semibold">{syncResult.matched}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Already read</p>
|
||||
<p className="text-2xl font-semibold">{syncResult.already_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Newly marked</p>
|
||||
<p className="text-2xl font-semibold text-success">{syncResult.newly_marked}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{syncResult.matched_books.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMatchedBooks(!showMatchedBooks)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showMatchedBooks ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{syncResult.matched_books.length} matched book{syncResult.matched_books.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{showMatchedBooks && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-success/5 rounded-lg text-sm space-y-1">
|
||||
{syncResult.matched_books.map((title, i) => (
|
||||
<p key={i} className="text-foreground truncate flex items-center gap-1.5" title={title}>
|
||||
{syncNewlyMarkedSet.has(title) && (
|
||||
<Icon name="check" size="sm" className="text-success shrink-0" />
|
||||
)}
|
||||
{title}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncResult.unmatched.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUnmatched(!showUnmatched)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showUnmatched ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{syncResult.unmatched.length} unmatched book{syncResult.unmatched.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{showUnmatched && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||
{syncResult.unmatched.map((title, i) => (
|
||||
<p key={i} className="text-muted-foreground truncate" title={title}>{title}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Past reports */}
|
||||
{reports.length > 0 && (
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">Sync History</h3>
|
||||
<div className="space-y-2">
|
||||
{reports.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => handleViewReport(r.id)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
selectedReport?.id === r.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/60 bg-muted/20 hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate ml-2" title={r.komga_url}>
|
||||
{r.komga_url}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-1 text-xs text-muted-foreground">
|
||||
<span>{r.total_komga_read} read</span>
|
||||
<span>{r.matched} matched</span>
|
||||
<span className="text-success">{r.newly_marked} new</span>
|
||||
{r.unmatched_count > 0 && (
|
||||
<span className="text-warning">{r.unmatched_count} unmatched</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selected report detail */}
|
||||
{selectedReport && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Komga read</p>
|
||||
<p className="text-2xl font-semibold">{selectedReport.total_komga_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Matched</p>
|
||||
<p className="text-2xl font-semibold">{selectedReport.matched}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Already read</p>
|
||||
<p className="text-2xl font-semibold">{selectedReport.already_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Newly marked</p>
|
||||
<p className="text-2xl font-semibold text-success">{selectedReport.newly_marked}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedReport.matched_books && selectedReport.matched_books.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReportMatchedBooks(!showReportMatchedBooks)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showReportMatchedBooks ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{selectedReport.matched_books.length} matched book{selectedReport.matched_books.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{showReportMatchedBooks && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-success/5 rounded-lg text-sm space-y-1">
|
||||
{selectedReport.matched_books.map((title, i) => (
|
||||
<p key={i} className="text-foreground truncate flex items-center gap-1.5" title={title}>
|
||||
{reportNewlyMarkedSet.has(title) && (
|
||||
<Icon name="check" size="sm" className="text-success shrink-0" />
|
||||
)}
|
||||
{title}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedReport.unmatched.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReportUnmatched(!showReportUnmatched)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showReportUnmatched ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{selectedReport.unmatched.length} unmatched book{selectedReport.unmatched.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
{showReportUnmatched && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||
{selectedReport.unmatched.map((title, i) => (
|
||||
<p key={i} className="text-muted-foreground truncate" title={title}>{title}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user