feat: Initial commit - Base application with Next.js - Configuration, Auth, Library navigation, CBZ/CBR reader, Cache, Responsive design
This commit is contained in:
38
.cursorrules
Normal file
38
.cursorrules
Normal file
@@ -0,0 +1,38 @@
|
||||
You are a french Senior Front-End Developer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning.
|
||||
|
||||
- Follow the user’s requirements carefully & to the letter.
|
||||
- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.
|
||||
- Confirm, then write code!
|
||||
- Always write correct, best practice, DRY principle (Dont Repeat Yourself), bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines .
|
||||
- Focus on easy and readability code, over being performant.
|
||||
- Fully implement all requested functionality.
|
||||
- Leave NO todo’s, placeholders or missing pieces.
|
||||
- Ensure code is complete! Verify thoroughly finalised.
|
||||
- Include all required imports, and ensure proper naming of key components.
|
||||
- Be concise Minimize any other prose.
|
||||
- If you think there might not be a correct answer, you say so.
|
||||
- If you do not know the answer, say so, instead of guessing.
|
||||
|
||||
### Coding Environment
|
||||
|
||||
The user asks questions about the following coding languages:
|
||||
|
||||
- ReactJS
|
||||
- NextJS
|
||||
- JavaScript
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
- HTML
|
||||
- CSS
|
||||
|
||||
### Code Implementation Guidelines
|
||||
|
||||
Follow these rules when you write code:
|
||||
|
||||
- Use early returns whenever possible to make the code more readable.
|
||||
- Always use Tailwind classes for styling HTML elements; avoid using CSS or tags.
|
||||
- Use “class:” instead of the tertiary operator in class tags whenever possible.
|
||||
- Use descriptive variable and function/const names. Also, event functions should be named with a “handle” prefix, like “handleClick” for onClick and “handleKeyDown” for onKeyDown.
|
||||
- Implement accessibility features on elements. For example, a tag should have a tabindex=“0”, aria-label, on:click, and on:keydown, and similar attributes.
|
||||
- Use consts instead of functions, for example, “const toggle = () =>”. Also, define a type if possible.
|
||||
- Test by launching the application and checking the result each time.
|
||||
8
.env.local.example
Normal file
8
.env.local.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# URL de l'application
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# URL par défaut du serveur Komga (optionnel)
|
||||
NEXT_PUBLIC_DEFAULT_KOMGA_URL=http://localhost:8080
|
||||
|
||||
# Version de l'application (depuis package.json)
|
||||
NEXT_PUBLIC_APP_VERSION=$npm_package_version
|
||||
7
.eslintrc.json
Normal file
7
.eslintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "prettier"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"no-console": "warn"
|
||||
}
|
||||
}
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"printWidth": 100
|
||||
}
|
||||
248
devbook.md
Normal file
248
devbook.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Plan de développement - Paniels (Komga Reader)
|
||||
|
||||
## 🎯 Objectif
|
||||
|
||||
Créer une application web moderne avec Next.js permettant de lire des fichiers CBZ, CBR, EPUB et PDF via un serveur Komga.
|
||||
|
||||
## 📋 Fonctionnalités principales
|
||||
|
||||
- [x] Interface de connexion
|
||||
- [x] Page de paramétrage pour la configuration Komga
|
||||
- [x] Visualisation et navigation dans la bibliothèque
|
||||
- [x] Liste des bibliothèques
|
||||
- [x] Affichage des séries par bibliothèque
|
||||
- [x] Couvertures et informations des séries
|
||||
- [ ] Filtres et recherche
|
||||
- [ ] Pagination
|
||||
- [x] Lecteur de fichiers (CBZ, CBR)
|
||||
- [x] Navigation entre les pages
|
||||
- [x] Mode plein écran
|
||||
- [x] Raccourcis clavier
|
||||
- [ ] Mode double page
|
||||
- [ ] Zoom et pan
|
||||
- [ ] Préchargement des pages
|
||||
|
||||
## 🛠 Configuration initiale
|
||||
|
||||
- [x] Mise en place du projet Next.js
|
||||
- [x] Configuration TypeScript
|
||||
- [x] Configuration ESLint et Prettier
|
||||
- [x] Configuration Tailwind CSS
|
||||
- [x] Configuration des alias de chemins
|
||||
- [x] Mise en place de l'authentification
|
||||
- [x] Configuration du stockage sécurisé des credentials
|
||||
- [x] Service de stockage avec localStorage/sessionStorage
|
||||
- [x] Encodage basique des données sensibles
|
||||
- [x] Gestion des cookies pour le middleware
|
||||
- [x] Middleware d'authentification (protection des routes)
|
||||
- [x] Protection des routes publiques/privées
|
||||
- [x] Redirection vers la page de login
|
||||
- [x] Gestion des routes d'API
|
||||
- [x] Configuration des variables d'environnement
|
||||
- [x] Création du fichier .env.local.example
|
||||
- [x] URL de l'application
|
||||
- [x] URL par défaut du serveur Komga
|
||||
- [x] Version de l'application
|
||||
- [x] Types pour les variables d'environnement
|
||||
- [x] Déclaration des types ProcessEnv
|
||||
- [x] Variables publiques (NEXT*PUBLIC*\*)
|
||||
|
||||
## 📚 Structure de l'application
|
||||
|
||||
- [x] Mise en place de l'architecture des dossiers
|
||||
|
||||
- [x] Components
|
||||
- [x] Layouts
|
||||
- [x] Pages (routes)
|
||||
- [x] Services (API)
|
||||
- [x] Types
|
||||
- [x] Hooks personnalisés
|
||||
- [x] Utils
|
||||
|
||||
- [x] Création des types TypeScript
|
||||
- [x] Types pour l'API Komga
|
||||
- [x] Types des utilisateurs et rôles
|
||||
- [x] Types des bibliothèques
|
||||
- [x] Types des séries et livres
|
||||
- [x] Types des métadonnées
|
||||
- [x] Types pour les états d'authentification
|
||||
- [x] Configuration d'authentification
|
||||
- [x] État d'authentification
|
||||
- [x] Gestion des erreurs
|
||||
- [x] Types pour les préférences utilisateur
|
||||
|
||||
## 🔒 Authentification et Configuration
|
||||
|
||||
- [x] Page de connexion
|
||||
- [x] Formulaire de connexion (email/password)
|
||||
- [x] Validation des champs
|
||||
- [x] Gestion des erreurs de connexion
|
||||
- [x] Stockage sécurisé du token
|
||||
- [x] Redirection après connexion
|
||||
- [x] Page de paramètres
|
||||
- [x] Formulaire de configuration Komga
|
||||
- [x] Validation de l'URL du serveur
|
||||
- [x] Test de connexion en direct
|
||||
- [x] Vérification des credentials via l'API Komga
|
||||
- [x] Gestion des erreurs détaillée
|
||||
- [x] Messages d'erreur contextuels
|
||||
- [x] Sauvegarde des préférences
|
||||
- [x] Stockage sécurisé des credentials Komga
|
||||
- [x] Persistance des paramètres
|
||||
|
||||
## 📱 Interface utilisateur
|
||||
|
||||
- [x] Layout principal
|
||||
- [x] Header avec navigation
|
||||
- [x] Sidebar rétractable
|
||||
- [x] Thème clair/sombre
|
||||
- [x] Responsive design
|
||||
- [x] Page d'accueil
|
||||
- [x] Présentation des fonctionnalités principales
|
||||
- [ ] Liste des collections récentes
|
||||
- [ ] Barre de recherche
|
||||
- [ ] Filtres avancés
|
||||
- [ ] Tri personnalisable
|
||||
- [x] Page de collection
|
||||
- [x] Grille de séries avec lazy loading
|
||||
- [x] Affichage des couvertures
|
||||
- [x] Métadonnées des séries
|
||||
- [x] État des séries (En cours, Terminé, etc.)
|
||||
- [ ] Vue liste/grille
|
||||
- [ ] Filtres et tri
|
||||
- [x] Page de détails de la série
|
||||
- [x] Couverture et informations
|
||||
- [x] Liste des tomes
|
||||
- [ ] Progression de lecture
|
||||
- [x] Bouton de lecture contextuel
|
||||
- [x] Page de détails du tome
|
||||
- [x] Couverture et informations
|
||||
- [x] Métadonnées (auteurs, date, etc.)
|
||||
- [x] Bouton de lecture
|
||||
- [x] Lecteur plein écran
|
||||
|
||||
## 🔄 Intégration Komga
|
||||
|
||||
- [x] Service d'API
|
||||
- [x] Client HTTP avec fetch natif
|
||||
- [x] Gestion des tokens Basic Auth
|
||||
- [x] Cache des réponses
|
||||
- [x] Cache en mémoire côté serveur
|
||||
- [x] TTL configurable (5 minutes par défaut)
|
||||
- [x] Cache par route et paramètres
|
||||
- [x] Endpoints
|
||||
- [x] Authentication
|
||||
- [x] Bibliothèques
|
||||
- [x] Séries
|
||||
- [x] Livres
|
||||
- [x] Pages
|
||||
- [x] Gestion des erreurs
|
||||
- [x] Retry automatique
|
||||
- [x] Feedback utilisateur
|
||||
- [x] Messages d'erreur détaillés
|
||||
|
||||
## 🎨 UI/UX
|
||||
|
||||
- [x] Design responsive
|
||||
- [x] Mobile-first
|
||||
- [x] Breakpoints cohérents
|
||||
- [x] Touch-friendly
|
||||
- [x] Animations et transitions
|
||||
- [x] Page transitions
|
||||
- [x] Loading states
|
||||
- [x] Micro-interactions
|
||||
- [x] Messages de feedback
|
||||
- [x] Toasts pour les actions
|
||||
- [x] Messages d'erreur contextuels
|
||||
- [x] Indicateurs de progression
|
||||
- [x] États de chargement
|
||||
- [x] Skeletons
|
||||
- [x] Suspense boundaries
|
||||
- [x] Loading spinners
|
||||
- [x] Gestion des erreurs UI
|
||||
- [x] Error boundaries
|
||||
- [x] Fallbacks élégants
|
||||
- [x] Recovery options
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
- [ ] Tests unitaires
|
||||
- [ ] Services
|
||||
- [ ] Hooks
|
||||
- [ ] Utils
|
||||
- [ ] Tests d'intégration
|
||||
- [ ] Flows utilisateur
|
||||
- [ ] API integration
|
||||
- [ ] Tests E2E
|
||||
- [ ] User journeys
|
||||
- [ ] Cross-browser
|
||||
|
||||
## 📦 Déploiement
|
||||
|
||||
- [ ] Configuration du build
|
||||
- [ ] Optimisations de build
|
||||
- [ ] Analyse de bundle
|
||||
- [ ] Scripts de déploiement
|
||||
- [ ] CI/CD
|
||||
- [ ] Environnements
|
||||
- [ ] Documentation d'installation
|
||||
- [ ] Requirements
|
||||
- [ ] Step-by-step guide
|
||||
|
||||
## 📝 Documentation
|
||||
|
||||
- [ ] README
|
||||
- [ ] Installation
|
||||
- [ ] Configuration
|
||||
- [ ] Development
|
||||
- [ ] Guide d'utilisation
|
||||
- [ ] Features
|
||||
- [ ] Shortcuts
|
||||
- [ ] Tips & tricks
|
||||
- [ ] Documentation API
|
||||
- [ ] Endpoints
|
||||
- [ ] Types
|
||||
- [ ] Examples
|
||||
|
||||
## 🔍 Optimisations
|
||||
|
||||
- [x] Performance
|
||||
- [x] Optimisation des images
|
||||
- [x] Format WebP
|
||||
- [x] Responsive images
|
||||
- [x] Lazy loading
|
||||
- [x] Components
|
||||
- [x] Images
|
||||
- [x] Routes
|
||||
- [x] Mise en cache
|
||||
- [x] API responses
|
||||
- [x] Static assets
|
||||
- [x] Images
|
||||
- [ ] SEO
|
||||
- [ ] Meta tags
|
||||
- [ ] Sitemap
|
||||
- [ ] robots.txt
|
||||
- [x] Accessibilité
|
||||
- [x] ARIA labels
|
||||
- [x] Keyboard navigation
|
||||
- [x] Screen readers
|
||||
- [x] Color contrast
|
||||
|
||||
## 🔄 Futures évolutions possibles
|
||||
|
||||
- [ ] Support de nouveaux formats
|
||||
- [ ] EPUB
|
||||
- [ ] PDF
|
||||
- [ ] Synchronisation des favoris
|
||||
- [ ] Sync avec Komga
|
||||
- [ ] Listes personnalisées
|
||||
- [ ] Mode hors ligne
|
||||
- [ ] Service worker
|
||||
- [ ] Sync en background
|
||||
- [ ] PWA
|
||||
- [ ] Installation
|
||||
- [ ] Notifications
|
||||
- [ ] Background sync
|
||||
- [ ] Support multi-langues
|
||||
- [ ] i18n
|
||||
- [ ] RTL support
|
||||
6289
package-lock.json
generated
Normal file
6289
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "paniels",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.323.0",
|
||||
"next": "^14.1.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.16",
|
||||
"@types/react": "^18.2.52",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.1.0",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
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;
|
||||
}
|
||||
80
tailwind.config.ts
Normal file
80
tailwind.config.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./pages/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config;
|
||||
|
||||
export default config;
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user