feat: thumbnails : part1
This commit is contained in:
43
apps/backoffice/app/api/books/[bookId]/thumbnail/route.ts
Normal file
43
apps/backoffice/app/api/books/[bookId]/thumbnail/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ bookId: string }> }
|
||||
) {
|
||||
const { bookId } = await params;
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||
const apiUrl = `${apiBaseUrl}/books/${bookId}/thumbnail`;
|
||||
|
||||
const token = process.env.API_BOOTSTRAP_TOKEN;
|
||||
if (!token) {
|
||||
return new NextResponse("API token not configured", { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
||||
status: response.status
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "image/webp";
|
||||
const imageBuffer = await response.arrayBuffer();
|
||||
|
||||
return new NextResponse(imageBuffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching thumbnail:", error);
|
||||
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||
}
|
||||
|
||||
export function BookCard({ book }: BookCardProps) {
|
||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/pages/1?format=webp&width=200`;
|
||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
||||
import { Settings, CacheStats, ClearCacheResponse } from "../../lib/api";
|
||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats } from "../../lib/api";
|
||||
|
||||
interface SettingsPageProps {
|
||||
initialSettings: Settings;
|
||||
initialCacheStats: CacheStats;
|
||||
initialThumbnailStats: ThumbnailStats;
|
||||
}
|
||||
|
||||
export default function SettingsPage({ initialSettings, initialCacheStats }: SettingsPageProps) {
|
||||
const [settings, setSettings] = useState<Settings>(initialSettings);
|
||||
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) {
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
...initialSettings,
|
||||
thumbnail: initialSettings.thumbnail || { enabled: true, width: 300, height: 400, quality: 80, format: "webp", directory: "/data/thumbnails" }
|
||||
});
|
||||
const [cacheStats, setCacheStats] = useState<CacheStats>(initialCacheStats);
|
||||
const [thumbnailStats, setThumbnailStats] = useState<ThumbnailStats>(initialThumbnailStats);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [clearResult, setClearResult] = useState<ClearCacheResponse | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -299,6 +304,131 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Thumbnail Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="image" size="md" />
|
||||
Thumbnails
|
||||
</CardTitle>
|
||||
<CardDescription>Configure thumbnail generation during indexing</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">Enable Thumbnails</label>
|
||||
<FormSelect
|
||||
value={settings.thumbnail.enabled ? "true" : "false"}
|
||||
onChange={(e) => {
|
||||
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, enabled: e.target.value === "true" } };
|
||||
setSettings(newSettings);
|
||||
handleUpdateSetting("thumbnail", newSettings.thumbnail);
|
||||
}}
|
||||
>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Output Format</label>
|
||||
<FormSelect
|
||||
value={settings.thumbnail.format}
|
||||
onChange={(e) => {
|
||||
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, format: e.target.value } };
|
||||
setSettings(newSettings);
|
||||
handleUpdateSetting("thumbnail", newSettings.thumbnail);
|
||||
}}
|
||||
>
|
||||
<option value="webp">WebP (Recommended)</option>
|
||||
<option value="jpeg">JPEG</option>
|
||||
<option value="png">PNG</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Width (px)</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={50}
|
||||
max={600}
|
||||
value={settings.thumbnail.width}
|
||||
onChange={(e) => {
|
||||
const width = parseInt(e.target.value) || 300;
|
||||
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, width } };
|
||||
setSettings(newSettings);
|
||||
}}
|
||||
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Height (px)</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={50}
|
||||
max={800}
|
||||
value={settings.thumbnail.height}
|
||||
onChange={(e) => {
|
||||
const height = parseInt(e.target.value) || 400;
|
||||
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, height } };
|
||||
setSettings(newSettings);
|
||||
}}
|
||||
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
||||
/>
|
||||
</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.thumbnail.quality}
|
||||
onChange={(e) => {
|
||||
const quality = parseInt(e.target.value) || 80;
|
||||
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, quality } };
|
||||
setSettings(newSettings);
|
||||
}}
|
||||
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Thumbnail Directory</label>
|
||||
<FormInput
|
||||
value={settings.thumbnail.directory}
|
||||
onChange={(e) => {
|
||||
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, directory: e.target.value } };
|
||||
setSettings(newSettings);
|
||||
}}
|
||||
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Size</p>
|
||||
<p className="text-2xl font-semibold">{thumbnailStats.total_size_mb.toFixed(2)} MB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Files</p>
|
||||
<p className="text-2xl font-semibold">{thumbnailStats.file_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Directory</p>
|
||||
<p className="text-sm font-mono truncate" title={thumbnailStats.directory}>{thumbnailStats.directory}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Note: Thumbnail settings are used during indexing. Existing thumbnails will not be regenerated automatically.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSettings, getCacheStats } from "../../lib/api";
|
||||
import { getSettings, getCacheStats, getThumbnailStats } from "../../lib/api";
|
||||
import SettingsPage from "./SettingsPage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -7,7 +7,8 @@ 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 }
|
||||
limits: { concurrent_renders: 4, timeout_seconds: 12, rate_limit_per_second: 120 },
|
||||
thumbnail: { enabled: true, width: 300, height: 400, quality: 80, format: "webp", directory: "/data/thumbnails" }
|
||||
}));
|
||||
|
||||
const cacheStats = await getCacheStats().catch(() => ({
|
||||
@@ -16,5 +17,11 @@ export default async function SettingsPageWrapper() {
|
||||
directory: "/tmp/stripstream-image-cache"
|
||||
}));
|
||||
|
||||
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} />;
|
||||
const thumbnailStats = await getThumbnailStats().catch(() => ({
|
||||
total_size_mb: 0,
|
||||
file_count: 0,
|
||||
directory: "/data/thumbnails"
|
||||
}));
|
||||
|
||||
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} />;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,10 @@ function config() {
|
||||
return { baseUrl: baseUrl.replace(/\/$/, ""), token };
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const { baseUrl, token } = config();
|
||||
const headers = new Headers(init?.headers || {});
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
@@ -109,7 +112,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store"
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -130,7 +133,7 @@ export async function fetchLibraries() {
|
||||
export async function createLibrary(name: string, rootPath: string) {
|
||||
return apiFetch<LibraryDto>("/libraries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, root_path: rootPath })
|
||||
body: JSON.stringify({ name, root_path: rootPath }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,12 +146,21 @@ export async function scanLibrary(libraryId: string, full?: boolean) {
|
||||
if (full) body.full = true;
|
||||
return apiFetch<IndexJobDto>(`/libraries/${libraryId}/scan`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLibraryMonitoring(libraryId: string, monitorEnabled: boolean, scanMode: string, watcherEnabled?: boolean) {
|
||||
const body: { monitor_enabled: boolean; scan_mode: string; watcher_enabled?: boolean } = {
|
||||
export async function updateLibraryMonitoring(
|
||||
libraryId: string,
|
||||
monitorEnabled: boolean,
|
||||
scanMode: string,
|
||||
watcherEnabled?: boolean,
|
||||
) {
|
||||
const body: {
|
||||
monitor_enabled: boolean;
|
||||
scan_mode: string;
|
||||
watcher_enabled?: boolean;
|
||||
} = {
|
||||
monitor_enabled: monitorEnabled,
|
||||
scan_mode: scanMode,
|
||||
};
|
||||
@@ -157,7 +169,7 @@ export async function updateLibraryMonitoring(libraryId: string, monitorEnabled:
|
||||
}
|
||||
return apiFetch<LibraryDto>(`/libraries/${libraryId}/monitoring`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body)
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,7 +183,7 @@ export async function rebuildIndex(libraryId?: string, full?: boolean) {
|
||||
if (full) body.full = true;
|
||||
return apiFetch<IndexJobDto>("/index/rebuild", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,7 +203,7 @@ export async function listTokens() {
|
||||
export async function createToken(name: string, scope: string) {
|
||||
return apiFetch<{ token: string }>("/admin/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, scope })
|
||||
body: JSON.stringify({ name, scope }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,13 +211,18 @@ export async function revokeToken(id: string) {
|
||||
return apiFetch<void>(`/admin/tokens/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function fetchBooks(libraryId?: string, series?: string, cursor?: string, limit: number = 50): Promise<BooksPageDto> {
|
||||
export async function fetchBooks(
|
||||
libraryId?: string,
|
||||
series?: string,
|
||||
cursor?: string,
|
||||
limit: number = 50,
|
||||
): Promise<BooksPageDto> {
|
||||
const params = new URLSearchParams();
|
||||
if (libraryId) params.set("library_id", libraryId);
|
||||
if (series) params.set("series", series);
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
params.set("limit", limit.toString());
|
||||
|
||||
|
||||
return apiFetch<BooksPageDto>(`/books?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -214,27 +231,35 @@ export type SeriesPageDto = {
|
||||
next_cursor: string | null;
|
||||
};
|
||||
|
||||
export async function fetchSeries(libraryId: string, cursor?: string, limit: number = 50): Promise<SeriesPageDto> {
|
||||
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()}`);
|
||||
|
||||
return apiFetch<SeriesPageDto>(
|
||||
`/libraries/${libraryId}/series?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchBooks(query: string, libraryId?: string, limit: number = 20): Promise<SearchResponseDto> {
|
||||
export async function searchBooks(
|
||||
query: string,
|
||||
libraryId?: string,
|
||||
limit: number = 20,
|
||||
): Promise<SearchResponseDto> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("q", query);
|
||||
if (libraryId) params.set("library_id", libraryId);
|
||||
params.set("limit", limit.toString());
|
||||
|
||||
|
||||
return apiFetch<SearchResponseDto>(`/search?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function getBookCoverUrl(bookId: string): string {
|
||||
// Utiliser une route API locale pour éviter les problèmes CORS
|
||||
// Le navigateur ne peut pas accéder à http://api:8080 (hostname Docker interne)
|
||||
return `/api/books/${bookId}/pages/1?format=webp&width=200`;
|
||||
return `/api/books/${bookId}/thumbnail`;
|
||||
}
|
||||
|
||||
export type Settings = {
|
||||
@@ -254,6 +279,14 @@ export type Settings = {
|
||||
timeout_seconds: number;
|
||||
rate_limit_per_second: number;
|
||||
};
|
||||
thumbnail: {
|
||||
enabled: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
quality: number;
|
||||
format: string;
|
||||
directory: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CacheStats = {
|
||||
@@ -267,6 +300,12 @@ export type ClearCacheResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ThumbnailStats = {
|
||||
total_size_mb: number;
|
||||
file_count: number;
|
||||
directory: string;
|
||||
};
|
||||
|
||||
export async function getSettings() {
|
||||
return apiFetch<Settings>("/settings");
|
||||
}
|
||||
@@ -274,7 +313,7 @@ export async function getSettings() {
|
||||
export async function updateSetting(key: string, value: unknown) {
|
||||
return apiFetch<unknown>(`/settings/${key}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ value })
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,5 +322,11 @@ export async function getCacheStats() {
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
return apiFetch<ClearCacheResponse>("/settings/cache/clear", { method: "POST" });
|
||||
return apiFetch<ClearCacheResponse>("/settings/cache/clear", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getThumbnailStats() {
|
||||
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 8082",
|
||||
"dev": "next dev -p 7082",
|
||||
"build": "next build",
|
||||
"start": "next start -p 8082"
|
||||
"start": "next start -p 7082"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^16.1.6",
|
||||
|
||||
Reference in New Issue
Block a user