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>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { ThemeProvider } from "next-themes";
import { useState } from "react";
import { Header } from "@/components/layout/Header";
import { Sidebar } from "@/components/layout/Sidebar";
export default function ClientLayout({ children }: { children: React.ReactNode }) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<div className="relative min-h-screen">
<Header onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} />
<Sidebar isOpen={isSidebarOpen} />
<main className="container pt-4 md:pt-8">{children}</main>
</div>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,48 @@
import { Menu, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
interface HeaderProps {
onToggleSidebar: () => void;
}
export function Header({ onToggleSidebar }: HeaderProps) {
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
};
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 max-w-screen-2xl items-center">
<button
onClick={onToggleSidebar}
className="mr-2 px-2 hover:bg-accent hover:text-accent-foreground rounded-md"
aria-label="Toggle sidebar"
>
<Menu className="h-5 w-5" />
</button>
<div className="mr-4 hidden md:flex">
<a className="mr-6 flex items-center space-x-2" href="/">
<span className="hidden font-bold sm:inline-block">Paniels</span>
</a>
</div>
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
<nav className="flex items-center">
<button
onClick={toggleTheme}
className="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-md"
aria-label="Toggle theme"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</button>
</nav>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,76 @@
import { BookOpen, Home, Library, Settings } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface SidebarProps {
isOpen: boolean;
}
export function Sidebar({ isOpen }: SidebarProps) {
const pathname = usePathname();
const navigation = [
{
name: "Accueil",
href: "/",
icon: Home,
},
{
name: "Bibliothèques",
href: "/libraries",
icon: Library,
},
{
name: "Collections",
href: "/collections",
icon: BookOpen,
},
];
return (
<aside
className={cn(
"fixed left-0 top-14 z-30 h-[calc(100vh-3.5rem)] w-64 border-r border-border/40 bg-background transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="space-y-4 py-4">
<div className="px-3 py-2">
<div className="space-y-1">
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">Navigation</h2>
{navigation.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground",
pathname === item.href ? "bg-accent" : "transparent"
)}
>
<item.icon className="mr-2 h-4 w-4" />
{item.name}
</Link>
))}
</div>
</div>
<div className="px-3 py-2">
<div className="space-y-1">
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">Configuration</h2>
<Link
href="/settings"
className={cn(
"flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground",
pathname === "/settings" ? "bg-accent" : "transparent"
)}
>
<Settings className="mr-2 h-4 w-4" />
Préférences
</Link>
</div>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,115 @@
import { KomgaLibrary } from "@/types/komga";
import { Book, ImageOff } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
interface LibraryGridProps {
libraries: KomgaLibrary[];
onLibraryClick?: (library: KomgaLibrary) => void;
getLibraryThumbnailUrl: (libraryId: string) => string;
}
// Fonction utilitaire pour formater la date de manière sécurisée
const formatDate = (dateString: string): string => {
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return "Date non disponible";
}
return new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
} catch (error) {
console.error("Erreur lors du formatage de la date:", error);
return "Date non disponible";
}
};
export function LibraryGrid({
libraries,
onLibraryClick,
getLibraryThumbnailUrl,
}: LibraryGridProps) {
if (!libraries.length) {
return (
<div className="text-center p-8">
<p className="text-muted-foreground">Aucune bibliothèque disponible</p>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{libraries.map((library) => (
<LibraryCard
key={library.id}
library={library}
onClick={() => onLibraryClick?.(library)}
getLibraryThumbnailUrl={getLibraryThumbnailUrl}
/>
))}
</div>
);
}
interface LibraryCardProps {
library: KomgaLibrary;
onClick?: () => void;
getLibraryThumbnailUrl: (libraryId: string) => string;
}
function LibraryCard({ library, onClick, getLibraryThumbnailUrl }: LibraryCardProps) {
const [imageError, setImageError] = useState(false);
return (
<button
onClick={onClick}
className="group relative flex flex-col h-48 rounded-lg border bg-card text-card-foreground shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden"
>
{/* Image de couverture */}
<div className="absolute inset-0 bg-muted">
{!imageError ? (
<Image
src={getLibraryThumbnailUrl(library.id)}
alt={`Couverture de ${library.name}`}
fill
className="object-cover opacity-20 group-hover:opacity-30 transition-opacity"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
onError={() => setImageError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center opacity-20">
<ImageOff className="w-12 h-12" />
</div>
)}
</div>
{/* Contenu */}
<div className="relative h-full flex flex-col p-6">
<div className="flex items-center gap-3 mb-4">
<Book className="h-6 w-6 shrink-0" />
<h3 className="text-lg font-semibold line-clamp-1">{library.name}</h3>
</div>
<div className="mt-auto space-y-2">
<div className="flex items-center justify-between text-sm">
<span
className={`px-2 py-0.5 rounded-full text-xs ${
library.unavailable
? "bg-destructive/10 text-destructive"
: "bg-green-500/10 text-green-500"
}`}
>
{library.unavailable ? "Non disponible" : "Disponible"}
</span>
</div>
<div className="text-xs text-muted-foreground">
Dernière mise à jour : {formatDate(library.lastModified)}
</div>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { KomgaSeries } from "@/types/komga";
import { Book, ImageOff } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface SeriesGridProps {
series: KomgaSeries[];
serverUrl: string;
}
// Fonction utilitaire pour obtenir les informations de lecture d'une série
const getReadingStatusInfo = (series: KomgaSeries): { label: string; className: string } => {
const { booksCount, booksReadCount, booksUnreadCount } = series;
const booksInProgressCount = booksCount - (booksReadCount + booksUnreadCount);
if (booksReadCount === booksCount) {
return {
label: "Lu",
className: "bg-green-500/10 text-green-500",
};
}
if (booksInProgressCount > 0 || (booksReadCount > 0 && booksReadCount < booksCount)) {
return {
label: "En cours",
className: "bg-blue-500/10 text-blue-500",
};
}
return {
label: "Non lu",
className: "bg-yellow-500/10 text-yellow-500",
};
};
export function SeriesGrid({ series, serverUrl }: SeriesGridProps) {
const router = useRouter();
if (!series.length) {
return (
<div className="text-center p-8">
<p className="text-muted-foreground">Aucune série disponible</p>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-5">
{series.map((series) => (
<SeriesCard
key={series.id}
series={series}
onClick={() => router.push(`/series/${series.id}`)}
serverUrl={serverUrl}
/>
))}
</div>
);
}
interface SeriesCardProps {
series: KomgaSeries;
onClick?: () => void;
serverUrl: string;
}
function SeriesCard({ series, onClick, serverUrl }: SeriesCardProps) {
const [imageError, setImageError] = useState(false);
const statusInfo = getReadingStatusInfo(series);
return (
<button
onClick={onClick}
className="group relative flex flex-col rounded-lg border bg-card text-card-foreground shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden"
>
{/* Image de couverture */}
<div className="relative aspect-[2/3] bg-muted">
{!imageError ? (
<Image
src={`/api/komga/images/series/${series.id}/thumbnail`}
alt={`Couverture de ${series.metadata.title}`}
fill
className="object-cover"
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 20vw"
onError={() => setImageError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageOff className="w-12 h-12" />
</div>
)}
</div>
{/* Contenu */}
<div className="flex flex-col p-2">
<h3 className="font-medium line-clamp-2 text-sm">{series.metadata.title}</h3>
<div className="mt-1 text-xs text-muted-foreground space-y-1">
<div className="flex items-center gap-1">
<Book className="h-3 w-3" />
<span>
{series.booksCount} tome{series.booksCount > 1 ? "s" : ""}
</span>
</div>
<div className="flex items-center">
<span className={`px-1.5 py-0.5 rounded-full text-[10px] ${statusInfo.className}`}>
{statusInfo.label}
</span>
</div>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { KomgaBook } from "@/types/komga";
import { ChevronLeft, ChevronRight, ImageOff, Loader2 } from "lucide-react";
import Image from "next/image";
import { useEffect, useState, useCallback } from "react";
interface BookReaderProps {
book: KomgaBook;
pages: number[];
onClose?: () => void;
}
export function BookReader({ book, pages, onClose }: BookReaderProps) {
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [imageError, setImageError] = useState(false);
const handlePreviousPage = useCallback(() => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
setIsLoading(true);
setImageError(false);
}
}, [currentPage]);
const handleNextPage = useCallback(() => {
if (currentPage < pages.length) {
setCurrentPage(currentPage + 1);
setIsLoading(true);
setImageError(false);
}
}, [currentPage, pages.length]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft") {
handlePreviousPage();
} else if (event.key === "ArrowRight") {
handleNextPage();
} else if (event.key === "Escape" && onClose) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handlePreviousPage, handleNextPage, onClose]);
return (
<div className="fixed inset-0 bg-background/95 backdrop-blur-sm z-50">
<div className="relative h-full flex items-center justify-center">
{/* Bouton précédent */}
{currentPage > 1 && (
<button
onClick={handlePreviousPage}
className="absolute left-4 p-2 rounded-full bg-background/50 hover:bg-background/80 transition-colors"
aria-label="Page précédente"
>
<ChevronLeft className="h-8 w-8" />
</button>
)}
{/* Page courante */}
<div className="relative h-full max-h-full w-auto max-w-full p-4">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
)}
{!imageError ? (
<Image
src={`/api/komga/books/${book.id}/pages/${currentPage}`}
alt={`Page ${currentPage}`}
className="h-full w-auto object-contain"
width={800}
height={1200}
priority
onLoad={() => setIsLoading(false)}
onError={() => {
setIsLoading(false);
setImageError(true);
}}
/>
) : (
<div className="h-full w-96 flex items-center justify-center bg-muted rounded-lg">
<ImageOff className="h-12 w-12" />
</div>
)}
</div>
{/* Bouton suivant */}
{currentPage < pages.length && (
<button
onClick={handleNextPage}
className="absolute right-4 p-2 rounded-full bg-background/50 hover:bg-background/80 transition-colors"
aria-label="Page suivante"
>
<ChevronRight className="h-8 w-8" />
</button>
)}
{/* Indicateur de page */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-full bg-background/50 text-sm">
Page {currentPage} / {pages.length}
</div>
{/* Bouton fermer */}
{onClose && (
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-full bg-background/50 hover:bg-background/80 transition-colors"
aria-label="Fermer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { KomgaBook } from "@/types/komga";
import { ImageOff } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
interface BookGridProps {
books: KomgaBook[];
onBookClick?: (book: KomgaBook) => void;
getBookThumbnailUrl: (bookId: string) => string;
}
export function BookGrid({ books, onBookClick, getBookThumbnailUrl }: BookGridProps) {
if (!books.length) {
return (
<div className="text-center p-8">
<p className="text-muted-foreground">Aucun tome disponible</p>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-6">
{books.map((book) => (
<BookCard
key={book.id}
book={book}
onClick={() => onBookClick?.(book)}
getBookThumbnailUrl={getBookThumbnailUrl}
/>
))}
</div>
);
}
interface BookCardProps {
book: KomgaBook;
onClick?: () => void;
getBookThumbnailUrl: (bookId: string) => string;
}
function BookCard({ book, onClick, getBookThumbnailUrl }: BookCardProps) {
const [imageError, setImageError] = useState(false);
return (
<button
onClick={onClick}
className="group relative flex flex-col rounded-lg border bg-card text-card-foreground shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden"
>
{/* Image de couverture */}
<div className="relative aspect-[2/3] bg-muted">
{!imageError ? (
<Image
src={getBookThumbnailUrl(book.id)}
alt={`Couverture de ${book.metadata.title}`}
fill
className="object-cover"
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 16.666vw, 16.666vw"
onError={() => setImageError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageOff className="w-12 h-12" />
</div>
)}
</div>
{/* Contenu */}
<div className="flex flex-col p-2">
<h3 className="font-medium line-clamp-2 text-sm">
{book.metadata.title || `Tome ${book.metadata.number}`}
</h3>
<div className="mt-1 text-xs text-muted-foreground space-y-1">
{book.metadata.releaseDate && (
<div>{new Date(book.metadata.releaseDate).toLocaleDateString()}</div>
)}
{book.size && <div className="text-[10px]">{book.size}</div>}
</div>
</div>
</button>
);
}

3
src/lib/config.ts Normal file
View File

@@ -0,0 +1,3 @@
export const config = {
serverUrl: process.env.NEXT_PUBLIC_KOMGA_URL || "http://localhost:8080",
};

View File

@@ -0,0 +1,75 @@
import { AuthError } from "@/types/auth";
import { storageService } from "./storage.service";
import { KomgaUser } from "@/types/komga";
interface AuthUser {
id: string;
email: string;
roles: string[];
authenticated: boolean;
}
// Utilisateur de développement
const DEV_USER = {
email: "demo@paniels.local",
password: "demo123",
userData: {
id: "1",
email: "demo@paniels.local",
roles: ["ROLE_USER"],
authenticated: true,
} as AuthUser,
};
class AuthService {
private static instance: AuthService;
private constructor() {}
public static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
/**
* Authentifie un utilisateur
*/
async login(email: string, password: string, remember: boolean = false): Promise<void> {
// En développement, on vérifie juste l'utilisateur de démo
if (email === DEV_USER.email && password === DEV_USER.password) {
storageService.setUserData(DEV_USER.userData, remember);
return;
}
throw {
code: "INVALID_CREDENTIALS",
message: "Email ou mot de passe incorrect",
} as AuthError;
}
/**
* Déconnecte l'utilisateur
*/
logout(): void {
storageService.clear();
}
/**
* Vérifie si l'utilisateur est connecté
*/
isAuthenticated(): boolean {
const user = storageService.getUserData<AuthUser>();
return !!user?.authenticated;
}
/**
* Récupère l'utilisateur connecté
*/
getCurrentUser(): AuthUser | null {
return storageService.getUserData<AuthUser>();
}
}
export const authService = AuthService.getInstance();

View File

@@ -0,0 +1,115 @@
class CacheService {
private static instance: CacheService;
private cacheName = "komga-cache-v1";
private defaultTTL = 5 * 60; // 5 minutes en secondes
private constructor() {}
public static getInstance(): CacheService {
if (!CacheService.instance) {
CacheService.instance = new CacheService();
}
return CacheService.instance;
}
/**
* Met en cache une réponse avec une durée de vie
*/
async set(key: string, response: Response, ttl: number = this.defaultTTL): Promise<void> {
if (typeof window === "undefined") return;
try {
const cache = await caches.open(this.cacheName);
const headers = new Headers(response.headers);
headers.append("x-cache-timestamp", Date.now().toString());
headers.append("x-cache-ttl", ttl.toString());
const cachedResponse = new Response(await response.clone().blob(), {
status: response.status,
statusText: response.statusText,
headers,
});
await cache.put(key, cachedResponse);
} catch (error) {
console.error("Erreur lors de la mise en cache:", error);
}
}
/**
* Récupère une réponse du cache si elle est valide
*/
async get(key: string): Promise<Response | null> {
if (typeof window === "undefined") return null;
try {
const cache = await caches.open(this.cacheName);
const response = await cache.match(key);
if (!response) return null;
// Vérifier si la réponse est expirée
const timestamp = parseInt(response.headers.get("x-cache-timestamp") || "0");
const ttl = parseInt(response.headers.get("x-cache-ttl") || "0");
const now = Date.now();
if (now - timestamp > ttl * 1000) {
await cache.delete(key);
return null;
}
return response;
} catch (error) {
console.error("Erreur lors de la lecture du cache:", error);
return null;
}
}
/**
* Supprime une entrée du cache
*/
async delete(key: string): Promise<void> {
if (typeof window === "undefined") return;
try {
const cache = await caches.open(this.cacheName);
await cache.delete(key);
} catch (error) {
console.error("Erreur lors de la suppression du cache:", error);
}
}
/**
* Vide le cache
*/
async clear(): Promise<void> {
if (typeof window === "undefined") return;
try {
await caches.delete(this.cacheName);
} catch (error) {
console.error("Erreur lors du nettoyage du cache:", error);
}
}
/**
* Récupère une réponse du cache ou fait l'appel API si nécessaire
*/
async getOrFetch(
key: string,
fetcher: () => Promise<Response>,
ttl: number = this.defaultTTL
): Promise<Response> {
const cachedResponse = await this.get(key);
if (cachedResponse) {
return cachedResponse;
}
const response = await fetcher();
const clonedResponse = response.clone();
await this.set(key, clonedResponse, ttl);
return response;
}
}
export const cacheService = CacheService.getInstance();

View File

@@ -0,0 +1,153 @@
import { KomgaUser, KomgaLibrary, KomgaSeries, KomgaBook, ReadProgress } from "@/types/komga";
import { AuthConfig } from "@/types/auth";
import { storageService } from "./storage.service";
class KomgaService {
private static instance: KomgaService;
private constructor() {}
public static getInstance(): KomgaService {
if (!KomgaService.instance) {
KomgaService.instance = new KomgaService();
}
return KomgaService.instance;
}
/**
* Crée les headers d'authentification
*/
private getAuthHeaders(config?: AuthConfig): Headers {
const headers = new Headers();
const credentials = config || storageService.getCredentials();
if (credentials?.credentials) {
const { username, password } = credentials.credentials;
headers.set("Authorization", `Basic ${btoa(`${username}:${password}`)}`);
}
return headers;
}
/**
* Vérifie les credentials en récupérant l'utilisateur courant
*/
async checkCredentials(config: AuthConfig): Promise<KomgaUser> {
const response = await fetch(`${config.serverUrl}/api/v1/libraries`, {
headers: this.getAuthHeaders(config),
});
if (!response.ok) {
throw new Error("Invalid credentials");
}
return response.json();
}
/**
* Récupère les bibliothèques
*/
async getLibraries(): Promise<KomgaLibrary[]> {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
const response = await fetch(`${credentials.serverUrl}/api/v1/libraries`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch libraries");
}
return response.json();
}
/**
* Récupère l'URL de la couverture d'une bibliothèque
*/
getLibraryThumbnailUrl(libraryId: string): string {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
return `${credentials.serverUrl}/api/v1/libraries/${libraryId}/thumbnail`;
}
/**
* Récupère les séries d'une bibliothèque
*/
async getLibrarySeries(libraryId: string): Promise<KomgaSeries[]> {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
const response = await fetch(`${credentials.serverUrl}/api/v1/libraries/${libraryId}/series`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch series");
}
return response.json();
}
/**
* Récupère les livres d'une série
*/
async getSeriesBooks(seriesId: string): Promise<KomgaBook[]> {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
const response = await fetch(`${credentials.serverUrl}/api/v1/series/${seriesId}/books`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch books");
}
return response.json();
}
/**
* Récupère l'URL de la couverture d'un livre
*/
getBookThumbnailUrl(bookId: string): string {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
return `${credentials.serverUrl}/api/v1/books/${bookId}/thumbnail`;
}
/**
* Récupère l'URL de lecture d'un livre
*/
getBookReadingUrl(bookId: string): string {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
return `${credentials.serverUrl}/api/v1/books/${bookId}/pages/1`;
}
/**
* Récupère la progression de lecture d'une série
*/
async getSeriesReadProgress(seriesId: string): Promise<ReadProgress> {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
const response = await fetch(
`${credentials.serverUrl}/api/v1/series/${seriesId}/read-progress`,
{
headers: this.getAuthHeaders(),
}
);
if (!response.ok) {
throw new Error("Failed to fetch series read progress");
}
return response.json();
}
}
export const komgaService = KomgaService.getInstance();

View File

@@ -0,0 +1,83 @@
type CacheEntry = {
data: any;
timestamp: number;
ttl: number;
};
class ServerCacheService {
private static instance: ServerCacheService;
private cache: Map<string, CacheEntry>;
private defaultTTL = 5 * 60; // 5 minutes en secondes
private constructor() {
this.cache = new Map();
}
public static getInstance(): ServerCacheService {
if (!ServerCacheService.instance) {
ServerCacheService.instance = new ServerCacheService();
}
return ServerCacheService.instance;
}
/**
* Met en cache des données avec une durée de vie
*/
set(key: string, data: any, ttl: number = this.defaultTTL): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl,
});
}
/**
* Récupère des données du cache si elles sont valides
*/
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) return null;
const now = Date.now();
if (now - entry.timestamp > entry.ttl * 1000) {
this.cache.delete(key);
return null;
}
return entry.data;
}
/**
* Supprime une entrée du cache
*/
delete(key: string): void {
this.cache.delete(key);
}
/**
* Vide le cache
*/
clear(): void {
this.cache.clear();
}
/**
* Récupère des données du cache ou exécute la fonction si nécessaire
*/
async getOrSet<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = this.defaultTTL
): Promise<T> {
const cachedData = this.get(key);
if (cachedData) {
return cachedData;
}
const data = await fetcher();
this.set(key, data, ttl);
return data;
}
}
export const serverCacheService = ServerCacheService.getInstance();

View File

@@ -0,0 +1,116 @@
import { AuthConfig } from "@/types/auth";
const CREDENTIALS_KEY = "komgaCredentials";
const USER_KEY = "komgaUser";
class StorageService {
private static instance: StorageService;
private constructor() {}
public static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
/**
* Stocke les credentials de manière sécurisée
*/
setCredentials(config: AuthConfig, remember: boolean = false): void {
const storage = remember ? localStorage : sessionStorage;
// Encodage basique des credentials en base64
const encoded = btoa(JSON.stringify(config));
console.log("StorageService - Stockage des credentials:", {
storage: remember ? "localStorage" : "sessionStorage",
config: {
serverUrl: config.serverUrl,
hasCredentials: !!config.credentials,
},
});
storage.setItem(CREDENTIALS_KEY, encoded);
// Définir aussi un cookie pour le middleware
const cookieValue = `${CREDENTIALS_KEY}=${encoded}; path=/; samesite=strict`;
const maxAge = remember ? `; max-age=${30 * 24 * 60 * 60}` : "";
document.cookie = cookieValue + maxAge;
console.log("StorageService - Cookie défini:", cookieValue + maxAge);
}
/**
* Récupère les credentials stockés
*/
getCredentials(): AuthConfig | null {
if (typeof window === "undefined") return null;
const storage =
localStorage.getItem(CREDENTIALS_KEY) || sessionStorage.getItem(CREDENTIALS_KEY);
console.log("StorageService - Lecture des credentials:", {
fromLocalStorage: !!localStorage.getItem(CREDENTIALS_KEY),
fromSessionStorage: !!sessionStorage.getItem(CREDENTIALS_KEY),
value: storage,
});
if (!storage) return null;
try {
const config = JSON.parse(atob(storage));
console.log("StorageService - Credentials décodés:", {
serverUrl: config.serverUrl,
hasCredentials: !!config.credentials,
});
return config;
} catch (error) {
console.error("StorageService - Erreur de décodage des credentials:", error);
return null;
}
}
/**
* Stocke les données utilisateur
*/
setUserData<T>(data: T, remember: boolean = false): void {
const storage = remember ? localStorage : sessionStorage;
const encoded = btoa(JSON.stringify(data));
storage.setItem(USER_KEY, encoded);
// Définir aussi un cookie pour le middleware
document.cookie = `${USER_KEY}=${encoded}; path=/; samesite=strict; ${
remember ? `max-age=${30 * 24 * 60 * 60}` : ""
}`;
}
/**
* Récupère les données utilisateur
*/
getUserData<T>(): T | null {
if (typeof window === "undefined") return null;
const storage = localStorage.getItem(USER_KEY) || sessionStorage.getItem(USER_KEY);
if (!storage) return null;
try {
return JSON.parse(atob(storage));
} catch {
return null;
}
}
/**
* Efface toutes les données stockées
*/
clear(): void {
localStorage.removeItem(CREDENTIALS_KEY);
localStorage.removeItem(USER_KEY);
sessionStorage.removeItem(CREDENTIALS_KEY);
sessionStorage.removeItem(USER_KEY);
document.cookie = `${CREDENTIALS_KEY}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
document.cookie = `${USER_KEY}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
}
}
export const storageService = StorageService.getInstance();

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

70
src/middleware.ts Normal file
View File

@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Routes qui ne nécessitent pas d'authentification
const publicRoutes = ["/login", "/register"];
// Routes d'API qui ne nécessitent pas d'authentification
const publicApiRoutes = ["/api/auth/login", "/api/auth/register", "/api/komga/test"];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Vérifier si c'est une route publique
if (publicRoutes.includes(pathname) || publicApiRoutes.includes(pathname)) {
return NextResponse.next();
}
// Vérifier si c'est une route d'API
if (pathname.startsWith("/api/")) {
// Vérifier les credentials Komga
const configCookie = request.cookies.get("komgaCredentials");
if (!configCookie) {
return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 });
}
try {
JSON.parse(atob(configCookie.value));
} catch (error) {
return NextResponse.json({ error: "Configuration Komga invalide" }, { status: 401 });
}
return NextResponse.next();
}
// Pour les routes protégées, vérifier la présence de l'utilisateur
const user = request.cookies.get("komgaUser");
if (!user) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", pathname);
return NextResponse.redirect(loginUrl);
}
try {
const userData = JSON.parse(atob(user.value));
if (!userData.authenticated) {
throw new Error("User not authenticated");
}
} catch (error) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
// Configuration des routes à protéger
export const config = {
matcher: [
/*
* Match all request paths except:
* 1. /api/auth/* (authentication routes)
* 2. /_next/* (Next.js internals)
* 3. /fonts/* (inside public directory)
* 4. /favicon.ico, /sitemap.xml (public files)
*/
"/((?!api/auth|_next/static|_next/image|fonts|favicon.ico|sitemap.xml).*)",
],
};

