feat: Initial commit - Base application with Next.js - Configuration, Auth, Library navigation, CBZ/CBR reader, Cache, Responsive design

This commit is contained in:
Julien Froidefond
2025-02-11 21:04:40 +01:00
commit 33bdc43442
48 changed files with 9813 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(
request: Request,
{ params }: { params: { bookId: string; pageNumber: string } }
) {
try {
// Récupérer les credentials Komga depuis le cookie
const configCookie = cookies().get("komgaCredentials");
if (!configCookie) {
return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 });
}
let config;
try {
config = JSON.parse(atob(configCookie.value));
} catch (error) {
return NextResponse.json({ error: "Configuration Komga invalide" }, { status: 401 });
}
if (!config.credentials?.username || !config.credentials?.password) {
return NextResponse.json({ error: "Credentials Komga manquants" }, { status: 401 });
}
// Appel à l'API Komga
const response = await fetch(
`${config.serverUrl}/api/v1/books/${params.bookId}/pages/${params.pageNumber}`,
{
headers: {
Authorization: `Basic ${Buffer.from(
`${config.credentials.username}:${config.credentials.password}`
).toString("base64")}`,
},
}
);
if (!response.ok) {
return NextResponse.json(
{ error: "Erreur lors de la récupération de la page" },
{ status: response.status }
);
}
// Récupérer le type MIME de l'image
const contentType = response.headers.get("content-type");
const imageBuffer = await response.arrayBuffer();
// Retourner l'image avec le bon type MIME
return new NextResponse(imageBuffer, {
headers: {
"Content-Type": contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Erreur lors de la récupération de la page:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { config } from "@/lib/config";
import { serverCacheService } from "@/lib/services/server-cache.service";
export async function GET(request: Request, { params }: { params: { bookId: string } }) {
try {
// Récupérer les credentials Komga depuis le cookie
const cookieStore = cookies();
const configCookie = cookieStore.get("komgaCredentials");
console.log("API Books - Cookie komgaCredentials:", configCookie?.value);
if (!configCookie) {
console.log("API Books - Cookie komgaCredentials manquant");
return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 });
}
let komgaConfig;
try {
komgaConfig = JSON.parse(atob(configCookie.value));
console.log("API Books - Config décodée:", {
serverUrl: komgaConfig.serverUrl,
hasCredentials: !!komgaConfig.credentials,
});
} catch (error) {
console.error("API Books - Erreur de décodage du cookie:", error);
return NextResponse.json({ error: "Configuration Komga invalide" }, { status: 401 });
}
if (!komgaConfig.credentials?.username || !komgaConfig.credentials?.password) {
console.log("API Books - Credentials manquants dans la config");
return NextResponse.json({ error: "Credentials Komga manquants" }, { status: 401 });
}
const auth = Buffer.from(
`${komgaConfig.credentials.username}:${komgaConfig.credentials.password}`
).toString("base64");
console.log("API Books - Appel à l'API Komga pour le livre:", params.bookId);
// Clé de cache unique pour ce livre
const cacheKey = `book-${params.bookId}`;
// Fonction pour récupérer les données du livre
const fetchBookData = async () => {
// Récupération des détails du tome
const bookResponse = await fetch(`${komgaConfig.serverUrl}/api/v1/books/${params.bookId}`, {
headers: {
Authorization: `Basic ${auth}`,
},
});
if (!bookResponse.ok) {
console.error("API Books - Erreur de l'API Komga (book):", {
status: bookResponse.status,
statusText: bookResponse.statusText,
});
throw new Error("Erreur lors de la récupération des détails du tome");
}
const book = await bookResponse.json();
// Récupération des pages du tome
const pagesResponse = await fetch(
`${komgaConfig.serverUrl}/api/v1/books/${params.bookId}/pages`,
{
headers: {
Authorization: `Basic ${auth}`,
},
}
);
if (!pagesResponse.ok) {
console.error("API Books - Erreur de l'API Komga (pages):", {
status: pagesResponse.status,
statusText: pagesResponse.statusText,
});
throw new Error("Erreur lors de la récupération des pages du tome");
}
const pages = await pagesResponse.json();
// Retourner les données combinées
return {
book,
pages: pages.map((page: any) => page.number),
};
};
// Récupérer les données du cache ou faire l'appel API
const data = await serverCacheService.getOrSet(cacheKey, fetchBookData, 5 * 60); // Cache de 5 minutes
return NextResponse.json(data);
} catch (error) {
console.error("API Books - Erreur:", error);
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Erreur lors de la récupération des données",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(request: Request, { params }: { params: { bookId: string } }) {
try {
// Récupérer les credentials Komga depuis le cookie
const configCookie = cookies().get("komgaCredentials");
if (!configCookie) {
return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 });
}
let config;
try {
config = JSON.parse(atob(configCookie.value));
} catch (error) {
return NextResponse.json({ error: "Configuration Komga invalide" }, { status: 401 });
}
if (!config.credentials?.username || !config.credentials?.password) {
return NextResponse.json({ error: "Credentials Komga manquants" }, { status: 401 });
}
// Appel à l'API Komga
const response = await fetch(`${config.serverUrl}/api/v1/books/${params.bookId}/thumbnail`, {
headers: {
Authorization: `Basic ${Buffer.from(
`${config.credentials.username}:${config.credentials.password}`
).toString("base64")}`,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: "Erreur lors de la récupération de l'image" },
{ status: response.status }
);
}
// Récupérer le type MIME de l'image
const contentType = response.headers.get("content-type");
const imageBuffer = await response.arrayBuffer();
// Retourner l'image avec le bon type MIME
return new NextResponse(imageBuffer, {
headers: {
"Content-Type": contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Erreur lors de la récupération de l'image:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(request: Request, { params }: { params: { seriesId: string } }) {
try {
// Récupérer les credentials Komga depuis le cookie
const configCookie = cookies().get("komga_credentials");
if (!configCookie) {
return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 });
}
let config;
try {
config = JSON.parse(atob(configCookie.value));
} catch (error) {
return NextResponse.json({ error: "Configuration Komga invalide" }, { status: 401 });
}
// Appel à l'API Komga
const response = await fetch(`${config.serverUrl}/api/v1/series/${params.seriesId}/thumbnail`, {
headers: {
Authorization: `Basic ${Buffer.from(
`${config.credentials?.username}:${config.credentials?.password}`
).toString("base64")}`,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: "Erreur lors de la récupération de l'image" },
{ status: response.status }
);
}
// Récupérer le type MIME de l'image
const contentType = response.headers.get("content-type");
const imageBuffer = await response.arrayBuffer();
// Retourner l'image avec le bon type MIME
return new NextResponse(imageBuffer, {
headers: {
"Content-Type": contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Erreur lors de la récupération de l'image:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { serverCacheService } from "@/lib/services/server-cache.service";
export async function GET(request: Request, { params }: { params: { libraryId: string } }) {
try {
// Récupérer les credentials Komga depuis le cookie
const configCookie = cookies().get("komgaCredentials");
if (!configCookie) {
return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 });
}
let config;
try {
config = JSON.parse(atob(configCookie.value));
} catch (error) {
return NextResponse.json({ error: "Configuration Komga invalide" }, { status: 401 });
}
if (!config.credentials?.username || !config.credentials?.password) {
return NextResponse.json({ error: "Credentials Komga manquants" }, { status: 401 });
}
// Récupérer les paramètres de pagination depuis l'URL
const { searchParams } = new URL(request.url);
const page = searchParams.get("page") || "0";
const size = searchParams.get("size") || "20";
// Clé de cache unique pour cette page de séries
const cacheKey = `library-${params.libraryId}-series-${page}-${size}`;
// Fonction pour récupérer les séries
const fetchSeries = async () => {
const response = await fetch(
`${config.serverUrl}/api/v1/series?library_id=${params.libraryId}&page=${page}&size=${size}`,
{
headers: {
Authorization: `Basic ${Buffer.from(
`${config.credentials.username}:${config.credentials.password}`
).toString("base64")}`,
},
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
JSON.stringify({
error: "Erreur lors de la récupération des séries",
details: errorData,
})
);
}
return response.json();
};
// Récupérer les données du cache ou faire l'appel API
const data = await serverCacheService.getOrSet(cacheKey, fetchSeries, 5 * 60); // Cache de 5 minutes
return NextResponse.json(data);
} catch (error) {
console.error("Erreur lors de la récupération des séries:", error);
return NextResponse.json(
{
error: "Erreur serveur",
details: error instanceof Error ? error.message : "Erreur inconnue",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { AuthConfig } from "@/types/auth";
import { serverCacheService } from "@/lib/services/server-cache.service";
export async function GET() {
try {
// Vérifier l'authentification de l'utilisateur
const userCookie = cookies().get("komgaUser");
if (!userCookie) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
const userData = JSON.parse(atob(userCookie.value));
if (!userData.authenticated) {
throw new Error("User not authenticated");
}
} catch (error) {
return NextResponse.json({ error: "Session invalide" }, { status: 401 });
}
// Récupérer les credentials Komga depuis le cookie
const configCookie = cookies().get("komgaCredentials");
if (!configCookie) {
return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 });
}
let config: AuthConfig;
try {
config = JSON.parse(atob(configCookie.value));
} catch (error) {
return NextResponse.json({ error: "Configuration Komga invalide" }, { status: 401 });
}
// Clé de cache unique pour les bibliothèques
const cacheKey = "libraries";
// Fonction pour récupérer les bibliothèques
const fetchLibraries = async () => {
const response = await fetch(`${config.serverUrl}/api/v1/libraries`, {
headers: {
Authorization: `Basic ${Buffer.from(
`${config.credentials?.username}:${config.credentials?.password}`
).toString("base64")}`,
},
});
if (!response.ok) {
throw new Error("Erreur lors de la récupération des bibliothèques");
}
return response.json();
};
// Récupérer les données du cache ou faire l'appel API
const data = await serverCacheService.getOrSet(cacheKey, fetchLibraries, 5 * 60); // Cache de 5 minutes
return NextResponse.json(data);
} catch (error) {
console.error("Erreur lors de la récupération des bibliothèques:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { serverCacheService } from "@/services/serverCacheService";
export async function GET(request: Request, { params }: { params: { seriesId: string } }) {
const configCookie = cookies().get("komgaCredentials");
if (!configCookie) {
return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 });
}
try {
const config = JSON.parse(atob(configCookie.value));
const cacheKey = `series-${params.seriesId}-read-progress`;
const cachedData = await serverCacheService.get(cacheKey);
if (cachedData) {
return NextResponse.json(cachedData);
}
const readProgress = await fetchReadProgress(config, params.seriesId);
await serverCacheService.set(cacheKey, readProgress, 300); // Cache for 5 minutes
return NextResponse.json(readProgress);
} catch (error) {
return NextResponse.json(
{ error: "Erreur lors de la récupération de la progression" },
{ status: 500 }
);
}
}
async function fetchReadProgress(config: any, seriesId: string) {
const { serverUrl, credentials } = config;
const response = await fetch(`${serverUrl}/api/v1/series/${seriesId}/read-progress/tachiyomi`, {
headers: {
Authorization: `Basic ${credentials}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}

View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(request: Request, { params }: { params: { seriesId: string } }) {
try {
// Récupérer les credentials Komga depuis le cookie
const configCookie = cookies().get("komga_credentials");
if (!configCookie) {
return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 });
}
let config;
try {
config = JSON.parse(atob(configCookie.value));
} catch (error) {
return NextResponse.json({ error: "Configuration Komga invalide" }, { status: 401 });
}
if (!config.credentials?.username || !config.credentials?.password) {
return NextResponse.json({ error: "Credentials Komga manquants" }, { status: 401 });
}
const auth = Buffer.from(
`${config.credentials.username}:${config.credentials.password}`
).toString("base64");
// Appel à l'API Komga pour récupérer les détails de la série
const [seriesResponse, booksResponse] = await Promise.all([
// Détails de la série
fetch(`${config.serverUrl}/api/v1/series/${params.seriesId}`, {
headers: {
Authorization: `Basic ${auth}`,
},
}),
// Liste des tomes (on récupère tous les tomes avec size=1000)
fetch(
`${config.serverUrl}/api/v1/series/${params.seriesId}/books?page=0&size=1000&unpaged=true&sort=metadata.numberSort,asc`,
{
headers: {
Authorization: `Basic ${auth}`,
},
}
),
]);
if (!seriesResponse.ok || !booksResponse.ok) {
const errorResponse = !seriesResponse.ok ? seriesResponse : booksResponse;
const errorData = await errorResponse.json().catch(() => null);
return NextResponse.json(
{
error: "Erreur lors de la récupération des données de la série",
details: errorData,
},
{ status: errorResponse.status }
);
}
const [series, booksData] = await Promise.all([seriesResponse.json(), booksResponse.json()]);
// On extrait la liste des tomes de la réponse paginée
const books = booksData.content;
return NextResponse.json({ series, books });
} catch (error) {
console.error("Erreur lors de la récupération de la série:", error);
return NextResponse.json(
{
error: "Erreur serveur",
details: error instanceof Error ? error.message : "Erreur inconnue",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
const { serverUrl, username, password } = await request.json();
// Vérification des paramètres requis
if (!serverUrl || !username || !password) {
return NextResponse.json({ error: "Tous les champs sont requis" }, { status: 400 });
}
// Test de connexion à Komga en utilisant la route /api/v1/libraries
const response = await fetch(`${serverUrl}/api/v1/libraries`, {
headers: {
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
},
});
// Log de la réponse pour le debug
console.log("Komga response status:", response.status);
console.log("Komga response headers:", Object.fromEntries(response.headers.entries()));
if (!response.ok) {
let errorMessage = "Impossible de se connecter au serveur Komga";
let errorDetails = null;
try {
errorDetails = await response.json();
} catch (e) {
// Si on ne peut pas parser la réponse, on utilise le texte brut
try {
errorDetails = await response.text();
} catch (e) {
// Si on ne peut pas récupérer le texte non plus, on garde le message par défaut
}
}
// Personnalisation du message d'erreur en fonction du status
if (response.status === 401) {
errorMessage = "Identifiants Komga invalides";
} else if (response.status === 404) {
errorMessage = "Le serveur Komga n'est pas accessible à cette adresse";
}
return NextResponse.json(
{
error: errorMessage,
details: {
status: response.status,
statusText: response.statusText,
errorDetails,
},
},
{ status: response.status }
);
}
const libraries = await response.json();
return NextResponse.json({ success: true, libraries });
} catch (error) {
console.error("Erreur lors du test de connexion:", error);
return NextResponse.json(
{
error: "Le serveur Komga est inaccessible",
details: error instanceof Error ? error.message : "Erreur inconnue",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(request: Request, { params }: { params: { path: string[] } }) {
try {
// Récupérer les credentials Komga depuis le cookie
const configCookie = cookies().get("komga_credentials");
if (!configCookie) {
return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 });
}
let config;
try {
config = JSON.parse(atob(configCookie.value));
} catch (error) {
return NextResponse.json({ error: "Configuration Komga invalide" }, { status: 401 });
}
// Reconstruire le chemin de l'image
const imagePath = params.path.join("/");
const imageUrl = `${config.serverUrl}/api/v1/${imagePath}`;
// Appel à l'API Komga
const response = await fetch(imageUrl, {
headers: {
Authorization: `Basic ${Buffer.from(
`${config.credentials?.username}:${config.credentials?.password}`
).toString("base64")}`,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: "Erreur lors de la récupération de l'image" },
{ status: response.status }
);
}
// Récupérer les headers de l'image
const contentType = response.headers.get("content-type");
const buffer = await response.arrayBuffer();
// Retourner l'image avec les bons headers
return new NextResponse(buffer, {
headers: {
"Content-Type": contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000",
},
});
} catch (error) {
console.error("Erreur lors de la récupération de l'image:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@@ -0,0 +1,160 @@
"use client";
import { useRouter } from "next/navigation";
import { KomgaBook } from "@/types/komga";
import { useEffect, useState } from "react";
import { BookReader } from "@/components/reader/BookReader";
import { ImageOff } from "lucide-react";
import Image from "next/image";
interface BookData {
book: KomgaBook;
pages: number[];
}
export default function BookPage({ params }: { params: { bookId: string } }) {
const router = useRouter();
const [data, setData] = useState<BookData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
const [isReading, setIsReading] = useState(false);
useEffect(() => {
const fetchBookData = async () => {
try {
const response = await fetch(`/api/komga/books/${params.bookId}`);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Erreur lors de la récupération du tome");
}
const data = await response.json();
setData(data);
} catch (error) {
console.error("Erreur:", error);
setError(error instanceof Error ? error.message : "Une erreur est survenue");
} finally {
setIsLoading(false);
}
};
fetchBookData();
}, [params.bookId]);
const handleStartReading = () => {
setIsReading(true);
};
const handleCloseReader = () => {
setIsReading(false);
};
if (isLoading) {
return (
<div className="container py-8 space-y-8 animate-pulse">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-48 h-72 bg-muted rounded-lg" />
<div className="flex-1 space-y-4">
<div className="h-8 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-1/4" />
<div className="h-24 bg-muted rounded" />
</div>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="container py-8">
<div className="rounded-md bg-destructive/15 p-4">
<p className="text-sm text-destructive">{error || "Données non disponibles"}</p>
</div>
</div>
);
}
const { book, pages } = data;
return (
<>
<div className="container py-8 space-y-8">
{/* En-tête du tome */}
<div className="flex flex-col md:flex-row gap-8">
{/* Couverture */}
<div className="w-48 shrink-0">
<div className="relative aspect-[2/3] rounded-lg overflow-hidden bg-muted">
{!imageError ? (
<Image
src={`/api/komga/images/books/${book.id}/thumbnail`}
alt={`Couverture de ${book.metadata.title}`}
fill
className="object-cover"
onError={() => setImageError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageOff className="w-12 h-12" />
</div>
)}
</div>
</div>
{/* Informations */}
<div className="flex-1 space-y-4">
<div>
<h1 className="text-3xl font-bold">
{book.metadata.title || `Tome ${book.metadata.number}`}
</h1>
<p className="text-muted-foreground">
{book.seriesTitle} - Tome {book.metadata.number}
</p>
</div>
{book.metadata.summary && (
<p className="text-muted-foreground">{book.metadata.summary}</p>
)}
<div className="space-y-1 text-sm text-muted-foreground">
{book.metadata.releaseDate && (
<div>
<span className="font-medium">Date de sortie :</span>{" "}
{new Date(book.metadata.releaseDate).toLocaleDateString()}
</div>
)}
{book.metadata.authors?.length > 0 && (
<div>
<span className="font-medium">Auteurs :</span>{" "}
{book.metadata.authors
.map((author) => `${author.name} (${author.role})`)
.join(", ")}
</div>
)}
{book.size && (
<div>
<span className="font-medium">Taille :</span> {book.size}
</div>
)}
{book.media.pagesCount > 0 && (
<div>
<span className="font-medium">Pages :</span> {book.media.pagesCount}
</div>
)}
</div>
{/* Bouton de lecture */}
<button
onClick={handleStartReading}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Commencer la lecture
</button>
</div>
</div>
</div>
{/* Lecteur */}
{isReading && <BookReader book={book} pages={pages} onClose={handleCloseReader} />}
</>
);
}

23
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "@/styles/globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Paniels - Komga Reader",
description: "A modern web reader for Komga",
};
// Composant client séparé pour le layout
import ClientLayout from "@/components/layout/ClientLayout";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr" suppressHydrationWarning>
<body className={inter.className}>
<ClientLayout>{children}</ClientLayout>
</body>
</html>
);
}

View File

@@ -0,0 +1,90 @@
import { cookies } from "next/headers";
import { SeriesGrid } from "@/components/library/SeriesGrid";
import { KomgaSeries } from "@/types/komga";
async function getLibrarySeries(libraryId: string) {
const configCookie = cookies().get("komgaCredentials");
if (!configCookie) {
throw new Error("Configuration Komga manquante");
}
try {
const config = JSON.parse(atob(configCookie.value));
if (!config.serverUrl || !config.credentials?.username || !config.credentials?.password) {
throw new Error("Configuration Komga invalide ou incomplète");
}
console.log("Config:", {
serverUrl: config.serverUrl,
hasCredentials: !!config.credentials,
username: config.credentials.username,
});
const url = `${config.serverUrl}/api/v1/series?library_id=${libraryId}&page=0&size=100`;
console.log("URL de l'API:", url);
const credentials = `${config.credentials.username}:${config.credentials.password}`;
const auth = Buffer.from(credentials).toString("base64");
const response = await fetch(url, {
headers: {
Authorization: `Basic ${auth}`,
Accept: "application/json",
},
cache: "no-store", // Désactiver le cache pour le debug
});
if (!response.ok) {
const errorText = await response.text();
console.error("Réponse de l'API non valide:", {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: errorText,
});
throw new Error(`Erreur HTTP: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log("Données reçues:", {
totalElements: data.totalElements,
totalPages: data.totalPages,
numberOfElements: data.numberOfElements,
});
return { data, serverUrl: config.serverUrl };
} catch (error) {
console.error("Erreur détaillée:", {
message: error instanceof Error ? error.message : "Erreur inconnue",
stack: error instanceof Error ? error.stack : undefined,
error,
});
throw error instanceof Error ? error : new Error("Erreur lors de la récupération des séries");
}
}
export default async function LibraryPage({ params }: { params: { libraryId: string } }) {
try {
const { data: series, serverUrl } = await getLibrarySeries(params.libraryId);
return (
<div className="container py-8 space-y-8">
<h1 className="text-3xl font-bold">Séries</h1>
<SeriesGrid series={series.content || []} serverUrl={serverUrl} />
</div>
);
} catch (error) {
return (
<div className="container py-8 space-y-8">
<h1 className="text-3xl font-bold">Séries</h1>
<div className="rounded-md bg-destructive/15 p-4">
<p className="text-sm text-destructive">
{error instanceof Error ? error.message : "Erreur lors de la récupération des séries"}
</p>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { KomgaLibrary } from "@/types/komga";
import { LibraryGrid } from "@/components/library/LibraryGrid";
import { storageService } from "@/lib/services/storage.service";
export default function LibrariesPage() {
const router = useRouter();
const [libraries, setLibraries] = useState<KomgaLibrary[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchLibraries = async () => {
try {
const response = await fetch("/api/komga/libraries");
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Erreur lors de la récupération des bibliothèques");
}
const data = await response.json();
setLibraries(data);
} catch (error) {
console.error("Erreur:", error);
setError(error instanceof Error ? error.message : "Une erreur est survenue");
} finally {
setIsLoading(false);
}
};
fetchLibraries();
}, []);
const handleLibraryClick = (library: KomgaLibrary) => {
router.push(`/libraries/${library.id}`);
};
const getLibraryThumbnailUrl = (libraryId: string): string => {
return `/api/komga/thumbnail/libraries/${libraryId}/thumbnail`;
};
if (isLoading) {
return (
<div className="container">
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold">Bibliothèques</h1>
<p className="text-muted-foreground mt-2">Chargement des bibliothèques...</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 animate-pulse">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-32 rounded-lg border bg-muted" />
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="container">
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold">Bibliothèques</h1>
<p className="text-muted-foreground mt-2">Une erreur est survenue</p>
</div>
<div className="rounded-md bg-destructive/15 p-4">
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
</div>
);
}
return (
<div className="container">
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold">Bibliothèques</h1>
<p className="text-muted-foreground mt-2">Explorez vos bibliothèques Komga</p>
</div>
<LibraryGrid
libraries={libraries}
onLibraryClick={handleLibraryClick}
getLibraryThumbnailUrl={getLibraryThumbnailUrl}
/>
</div>
</div>
);
}

140
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authService } from "@/lib/services/auth.service";
import { AuthError } from "@/types/auth";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AuthError | null>(null);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsLoading(true);
setError(null);
const formData = new FormData(event.currentTarget);
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const remember = formData.get("remember") === "on";
try {
await authService.login(email, password, remember);
const from = searchParams.get("from") || "/";
router.push(from);
} catch (error) {
setError(error as AuthError);
} finally {
setIsLoading(false);
}
};
return (
<div className="container relative min-h-[calc(100vh-theme(spacing.14))] flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
<div className="relative z-20 flex items-center text-lg font-medium">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-6 w-6"
>
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>
Paniels
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
Profitez de vos BD, mangas et comics préférés avec une expérience de lecture moderne
et fluide.
</p>
<footer className="text-sm text-muted-foreground">
Identifiants de démo : demo@paniels.local / demo123
</footer>
</blockquote>
</div>
</div>
<div className="lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Connexion</h1>
<p className="text-sm text-muted-foreground">
Connectez-vous pour accéder à votre bibliothèque
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
defaultValue="demo@paniels.local"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div className="space-y-2">
<label
htmlFor="password"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Mot de passe
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
defaultValue="demo123"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div className="flex items-center space-x-2">
<input
id="remember"
name="remember"
type="checkbox"
defaultChecked
className="h-4 w-4 rounded border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<label
htmlFor="remember"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Se souvenir de moi
</label>
</div>
{error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error.message}
</div>
)}
<button
type="submit"
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
{isLoading ? "Connexion en cours..." : "Se connecter"}
</button>
</form>
</div>
</div>
</div>
);
}

37
src/app/page.tsx Normal file
View File

@@ -0,0 +1,37 @@
"use client";
export default function Home() {
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold">Bienvenue sur Paniels</h1>
<p className="text-muted-foreground mt-2">
Votre lecteur Komga moderne pour lire vos BD, mangas et comics préférés.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="rounded-lg border bg-card text-card-foreground shadow-sm p-6">
<h2 className="font-semibold mb-2">Bibliothèques</h2>
<p className="text-sm text-muted-foreground">
Accédez à vos bibliothèques Komga et parcourez vos collections.
</p>
</div>
<div className="rounded-lg border bg-card text-card-foreground shadow-sm p-6">
<h2 className="font-semibold mb-2">Collections</h2>
<p className="text-sm text-muted-foreground">
Organisez vos lectures en collections thématiques.
</p>
</div>
<div className="rounded-lg border bg-card text-card-foreground shadow-sm p-6">
<h2 className="font-semibold mb-2">Lecture</h2>
<p className="text-sm text-muted-foreground">
Profitez d'une expérience de lecture fluide et confortable.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { useRouter } from "next/navigation";
import { KomgaSeries, KomgaBook } from "@/types/komga";
import { useEffect, useState } from "react";
import { BookGrid } from "@/components/series/BookGrid";
import { ImageOff } from "lucide-react";
import Image from "next/image";
interface SeriesData {
series: KomgaSeries;
books: KomgaBook[];
}
export default function SeriesPage({ params }: { params: { seriesId: string } }) {
const router = useRouter();
const [data, setData] = useState<SeriesData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
useEffect(() => {
const fetchSeriesData = async () => {
try {
const response = await fetch(`/api/komga/series/${params.seriesId}`);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Erreur lors de la récupération de la série");
}
const data = await response.json();
setData(data);
} catch (error) {
console.error("Erreur:", error);
setError(error instanceof Error ? error.message : "Une erreur est survenue");
} finally {
setIsLoading(false);
}
};
fetchSeriesData();
}, [params.seriesId]);
const handleBookClick = (book: KomgaBook) => {
router.push(`/books/${book.id}`);
};
const getBookThumbnailUrl = (bookId: string) => {
return `/api/komga/images/books/${bookId}/thumbnail`;
};
if (isLoading) {
return (
<div className="container py-8 space-y-8 animate-pulse">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-48 h-72 bg-muted rounded-lg" />
<div className="flex-1 space-y-4">
<div className="h-8 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-1/4" />
<div className="h-24 bg-muted rounded" />
</div>
</div>
<div className="space-y-4">
<div className="h-6 bg-muted rounded w-32" />
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="rounded-lg border bg-card overflow-hidden">
<div className="aspect-[2/3] bg-muted" />
<div className="p-2 space-y-2">
<div className="h-4 bg-muted rounded" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="container py-8">
<div className="rounded-md bg-destructive/15 p-4">
<p className="text-sm text-destructive">{error || "Données non disponibles"}</p>
</div>
</div>
);
}
const { series, books } = data;
return (
<div className="container py-8 space-y-8">
{/* En-tête de la série */}
<div className="flex flex-col md:flex-row gap-8">
{/* Couverture */}
<div className="w-48 shrink-0">
<div className="relative aspect-[2/3] rounded-lg overflow-hidden bg-muted">
{!imageError ? (
<Image
src={`/api/komga/images/series/${series.id}/thumbnail`}
alt={`Couverture de ${series.metadata.title}`}
fill
className="object-cover"
onError={() => setImageError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageOff className="w-12 h-12" />
</div>
)}
</div>
</div>
{/* Informations */}
<div className="flex-1 space-y-4">
<div>
<h1 className="text-3xl font-bold">{series.metadata.title}</h1>
{series.metadata.status && (
<span
className={`mt-2 inline-block px-2 py-1 rounded-full text-xs ${
series.metadata.status === "ENDED"
? "bg-green-500/10 text-green-500"
: series.metadata.status === "ONGOING"
? "bg-blue-500/10 text-blue-500"
: series.metadata.status === "ABANDONED"
? "bg-destructive/10 text-destructive"
: "bg-yellow-500/10 text-yellow-500"
}`}
>
{series.metadata.status === "ENDED"
? "Terminé"
: series.metadata.status === "ONGOING"
? "En cours"
: series.metadata.status === "ABANDONED"
? "Abandonné"
: "En pause"}
</span>
)}
</div>
{series.metadata.summary && (
<p className="text-muted-foreground">{series.metadata.summary}</p>
)}
<div className="space-y-1 text-sm text-muted-foreground">
{series.metadata.publisher && (
<div>
<span className="font-medium">Éditeur :</span> {series.metadata.publisher}
</div>
)}
{series.metadata.genres?.length > 0 && (
<div>
<span className="font-medium">Genres :</span> {series.metadata.genres.join(", ")}
</div>
)}
{series.metadata.tags?.length > 0 && (
<div>
<span className="font-medium">Tags :</span> {series.metadata.tags.join(", ")}
</div>
)}
{series.metadata.language && (
<div>
<span className="font-medium">Langue :</span>{" "}
{new Intl.DisplayNames([navigator.language], { type: "language" }).of(
series.metadata.language
)}
</div>
)}
{series.metadata.ageRating && (
<div>
<span className="font-medium">Âge recommandé :</span> {series.metadata.ageRating}+
</div>
)}
</div>
</div>
</div>
{/* Grille des tomes */}
<div className="space-y-4">
<h2 className="text-2xl font-semibold">
Tomes <span className="text-muted-foreground">({books.length})</span>
</h2>
<BookGrid
books={books}
onBookClick={handleBookClick}
getBookThumbnailUrl={getBookThumbnailUrl}
/>
</div>
</div>
);
}

220
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,220 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { storageService } from "@/lib/services/storage.service";
import { AuthError } from "@/types/auth";
export default function SettingsPage() {
const router = useRouter();
const formRef = useRef<HTMLFormElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AuthError | null>(null);
const [success, setSuccess] = useState(false);
const [config, setConfig] = useState({
serverUrl: "",
username: "",
password: "",
});
useEffect(() => {
// Charger la configuration existante
const savedConfig = storageService.getCredentials();
if (savedConfig) {
setConfig({
serverUrl: savedConfig.serverUrl,
username: savedConfig.credentials?.username || "",
password: savedConfig.credentials?.password || "",
});
}
}, []);
const handleTest = async () => {
if (!formRef.current) return;
setIsLoading(true);
setError(null);
setSuccess(false);
const formData = new FormData(formRef.current);
const serverUrl = formData.get("serverUrl") as string;
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try {
const response = await fetch("/api/komga/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
serverUrl: serverUrl.trim(),
username,
password,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(
`${data.error}${
data.details ? `\n\nDétails: ${JSON.stringify(data.details, null, 2)}` : ""
}`
);
}
setSuccess(true);
} catch (error) {
console.error("Erreur de test:", error);
setError({
code: "INVALID_SERVER_URL",
message:
error instanceof Error ? error.message : "Impossible de se connecter au serveur Komga",
});
} finally {
setIsLoading(false);
}
};
const handleSave = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSuccess(false);
const formData = new FormData(event.currentTarget);
const serverUrl = formData.get("serverUrl") as string;
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const newConfig = {
serverUrl: serverUrl.trim(),
username,
password,
};
storageService.setCredentials(
{
serverUrl: newConfig.serverUrl,
credentials: { username: newConfig.username, password: newConfig.password },
},
true
);
setConfig(newConfig);
setSuccess(true);
};
return (
<div className="container max-w-2xl">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Préférences</h1>
<p className="text-muted-foreground mt-2">Configurez votre connexion au serveur Komga</p>
</div>
<div className="rounded-lg border bg-card text-card-foreground shadow-sm p-6">
<h2 className="text-xl font-semibold mb-4">Configuration du serveur Komga</h2>
<p className="text-sm text-muted-foreground mb-6">
Ces identifiants sont différents de ceux utilisés pour vous connecter à l'application.
Il s'agit des identifiants de votre serveur Komga.
</p>
<form ref={formRef} className="space-y-8" onSubmit={handleSave}>
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor="serverUrl"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
URL du serveur Komga
</label>
<input
id="serverUrl"
name="serverUrl"
type="url"
placeholder="https://komga.example.com"
defaultValue={config.serverUrl || process.env.NEXT_PUBLIC_DEFAULT_KOMGA_URL}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p className="text-sm text-muted-foreground">
L'URL complète de votre serveur Komga, par exemple: https://komga.example.com
</p>
</div>
<div className="space-y-2">
<label
htmlFor="username"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Identifiant Komga
</label>
<input
id="username"
name="username"
type="text"
defaultValue={config.username}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p className="text-sm text-muted-foreground">
L'identifiant de votre compte sur le serveur Komga
</p>
</div>
<div className="space-y-2">
<label
htmlFor="password"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Mot de passe Komga
</label>
<input
id="password"
name="password"
type="password"
defaultValue={config.password}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p className="text-sm text-muted-foreground">
Le mot de passe de votre compte sur le serveur Komga
</p>
</div>
</div>
{error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error.message}
</div>
)}
{success && (
<div className="rounded-md bg-green-500/15 p-3 text-sm text-green-500">
{isLoading ? "Test de connexion réussi" : "Configuration sauvegardée"}
</div>
)}
<div className="flex space-x-4">
<button
type="button"
disabled={isLoading}
onClick={handleTest}
className="flex-1 inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
{isLoading ? "Test en cours..." : "Tester la connexion"}
</button>
<button
type="submit"
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
Sauvegarder
</button>
</div>
</form>
</div>
</div>
</div>
);
}