feat: add image optimization and settings page

- Add persistent disk cache for processed images
- Optimize image processing with short-circuit and quality settings
- Add WebP lossy encoding with configurable quality
- Add settings API endpoints (GET/POST /settings, cache management)
- Add database table for app configuration
- Add /settings page in backoffice for image/cache/limits config
- Add cache stats and clear functionality
- Update navigation with settings link
This commit is contained in:
2026-03-07 09:12:06 +01:00
parent 9141edfaa9
commit 292c61566c
19 changed files with 1038 additions and 66 deletions

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
try {
const { key } = await params;
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const token = process.env.API_BOOTSTRAP_TOKEN;
const response = await fetch(`${baseUrl}/settings/${key}`, {
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store"
});
if (!response.ok) {
return NextResponse.json({ error: "Failed to fetch setting" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
try {
const { key } = await params;
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const token = process.env.API_BOOTSTRAP_TOKEN;
const body = await request.json();
const response = await fetch(`${baseUrl}/settings/${key}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
cache: "no-store"
});
if (!response.ok) {
return NextResponse.json({ error: "Failed to update setting" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const token = process.env.API_BOOTSTRAP_TOKEN;
const response = await fetch(`${baseUrl}/settings/cache/clear`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store"
});
if (!response.ok) {
return NextResponse.json({ error: "Failed to clear cache" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
try {
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const token = process.env.API_BOOTSTRAP_TOKEN;
const response = await fetch(`${baseUrl}/settings/cache/stats`, {
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store"
});
if (!response.ok) {
return NextResponse.json({ error: "Failed to fetch cache stats" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
try {
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
const token = process.env.API_BOOTSTRAP_TOKEN;
const response = await fetch(`${baseUrl}/settings`, {
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store"
});
if (!response.ok) {
return NextResponse.json({ error: "Failed to fetch settings" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -1,4 +1,4 @@
type IconName = "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "series";
type IconName = "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "series" | "settings";
interface PageIconProps {
name: IconName;
@@ -36,6 +36,12 @@ const icons: Record<IconName, React.ReactNode> = {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
settings: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
};
const colors: Record<IconName, string> = {
@@ -45,6 +51,7 @@ const colors: Record<IconName, string> = {
jobs: "text-warning",
tokens: "text-error",
series: "text-primary",
settings: "text-muted-foreground",
};
export function PageIcon({ name, className = "" }: PageIconProps) {
@@ -88,6 +95,12 @@ export function NavIcon({ name, className = "" }: { name: IconName; className?:
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
settings: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
};
return <span className={className}>{navIcons[name]}</span>;

View File

@@ -14,9 +14,9 @@ export const metadata: Metadata = {
};
type NavItem = {
href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens";
href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens" | "/settings";
label: string;
icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens";
icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "settings";
};
const navItems: NavItem[] = [
@@ -25,6 +25,7 @@ const navItems: NavItem[] = [
{ href: "/libraries", label: "Libraries", icon: "libraries" },
{ href: "/jobs", label: "Jobs", icon: "jobs" },
{ href: "/tokens", label: "Tokens", icon: "tokens" },
{ href: "/settings", label: "Settings", icon: "settings" },
];
export default function RootLayout({ children }: { children: ReactNode }) {

View File

@@ -1,4 +1,5 @@
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto } from "../../../../lib/api";
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
import { CursorPagination } from "../../../components/ui";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
@@ -7,26 +8,36 @@ import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
export const dynamic = "force-dynamic";
export default async function LibrarySeriesPage({
params
params,
searchParams
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { id } = await params;
const searchParamsAwaited = await searchParams;
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
const [library, series] = await Promise.all([
const [library, seriesPage] = await Promise.all([
fetchLibraries().then(libs => libs.find(l => l.id === id)),
fetchSeries(id).catch(() => [] as SeriesDto[])
fetchSeries(id, cursor, limit).catch(() => ({ items: [] as SeriesDto[], next_cursor: null }) as SeriesPageDto)
]);
if (!library) {
notFound();
}
const series = seriesPage.items;
const nextCursor = seriesPage.next_cursor;
const hasNextPage = !!nextCursor;
const hasPrevPage = !!cursor;
return (
<div className="space-y-6">
<LibrarySubPageHeader
library={library}
title={`Series (${series.length})`}
title="Series"
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
@@ -36,35 +47,45 @@ export default async function LibrarySeriesPage({
/>
{series.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{series.map((s) => (
<Link
key={s.name}
href={`/libraries/${id}/books?series=${encodeURIComponent(s.name)}`}
className="group"
>
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
<div className="aspect-[2/3] relative bg-muted/50">
<Image
src={getBookCoverUrl(s.first_book_id)}
alt={`Cover of ${s.name}`}
fill
className="object-cover"
unoptimized
/>
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{series.map((s) => (
<Link
key={s.name}
href={`/libraries/${id}/books?series=${encodeURIComponent(s.name)}`}
className="group"
>
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
<div className="aspect-[2/3] relative bg-muted/50">
<Image
src={getBookCoverUrl(s.first_book_id)}
alt={`Cover of ${s.name}`}
fill
className="object-cover"
unoptimized
/>
</div>
<div className="p-3">
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
{s.name === "unclassified" ? "Unclassified" : s.name}
</h3>
<p className="text-xs text-muted-foreground mt-1">
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="p-3">
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
{s.name === "unclassified" ? "Unclassified" : s.name}
</h3>
<p className="text-xs text-muted-foreground mt-1">
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
</p>
</div>
</div>
</Link>
))}
</div>
</Link>
))}
</div>
<CursorPagination
hasNextPage={hasNextPage}
hasPrevPage={hasPrevPage}
pageSize={limit}
currentCount={series.length}
nextCursor={nextCursor}
/>
</>
) : (
<div className="text-center py-12 text-muted-foreground">
<p>No series found in this library</p>

View File

@@ -32,8 +32,8 @@ export default async function LibrariesPage() {
const seriesCounts = await Promise.all(
libraries.map(async (lib) => {
try {
const series = await fetchSeries(lib.id);
return { id: lib.id, count: series.length };
const seriesPage = await fetchSeries(lib.id);
return { id: lib.id, count: seriesPage.items.length };
} catch {
return { id: lib.id, count: 0 };
}

View File

@@ -0,0 +1,303 @@
"use client";
import { useState } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
import { Settings, CacheStats, ClearCacheResponse } from "../../lib/api";
interface SettingsPageProps {
initialSettings: Settings;
initialCacheStats: CacheStats;
}
export default function SettingsPage({ initialSettings, initialCacheStats }: SettingsPageProps) {
const [settings, setSettings] = useState<Settings>(initialSettings);
const [cacheStats, setCacheStats] = useState<CacheStats>(initialCacheStats);
const [isClearing, setIsClearing] = useState(false);
const [clearResult, setClearResult] = useState<ClearCacheResponse | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
async function handleUpdateSetting(key: string, value: unknown) {
setIsSaving(true);
setSaveMessage(null);
try {
const response = await fetch(`/api/settings/${key}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value })
});
if (response.ok) {
setSaveMessage("Settings saved successfully");
setTimeout(() => setSaveMessage(null), 3000);
} else {
setSaveMessage("Failed to save settings");
}
} catch (error) {
setSaveMessage("Error saving settings");
} finally {
setIsSaving(false);
}
}
async function handleClearCache() {
setIsClearing(true);
setClearResult(null);
try {
const response = await fetch("/api/settings/cache/clear", { method: "POST" });
const result = await response.json();
setClearResult(result);
// Refresh cache stats
const statsResponse = await fetch("/api/settings/cache/stats");
if (statsResponse.ok) {
const stats = await statsResponse.json();
setCacheStats(stats);
}
} catch (error) {
setClearResult({ success: false, message: "Failed to clear cache" });
} finally {
setIsClearing(false);
}
}
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</h1>
</div>
{saveMessage && (
<Card className="mb-6 border-success/50 bg-success/5">
<CardContent className="pt-6">
<p className="text-success">{saveMessage}</p>
</CardContent>
</Card>
)}
{/* Image Processing Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Image Processing</CardTitle>
<CardDescription>Configure how images are processed and compressed</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">Output Format</label>
<FormSelect
value={settings.image_processing.format}
onChange={(e) => {
const newSettings = { ...settings, image_processing: { ...settings.image_processing, format: e.target.value } };
setSettings(newSettings);
handleUpdateSetting("image_processing", newSettings.image_processing);
}}
>
<option value="webp">WebP (Recommended)</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
</FormSelect>
</FormField>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">Quality (1-100)</label>
<FormInput
type="number"
min={1}
max={100}
value={settings.image_processing.quality}
onChange={(e) => {
const quality = parseInt(e.target.value) || 85;
const newSettings = { ...settings, image_processing: { ...settings.image_processing, quality } };
setSettings(newSettings);
}}
onBlur={() => handleUpdateSetting("image_processing", settings.image_processing)}
/>
</FormField>
</FormRow>
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">Resize Filter</label>
<FormSelect
value={settings.image_processing.filter}
onChange={(e) => {
const newSettings = { ...settings, image_processing: { ...settings.image_processing, filter: e.target.value } };
setSettings(newSettings);
handleUpdateSetting("image_processing", newSettings.image_processing);
}}
>
<option value="lanczos3">Lanczos3 (Best Quality)</option>
<option value="triangle">Triangle (Faster)</option>
<option value="nearest">Nearest (Fastest)</option>
</FormSelect>
</FormField>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">Max Width (px)</label>
<FormInput
type="number"
min={100}
max={2160}
value={settings.image_processing.max_width}
onChange={(e) => {
const max_width = parseInt(e.target.value) || 2160;
const newSettings = { ...settings, image_processing: { ...settings.image_processing, max_width } };
setSettings(newSettings);
}}
onBlur={() => handleUpdateSetting("image_processing", settings.image_processing)}
/>
</FormField>
</FormRow>
</div>
</CardContent>
</Card>
{/* Cache Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Cache</CardTitle>
<CardDescription>Manage the image cache and storage</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
<div>
<p className="text-sm text-muted-foreground">Cache Size</p>
<p className="text-2xl font-semibold">{cacheStats.total_size_mb.toFixed(2)} MB</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Files</p>
<p className="text-2xl font-semibold">{cacheStats.file_count}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Directory</p>
<p className="text-sm font-mono truncate" title={cacheStats.directory}>{cacheStats.directory}</p>
</div>
</div>
{clearResult && (
<div className={`p-3 rounded-lg ${clearResult.success ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive'}`}>
{clearResult.message}
</div>
)}
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">Cache Directory</label>
<FormInput
value={settings.cache.directory}
onChange={(e) => {
const newSettings = { ...settings, cache: { ...settings.cache, directory: e.target.value } };
setSettings(newSettings);
}}
onBlur={() => handleUpdateSetting("cache", settings.cache)}
/>
</FormField>
<FormField className="w-32">
<label className="text-sm font-medium text-muted-foreground mb-1 block">Max Size (MB)</label>
<FormInput
type="number"
value={settings.cache.max_size_mb}
onChange={(e) => {
const max_size_mb = parseInt(e.target.value) || 10000;
const newSettings = { ...settings, cache: { ...settings.cache, max_size_mb } };
setSettings(newSettings);
}}
onBlur={() => handleUpdateSetting("cache", settings.cache)}
/>
</FormField>
</FormRow>
<Button
onClick={handleClearCache}
disabled={isClearing}
variant="destructive"
>
{isClearing ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Clearing...
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Clear Cache
</>
)}
</Button>
</div>
</CardContent>
</Card>
{/* Limits Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Performance Limits</CardTitle>
<CardDescription>Configure API performance and rate limiting</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">Concurrent Renders</label>
<FormInput
type="number"
min={1}
max={20}
value={settings.limits.concurrent_renders}
onChange={(e) => {
const concurrent_renders = parseInt(e.target.value) || 4;
const newSettings = { ...settings, limits: { ...settings.limits, concurrent_renders } };
setSettings(newSettings);
}}
onBlur={() => handleUpdateSetting("limits", settings.limits)}
/>
</FormField>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">Timeout (seconds)</label>
<FormInput
type="number"
min={5}
max={60}
value={settings.limits.timeout_seconds}
onChange={(e) => {
const timeout_seconds = parseInt(e.target.value) || 12;
const newSettings = { ...settings, limits: { ...settings.limits, timeout_seconds } };
setSettings(newSettings);
}}
onBlur={() => handleUpdateSetting("limits", settings.limits)}
/>
</FormField>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">Rate Limit (req/s)</label>
<FormInput
type="number"
min={10}
max={1000}
value={settings.limits.rate_limit_per_second}
onChange={(e) => {
const rate_limit_per_second = parseInt(e.target.value) || 120;
const newSettings = { ...settings, limits: { ...settings.limits, rate_limit_per_second } };
setSettings(newSettings);
}}
onBlur={() => handleUpdateSetting("limits", settings.limits)}
/>
</FormField>
</FormRow>
<p className="text-sm text-muted-foreground">
Note: Changes to limits require a server restart to take effect.
</p>
</div>
</CardContent>
</Card>
</>
);
}

View File

@@ -0,0 +1,20 @@
import { getSettings, getCacheStats } from "../../lib/api";
import SettingsPage from "./SettingsPage";
export const dynamic = "force-dynamic";
export default async function SettingsPageWrapper() {
const settings = await getSettings().catch(() => ({
image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 },
cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 },
limits: { concurrent_renders: 4, timeout_seconds: 12, rate_limit_per_second: 120 }
}));
const cacheStats = await getCacheStats().catch(() => ({
total_size_mb: 0,
file_count: 0,
directory: "/tmp/stripstream-image-cache"
}));
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} />;
}

View File

@@ -209,8 +209,17 @@ export async function fetchBooks(libraryId?: string, series?: string, cursor?: s
return apiFetch<BooksPageDto>(`/books?${params.toString()}`);
}
export async function fetchSeries(libraryId: string): Promise<SeriesDto[]> {
return apiFetch<SeriesDto[]>(`/libraries/${libraryId}/series`);
export type SeriesPageDto = {
items: SeriesDto[];
next_cursor: string | null;
};
export async function fetchSeries(libraryId: string, cursor?: string, limit: number = 50): Promise<SeriesPageDto> {
const params = new URLSearchParams();
if (cursor) params.set("cursor", cursor);
params.set("limit", limit.toString());
return apiFetch<SeriesPageDto>(`/libraries/${libraryId}/series?${params.toString()}`);
}
export async function searchBooks(query: string, libraryId?: string, limit: number = 20): Promise<SearchResponseDto> {
@@ -227,3 +236,52 @@ export function getBookCoverUrl(bookId: string): string {
// Le navigateur ne peut pas accéder à http://api:8080 (hostname Docker interne)
return `/api/books/${bookId}/pages/1?format=webp&width=200`;
}
export type Settings = {
image_processing: {
format: string;
quality: number;
filter: string;
max_width: number;
};
cache: {
enabled: boolean;
directory: string;
max_size_mb: number;
};
limits: {
concurrent_renders: number;
timeout_seconds: number;
rate_limit_per_second: number;
};
};
export type CacheStats = {
total_size_mb: number;
file_count: number;
directory: string;
};
export type ClearCacheResponse = {
success: boolean;
message: string;
};
export async function getSettings() {
return apiFetch<Settings>("/settings");
}
export async function updateSetting(key: string, value: unknown) {
return apiFetch<unknown>(`/settings/${key}`, {
method: "POST",
body: JSON.stringify({ value })
});
}
export async function getCacheStats() {
return apiFetch<CacheStats>("/settings/cache/stats");
}
export async function clearCache() {
return apiFetch<ClearCacheResponse>("/settings/cache/clear", { method: "POST" });
}

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.