76
src/styles/globals.css Normal file
View File

@@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

27
src/types/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import { KomgaUser } from "./komga";
export interface AuthConfig {
serverUrl: string;
credentials?: {
username: string;
password: string;
};
}
export interface AuthState {
isAuthenticated: boolean;
user: KomgaUser | null;
serverUrl: string | null;
}
export interface AuthError {
code: AuthErrorCode;
message: string;
}
export type AuthErrorCode =
| "INVALID_CREDENTIALS"
| "INVALID_SERVER_URL"
| "SERVER_UNREACHABLE"
| "NETWORK_ERROR"
| "UNKNOWN_ERROR";

7
src/types/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_APP_URL: string;
NEXT_PUBLIC_DEFAULT_KOMGA_URL?: string;
NEXT_PUBLIC_APP_VERSION: string;
}
}

100
src/types/komga.ts Normal file
View File

@@ -0,0 +1,100 @@
export interface KomgaUser {
id: string;
email: string;
roles: KomgaRole[];
sharedAllLibraries: boolean;
sharedLibrariesIds: string[];
authenticated: boolean;
authorities: string[];
}
export type KomgaRole = "ROLE_ADMIN" | "ROLE_USER";
export interface KomgaLibrary {
id: string;
name: string;
root: string;
importLastModified: string;
lastModified: string;
unavailable: boolean;
}
export interface KomgaSeries {
id: string;
libraryId: string;
name: string;
url: string;
created: string;
lastModified: string;
fileLastModified: string;
booksCount: number;
booksReadCount: number;
booksUnreadCount: number;
metadata: SeriesMetadata;
booksMetadata: BooksMetadata;
}
export interface SeriesMetadata {
status: "ENDED" | "ONGOING" | "ABANDONED" | "HIATUS";
title: string;
titleSort: string;
summary: string;
publisher: string;
readingDirection: "LEFT_TO_RIGHT" | "RIGHT_TO_LEFT" | "VERTICAL" | "WEBTOON";
ageRating: number | null;
language: string;
genres: string[];
tags: string[];
}
export interface BooksMetadata {
created: string;
lastModified: string;
authors: Author[];
}
export interface Author {
name: string;
role: string;
}
export interface ReadProgress {
booksCount: number;
booksReadCount: number;
booksUnreadCount: number;
booksInProgressCount: number;
completed: boolean;
}
export interface KomgaBook {
id: string;
seriesId: string;
seriesTitle: string;
name: string;
url: string;
number: number;
created: string;
lastModified: string;
fileLastModified: string;
sizeBytes: number;
size: string;
media: BookMedia;
metadata: BookMetadata;
}
export interface BookMedia {
status: "READY" | "UNKNOWN" | "ERROR";
mediaType: string;
pagesCount: number;
}
export interface BookMetadata {
title: string;
titleSort: string;
summary: string;
number: string;
authors: Author[];
tags: string[];
releaseDate: string;
isbn: string;
}