feat: Initial commit - Base application with Next.js - Configuration, Auth, Library navigation, CBZ/CBR reader, Cache, Responsive design
This commit is contained in:
60
src/app/api/komga/books/[bookId]/pages/[pageNumber]/route.ts
Normal file
60
src/app/api/komga/books/[bookId]/pages/[pageNumber]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
104
src/app/api/komga/books/[bookId]/route.ts
Normal file
104
src/app/api/komga/books/[bookId]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/app/api/komga/images/books/[bookId]/thumbnail/route.ts
Normal file
54
src/app/api/komga/images/books/[bookId]/thumbnail/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
72
src/app/api/komga/libraries/[libraryId]/series/route.ts
Normal file
72
src/app/api/komga/libraries/[libraryId]/series/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/app/api/komga/libraries/route.ts
Normal file
64
src/app/api/komga/libraries/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
src/app/api/komga/series/[seriesId]/read-progress/route.ts
Normal file
46
src/app/api/komga/series/[seriesId]/read-progress/route.ts
Normal 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();
|
||||
}
|
||||
74
src/app/api/komga/series/[seriesId]/route.ts
Normal file
74
src/app/api/komga/series/[seriesId]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/app/api/komga/test/route.ts
Normal file
70
src/app/api/komga/test/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/app/api/komga/thumbnail/[...path]/route.ts
Normal file
54
src/app/api/komga/thumbnail/[...path]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
160
src/app/books/[bookId]/page.tsx
Normal file
160
src/app/books/[bookId]/page.tsx
Normal 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
23
src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/app/libraries/[libraryId]/page.tsx
Normal file
90
src/app/libraries/[libraryId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
95
src/app/libraries/page.tsx
Normal file
95
src/app/libraries/page.tsx
Normal 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
140
src/app/login/page.tsx
Normal 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
37
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
src/app/series/[seriesId]/page.tsx
Normal file
192
src/app/series/[seriesId]/page.tsx
Normal 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
220
src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/layout/ClientLayout.tsx
Normal file
20
src/components/layout/ClientLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/layout/Header.tsx
Normal file
48
src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/layout/Sidebar.tsx
Normal file
76
src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/components/library/LibraryGrid.tsx
Normal file
115
src/components/library/LibraryGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/components/library/SeriesGrid.tsx
Normal file
116
src/components/library/SeriesGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/components/reader/BookReader.tsx
Normal file
134
src/components/reader/BookReader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/components/series/BookGrid.tsx
Normal file
83
src/components/series/BookGrid.tsx
Normal 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
3
src/lib/config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const config = {
|
||||
serverUrl: process.env.NEXT_PUBLIC_KOMGA_URL || "http://localhost:8080",
|
||||
};
|
||||
75
src/lib/services/auth.service.ts
Normal file
75
src/lib/services/auth.service.ts
Normal 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();
|
||||
115
src/lib/services/cache.service.ts
Normal file
115
src/lib/services/cache.service.ts
Normal 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();
|
||||
153
src/lib/services/komga.service.ts
Normal file
153
src/lib/services/komga.service.ts
Normal 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();
|
||||
83
src/lib/services/server-cache.service.ts
Normal file
83
src/lib/services/server-cache.service.ts
Normal 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();
|
||||
116
src/lib/services/storage.service.ts
Normal file
116
src/lib/services/storage.service.ts
Normal 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
6
src/lib/utils.ts
Normal 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
70
src/middleware.ts
Normal 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
76
src/styles/globals.css
Normal 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
27
src/types/auth.ts
Normal 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
7
src/types/env.d.ts
vendored
Normal 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
100
src/types/komga.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user