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

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

38
.cursorrules Normal file
View 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 users 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 todos, 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
View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"tabWidth": 2,
"useTabs": false,
"printWidth": 100
}

248
devbook.md Normal file
View 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

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

70
src/middleware.ts Normal file
View File

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

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

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

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

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

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

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

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

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

80
tailwind.config.ts Normal file
View 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
View 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"]
}