chore: update various components and services for improved functionality and consistency, including formatting adjustments and minor refactors

This commit is contained in:
Julien Froidefond
2025-12-07 09:54:05 +01:00
parent 4f5724c0ff
commit 39e3328123
141 changed files with 5292 additions and 3243 deletions

3
ENV.md
View File

@@ -1,6 +1,7 @@
# Variables d'environnement requises # Variables d'environnement requises
## Production (.env) ## Production (.env)
```env ```env
# Database Configuration (SQLite) # Database Configuration (SQLite)
DATABASE_URL=file:./data/stripstream.db DATABASE_URL=file:./data/stripstream.db
@@ -30,9 +31,11 @@ NODE_ENV=production
``` ```
## Génération du secret NextAuth ## Génération du secret NextAuth
```bash ```bash
openssl rand -base64 32 openssl rand -base64 32
``` ```
## Développement ## Développement
Pour le développement, les variables sont définies directement dans `docker-compose.dev.yml`. Pour le développement, les variables sont définies directement dans `docker-compose.dev.yml`.

View File

@@ -15,12 +15,14 @@ CACHE_DEBUG=true
### Configuration ### Configuration
#### Développement (docker-compose.dev.yml) #### Développement (docker-compose.dev.yml)
```yaml ```yaml
environment: environment:
- CACHE_DEBUG=true - CACHE_DEBUG=true
``` ```
#### Production (.env) #### Production (.env)
```env ```env
CACHE_DEBUG=true CACHE_DEBUG=true
``` ```
@@ -30,49 +32,61 @@ CACHE_DEBUG=true
Les logs de cache apparaissent dans la console serveur avec le format suivant : Les logs de cache apparaissent dans la console serveur avec le format suivant :
### Cache HIT (donnée valide) ### Cache HIT (donnée valide)
``` ```
[CACHE HIT] home-ongoing | HOME | 0.45ms [CACHE HIT] home-ongoing | HOME | 0.45ms
``` ```
- ✅ Donnée trouvée en cache - ✅ Donnée trouvée en cache
- ✅ Donnée encore valide (pas expirée) - ✅ Donnée encore valide (pas expirée)
- ⚡ Retour immédiat (très rapide) - ⚡ Retour immédiat (très rapide)
### Cache STALE (donnée expirée) ### Cache STALE (donnée expirée)
``` ```
[CACHE STALE] home-ongoing | HOME | 0.52ms [CACHE STALE] home-ongoing | HOME | 0.52ms
``` ```
- ✅ Donnée trouvée en cache - ✅ Donnée trouvée en cache
- ⚠️ Donnée expirée mais toujours retournée - ⚠️ Donnée expirée mais toujours retournée
- 🔄 Revalidation lancée en background - 🔄 Revalidation lancée en background
### Cache MISS (pas de donnée) ### Cache MISS (pas de donnée)
``` ```
[CACHE MISS] home-ongoing | HOME [CACHE MISS] home-ongoing | HOME
``` ```
- ❌ Aucune donnée en cache - ❌ Aucune donnée en cache
- 🌐 Fetch normal depuis Komga - 🌐 Fetch normal depuis Komga
- 💾 Mise en cache automatique - 💾 Mise en cache automatique
### Cache SET (mise en cache) ### Cache SET (mise en cache)
``` ```
[CACHE SET] home-ongoing | HOME | 324.18ms [CACHE SET] home-ongoing | HOME | 324.18ms
``` ```
- 💾 Donnée mise en cache après fetch - 💾 Donnée mise en cache après fetch
- 📊 Temps total incluant le fetch Komga - 📊 Temps total incluant le fetch Komga
- ✅ Prochaines requêtes seront rapides - ✅ Prochaines requêtes seront rapides
### Cache REVALIDATE (revalidation background) ### Cache REVALIDATE (revalidation background)
``` ```
[CACHE REVALIDATE] home-ongoing | HOME | 287.45ms [CACHE REVALIDATE] home-ongoing | HOME | 287.45ms
``` ```
- 🔄 Revalidation en background (après STALE) - 🔄 Revalidation en background (après STALE)
- 🌐 Nouvelle donnée fetched depuis Komga - 🌐 Nouvelle donnée fetched depuis Komga
- 💾 Cache mis à jour pour les prochaines requêtes - 💾 Cache mis à jour pour les prochaines requêtes
### Erreur de revalidation ### Erreur de revalidation
``` ```
[CACHE REVALIDATE ERROR] home-ongoing: Error: ... [CACHE REVALIDATE ERROR] home-ongoing: Error: ...
``` ```
- ❌ Échec de la revalidation background - ❌ Échec de la revalidation background
- ⚠️ Cache ancien conservé - ⚠️ Cache ancien conservé
- 🔄 Retry au prochain STALE - 🔄 Retry au prochain STALE
@@ -81,14 +95,14 @@ Les logs de cache apparaissent dans la console serveur avec le format suivant :
Les logs affichent le type de TTL utilisé : Les logs affichent le type de TTL utilisé :
| Type | TTL | Usage | | Type | TTL | Usage |
|------|-----|-------| | ----------- | ------- | ------------------ |
| `DEFAULT` | 5 min | Données génériques | | `DEFAULT` | 5 min | Données génériques |
| `HOME` | 10 min | Page d'accueil | | `HOME` | 10 min | Page d'accueil |
| `LIBRARIES` | 24h | Bibliothèques | | `LIBRARIES` | 24h | Bibliothèques |
| `SERIES` | 5 min | Séries | | `SERIES` | 5 min | Séries |
| `BOOKS` | 5 min | Livres | | `BOOKS` | 5 min | Livres |
| `IMAGES` | 7 jours | Images | | `IMAGES` | 7 jours | Images |
## Exemple de session complète ## Exemple de session complète
@@ -113,22 +127,27 @@ Les logs affichent le type de TTL utilisé :
### 1. DevTools du navigateur ### 1. DevTools du navigateur
#### Network Tab #### Network Tab
- Temps de réponse < 50ms = probablement du cache serveur - Temps de réponse < 50ms = probablement du cache serveur
- Headers `X-Cache` si configurés - Headers `X-Cache` si configurés
- Onglet "Timing" pour détails - Onglet "Timing" pour détails
#### Application → Cache Storage #### Application → Cache Storage
Inspectez le cache du Service Worker : Inspectez le cache du Service Worker :
- `stripstream-cache-v1` : Ressources statiques - `stripstream-cache-v1` : Ressources statiques
- `stripstream-images-v1` : Images (covers + pages) - `stripstream-images-v1` : Images (covers + pages)
Actions disponibles : Actions disponibles :
- ✅ Voir le contenu de chaque cache - ✅ Voir le contenu de chaque cache
- 🔍 Chercher une URL spécifique - 🔍 Chercher une URL spécifique
- 🗑️ Supprimer des entrées - 🗑️ Supprimer des entrées
- 🧹 Vider complètement un cache - 🧹 Vider complètement un cache
#### Application → Service Workers #### Application → Service Workers
- État du Service Worker - État du Service Worker
- "Unregister" pour le désactiver - "Unregister" pour le désactiver
- "Update" pour forcer une mise à jour - "Update" pour forcer une mise à jour
@@ -137,10 +156,13 @@ Actions disponibles :
### 2. API de monitoring ### 2. API de monitoring
#### Taille du cache #### Taille du cache
```bash ```bash
curl http://localhost:3000/api/komga/cache/size curl http://localhost:3000/api/komga/cache/size
``` ```
Response : Response :
```json ```json
{ {
"sizeInBytes": 15728640, "sizeInBytes": 15728640,
@@ -149,10 +171,13 @@ Response :
``` ```
#### Mode actuel #### Mode actuel
```bash ```bash
curl http://localhost:3000/api/komga/cache/mode curl http://localhost:3000/api/komga/cache/mode
``` ```
Response : Response :
```json ```json
{ {
"mode": "memory" "mode": "memory"
@@ -160,11 +185,13 @@ Response :
``` ```
#### Vider le cache #### Vider le cache
```bash ```bash
curl -X POST http://localhost:3000/api/komga/cache/clear curl -X POST http://localhost:3000/api/komga/cache/clear
``` ```
#### Changer de mode #### Changer de mode
```bash ```bash
curl -X POST http://localhost:3000/api/komga/cache/mode \ curl -X POST http://localhost:3000/api/komga/cache/mode \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -190,6 +217,7 @@ cat .cache/user-id/home-ongoing.json | jq
``` ```
Exemple de contenu : Exemple de contenu :
```json ```json
{ {
"data": { "data": {
@@ -206,6 +234,7 @@ Exemple de contenu :
### Identifier un problème de cache ### Identifier un problème de cache
**Symptôme** : Les données ne se rafraîchissent pas **Symptôme** : Les données ne se rafraîchissent pas
```bash ```bash
# 1. Vérifier si STALE + REVALIDATE se produisent # 1. Vérifier si STALE + REVALIDATE se produisent
CACHE_DEBUG=true CACHE_DEBUG=true
@@ -222,6 +251,7 @@ CACHE_DEBUG=true
### Optimiser les performances ### Optimiser les performances
**Objectif** : Identifier les requêtes lentes **Objectif** : Identifier les requêtes lentes
```bash ```bash
# Activer les logs # Activer les logs
CACHE_DEBUG=true CACHE_DEBUG=true
@@ -231,7 +261,8 @@ CACHE_DEBUG=true
[CACHE SET] library-456-all-series | SERIES | 2847.32ms # ⚠️ Très lent ! [CACHE SET] library-456-all-series | SERIES | 2847.32ms # ⚠️ Très lent !
``` ```
**Solution** : **Solution** :
- Vérifier la taille des bibliothèques - Vérifier la taille des bibliothèques
- Augmenter le TTL pour ces données - Augmenter le TTL pour ces données
- Considérer la pagination - Considérer la pagination
@@ -251,29 +282,33 @@ En mode `file` : les caches survivent au redémarrage
### Temps de réponse normaux ### Temps de réponse normaux
| Scénario | Temps attendu | Log | | Scénario | Temps attendu | Log |
|----------|---------------|-----| | ----------------------- | ------------- | ------------------------------------ |
| Cache HIT | < 1ms | `[CACHE HIT] ... \| 0.45ms` | | Cache HIT | < 1ms | `[CACHE HIT] ... \| 0.45ms` |
| Cache STALE | < 1ms | `[CACHE STALE] ... \| 0.52ms` | | Cache STALE | < 1ms | `[CACHE STALE] ... \| 0.52ms` |
| Cache MISS (petit) | 50-200ms | `[CACHE SET] ... \| 124.18ms` | | Cache MISS (petit) | 50-200ms | `[CACHE SET] ... \| 124.18ms` |
| Cache MISS (gros) | 200-1000ms | `[CACHE SET] ... \| 847.32ms` | | Cache MISS (gros) | 200-1000ms | `[CACHE SET] ... \| 847.32ms` |
| Revalidate (background) | Variable | `[CACHE REVALIDATE] ... \| 287.45ms` | | Revalidate (background) | Variable | `[CACHE REVALIDATE] ... \| 287.45ms` |
### Signaux d'alerte ### Signaux d'alerte
⚠️ **Cache HIT > 10ms** ⚠️ **Cache HIT > 10ms**
- Problème : Disque lent (mode file) - Problème : Disque lent (mode file)
- Solution : Vérifier les I/O, passer en mode memory - Solution : Vérifier les I/O, passer en mode memory
⚠️ **Cache MISS > 2000ms** ⚠️ **Cache MISS > 2000ms**
- Problème : Komga très lent ou données énormes - Problème : Komga très lent ou données énormes
- Solution : Vérifier Komga, optimiser la requête - Solution : Vérifier Komga, optimiser la requête
⚠️ **REVALIDATE ERROR fréquents** ⚠️ **REVALIDATE ERROR fréquents**
- Problème : Komga instable ou réseau - Problème : Komga instable ou réseau
- Solution : Augmenter les timeouts, vérifier la connectivité - Solution : Augmenter les timeouts, vérifier la connectivité
⚠️ **Trop de MISS successifs** ⚠️ **Trop de MISS successifs**
- Problème : Cache pas conservé ou TTL trop court - Problème : Cache pas conservé ou TTL trop court
- Solution : Vérifier le mode, augmenter les TTL - Solution : Vérifier le mode, augmenter les TTL
@@ -294,12 +329,14 @@ Les logs sont **automatiquement désactivés** si la variable n'est pas définie
## Logs et performance ## Logs et performance
**Impact sur les performances** : **Impact sur les performances** :
- Overhead : < 0.1ms par opération - Overhead : < 0.1ms par opération
- Pas d'écriture disque (juste console) - Pas d'écriture disque (juste console)
- Pas d'accumulation en mémoire - Pas d'accumulation en mémoire
- Safe pour la production - Safe pour la production
**Recommandations** : **Recommandations** :
- ✅ Activé en développement - ✅ Activé en développement
- ✅ Activé temporairement en production pour diagnostics - ✅ Activé temporairement en production pour diagnostics
- ❌ Pas nécessaire en production normale - ❌ Pas nécessaire en production normale
@@ -307,6 +344,7 @@ Les logs sont **automatiquement désactivés** si la variable n'est pas définie
## Conclusion ## Conclusion
Le système de logs de cache est conçu pour être : Le système de logs de cache est conçu pour être :
- 🎯 **Simple** : Format clair et concis - 🎯 **Simple** : Format clair et concis
-**Rapide** : Impact négligeable sur les performances -**Rapide** : Impact négligeable sur les performances
- 🔧 **Utile** : Informations essentielles pour le debug - 🔧 **Utile** : Informations essentielles pour le debug
@@ -314,4 +352,3 @@ Le système de logs de cache est conçu pour être :
Pour la plupart des besoins de debug, les DevTools du navigateur suffisent. Pour la plupart des besoins de debug, les DevTools du navigateur suffisent.
Les logs serveur sont utiles pour comprendre le comportement du cache côté backend. Les logs serveur sont utiles pour comprendre le comportement du cache côté backend.

View File

@@ -34,9 +34,11 @@ Le système de caching est organisé en **3 couches indépendantes** avec des re
## Couche 1 : Service Worker (Client) ## Couche 1 : Service Worker (Client)
### Fichier ### Fichier
`public/sw.js` `public/sw.js`
### Responsabilité ### Responsabilité
- Support offline de l'application - Support offline de l'application
- Cache persistant des images (couvertures et pages de livres) - Cache persistant des images (couvertures et pages de livres)
- Cache des ressources statiques Next.js - Cache des ressources statiques Next.js
@@ -44,20 +46,22 @@ Le système de caching est organisé en **3 couches indépendantes** avec des re
### Stratégies ### Stratégies
#### Images : Cache-First #### Images : Cache-First
```javascript ```javascript
// Pour toutes les images (covers + pages) // Pour toutes les images (covers + pages)
const isImageResource = (url) => { const isImageResource = (url) => {
return ( return (
(url.includes("/api/v1/books/") && (url.includes("/api/v1/books/") &&
(url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) || (url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) ||
(url.includes("/api/komga/images/") && (url.includes("/api/komga/images/") &&
(url.includes("/series/") || url.includes("/books/")) && (url.includes("/series/") || url.includes("/books/")) &&
url.includes("/thumbnail")) url.includes("/thumbnail"))
); );
}; };
``` ```
**Comportement** : **Comportement** :
1. Vérifier si l'image est dans le cache 1. Vérifier si l'image est dans le cache
2. Si oui → retourner depuis le cache 2. Si oui → retourner depuis le cache
3. Si non → fetch depuis le réseau 3. Si non → fetch depuis le réseau
@@ -65,11 +69,13 @@ const isImageResource = (url) => {
5. Si échec → retourner 404 5. Si échec → retourner 404
**Avantages** : **Avantages** :
- Performance maximale (lecture instantanée depuis le cache) - Performance maximale (lecture instantanée depuis le cache)
- Fonctionne offline une fois les images chargées - Fonctionne offline une fois les images chargées
- Économise la bande passante - Économise la bande passante
#### Navigation et ressources statiques : Network-First #### Navigation et ressources statiques : Network-First
```javascript ```javascript
// Pour les pages et ressources _next/static // Pour les pages et ressources _next/static
event.respondWith( event.respondWith(
@@ -85,7 +91,7 @@ event.respondWith(
// Fallback sur le cache si offline // Fallback sur le cache si offline
const cachedResponse = await cache.match(request); const cachedResponse = await cache.match(request);
if (cachedResponse) return cachedResponse; if (cachedResponse) return cachedResponse;
// Page offline si navigation // Page offline si navigation
if (request.mode === "navigate") { if (request.mode === "navigate") {
return cache.match("/offline.html"); return cache.match("/offline.html");
@@ -95,18 +101,20 @@ event.respondWith(
``` ```
**Avantages** : **Avantages** :
- Toujours la dernière version quand online - Toujours la dernière version quand online
- Fallback offline si nécessaire - Fallback offline si nécessaire
- Navigation fluide même sans connexion - Navigation fluide même sans connexion
### Caches ### Caches
| Cache | Usage | Stratégie | Taille | | Cache | Usage | Stratégie | Taille |
|-------|-------|-----------|--------| | ----------------------- | ---------------------------- | ------------- | -------- |
| `stripstream-cache-v1` | Ressources statiques + pages | Network-First | ~5 MB | | `stripstream-cache-v1` | Ressources statiques + pages | Network-First | ~5 MB |
| `stripstream-images-v1` | Images (covers + pages) | Cache-First | Illimité | | `stripstream-images-v1` | Images (covers + pages) | Cache-First | Illimité |
### Nettoyage ### Nettoyage
- Automatique lors de l'activation du Service Worker - Automatique lors de l'activation du Service Worker
- Suppression des anciennes versions de cache - Suppression des anciennes versions de cache
- Pas d'expiration (contrôlé par l'utilisateur via les paramètres du navigateur) - Pas d'expiration (contrôlé par l'utilisateur via les paramètres du navigateur)
@@ -114,9 +122,11 @@ event.respondWith(
## Couche 2 : ServerCacheService (Serveur) ## Couche 2 : ServerCacheService (Serveur)
### Fichier ### Fichier
`src/lib/services/server-cache.service.ts` `src/lib/services/server-cache.service.ts`
### Responsabilité ### Responsabilité
- Cache des réponses API Komga côté serveur - Cache des réponses API Komga côté serveur
- Optimisation des temps de réponse - Optimisation des temps de réponse
- Réduction de la charge sur Komga - Réduction de la charge sur Komga
@@ -126,6 +136,7 @@ event.respondWith(
Cette stratégie est **la clé de la performance** de l'application. Cette stratégie est **la clé de la performance** de l'application.
#### Principe #### Principe
``` ```
Requête → Cache existe ? Requête → Cache existe ?
├─ Non → Fetch normal + mise en cache ├─ Non → Fetch normal + mise en cache
@@ -136,6 +147,7 @@ Requête → Cache existe ?
``` ```
#### Implémentation #### Implémentation
```typescript ```typescript
async getOrSet<T>( async getOrSet<T>(
key: string, key: string,
@@ -144,7 +156,7 @@ async getOrSet<T>(
): Promise<T> { ): Promise<T> {
const cacheKey = `${user.id}-${key}`; const cacheKey = `${user.id}-${key}`;
const cachedResult = this.getStale(cacheKey); const cachedResult = this.getStale(cacheKey);
if (cachedResult !== null) { if (cachedResult !== null) {
const { data, isStale } = cachedResult; const { data, isStale } = cachedResult;
@@ -164,12 +176,14 @@ async getOrSet<T>(
``` ```
#### Avantages #### Avantages
**Temps de réponse constant** : Le cache expiré est retourné instantanément **Temps de réponse constant** : Le cache expiré est retourné instantanément
**Données fraîches** : Revalidation en background pour la prochaine requête **Données fraîches** : Revalidation en background pour la prochaine requête
**Pas de délai** : L'utilisateur ne subit jamais l'attente de revalidation **Pas de délai** : L'utilisateur ne subit jamais l'attente de revalidation
**Résilience** : Même si Komga est lent, l'app reste rapide **Résilience** : Même si Komga est lent, l'app reste rapide
#### Inconvénients #### Inconvénients
⚠️ Les données peuvent être légèrement obsolètes (jusqu'au prochain refresh) ⚠️ Les données peuvent être légèrement obsolètes (jusqu'au prochain refresh)
⚠️ Nécessite un cache initialisé (première requête toujours lente) ⚠️ Nécessite un cache initialisé (première requête toujours lente)
@@ -178,9 +192,11 @@ async getOrSet<T>(
L'utilisateur peut choisir entre deux modes : L'utilisateur peut choisir entre deux modes :
#### Mode Mémoire (par défaut) #### Mode Mémoire (par défaut)
```typescript ```typescript
cacheMode: "memory" cacheMode: "memory";
``` ```
- Cache stocké en RAM - Cache stocké en RAM
- **Performances** : Très rapide (lecture < 1ms) - **Performances** : Très rapide (lecture < 1ms)
- **Persistance** : Perdu au redémarrage du serveur - **Persistance** : Perdu au redémarrage du serveur
@@ -188,9 +204,11 @@ cacheMode: "memory"
- **Idéal pour** : Développement, faible charge - **Idéal pour** : Développement, faible charge
#### Mode Fichier #### Mode Fichier
```typescript ```typescript
cacheMode: "file" cacheMode: "file";
``` ```
- Cache stocké sur disque (`.cache/`) - Cache stocké sur disque (`.cache/`)
- **Performances** : Rapide (lecture 5-10ms) - **Performances** : Rapide (lecture 5-10ms)
- **Persistance** : Survit aux redémarrages - **Persistance** : Survit aux redémarrages
@@ -201,14 +219,14 @@ cacheMode: "file"
Chaque type de données a un TTL configuré : Chaque type de données a un TTL configuré :
| Type | TTL par défaut | Justification | | Type | TTL par défaut | Justification |
|------|----------------|---------------| | ----------- | -------------- | ---------------------------------- |
| `DEFAULT` | 5 minutes | Données génériques | | `DEFAULT` | 5 minutes | Données génériques |
| `HOME` | 10 minutes | Page d'accueil (données agrégées) | | `HOME` | 10 minutes | Page d'accueil (données agrégées) |
| `LIBRARIES` | 24 heures | Bibliothèques (rarement modifiées) | | `LIBRARIES` | 24 heures | Bibliothèques (rarement modifiées) |
| `SERIES` | 5 minutes | Séries (métadonnées + progression) | | `SERIES` | 5 minutes | Séries (métadonnées + progression) |
| `BOOKS` | 5 minutes | Livres (métadonnées + progression) | | `BOOKS` | 5 minutes | Livres (métadonnées + progression) |
| `IMAGES` | 7 jours | Images (immuables) | | `IMAGES` | 7 jours | Images (immuables) |
#### Configuration personnalisée #### Configuration personnalisée
@@ -235,6 +253,7 @@ const cacheKey = `${user.id}-${key}`;
``` ```
**Avantages** : **Avantages** :
- Pas de collision entre utilisateurs - Pas de collision entre utilisateurs
- Progression de lecture individuelle - Progression de lecture individuelle
- Préférences personnalisées - Préférences personnalisées
@@ -244,18 +263,21 @@ const cacheKey = `${user.id}-${key}`;
Le cache peut être invalidé : Le cache peut être invalidé :
#### Manuellement #### Manuellement
```typescript ```typescript
await cacheService.delete(key); // Une clé await cacheService.delete(key); // Une clé
await cacheService.deleteAll(prefix); // Toutes les clés avec préfixe await cacheService.deleteAll(prefix); // Toutes les clés avec préfixe
await cacheService.clear(); // Tout le cache await cacheService.clear(); // Tout le cache
``` ```
#### Automatiquement #### Automatiquement
- Lors d'une mise à jour de progression - Lors d'une mise à jour de progression
- Lors d'un changement de favoris - Lors d'un changement de favoris
- Lors de la suppression d'une série - Lors de la suppression d'une série
#### API #### API
``` ```
DELETE /api/komga/cache/clear // Vider tout le cache DELETE /api/komga/cache/clear // Vider tout le cache
DELETE /api/komga/home // Invalider le cache home DELETE /api/komga/home // Invalider le cache home
@@ -264,12 +286,14 @@ DELETE /api/komga/home // Invalider le cache home
## Couche 3 : Cache HTTP (Navigateur) ## Couche 3 : Cache HTTP (Navigateur)
### Responsabilité ### Responsabilité
- Cache basique géré par le navigateur - Cache basique géré par le navigateur
- Headers HTTP standard - Headers HTTP standard
### Configuration ### Configuration
#### Next.js ISR (Incremental Static Regeneration) #### Next.js ISR (Incremental Static Regeneration)
```typescript ```typescript
export const revalidate = 60; // Revalidation toutes les 60 secondes export const revalidate = 60; // Revalidation toutes les 60 secondes
``` ```
@@ -279,20 +303,23 @@ Utilisé uniquement pour les routes avec rendu statique.
#### Headers explicites (désactivé) #### Headers explicites (désactivé)
Les headers HTTP explicites ont été **supprimés** car : Les headers HTTP explicites ont été **supprimés** car :
- Le ServerCacheService gère déjà le caching efficacement - Le ServerCacheService gère déjà le caching efficacement
- Évite la confusion entre plusieurs couches de cache - Évite la confusion entre plusieurs couches de cache
- Simplifie le debugging - Simplifie le debugging
Avant (supprimé) : Avant (supprimé) :
```typescript ```typescript
NextResponse.json(data, { NextResponse.json(data, {
headers: { headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
} },
}); });
``` ```
Maintenant : Maintenant :
```typescript ```typescript
NextResponse.json(data); // Pas de headers NextResponse.json(data); // Pas de headers
``` ```
@@ -322,29 +349,32 @@ Exemple : Chargement de la page d'accueil
### Temps de réponse typiques ### Temps de réponse typiques
| Scénario | Temps | Détails | | Scénario | Temps | Détails |
|----------|-------|---------| | ----------------------------- | ----------- | -------------------------- |
| Cache ServerCache valide + SW | ~50ms | Optimal | | Cache ServerCache valide + SW | ~50ms | Optimal |
| Cache ServerCache expiré + SW | ~50ms | Revalidation en background | | Cache ServerCache expiré + SW | ~50ms | Revalidation en background |
| Pas de cache ServerCache + SW | ~200-500ms | Première requête | | Pas de cache ServerCache + SW | ~200-500ms | Première requête |
| Cache SW uniquement | ~10ms | Images seulement | | Cache SW uniquement | ~10ms | Images seulement |
| Tout à froid | ~500-1000ms | Pire cas | | Tout à froid | ~500-1000ms | Pire cas |
## Cas d'usage ## Cas d'usage
### 1. Première visite ### 1. Première visite
``` ```
User → App → Komga (tous les caches vides) User → App → Komga (tous les caches vides)
Temps : ~500-1000ms Temps : ~500-1000ms
``` ```
### 2. Visite suivante (online) ### 2. Visite suivante (online)
``` ```
User → ServerCache (valide) → Images SW User → ServerCache (valide) → Images SW
Temps : ~50ms Temps : ~50ms
``` ```
### 3. Cache expiré (online) ### 3. Cache expiré (online)
``` ```
User → ServerCache (stale) → Retour immédiat User → ServerCache (stale) → Retour immédiat
@@ -353,6 +383,7 @@ Temps ressenti : ~50ms (aucun délai)
``` ```
### 4. Mode offline ### 4. Mode offline
``` ```
User → Service Worker cache uniquement User → Service Worker cache uniquement
Fonctionnalités : Fonctionnalités :
@@ -373,6 +404,7 @@ CACHE_DEBUG=true
``` ```
**Format des logs** : **Format des logs** :
``` ```
[CACHE HIT] home-ongoing | HOME | 0.45ms # Cache valide [CACHE HIT] home-ongoing | HOME | 0.45ms # Cache valide
[CACHE STALE] home-ongoing | HOME | 0.52ms # Cache expiré (retourné + revalidation) [CACHE STALE] home-ongoing | HOME | 0.52ms # Cache expiré (retourné + revalidation)
@@ -386,24 +418,28 @@ CACHE_DEBUG=true
### API de monitoring ### API de monitoring
#### Taille du cache serveur #### Taille du cache serveur
```bash ```bash
GET /api/komga/cache/size GET /api/komga/cache/size
Response: { sizeInBytes: 15728640, itemCount: 234 } Response: { sizeInBytes: 15728640, itemCount: 234 }
``` ```
#### Mode de cache actuel #### Mode de cache actuel
```bash ```bash
GET /api/komga/cache/mode GET /api/komga/cache/mode
Response: { mode: "memory" } Response: { mode: "memory" }
``` ```
#### Changer le mode #### Changer le mode
```bash ```bash
POST /api/komga/cache/mode POST /api/komga/cache/mode
Body: { mode: "file" } Body: { mode: "file" }
``` ```
#### Vider le cache #### Vider le cache
```bash ```bash
POST /api/komga/cache/clear POST /api/komga/cache/clear
``` ```
@@ -411,21 +447,26 @@ POST /api/komga/cache/clear
### DevTools du navigateur ### DevTools du navigateur
#### Network Tab #### Network Tab
- Temps de réponse < 50ms = cache serveur - Temps de réponse < 50ms = cache serveur
- Headers `X-Cache` si configurés - Headers `X-Cache` si configurés
- Onglet "Timing" pour détails - Onglet "Timing" pour détails
#### Application → Cache Storage #### Application → Cache Storage
Inspecter le Service Worker : Inspecter le Service Worker :
- `stripstream-cache-v1` : Ressources statiques - `stripstream-cache-v1` : Ressources statiques
- `stripstream-images-v1` : Images - `stripstream-images-v1` : Images
Actions disponibles : Actions disponibles :
- Voir le contenu - Voir le contenu
- Supprimer des entrées - Supprimer des entrées
- Vider complètement - Vider complètement
#### Application → Service Workers #### Application → Service Workers
- État du Service Worker - État du Service Worker
- "Unregister" pour le désactiver - "Unregister" pour le désactiver
- "Update" pour forcer une mise à jour - "Update" pour forcer une mise à jour
@@ -433,21 +474,25 @@ Actions disponibles :
## Optimisations futures possibles ## Optimisations futures possibles
### 1. Cache Redis (optionnel) ### 1. Cache Redis (optionnel)
- Pour un déploiement multi-instances - Pour un déploiement multi-instances
- Cache partagé entre plusieurs serveurs - Cache partagé entre plusieurs serveurs
- TTL natif Redis - TTL natif Redis
### 2. Compression ### 2. Compression
- Compresser les données en cache (Brotli/Gzip) - Compresser les données en cache (Brotli/Gzip)
- Économie d'espace disque/mémoire - Économie d'espace disque/mémoire
- Trade-off CPU vs espace - Trade-off CPU vs espace
### 3. Prefetching intelligent ### 3. Prefetching intelligent
- Précharger les séries en cours de lecture - Précharger les séries en cours de lecture
- Précharger les pages suivantes dans le reader - Précharger les pages suivantes dans le reader
- Basé sur l'historique utilisateur - Basé sur l'historique utilisateur
### 4. Cache Analytics ### 4. Cache Analytics
- Ratio hit/miss - Ratio hit/miss
- Temps de réponse moyens - Temps de réponse moyens
- Identification des données les plus consultées - Identification des données les plus consultées
@@ -456,7 +501,8 @@ Actions disponibles :
### Pour les développeurs ### Pour les développeurs
**Utiliser BaseApiService.fetchWithCache()** **Utiliser BaseApiService.fetchWithCache()**
```typescript ```typescript
await this.fetchWithCache<T>( await this.fetchWithCache<T>(
"cache-key", "cache-key",
@@ -465,12 +511,14 @@ await this.fetchWithCache<T>(
); );
``` ```
**Invalider le cache après modification** **Invalider le cache après modification**
```typescript ```typescript
await HomeService.invalidateHomeCache(); await HomeService.invalidateHomeCache();
``` ```
**Choisir le bon TTL** **Choisir le bon TTL**
- Court (1-5 min) : Données qui changent souvent - Court (1-5 min) : Données qui changent souvent
- Moyen (10-30 min) : Données agrégées - Moyen (10-30 min) : Données agrégées
- Long (24h+) : Données quasi-statiques - Long (24h+) : Données quasi-statiques
@@ -499,4 +547,3 @@ Le système de caching de StripStream est conçu pour :
🧹 **Simplicité** : 3 couches bien définies, pas de redondance 🧹 **Simplicité** : 3 couches bien définies, pas de redondance
Le système est maintenu simple avec des responsabilités claires pour chaque couche, facilitant la maintenance et l'évolution future. Le système est maintenu simple avec des responsabilités claires pour chaque couche, facilitant la maintenance et l'évolution future.

View File

@@ -7,12 +7,10 @@ Service de gestion de l'authentification
### Méthodes ### Méthodes
- `loginUser(email: string, password: string): Promise<UserData>` - `loginUser(email: string, password: string): Promise<UserData>`
- Authentifie un utilisateur - Authentifie un utilisateur
- Retourne les données utilisateur - Retourne les données utilisateur
- `createUser(email: string, password: string): Promise<UserData>` - `createUser(email: string, password: string): Promise<UserData>`
- Crée un nouvel utilisateur - Crée un nouvel utilisateur
- Retourne les données utilisateur - Retourne les données utilisateur
@@ -26,12 +24,10 @@ Service de gestion des bibliothèques
### Méthodes ### Méthodes
- `getLibraries(): Promise<Library[]>` - `getLibraries(): Promise<Library[]>`
- Récupère la liste des bibliothèques - Récupère la liste des bibliothèques
- Met en cache les résultats - Met en cache les résultats
- `getLibrary(libraryId: string): Promise<Library>` - `getLibrary(libraryId: string): Promise<Library>`
- Récupère une bibliothèque spécifique - Récupère une bibliothèque spécifique
- Lance une erreur si non trouvée - Lance une erreur si non trouvée
@@ -51,11 +47,9 @@ Service de gestion des séries
### Méthodes ### Méthodes
- `getSeries(seriesId: string): Promise<Series>` - `getSeries(seriesId: string): Promise<Series>`
- Récupère les détails d'une série - Récupère les détails d'une série
- `getSeriesBooks(seriesId: string, page: number = 0, size: number = 24, unreadOnly: boolean = false): Promise<LibraryResponse<KomgaBook>>` - `getSeriesBooks(seriesId: string, page: number = 0, size: number = 24, unreadOnly: boolean = false): Promise<LibraryResponse<KomgaBook>>`
- Récupère les livres d'une série - Récupère les livres d'une série
- Supporte la pagination et le filtrage - Supporte la pagination et le filtrage
@@ -69,19 +63,15 @@ Service de gestion des livres
### Méthodes ### Méthodes
- `getBook(bookId: string): Promise<{ book: KomgaBook; pages: number[] }>` - `getBook(bookId: string): Promise<{ book: KomgaBook; pages: number[] }>`
- Récupère les détails d'un livre et ses pages - Récupère les détails d'un livre et ses pages
- `updateReadProgress(bookId: string, page: number, completed: boolean = false): Promise<void>` - `updateReadProgress(bookId: string, page: number, completed: boolean = false): Promise<void>`
- Met à jour la progression de lecture - Met à jour la progression de lecture
- `getPage(bookId: string, pageNumber: number): Promise<Response>` - `getPage(bookId: string, pageNumber: number): Promise<Response>`
- Récupère une page spécifique d'un livre - Récupère une page spécifique d'un livre
- `getCover(bookId: string): Promise<Response>` - `getCover(bookId: string): Promise<Response>`
- Récupère la couverture d'un livre - Récupère la couverture d'un livre
- `getPageThumbnail(bookId: string, pageNumber: number): Promise<Response>` - `getPageThumbnail(bookId: string, pageNumber: number): Promise<Response>`
@@ -94,16 +84,13 @@ Service de gestion des images
### Méthodes ### Méthodes
- `getImage(path: string): Promise<ImageResponse>` - `getImage(path: string): Promise<ImageResponse>`
- Récupère une image depuis le serveur - Récupère une image depuis le serveur
- Gère le cache des images - Gère le cache des images
- `getSeriesThumbnailUrl(seriesId: string): string` - `getSeriesThumbnailUrl(seriesId: string): string`
- Génère l'URL de la miniature d'une série - Génère l'URL de la miniature d'une série
- `getBookThumbnailUrl(bookId: string): string` - `getBookThumbnailUrl(bookId: string): string`
- Génère l'URL de la miniature d'un livre - Génère l'URL de la miniature d'un livre
- `getBookPageUrl(bookId: string, pageNumber: number): string` - `getBookPageUrl(bookId: string, pageNumber: number): string`
@@ -116,15 +103,12 @@ Service de gestion de la configuration
### Méthodes ### Méthodes
- `getConfig(): Promise<Config>` - `getConfig(): Promise<Config>`
- Récupère la configuration Komga - Récupère la configuration Komga
- `saveConfig(config: Config): Promise<Config>` - `saveConfig(config: Config): Promise<Config>`
- Sauvegarde la configuration Komga - Sauvegarde la configuration Komga
- `getTTLConfig(): Promise<TTLConfig>` - `getTTLConfig(): Promise<TTLConfig>`
- Récupère la configuration TTL - Récupère la configuration TTL
- `saveTTLConfig(config: TTLConfig): Promise<TTLConfig>` - `saveTTLConfig(config: TTLConfig): Promise<TTLConfig>`
@@ -137,7 +121,6 @@ Service de gestion du cache serveur
### Méthodes ### Méthodes
- `getCacheMode(): string` - `getCacheMode(): string`
- Récupère le mode de cache actuel - Récupère le mode de cache actuel
- `clearCache(): void` - `clearCache(): void`
@@ -159,7 +142,6 @@ Service de gestion des préférences
### Méthodes ### Méthodes
- `getPreferences(): Promise<Preferences>` - `getPreferences(): Promise<Preferences>`
- Récupère les préférences utilisateur - Récupère les préférences utilisateur
- `savePreferences(preferences: Preferences): Promise<void>` - `savePreferences(preferences: Preferences): Promise<void>`
@@ -182,15 +164,12 @@ Service de base pour les appels API
### Méthodes ### Méthodes
- `buildUrl(config: Config, path: string, params?: Record<string, string>): string` - `buildUrl(config: Config, path: string, params?: Record<string, string>): string`
- Construit une URL d'API - Construit une URL d'API
- `getAuthHeaders(config: Config): Headers` - `getAuthHeaders(config: Config): Headers`
- Génère les en-têtes d'authentification - Génère les en-têtes d'authentification
- `fetchFromApi<T>(url: string, headers: Headers, raw?: boolean): Promise<T>` - `fetchFromApi<T>(url: string, headers: Headers, raw?: boolean): Promise<T>`
- Effectue un appel API avec gestion d'erreurs - Effectue un appel API avec gestion d'erreurs
- `fetchWithCache<T>(key: string, fetcher: () => Promise<T>, type: CacheType): Promise<T>` - `fetchWithCache<T>(key: string, fetcher: () => Promise<T>, type: CacheType): Promise<T>`

View File

@@ -8,13 +8,13 @@ const nextConfig = {
return config; return config;
}, },
// Configuration pour améliorer la résolution DNS // Configuration pour améliorer la résolution DNS
serverExternalPackages: ['dns', 'pino', 'pino-pretty'], serverExternalPackages: ["dns", "pino", "pino-pretty"],
// Optimisations pour Docker dev // Optimisations pour Docker dev
turbopack: { turbopack: {
rules: { rules: {
'*.svg': { "*.svg": {
loaders: ['@svgr/webpack'], loaders: ["@svgr/webpack"],
as: '*.js', as: "*.js",
}, },
}, },
}, },

5821
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@@ -8,7 +8,10 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: system-ui, -apple-system, sans-serif; font-family:
system-ui,
-apple-system,
sans-serif;
background-color: #0f172a; background-color: #0f172a;
color: #e2e8f0; color: #e2e8f0;
min-height: 100vh; min-height: 100vh;

View File

@@ -172,9 +172,7 @@ self.addEventListener("activate", (event) => {
const cacheNames = await caches.keys(); const cacheNames = await caches.keys();
const cachesToDelete = cacheNames.filter( const cachesToDelete = cacheNames.filter(
(name) => (name) =>
name.startsWith("stripstream-") && name.startsWith("stripstream-") && name !== BOOKS_CACHE && !name.endsWith(`-${VERSION}`)
name !== BOOKS_CACHE &&
!name.endsWith(`-${VERSION}`)
); );
await Promise.all(cachesToDelete.map((name) => caches.delete(name))); await Promise.all(cachesToDelete.map((name) => caches.delete(name)));

View File

@@ -10,7 +10,7 @@ const prisma = new PrismaClient();
async function checkDatabase() { async function checkDatabase() {
try { try {
console.log("🔍 Checking database content..."); console.log("🔍 Checking database content...");
// Vérifier les utilisateurs // Vérifier les utilisateurs
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
select: { select: {
@@ -20,22 +20,23 @@ async function checkDatabase() {
createdAt: true, createdAt: true,
}, },
}); });
console.log(`📊 Found ${users.length} users:`); console.log(`📊 Found ${users.length} users:`);
users.forEach(user => { users.forEach((user) => {
console.log(` - ID: ${user.id}, Email: ${user.email}, Roles: ${JSON.stringify(user.roles)}, Created: ${user.createdAt}`); console.log(
` - ID: ${user.id}, Email: ${user.email}, Roles: ${JSON.stringify(user.roles)}, Created: ${user.createdAt}`
);
}); });
// Vérifier les configurations // Vérifier les configurations
const komgaConfigs = await prisma.komgaConfig.count(); const komgaConfigs = await prisma.komgaConfig.count();
const preferences = await prisma.preferences.count(); const preferences = await prisma.preferences.count();
const favorites = await prisma.favorite.count(); const favorites = await prisma.favorite.count();
console.log(`📊 Database stats:`); console.log(`📊 Database stats:`);
console.log(` - KomgaConfigs: ${komgaConfigs}`); console.log(` - KomgaConfigs: ${komgaConfigs}`);
console.log(` - Preferences: ${preferences}`); console.log(` - Preferences: ${preferences}`);
console.log(` - Favorites: ${favorites}`); console.log(` - Favorites: ${favorites}`);
} catch (error) { } catch (error) {
console.error("❌ Error checking database:", error); console.error("❌ Error checking database:", error);
} finally { } finally {
@@ -44,4 +45,3 @@ async function checkDatabase() {
} }
checkDatabase(); checkDatabase();

View File

@@ -22,7 +22,7 @@ async function initializeAdminUser() {
if (existingAdmin) { if (existingAdmin) {
// Vérifier si l'utilisateur a le rôle admin // Vérifier si l'utilisateur a le rôle admin
const hasAdminRole = existingAdmin.roles.includes("ROLE_ADMIN"); const hasAdminRole = existingAdmin.roles.includes("ROLE_ADMIN");
if (hasAdminRole) { if (hasAdminRole) {
console.log(`✅ Admin user ${ADMIN_EMAIL} already exists with admin role`); console.log(`✅ Admin user ${ADMIN_EMAIL} already exists with admin role`);
} else { } else {
@@ -60,7 +60,7 @@ async function initializeAdminUser() {
async function main() { async function main() {
console.log("🔧 Initializing SQLite database..."); console.log("🔧 Initializing SQLite database...");
try { try {
await initializeAdminUser(); await initializeAdminUser();
console.log("✅ Database initialization completed"); console.log("✅ Database initialization completed");
@@ -72,4 +72,3 @@ async function main() {
} }
main(); main();

View File

@@ -2,8 +2,8 @@
/** /**
* Script de réinitialisation forcée du mot de passe admin * Script de réinitialisation forcée du mot de passe admin
* Force la mise à jour du mot de passe du compte admin * Force la mise à jour du mot de passe du compte admin
* *
* Usage: * Usage:
* pnpm reset-admin-password [nouveau-mot-de-passe] * pnpm reset-admin-password [nouveau-mot-de-passe]
* pnpm reset-admin-password [email] [nouveau-mot-de-passe] * pnpm reset-admin-password [email] [nouveau-mot-de-passe]
* docker compose exec app pnpm reset-admin-password [nouveau-mot-de-passe] * docker compose exec app pnpm reset-admin-password [nouveau-mot-de-passe]
@@ -71,7 +71,7 @@ async function resetAdminPassword() {
async function main() { async function main() {
console.log("🔧 Starting admin password reset..."); console.log("🔧 Starting admin password reset...");
try { try {
await resetAdminPassword(); await resetAdminPassword();
console.log("✅ Admin password reset completed"); console.log("✅ Admin password reset completed");
@@ -83,4 +83,3 @@ async function main() {
} }
main(); main();

View File

@@ -4,7 +4,7 @@ import { UserService } from "@/lib/services/user.service";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";
export default async function AccountPage() { export default async function AccountPage() {
try { try {

View File

@@ -4,12 +4,12 @@ import { isAdmin } from "@/lib/auth-utils";
import { AdminContent } from "@/components/admin/AdminContent"; import { AdminContent } from "@/components/admin/AdminContent";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";
export default async function AdminPage() { export default async function AdminPage() {
try { try {
const hasAdminAccess = await isAdmin(); const hasAdminAccess = await isAdmin();
if (!hasAdminAccess) { if (!hasAdminAccess) {
redirect("/"); redirect("/");
} }

View File

@@ -13,9 +13,13 @@ export async function GET() {
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ error: error.message, code: error.code }, { error: error.message, code: error.code },
{ {
status: error.code === "AUTH_FORBIDDEN" ? 403 : status:
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500 error.code === "AUTH_FORBIDDEN"
? 403
: error.code === "AUTH_UNAUTHENTICATED"
? 401
: 500,
} }
); );
} }
@@ -26,4 +30,3 @@ export async function GET() {
); );
} }
} }

View File

@@ -14,17 +14,14 @@ export async function PUT(
const { newPassword } = body; const { newPassword } = body;
if (!newPassword) { if (!newPassword) {
return NextResponse.json( return NextResponse.json({ error: "Nouveau mot de passe manquant" }, { status: 400 });
{ error: "Nouveau mot de passe manquant" },
{ status: 400 }
);
} }
// Vérifier que le mot de passe est fort // Vérifier que le mot de passe est fort
if (!AuthServerService.isPasswordStrong(newPassword)) { if (!AuthServerService.isPasswordStrong(newPassword)) {
return NextResponse.json( return NextResponse.json(
{ {
error: "Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre" error: "Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre",
}, },
{ status: 400 } { status: 400 }
); );
@@ -39,11 +36,17 @@ export async function PUT(
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ error: error.message, code: error.code }, { error: error.message, code: error.code },
{ {
status: error.code === "AUTH_FORBIDDEN" ? 403 : status:
error.code === "AUTH_UNAUTHENTICATED" ? 401 : error.code === "AUTH_FORBIDDEN"
error.code === "AUTH_USER_NOT_FOUND" ? 404 : ? 403
error.code === "ADMIN_CANNOT_RESET_OWN_PASSWORD" ? 400 : 500 : error.code === "AUTH_UNAUTHENTICATED"
? 401
: error.code === "AUTH_USER_NOT_FOUND"
? 404
: error.code === "ADMIN_CANNOT_RESET_OWN_PASSWORD"
? 400
: 500,
} }
); );
} }
@@ -54,4 +57,3 @@ export async function PUT(
); );
} }
} }

View File

@@ -13,10 +13,7 @@ export async function PATCH(
const { roles } = body; const { roles } = body;
if (!roles || !Array.isArray(roles)) { if (!roles || !Array.isArray(roles)) {
return NextResponse.json( return NextResponse.json({ error: "Rôles invalides" }, { status: 400 });
{ error: "Rôles invalides" },
{ status: 400 }
);
} }
await AdminService.updateUserRoles(userId, roles); await AdminService.updateUserRoles(userId, roles);
@@ -28,10 +25,15 @@ export async function PATCH(
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ error: error.message, code: error.code }, { error: error.message, code: error.code },
{ {
status: error.code === "AUTH_FORBIDDEN" ? 403 : status:
error.code === "AUTH_UNAUTHENTICATED" ? 401 : error.code === "AUTH_FORBIDDEN"
error.code === "AUTH_USER_NOT_FOUND" ? 404 : 500 ? 403
: error.code === "AUTH_UNAUTHENTICATED"
? 401
: error.code === "AUTH_USER_NOT_FOUND"
? 404
: 500,
} }
); );
} }
@@ -58,11 +60,17 @@ export async function DELETE(
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ error: error.message, code: error.code }, { error: error.message, code: error.code },
{ {
status: error.code === "AUTH_FORBIDDEN" ? 403 : status:
error.code === "AUTH_UNAUTHENTICATED" ? 401 : error.code === "AUTH_FORBIDDEN"
error.code === "AUTH_USER_NOT_FOUND" ? 404 : ? 403
error.code === "ADMIN_CANNOT_DELETE_SELF" ? 400 : 500 : error.code === "AUTH_UNAUTHENTICATED"
? 401
: error.code === "AUTH_USER_NOT_FOUND"
? 404
: error.code === "ADMIN_CANNOT_DELETE_SELF"
? 400
: 500,
} }
); );
} }
@@ -73,4 +81,3 @@ export async function DELETE(
); );
} }
} }

View File

@@ -13,9 +13,13 @@ export async function GET() {
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ error: error.message, code: error.code }, { error: error.message, code: error.code },
{ {
status: error.code === "AUTH_FORBIDDEN" ? 403 : status:
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500 error.code === "AUTH_FORBIDDEN"
? 403
: error.code === "AUTH_UNAUTHENTICATED"
? 401
: 500,
} }
); );
} }

View File

@@ -1,3 +1,3 @@
import { handlers } from "@/lib/auth"; import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers; export const { GET, POST } = handlers;

View File

@@ -44,10 +44,10 @@ export async function GET(
return { buffer, contentType }; return { buffer, contentType };
} }
); );
// Cloner le buffer pour cette requête pour éviter tout partage de référence // Cloner le buffer pour cette requête pour éviter tout partage de référence
const clonedBuffer = buffer.slice(0); const clonedBuffer = buffer.slice(0);
const headers = new Headers(); const headers = new Headers();
headers.set("Content-Type", contentType); headers.set("Content-Type", contentType);
headers.set("Cache-Control", "public, max-age=31536000"); // Cache for 1 year headers.set("Cache-Control", "public, max-age=31536000"); // Cache for 1 year

View File

@@ -16,7 +16,7 @@ export async function GET(
const data: KomgaBookWithPages = await BookService.getBook(bookId); const data: KomgaBookWithPages = await BookService.getBook(bookId);
const nextBook = await BookService.getNextBook(bookId, data.book.seriesId); const nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
return NextResponse.json({ ...data, nextBook }); return NextResponse.json({ ...data, nextBook });
} catch (error) { } catch (error) {
logger.error({ err: error }, "API Books - Erreur:"); logger.error({ err: error }, "API Books - Erreur:");

View File

@@ -10,13 +10,13 @@ export async function POST() {
try { try {
const cacheService: ServerCacheService = await getServerCacheService(); const cacheService: ServerCacheService = await getServerCacheService();
await cacheService.clear(); await cacheService.clear();
// Revalider toutes les pages importantes après le vidage du cache // Revalider toutes les pages importantes après le vidage du cache
revalidatePath("/"); revalidatePath("/");
revalidatePath("/libraries"); revalidatePath("/libraries");
revalidatePath("/series"); revalidatePath("/series");
revalidatePath("/books"); revalidatePath("/books");
return NextResponse.json({ message: "🧹 Cache vidé avec succès" }); return NextResponse.json({ message: "🧹 Cache vidé avec succès" });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la suppression du cache:"); logger.error({ err: error }, "Erreur lors de la suppression du cache:");

View File

@@ -9,7 +9,7 @@ export async function GET() {
try { try {
const cacheService: ServerCacheService = await getServerCacheService(); const cacheService: ServerCacheService = await getServerCacheService();
const entries = await cacheService.getCacheEntries(); const entries = await cacheService.getCacheEntries();
return NextResponse.json({ entries }); return NextResponse.json({ entries });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération des entrées du cache"); logger.error({ err: error }, "Erreur lors de la récupération des entrées du cache");
@@ -25,4 +25,3 @@ export async function GET() {
); );
} }
} }

View File

@@ -9,11 +9,11 @@ export async function GET() {
try { try {
const cacheService: ServerCacheService = await getServerCacheService(); const cacheService: ServerCacheService = await getServerCacheService();
const { sizeInBytes, itemCount } = await cacheService.getCacheSize(); const { sizeInBytes, itemCount } = await cacheService.getCacheSize();
return NextResponse.json({ return NextResponse.json({
sizeInBytes, sizeInBytes,
itemCount, itemCount,
mode: cacheService.getCacheMode() mode: cacheService.getCacheMode(),
}); });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la taille du cache:"); logger.error({ err: error }, "Erreur lors de la récupération de la taille du cache:");
@@ -29,4 +29,3 @@ export async function GET() {
); );
} }
} }

View File

@@ -10,10 +10,10 @@ import logger from "@/lib/logger";
export async function GET() { export async function GET() {
try { try {
const favoriteIds: string[] = await FavoriteService.getAllFavoriteIds(); const favoriteIds: string[] = await FavoriteService.getAllFavoriteIds();
// Valider que chaque série existe encore dans Komga // Valider que chaque série existe encore dans Komga
const validFavoriteIds: string[] = []; const validFavoriteIds: string[] = [];
for (const seriesId of favoriteIds) { for (const seriesId of favoriteIds) {
try { try {
await SeriesService.getSeries(seriesId); await SeriesService.getSeries(seriesId);
@@ -27,7 +27,7 @@ export async function GET() {
} }
} }
} }
return NextResponse.json(validFavoriteIds); return NextResponse.json(validFavoriteIds);
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {

View File

@@ -67,4 +67,3 @@ export async function DELETE() {
); );
} }
} }

View File

@@ -20,10 +20,10 @@ export async function GET(
return response; return response;
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la page du livre:"); logger.error({ err: error }, "Erreur lors de la récupération de la page du livre:");
// Chercher un status HTTP 404 dans la chaîne d'erreurs // Chercher un status HTTP 404 dans la chaîne d'erreurs
const httpStatus = findHttpStatus(error); const httpStatus = findHttpStatus(error);
if (httpStatus === 404) { if (httpStatus === 404) {
const { bookId, pageNumber } = await params; const { bookId, pageNumber } = await params;
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -39,7 +39,7 @@ export async function GET(
{ status: 404 } { status: 404 }
); );
} }
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -34,10 +34,10 @@ export async function GET(
return response; return response;
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la miniature de la page:"); logger.error({ err: error }, "Erreur lors de la récupération de la miniature de la page:");
// Chercher un status HTTP 404 dans la chaîne d'erreurs // Chercher un status HTTP 404 dans la chaîne d'erreurs
const httpStatus = findHttpStatus(error); const httpStatus = findHttpStatus(error);
if (httpStatus === 404) { if (httpStatus === 404) {
const { bookId, pageNumber: pageNumberParam } = await params; const { bookId, pageNumber: pageNumberParam } = await params;
const pageNumber: number = parseInt(pageNumberParam); const pageNumber: number = parseInt(pageNumberParam);
@@ -54,7 +54,7 @@ export async function GET(
{ status: 404 } { status: 404 }
); );
} }
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -18,10 +18,10 @@ export async function GET(
return response; return response;
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la miniature du livre:"); logger.error({ err: error }, "Erreur lors de la récupération de la miniature du livre:");
// Chercher un status HTTP 404 dans la chaîne d'erreurs // Chercher un status HTTP 404 dans la chaîne d'erreurs
const httpStatus = findHttpStatus(error); const httpStatus = findHttpStatus(error);
if (httpStatus === 404) { if (httpStatus === 404) {
const bookId: string = (await params).bookId; const bookId: string = (await params).bookId;
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -37,7 +37,7 @@ export async function GET(
{ status: 404 } { status: 404 }
); );
} }
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -20,10 +20,10 @@ export async function GET(
return response; return response;
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la couverture de la série:"); logger.error({ err: error }, "Erreur lors de la récupération de la couverture de la série:");
// Chercher un status HTTP 404 dans la chaîne d'erreurs // Chercher un status HTTP 404 dans la chaîne d'erreurs
const httpStatus = findHttpStatus(error); const httpStatus = findHttpStatus(error);
if (httpStatus === 404) { if (httpStatus === 404) {
const seriesId: string = (await params).seriesId; const seriesId: string = (await params).seriesId;
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -39,7 +39,7 @@ export async function GET(
{ status: 404 } { status: 404 }
); );
} }
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -17,10 +17,10 @@ export async function GET(
return response; return response;
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la miniature de la série"); logger.error({ err: error }, "Erreur lors de la récupération de la miniature de la série");
// Chercher un status HTTP 404 dans la chaîne d'erreurs // Chercher un status HTTP 404 dans la chaîne d'erreurs
const httpStatus = findHttpStatus(error); const httpStatus = findHttpStatus(error);
if (httpStatus === 404) { if (httpStatus === 404) {
const seriesId: string = (await params).seriesId; const seriesId: string = (await params).seriesId;
logger.info(`📷 Image not found for series: ${seriesId}`); logger.info(`📷 Image not found for series: ${seriesId}`);
@@ -35,7 +35,7 @@ export async function GET(
{ status: 404 } { status: 404 }
); );
} }
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -12,7 +12,7 @@ export async function POST(
) { ) {
try { try {
const libraryId: string = (await params).libraryId; const libraryId: string = (await params).libraryId;
// Scan library with deep=false // Scan library with deep=false
await LibraryService.scanLibrary(libraryId, false); await LibraryService.scanLibrary(libraryId, false);
@@ -43,4 +43,3 @@ export async function POST(
); );
} }
} }

View File

@@ -16,7 +16,7 @@ export async function GET(
try { try {
const libraryId: string = (await params).libraryId; const libraryId: string = (await params).libraryId;
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get("page") || "0"); const page = parseInt(searchParams.get("page") || "0");
const size = parseInt(searchParams.get("size") || String(DEFAULT_PAGE_SIZE)); const size = parseInt(searchParams.get("size") || String(DEFAULT_PAGE_SIZE));
const unreadOnly = searchParams.get("unread") === "true"; const unreadOnly = searchParams.get("unread") === "true";
@@ -24,15 +24,15 @@ export async function GET(
const [series, library] = await Promise.all([ const [series, library] = await Promise.all([
LibraryService.getLibrarySeries(libraryId, page, size, unreadOnly, search), LibraryService.getLibrarySeries(libraryId, page, size, unreadOnly, search),
LibraryService.getLibrary(libraryId) LibraryService.getLibrary(libraryId),
]); ]);
return NextResponse.json( return NextResponse.json(
{ series, library }, { series, library },
{ {
headers: { headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
} },
} }
); );
} catch (error) { } catch (error) {
@@ -68,7 +68,7 @@ export async function DELETE(
) { ) {
try { try {
const libraryId: string = (await params).libraryId; const libraryId: string = (await params).libraryId;
await LibraryService.invalidateLibrarySeriesCache(libraryId); await LibraryService.invalidateLibrarySeriesCache(libraryId);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
@@ -98,4 +98,3 @@ export async function DELETE(
); );
} }
} }

View File

@@ -51,4 +51,3 @@ export async function GET(request: NextRequest) {
); );
} }
} }

View File

@@ -16,22 +16,22 @@ export async function GET(
try { try {
const seriesId: string = (await params).seriesId; const seriesId: string = (await params).seriesId;
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get("page") || "0"); const page = parseInt(searchParams.get("page") || "0");
const size = parseInt(searchParams.get("size") || String(DEFAULT_PAGE_SIZE)); const size = parseInt(searchParams.get("size") || String(DEFAULT_PAGE_SIZE));
const unreadOnly = searchParams.get("unread") === "true"; const unreadOnly = searchParams.get("unread") === "true";
const [books, series] = await Promise.all([ const [books, series] = await Promise.all([
SeriesService.getSeriesBooks(seriesId, page, size, unreadOnly), SeriesService.getSeriesBooks(seriesId, page, size, unreadOnly),
SeriesService.getSeries(seriesId) SeriesService.getSeries(seriesId),
]); ]);
return NextResponse.json( return NextResponse.json(
{ books, series }, { books, series },
{ {
headers: { headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
} },
} }
); );
} catch (error) { } catch (error) {
@@ -67,10 +67,10 @@ export async function DELETE(
) { ) {
try { try {
const seriesId: string = (await params).seriesId; const seriesId: string = (await params).seriesId;
await Promise.all([ await Promise.all([
SeriesService.invalidateSeriesBooksCache(seriesId), SeriesService.invalidateSeriesBooksCache(seriesId),
SeriesService.invalidateSeriesCache(seriesId) SeriesService.invalidateSeriesCache(seriesId),
]); ]);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
@@ -100,4 +100,3 @@ export async function DELETE(
); );
} }
} }

View File

@@ -18,8 +18,8 @@ export async function GET(
const series: KomgaSeries = await SeriesService.getSeries(seriesId); const series: KomgaSeries = await SeriesService.getSeries(seriesId);
return NextResponse.json(series, { return NextResponse.json(series, {
headers: { headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
} },
}); });
} catch (error) { } catch (error) {
logger.error({ err: error }, "API Series - Erreur:"); logger.error({ err: error }, "API Series - Erreur:");

View File

@@ -1,4 +1,4 @@
import type { NextRequest} from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { PreferencesService } from "@/lib/services/preferences.service"; import { PreferencesService } from "@/lib/services/preferences.service";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
@@ -41,9 +41,8 @@ export async function GET() {
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const preferences: UserPreferences = await request.json(); const preferences: UserPreferences = await request.json();
const updatedPreferences: UserPreferences = await PreferencesService.updatePreferences( const updatedPreferences: UserPreferences =
preferences await PreferencesService.updatePreferences(preferences);
);
return NextResponse.json(updatedPreferences); return NextResponse.json(updatedPreferences);
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la mise à jour des préférences:"); logger.error({ err: error }, "Erreur lors de la mise à jour des préférences:");

View File

@@ -10,17 +10,15 @@ export async function PUT(request: NextRequest) {
const { currentPassword, newPassword } = body; const { currentPassword, newPassword } = body;
if (!currentPassword || !newPassword) { if (!currentPassword || !newPassword) {
return NextResponse.json( return NextResponse.json({ error: "Mots de passe manquants" }, { status: 400 });
{ error: "Mots de passe manquants" },
{ status: 400 }
);
} }
// Vérifier que le nouveau mot de passe est fort // Vérifier que le nouveau mot de passe est fort
if (!AuthServerService.isPasswordStrong(newPassword)) { if (!AuthServerService.isPasswordStrong(newPassword)) {
return NextResponse.json( return NextResponse.json(
{ {
error: "Le nouveau mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre" error:
"Le nouveau mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre",
}, },
{ status: 400 } { status: 400 }
); );
@@ -35,9 +33,13 @@ export async function PUT(request: NextRequest) {
if (error instanceof AppError) { if (error instanceof AppError) {
return NextResponse.json( return NextResponse.json(
{ error: error.message, code: error.code }, { error: error.message, code: error.code },
{ {
status: error.code === "AUTH_INVALID_PASSWORD" ? 400 : status:
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500 error.code === "AUTH_INVALID_PASSWORD"
? 400
: error.code === "AUTH_UNAUTHENTICATED"
? 401
: 500,
} }
); );
} }

View File

@@ -1,8 +1,3 @@
export default function BookReaderLayout({ export default function BookReaderLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>; return <>{children}</>;
} }

View File

@@ -4,7 +4,7 @@ import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
export default async function BookPage({ params }: { params: Promise<{ bookId: string }> }) { export default async function BookPage({ params }: { params: Promise<{ bookId: string }> }) {
const { bookId } = await params; const { bookId } = await params;
return ( return (
<Suspense fallback={<BookSkeleton />}> <Suspense fallback={<BookSkeleton />}>
<ClientBookPage bookId={bookId} /> <ClientBookPage bookId={bookId} />

View File

@@ -1,6 +1,6 @@
import { DownloadManager } from "@/components/downloads/DownloadManager"; import { DownloadManager } from "@/components/downloads/DownloadManager";
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";
export default function DownloadsPage() { export default function DownloadsPage() {
return ( return (

View File

@@ -12,7 +12,12 @@ import { defaultPreferences } from "@/types/preferences";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
const inter = Inter({ subsets: ["latin"], display: "swap", adjustFontFallback: false, preload: false }); const inter = Inter({
subsets: ["latin"],
display: "swap",
adjustFontFallback: false,
preload: false,
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -90,10 +95,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return ( return (
<html lang={locale} suppressHydrationWarning className="h-full"> <html lang={locale} suppressHydrationWarning className="h-full">
<head> <head>
<meta <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-touch-fullscreen" content="yes" /> <meta name="apple-touch-fullscreen" content="yes" />
@@ -145,16 +147,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
/> />
</head> </head>
<body <body
className={cn("min-h-screen bg-background font-sans antialiased h-full no-pinch-zoom", inter.className)} className={cn(
"min-h-screen bg-background font-sans antialiased h-full no-pinch-zoom",
inter.className
)}
> >
<AuthProvider> <AuthProvider>
<I18nProvider locale={locale}> <I18nProvider locale={locale}>
<PreferencesProvider initialPreferences={preferences}> <PreferencesProvider initialPreferences={preferences}>
<ClientLayout <ClientLayout initialLibraries={[]} initialFavorites={[]} userIsAdmin={userIsAdmin}>
initialLibraries={[]}
initialFavorites={[]}
userIsAdmin={userIsAdmin}
>
{children} {children}
</ClientLayout> </ClientLayout>
</PreferencesProvider> </PreferencesProvider>

View File

@@ -47,22 +47,22 @@ export function ClientLibraryPage({
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: String(currentPage - 1), page: String(currentPage - 1),
size: String(effectivePageSize), size: String(effectivePageSize),
unread: String(unreadOnly), unread: String(unreadOnly),
}); });
if (search) { if (search) {
params.append("search", search); params.append("search", search);
} }
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, { const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: 'default' // Utilise le cache HTTP du navigateur cache: "default", // Utilise le cache HTTP du navigateur
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR"); throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
@@ -86,28 +86,28 @@ export function ClientLibraryPage({
try { try {
// Invalidate cache via API // Invalidate cache via API
const cacheResponse = await fetch(`/api/komga/libraries/${libraryId}/series`, { const cacheResponse = await fetch(`/api/komga/libraries/${libraryId}/series`, {
method: 'DELETE', method: "DELETE",
}); });
if (!cacheResponse.ok) { if (!cacheResponse.ok) {
throw new Error("Error invalidating cache"); throw new Error("Error invalidating cache");
} }
// Recharger les données // Recharger les données
const params = new URLSearchParams({ const params = new URLSearchParams({
page: String(currentPage - 1), page: String(currentPage - 1),
size: String(effectivePageSize), size: String(effectivePageSize),
unread: String(unreadOnly), unread: String(unreadOnly),
}); });
if (search) { if (search) {
params.append("search", search); params.append("search", search);
} }
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, { const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: 'reload' // Force un nouveau fetch après invalidation cache: "reload", // Force un nouveau fetch après invalidation
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Error refreshing library"); throw new Error("Error refreshing library");
} }
@@ -115,7 +115,7 @@ export function ClientLibraryPage({
const data = await response.json(); const data = await response.json();
setLibrary(data.library); setLibrary(data.library);
setSeries(data.series); setSeries(data.series);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
logger.error({ err: error }, "Error during refresh:"); logger.error({ err: error }, "Error during refresh:");
@@ -133,15 +133,15 @@ export function ClientLibraryPage({
size: String(effectivePageSize), size: String(effectivePageSize),
unread: String(unreadOnly), unread: String(unreadOnly),
}); });
if (search) { if (search) {
params.append("search", search); params.append("search", search);
} }
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, { const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: 'reload' // Force un nouveau fetch lors du retry cache: "reload", // Force un nouveau fetch lors du retry
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR"); throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");

View File

@@ -50,7 +50,7 @@ export function ClientSeriesPage({
}); });
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, { const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: 'default' // Utilise le cache HTTP du navigateur cache: "default", // Utilise le cache HTTP du navigateur
}); });
if (!response.ok) { if (!response.ok) {
@@ -76,7 +76,7 @@ export function ClientSeriesPage({
try { try {
// Invalidate cache via API // Invalidate cache via API
const cacheResponse = await fetch(`/api/komga/series/${seriesId}/books`, { const cacheResponse = await fetch(`/api/komga/series/${seriesId}/books`, {
method: 'DELETE', method: "DELETE",
}); });
if (!cacheResponse.ok) { if (!cacheResponse.ok) {
@@ -91,7 +91,7 @@ export function ClientSeriesPage({
}); });
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, { const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: 'reload' // Force un nouveau fetch après invalidation cache: "reload", // Force un nouveau fetch après invalidation
}); });
if (!response.ok) { if (!response.ok) {
@@ -121,7 +121,7 @@ export function ClientSeriesPage({
}); });
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, { const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: 'reload' // Force un nouveau fetch lors du retry cache: "reload", // Force un nouveau fetch lors du retry
}); });
if (!response.ok) { if (!response.ok) {
@@ -204,4 +204,3 @@ export function ClientSeriesPage({
</> </>
); );
} }

View File

@@ -53,4 +53,3 @@ export default function SeriesLoading() {
</div> </div>
); );
} }

View File

@@ -4,7 +4,7 @@ import type { Metadata } from "next";
import type { KomgaConfig, TTLConfig } from "@/types/komga"; import type { KomgaConfig, TTLConfig } from "@/types/komga";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Préférences", title: "Préférences",

View File

@@ -75,7 +75,8 @@ export function ChangePasswordForm() {
<CardHeader> <CardHeader>
<CardTitle>Changer le mot de passe</CardTitle> <CardTitle>Changer le mot de passe</CardTitle>
<CardDescription> <CardDescription>
Assurez-vous d&apos;utiliser un mot de passe fort (8 caractères minimum, une majuscule et un chiffre) Assurez-vous d&apos;utiliser un mot de passe fort (8 caractères minimum, une majuscule et
un chiffre)
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -136,4 +137,3 @@ export function ChangePasswordForm() {
</Card> </Card>
); );
} }

View File

@@ -6,7 +6,9 @@ import { Mail, Calendar, Shield, Heart } from "lucide-react";
import type { UserProfile } from "@/lib/services/user.service"; import type { UserProfile } from "@/lib/services/user.service";
interface UserProfileCardProps { interface UserProfileCardProps {
profile: UserProfile & { stats: { favoritesCount: number; hasPreferences: boolean; hasKomgaConfig: boolean } }; profile: UserProfile & {
stats: { favoritesCount: number; hasPreferences: boolean; hasKomgaConfig: boolean };
};
} }
export function UserProfileCard({ profile }: UserProfileCardProps) { export function UserProfileCard({ profile }: UserProfileCardProps) {
@@ -65,12 +67,10 @@ export function UserProfileCard({ profile }: UserProfileCardProps) {
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Dernière mise à jour:{" "} Dernière mise à jour: {new Date(profile.updatedAt).toLocaleDateString("fr-FR")}
{new Date(profile.updatedAt).toLocaleDateString("fr-FR")}
</p> </p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }

View File

@@ -36,10 +36,7 @@ export function AdminContent({ initialUsers, initialStats }: AdminContentProps)
throw new Error("Erreur lors du rafraîchissement"); throw new Error("Erreur lors du rafraîchissement");
} }
const [newUsers, newStats] = await Promise.all([ const [newUsers, newStats] = await Promise.all([usersResponse.json(), statsResponse.json()]);
usersResponse.json(),
statsResponse.json(),
]);
setUsers(newUsers); setUsers(newUsers);
setStats(newStats); setStats(newStats);
@@ -65,9 +62,7 @@ export function AdminContent({ initialUsers, initialStats }: AdminContentProps)
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold">Administration</h1> <h1 className="text-3xl font-bold">Administration</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">Gérez les utilisateurs de la plateforme</p>
Gérez les utilisateurs de la plateforme
</p>
</div> </div>
<Button onClick={refreshData} disabled={isRefreshing}> <Button onClick={refreshData} disabled={isRefreshing}>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
@@ -85,4 +80,3 @@ export function AdminContent({ initialUsers, initialStats }: AdminContentProps)
</div> </div>
); );
} }

View File

@@ -21,12 +21,7 @@ interface DeleteUserDialogProps {
onSuccess: () => void; onSuccess: () => void;
} }
export function DeleteUserDialog({ export function DeleteUserDialog({ user, open, onOpenChange, onSuccess }: DeleteUserDialogProps) {
user,
open,
onOpenChange,
onSuccess,
}: DeleteUserDialogProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
@@ -89,4 +84,3 @@ export function DeleteUserDialog({
</AlertDialog> </AlertDialog>
); );
} }

View File

@@ -27,12 +27,7 @@ const AVAILABLE_ROLES = [
{ value: "ROLE_ADMIN", label: "Admin" }, { value: "ROLE_ADMIN", label: "Admin" },
]; ];
export function EditUserDialog({ export function EditUserDialog({ user, open, onOpenChange, onSuccess }: EditUserDialogProps) {
user,
open,
onOpenChange,
onSuccess,
}: EditUserDialogProps) {
const [selectedRoles, setSelectedRoles] = useState<string[]>(user.roles); const [selectedRoles, setSelectedRoles] = useState<string[]>(user.roles);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
@@ -125,4 +120,3 @@ export function EditUserDialog({
</Dialog> </Dialog>
); );
} }

View File

@@ -152,11 +152,7 @@ export function ResetPasswordDialog({
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isLoading}>
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isLoading}
>
Annuler Annuler
</Button> </Button>
<Button onClick={handleSubmit} disabled={isLoading}> <Button onClick={handleSubmit} disabled={isLoading}>
@@ -167,4 +163,3 @@ export function ResetPasswordDialog({
</Dialog> </Dialog>
); );
} }

View File

@@ -60,4 +60,3 @@ export function StatsCards({ stats }: StatsCardsProps) {
</div> </div>
); );
} }

View File

@@ -57,10 +57,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
<TableCell> <TableCell>
<div className="flex gap-1"> <div className="flex gap-1">
{user.roles.map((role) => ( {user.roles.map((role) => (
<Badge <Badge key={role} variant={role === "ROLE_ADMIN" ? "default" : "secondary"}>
key={role}
variant={role === "ROLE_ADMIN" ? "default" : "secondary"}
>
{role.replace("ROLE_", "")} {role.replace("ROLE_", "")}
</Badge> </Badge>
))} ))}
@@ -89,9 +86,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
)} )}
</TableCell> </TableCell>
<TableCell>{user._count?.favorites || 0}</TableCell> <TableCell>{user._count?.favorites || 0}</TableCell>
<TableCell> <TableCell>{new Date(user.createdAt).toLocaleDateString("fr-FR")}</TableCell>
{new Date(user.createdAt).toLocaleDateString("fr-FR")}
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
@@ -164,4 +159,3 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
</> </>
); );
} }

View File

@@ -52,11 +52,13 @@ export function RegisterForm({ from: _from }: RegisterFormProps) {
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
setError(data.error || { setError(
code: "AUTH_REGISTRATION_FAILED", data.error || {
name: "Registration failed", code: "AUTH_REGISTRATION_FAILED",
message: "Erreur lors de l'inscription", name: "Registration failed",
}); message: "Erreur lors de l'inscription",
}
);
return; return;
} }
@@ -96,13 +98,7 @@ export function RegisterForm({ from: _from }: RegisterFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">{t("login.form.password")}</Label> <Label htmlFor="password">{t("login.form.password")}</Label>
<Input <Input id="password" name="password" type="password" autoComplete="new-password" required />
id="password"
name="password"
type="password"
autoComplete="new-password"
required
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirmPassword">{t("login.form.confirmPassword")}</Label> <Label htmlFor="confirmPassword">{t("login.form.confirmPassword")}</Label>

View File

@@ -28,7 +28,9 @@ export function PullToRefreshIndicator({
className={cn( className={cn(
"fixed top-0 left-1/2 transform -translate-x-1/2 z-50 transition-all", "fixed top-0 left-1/2 transform -translate-x-1/2 z-50 transition-all",
isHiding ? "duration-300 ease-out" : "duration-200", isHiding ? "duration-300 ease-out" : "duration-200",
(isPulling || isRefreshing) && !isHiding ? "translate-y-0 opacity-100" : "-translate-y-full opacity-0" (isPulling || isRefreshing) && !isHiding
? "translate-y-0 opacity-100"
: "-translate-y-full opacity-0"
)} )}
style={{ style={{
transform: `translate(-50%, ${(isPulling || isRefreshing) && !isHiding ? (isRefreshing ? 60 : progress * 60) : -100}px)`, transform: `translate(-50%, ${(isPulling || isRefreshing) && !isHiding ? (isRefreshing ? 60 : progress * 60) : -100}px)`,
@@ -40,27 +42,26 @@ export function PullToRefreshIndicator({
<div <div
className={cn( className={cn(
"h-full transition-all duration-200 rounded-full", "h-full transition-all duration-200 rounded-full",
(canRefresh || isRefreshing) ? "bg-primary" : "bg-muted-foreground" canRefresh || isRefreshing ? "bg-primary" : "bg-muted-foreground"
)} )}
style={{ style={{
width: `${isRefreshing ? 200 : barWidth}px`, width: `${isRefreshing ? 200 : barWidth}px`,
}} }}
/> />
</div> </div>
{/* Icône centrée */} {/* Icône centrée */}
<div className="flex justify-center mt-2"> <div className="flex justify-center mt-2">
<div <div
className={cn( className={cn(
"flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200", "flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200",
(canRefresh || isRefreshing) ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground" canRefresh || isRefreshing
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)} )}
> >
<RefreshCw <RefreshCw
className={cn( className={cn("h-4 w-4 transition-all duration-200", isRefreshing && "animate-spin")}
"h-4 w-4 transition-all duration-200",
isRefreshing && "animate-spin"
)}
style={{ style={{
transform: isRefreshing ? "rotate(0deg)" : `rotate(${rotation}deg)`, transform: isRefreshing ? "rotate(0deg)" : `rotate(${rotation}deg)`,
animationDuration: isRefreshing ? "2s" : undefined, animationDuration: isRefreshing ? "2s" : undefined,
@@ -68,15 +69,21 @@ export function PullToRefreshIndicator({
/> />
</div> </div>
</div> </div>
{/* Message */} {/* Message */}
<div <div
className={cn( className={cn(
"mt-2 text-center text-xs transition-opacity duration-200", "mt-2 text-center text-xs transition-opacity duration-200",
(canRefresh || isRefreshing) ? "text-primary opacity-100" : "text-muted-foreground opacity-70" canRefresh || isRefreshing
? "text-primary opacity-100"
: "text-muted-foreground opacity-70"
)} )}
> >
{isRefreshing ? "Actualisation..." : canRefresh ? "Relâchez pour actualiser" : "Tirez pour actualiser"} {isRefreshing
? "Actualisation..."
: canRefresh
? "Relâchez pour actualiser"
: "Tirez pour actualiser"}
</div> </div>
</div> </div>
); );

View File

@@ -33,4 +33,3 @@ export function ViewModeButton({ onToggle }: ViewModeButtonProps) {
</Button> </Button>
); );
} }

View File

@@ -201,25 +201,8 @@ export function DownloadManager() {
)} )}
</div> </div>
<TabsContent value="all" className="space-y-4"> <TabsContent value="all" className="space-y-4">
{downloadedBooks.map(({ book, status }) => ( {downloadedBooks.map(({ book, status }) => (
<BookDownloadCard
key={book.id}
book={book}
status={status}
onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)}
/>
))}
{downloadedBooks.length === 0 && (
<p className="text-center text-muted-foreground p-8">{t("downloads.empty.all")}</p>
)}
</TabsContent>
<TabsContent value="downloading" className="space-y-4">
{downloadedBooks
.filter((b) => b.status.status === "downloading")
.map(({ book, status }) => (
<BookDownloadCard <BookDownloadCard
key={book.id} key={book.id}
book={book} book={book}
@@ -228,49 +211,66 @@ export function DownloadManager() {
onRetry={() => handleRetryDownload(book)} onRetry={() => handleRetryDownload(book)}
/> />
))} ))}
{downloadedBooks.filter((b) => b.status.status === "downloading").length === 0 && ( {downloadedBooks.length === 0 && (
<p className="text-center text-muted-foreground p-8"> <p className="text-center text-muted-foreground p-8">{t("downloads.empty.all")}</p>
{t("downloads.empty.downloading")} )}
</p> </TabsContent>
)}
</TabsContent>
<TabsContent value="available" className="space-y-4"> <TabsContent value="downloading" className="space-y-4">
{downloadedBooks {downloadedBooks
.filter((b) => b.status.status === "available") .filter((b) => b.status.status === "downloading")
.map(({ book, status }) => ( .map(({ book, status }) => (
<BookDownloadCard <BookDownloadCard
key={book.id} key={book.id}
book={book} book={book}
status={status} status={status}
onDelete={() => handleDeleteBook(book)} onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)} onRetry={() => handleRetryDownload(book)}
/> />
))} ))}
{downloadedBooks.filter((b) => b.status.status === "available").length === 0 && ( {downloadedBooks.filter((b) => b.status.status === "downloading").length === 0 && (
<p className="text-center text-muted-foreground p-8"> <p className="text-center text-muted-foreground p-8">
{t("downloads.empty.available")} {t("downloads.empty.downloading")}
</p> </p>
)} )}
</TabsContent> </TabsContent>
<TabsContent value="error" className="space-y-4"> <TabsContent value="available" className="space-y-4">
{downloadedBooks {downloadedBooks
.filter((b) => b.status.status === "error") .filter((b) => b.status.status === "available")
.map(({ book, status }) => ( .map(({ book, status }) => (
<BookDownloadCard <BookDownloadCard
key={book.id} key={book.id}
book={book} book={book}
status={status} status={status}
onDelete={() => handleDeleteBook(book)} onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)} onRetry={() => handleRetryDownload(book)}
/> />
))} ))}
{downloadedBooks.filter((b) => b.status.status === "error").length === 0 && ( {downloadedBooks.filter((b) => b.status.status === "available").length === 0 && (
<p className="text-center text-muted-foreground p-8">{t("downloads.empty.error")}</p> <p className="text-center text-muted-foreground p-8">
)} {t("downloads.empty.available")}
</TabsContent> </p>
</Tabs> )}
</TabsContent>
<TabsContent value="error" className="space-y-4">
{downloadedBooks
.filter((b) => b.status.status === "error")
.map(({ book, status }) => (
<BookDownloadCard
key={book.id}
book={book}
status={status}
onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)}
/>
))}
{downloadedBooks.filter((b) => b.status.status === "error").length === 0 && (
<p className="text-center text-muted-foreground p-8">{t("downloads.empty.error")}</p>
)}
</TabsContent>
</Tabs>
</div> </div>
</Container> </Container>
); );

View File

@@ -23,19 +23,19 @@ export function ClientHomePage() {
try { try {
const response = await fetch("/api/komga/home", { const response = await fetch("/api/komga/home", {
cache: 'default' // Utilise le cache HTTP du navigateur cache: "default", // Utilise le cache HTTP du navigateur
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE; const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
// Si la config Komga est manquante, rediriger vers les settings // Si la config Komga est manquante, rediriger vers les settings
if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) { if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) {
router.push("/settings"); router.push("/settings");
return; return;
} }
throw new Error(errorCode); throw new Error(errorCode);
} }
@@ -67,7 +67,7 @@ export function ClientHomePage() {
// Récupérer les nouvelles données // Récupérer les nouvelles données
const response = await fetch("/api/komga/home", { const response = await fetch("/api/komga/home", {
cache: 'reload' // Force un nouveau fetch après invalidation cache: "reload", // Force un nouveau fetch après invalidation
}); });
if (!response.ok) { if (!response.ok) {
@@ -128,4 +128,3 @@ export function ClientHomePage() {
</> </>
); );
} }

View File

@@ -20,10 +20,10 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
// Vérifier si la HeroSection a déjà été affichée // Vérifier si la HeroSection a déjà été affichée
useEffect(() => { useEffect(() => {
const heroShown = localStorage.getItem('heroSectionShown'); const heroShown = localStorage.getItem("heroSectionShown");
if (!heroShown && data.ongoing && data.ongoing.length > 0) { if (!heroShown && data.ongoing && data.ongoing.length > 0) {
setShowHero(true); setShowHero(true);
localStorage.setItem('heroSectionShown', 'true'); localStorage.setItem("heroSectionShown", "true");
} }
}, [data.ongoing]); }, [data.ongoing]);

View File

@@ -79,13 +79,11 @@ function MediaCard({ item, onClick }: MediaCardProps) {
const { t } = useTranslate(); const { t } = useTranslate();
const isSeries = "booksCount" in item; const isSeries = "booksCount" in item;
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id); const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
const title = isSeries const title = isSeries
? item.metadata.title ? item.metadata.title
: item.metadata.title || : item.metadata.title ||
(item.metadata.number (item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : "");
? t("navigation.volume", { number: item.metadata.number })
: "");
const handleClick = () => { const handleClick = () => {
// Pour les séries, toujours autoriser le clic // Pour les séries, toujours autoriser le clic
@@ -100,7 +98,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
onClick={handleClick} onClick={handleClick}
className={cn( className={cn(
"flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden", "flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden",
(!isSeries && !isAccessible) ? "cursor-not-allowed" : "cursor-pointer" !isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
)} )}
> >
<div className="relative aspect-[2/3] bg-muted"> <div className="relative aspect-[2/3] bg-muted">

View File

@@ -24,7 +24,12 @@ interface ClientLayoutProps {
userIsAdmin?: boolean; userIsAdmin?: boolean;
} }
export default function ClientLayout({ children, initialLibraries = [], initialFavorites = [], userIsAdmin = false }: ClientLayoutProps) { export default function ClientLayout({
children,
initialLibraries = [],
initialFavorites = [],
userIsAdmin = false,
}: ClientLayoutProps) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [randomBookId, setRandomBookId] = useState<string | null>(null); const [randomBookId, setRandomBookId] = useState<string | null>(null);
const pathname = usePathname(); const pathname = usePathname();
@@ -67,14 +72,14 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
const backgroundStyle = useMemo(() => { const backgroundStyle = useMemo(() => {
const bg = preferences.background; const bg = preferences.background;
const blur = bg.blur || 0; const blur = bg.blur || 0;
if (bg.type === "gradient" && bg.gradient) { if (bg.type === "gradient" && bg.gradient) {
return { return {
backgroundImage: bg.gradient, backgroundImage: bg.gradient,
filter: blur > 0 ? `blur(${blur}px)` : undefined, filter: blur > 0 ? `blur(${blur}px)` : undefined,
}; };
} }
if (bg.type === "image" && bg.imageUrl) { if (bg.type === "image" && bg.imageUrl) {
return { return {
backgroundImage: `url(${bg.imageUrl})`, backgroundImage: `url(${bg.imageUrl})`,
@@ -94,7 +99,7 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
filter: blur > 0 ? `blur(${blur}px)` : undefined, filter: blur > 0 ? `blur(${blur}px)` : undefined,
}; };
} }
return {}; return {};
}, [preferences.background, randomBookId]); }, [preferences.background, randomBookId]);
@@ -137,10 +142,10 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
}, []); }, []);
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader // Ne pas afficher le header et la sidebar sur les routes publiques et le reader
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith('/books/'); const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
const hasCustomBackground = const hasCustomBackground =
preferences.background.type === "gradient" || preferences.background.type === "gradient" ||
preferences.background.type === "image" || preferences.background.type === "image" ||
(preferences.background.type === "komga-random" && randomBookId); (preferences.background.type === "komga-random" && randomBookId);
const contentOpacity = (preferences.background.opacity || 100) / 100; const contentOpacity = (preferences.background.opacity || 100) / 100;
@@ -149,28 +154,27 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ImageCacheProvider> <ImageCacheProvider>
{/* Background fixe pour les images et gradients */} {/* Background fixe pour les images et gradients */}
{hasCustomBackground && ( {hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
<div <div
className="fixed inset-0 -z-10"
style={backgroundStyle}
/>
)}
<div
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`} className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
style={hasCustomBackground ? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` } : undefined} style={
hasCustomBackground
? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` }
: undefined
}
> >
{!isPublicRoute && ( {!isPublicRoute && (
<Header <Header
onToggleSidebar={handleToggleSidebar} onToggleSidebar={handleToggleSidebar}
onRefreshBackground={fetchRandomBook} onRefreshBackground={fetchRandomBook}
showRefreshBackground={preferences.background.type === "komga-random"} showRefreshBackground={preferences.background.type === "komga-random"}
/> />
)} )}
{!isPublicRoute && ( {!isPublicRoute && (
<Sidebar <Sidebar
isOpen={isSidebarOpen} isOpen={isSidebarOpen}
onClose={handleCloseSidebar} onClose={handleCloseSidebar}
initialLibraries={initialLibraries} initialLibraries={initialLibraries}
initialFavorites={initialFavorites} initialFavorites={initialFavorites}
userIsAdmin={userIsAdmin} userIsAdmin={userIsAdmin}
/> />

View File

@@ -11,7 +11,11 @@ interface HeaderProps {
showRefreshBackground?: boolean; showRefreshBackground?: boolean;
} }
export function Header({ onToggleSidebar, onRefreshBackground, showRefreshBackground = false }: HeaderProps) { export function Header({
onToggleSidebar,
onRefreshBackground,
showRefreshBackground = false,
}: HeaderProps) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
@@ -56,7 +60,9 @@ export function Header({ onToggleSidebar, onRefreshBackground, showRefreshBackgr
className="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-md disabled:opacity-50 disabled:cursor-not-allowed" className="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Rafraîchir l'image de fond" aria-label="Rafraîchir l'image de fond"
> >
<RefreshCw className={`h-[1.2rem] w-[1.2rem] ${isRefreshing ? 'animate-spin' : ''}`} /> <RefreshCw
className={`h-[1.2rem] w-[1.2rem] ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="sr-only">Rafraîchir l&apos;image de fond</span> <span className="sr-only">Rafraîchir l&apos;image de fond</span>
</button> </button>
)} )}

View File

@@ -1,6 +1,16 @@
"use client"; "use client";
import { Home, Library, Settings, LogOut, RefreshCw, Star, Download, User, Shield } from "lucide-react"; import {
Home,
Library,
Settings,
LogOut,
RefreshCw,
Star,
Download,
User,
Shield,
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
@@ -24,7 +34,13 @@ interface SidebarProps {
userIsAdmin?: boolean; userIsAdmin?: boolean;
} }
export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, userIsAdmin = false }: SidebarProps) { export function Sidebar({
isOpen,
onClose,
initialLibraries,
initialFavorites,
userIsAdmin = false,
}: SidebarProps) {
const { t } = useTranslate(); const { t } = useTranslate();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();

View File

@@ -16,19 +16,25 @@ interface LibraryHeaderProps {
refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>; refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
} }
export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }: LibraryHeaderProps) => { export const LibraryHeader = ({
library,
seriesCount,
series,
refreshLibrary,
}: LibraryHeaderProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
// Mémoriser la sélection des séries pour éviter les rerenders inutiles // Mémoriser la sélection des séries pour éviter les rerenders inutiles
const { randomSeries, backgroundSeries } = useMemo(() => { const { randomSeries, backgroundSeries } = useMemo(() => {
// Sélectionner une série aléatoire pour l'image centrale // Sélectionner une série aléatoire pour l'image centrale
const random = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null; const random = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null;
// Sélectionner une autre série aléatoire pour le fond (différente de celle du centre) // Sélectionner une autre série aléatoire pour le fond (différente de celle du centre)
const background = series.length > 1 const background =
? series.filter(s => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))] series.length > 1
: random; ? series.filter((s) => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))]
: random;
return { randomSeries: random, backgroundSeries: background }; return { randomSeries: random, backgroundSeries: background };
}, [series]); }, [series]);
@@ -76,23 +82,20 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
{/* Informations */} {/* Informations */}
<div className="flex-1 space-y-3 text-center md:text-left"> <div className="flex-1 space-y-3 text-center md:text-left">
<h1 className="text-3xl md:text-4xl font-bold text-foreground">{library.name}</h1> <h1 className="text-3xl md:text-4xl font-bold text-foreground">{library.name}</h1>
<div className="flex items-center gap-4 justify-center md:justify-start flex-wrap"> <div className="flex items-center gap-4 justify-center md:justify-start flex-wrap">
<StatusBadge status="unread" icon={Library}> <StatusBadge status="unread" icon={Library}>
{seriesCount === 1 {seriesCount === 1
? t("library.header.series", { count: seriesCount }) ? t("library.header.series", { count: seriesCount })
: t("library.header.series_plural", { count: seriesCount }) : t("library.header.series_plural", { count: seriesCount })}
}
</StatusBadge> </StatusBadge>
<RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} /> <RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} />
<ScanButton libraryId={library.id} /> <ScanButton libraryId={library.id} />
</div> </div>
{library.unavailable && ( {library.unavailable && (
<p className="text-sm text-destructive mt-2"> <p className="text-sm text-destructive mt-2">{t("library.header.unavailable")}</p>
{t("library.header.unavailable")}
</p>
)} )}
</div> </div>
</div> </div>
@@ -100,4 +103,3 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
</div> </div>
); );
}; };

View File

@@ -38,31 +38,31 @@ export function PaginatedSeriesGrid({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread); const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
const { isCompact, itemsPerPage: displayItemsPerPage, viewMode } = useDisplayPreferences(); const { isCompact, itemsPerPage: displayItemsPerPage, viewMode } = useDisplayPreferences();
// Utiliser la taille de page effective (depuis l'URL ou les préférences) // Utiliser la taille de page effective (depuis l'URL ou les préférences)
const effectivePageSize = pageSize || displayItemsPerPage; const effectivePageSize = pageSize || displayItemsPerPage;
const { t } = useTranslate(); const { t } = useTranslate();
const updateUrlParams = useCallback(async ( const updateUrlParams = useCallback(
updates: Record<string, string | null>, async (updates: Record<string, string | null>, replace: boolean = false) => {
replace: boolean = false const params = new URLSearchParams(searchParams.toString());
) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => { Object.entries(updates).forEach(([key, value]) => {
if (value === null) { if (value === null) {
params.delete(key); params.delete(key);
} else {
params.set(key, value);
}
});
if (replace) {
await router.replace(`${pathname}?${params.toString()}`);
} else { } else {
params.set(key, value); await router.push(`${pathname}?${params.toString()}`);
} }
}); },
[router, pathname, searchParams]
if (replace) { );
await router.replace(`${pathname}?${params.toString()}`);
} else {
await router.push(`${pathname}?${params.toString()}`);
}
}, [router, pathname, searchParams]);
// Update local state when prop changes // Update local state when prop changes
useEffect(() => { useEffect(() => {
@@ -89,7 +89,6 @@ export function PaginatedSeriesGrid({
}); });
}; };
const handlePageSizeChange = async (size: number) => { const handlePageSizeChange = async (size: number) => {
await updateUrlParams({ await updateUrlParams({
page: "1", page: "1",

View File

@@ -67,8 +67,7 @@ export function ScanButton({ libraryId }: ScanButtonProps) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("library.scan.error.title"), title: t("library.scan.error.title"),
description: description: error instanceof Error ? error.message : t("library.scan.error.description"),
error instanceof Error ? error.message : t("library.scan.error.description"),
}); });
} }
}; };
@@ -86,4 +85,3 @@ export function ScanButton({ libraryId }: ScanButtonProps) {
</Button> </Button>
); );
} }

View File

@@ -60,9 +60,8 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
}; };
const isCompleted = series.booksCount === series.booksReadCount; const isCompleted = series.booksCount === series.booksReadCount;
const progressPercentage = series.booksCount > 0 const progressPercentage =
? (series.booksReadCount / series.booksCount) * 100 series.booksCount > 0 ? (series.booksReadCount / series.booksCount) * 100 : 0;
: 0;
const statusInfo = getReadingStatusInfo(series, t); const statusInfo = getReadingStatusInfo(series, t);
@@ -91,7 +90,12 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0"> <h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
{series.metadata.title} {series.metadata.title}
</h3> </h3>
<span className={cn("px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}> <span
className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{statusInfo.label} {statusInfo.label}
</span> </span>
</div> </div>
@@ -101,7 +105,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<BookOpen className="h-3 w-3" /> <BookOpen className="h-3 w-3" />
<span> <span>
{series.booksCount === 1 {series.booksCount === 1
? t("series.book", { count: 1 }) ? t("series.book", { count: 1 })
: t("series.books", { count: series.booksCount })} : t("series.books", { count: series.booksCount })}
</span> </span>
@@ -109,9 +113,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && ( {series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex"> <div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
<span className="line-clamp-1"> <span className="line-clamp-1">{series.booksMetadata.authors[0].name}</span>
{series.booksMetadata.authors[0].name}
</span>
</div> </div>
)} )}
</div> </div>
@@ -146,9 +148,14 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
{series.metadata.title} {series.metadata.title}
</h3> </h3>
</div> </div>
{/* Badge de statut */} {/* Badge de statut */}
<span className={cn("px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}> <span
className={cn(
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{statusInfo.label} {statusInfo.label}
</span> </span>
</div> </div>
@@ -166,7 +173,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<BookOpen className="h-3 w-3" /> <BookOpen className="h-3 w-3" />
<span> <span>
{series.booksCount === 1 {series.booksCount === 1
? t("series.book", { count: 1 }) ? t("series.book", { count: 1 })
: t("series.books", { count: series.booksCount })} : t("series.books", { count: series.booksCount })}
</span> </span>
@@ -177,7 +184,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
<span className="line-clamp-1"> <span className="line-clamp-1">
{series.booksMetadata.authors.map(a => a.name).join(", ")} {series.booksMetadata.authors.map((a) => a.name).join(", ")}
</span> </span>
</div> </div>
)} )}
@@ -246,4 +253,3 @@ export function SeriesList({ series, isCompact = false }: SeriesListProps) {
</div> </div>
); );
} }

View File

@@ -27,7 +27,7 @@ export function ClientBookPage({ bookId }: ClientBookPageProps) {
setError(null); setError(null);
const response = await fetch(`/api/komga/books/${bookId}`); const response = await fetch(`/api/komga/books/${bookId}`);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR); throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
@@ -74,4 +74,3 @@ export function ClientBookPage({ bookId }: ClientBookPageProps) {
return <ClientBookWrapper book={data.book} pages={data.pages} nextBook={data.nextBook} />; return <ClientBookWrapper book={data.book} pages={data.pages} nextBook={data.nextBook} />;
} }

View File

@@ -19,5 +19,7 @@ export function ClientBookWrapper({ book, pages, nextBook }: ClientBookWrapperPr
router.push(`/series/${book.seriesId}`); router.push(`/series/${book.seriesId}`);
}; };
return <PhotoswipeReader book={book} pages={pages} onClose={handleCloseReader} nextBook={nextBook} />; return (
<PhotoswipeReader book={book} pages={pages} onClose={handleCloseReader} nextBook={nextBook} />
);
} }

View File

@@ -29,20 +29,29 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const { direction, toggleDirection, isRTL } = useReadingDirection(); const { direction, toggleDirection, isRTL } = useReadingDirection();
const { isFullscreen, toggleFullscreen } = useFullscreen(); const { isFullscreen, toggleFullscreen } = useFullscreen();
const { isDoublePage, shouldShowDoublePage, toggleDoublePage } = useDoublePageMode(); const { isDoublePage, shouldShowDoublePage, toggleDoublePage } = useDoublePageMode();
const { loadedImages, imageBlobUrls, prefetchPages, prefetchNextBook, handleForceReload, getPageUrl, prefetchCount } = useImageLoader({ const {
bookId: book.id, loadedImages,
pages, imageBlobUrls,
prefetchCount: preferences.readerPrefetchCount, prefetchPages,
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null prefetchNextBook,
}); handleForceReload,
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } = usePageNavigation({ getPageUrl,
book, prefetchCount,
} = useImageLoader({
bookId: book.id,
pages, pages,
isDoublePage, prefetchCount: preferences.readerPrefetchCount,
shouldShowDoublePage: (page) => shouldShowDoublePage(page, pages.length), nextBook: nextBook ? { id: nextBook.id, pages: [] } : null,
onClose,
nextBook,
}); });
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
usePageNavigation({
book,
pages,
isDoublePage,
shouldShowDoublePage: (page) => shouldShowDoublePage(page, pages.length),
onClose,
nextBook,
});
const { pswpRef, handleZoom } = usePhotoSwipeZoom({ const { pswpRef, handleZoom } = usePhotoSwipeZoom({
loadedImages, loadedImages,
currentPage, currentPage,
@@ -58,32 +67,44 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
// Activer le zoom dans le reader en enlevant la classe no-pinch-zoom // Activer le zoom dans le reader en enlevant la classe no-pinch-zoom
useEffect(() => { useEffect(() => {
document.body.classList.remove('no-pinch-zoom'); document.body.classList.remove("no-pinch-zoom");
return () => { return () => {
document.body.classList.add('no-pinch-zoom'); document.body.classList.add("no-pinch-zoom");
}; };
}, []); }, []);
// Prefetch current and next pages // Prefetch current and next pages
// Deduplication in useImageLoader prevents redundant requests // Deduplication in useImageLoader prevents redundant requests
// Server queue (RequestQueueService) handles concurrency limits // Server queue (RequestQueueService) handles concurrency limits
useEffect(() => { useEffect(() => {
// Prefetch pages starting from current page // Prefetch pages starting from current page
prefetchPages(currentPage, prefetchCount); prefetchPages(currentPage, prefetchCount);
// If double page mode, also prefetch additional pages for smooth double page navigation // If double page mode, also prefetch additional pages for smooth double page navigation
if (isDoublePage && shouldShowDoublePage(currentPage, pages.length) && currentPage + prefetchCount < pages.length) { if (
isDoublePage &&
shouldShowDoublePage(currentPage, pages.length) &&
currentPage + prefetchCount < pages.length
) {
prefetchPages(currentPage + prefetchCount, 1); prefetchPages(currentPage + prefetchCount, 1);
} }
// If we're near the end of the book, prefetch the next book // If we're near the end of the book, prefetch the next book
const pagesFromEnd = pages.length - currentPage; const pagesFromEnd = pages.length - currentPage;
if (pagesFromEnd <= prefetchCount && nextBook) { if (pagesFromEnd <= prefetchCount && nextBook) {
prefetchNextBook(prefetchCount); prefetchNextBook(prefetchCount);
} }
}, [currentPage, isDoublePage, shouldShowDoublePage, prefetchPages, prefetchNextBook, prefetchCount, pages.length, nextBook]); }, [
currentPage,
isDoublePage,
shouldShowDoublePage,
prefetchPages,
prefetchNextBook,
prefetchCount,
pages.length,
nextBook,
]);
// Keyboard events // Keyboard events
useEffect(() => { useEffect(() => {
@@ -109,43 +130,46 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
}; };
}, [handleNextPage, handlePreviousPage, onClose, isRTL, currentPage]); }, [handleNextPage, handlePreviousPage, onClose, isRTL, currentPage]);
const handleContainerClick = useCallback((e: React.MouseEvent) => { const handleContainerClick = useCallback(
// Vérifier si c'est un double-clic sur une image (e: React.MouseEvent) => {
const target = e.target as HTMLElement; // Vérifier si c'est un double-clic sur une image
const now = Date.now(); const target = e.target as HTMLElement;
const timeSinceLastClick = now - lastClickTimeRef.current; const now = Date.now();
const timeSinceLastClick = now - lastClickTimeRef.current;
if (target.tagName === 'IMG' && timeSinceLastClick < 300) {
// Double-clic sur une image if (target.tagName === "IMG" && timeSinceLastClick < 300) {
if (clickTimeoutRef.current) { // Double-clic sur une image
clearTimeout(clickTimeoutRef.current); if (clickTimeoutRef.current) {
clickTimeoutRef.current = null; clearTimeout(clickTimeoutRef.current);
clickTimeoutRef.current = null;
}
e.stopPropagation();
handleZoom();
lastClickTimeRef.current = 0;
} else if (target.tagName === "IMG") {
// Premier clic sur une image - attendre pour voir si c'est un double-clic
lastClickTimeRef.current = now;
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
clickTimeoutRef.current = setTimeout(() => {
setShowControls((prev) => !prev);
clickTimeoutRef.current = null;
}, 300);
} else {
// Clic ailleurs - toggle les contrôles immédiatement
setShowControls(!showControls);
lastClickTimeRef.current = 0;
} }
e.stopPropagation(); },
handleZoom(); [showControls, handleZoom]
lastClickTimeRef.current = 0; );
} else if (target.tagName === 'IMG') {
// Premier clic sur une image - attendre pour voir si c'est un double-clic
lastClickTimeRef.current = now;
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
clickTimeoutRef.current = setTimeout(() => {
setShowControls(prev => !prev);
clickTimeoutRef.current = null;
}, 300);
} else {
// Clic ailleurs - toggle les contrôles immédiatement
setShowControls(!showControls);
lastClickTimeRef.current = 0;
}
}, [showControls, handleZoom]);
return ( return (
<ReaderContainer onContainerClick={handleContainerClick}> <ReaderContainer onContainerClick={handleContainerClick}>
@@ -173,7 +197,11 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
showThumbnails={showThumbnails} showThumbnails={showThumbnails}
onToggleThumbnails={() => setShowThumbnails(!showThumbnails)} onToggleThumbnails={() => setShowThumbnails(!showThumbnails)}
onZoom={handleZoom} onZoom={handleZoom}
onForceReload={() => handleForceReload(currentPage, isDoublePage, (page) => shouldShowDoublePage(page, pages.length))} onForceReload={() =>
handleForceReload(currentPage, isDoublePage, (page) =>
shouldShowDoublePage(page, pages.length)
)
}
/> />
<PageDisplay <PageDisplay
@@ -196,4 +224,3 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
</ReaderContainer> </ReaderContainer>
); );
} }

View File

@@ -44,26 +44,28 @@ export function PageDisplay({
<div <div
className={cn( className={cn(
"relative h-full flex items-center", "relative h-full flex items-center",
isDoublePage && shouldShowDoublePage(currentPage) isDoublePage && shouldShowDoublePage(currentPage) ? "w-1/2" : "w-full justify-center",
? "w-1/2" isDoublePage &&
: "w-full justify-center", shouldShowDoublePage(currentPage) && {
isDoublePage && shouldShowDoublePage(currentPage) && { "order-2 justify-start": isRTL,
"order-2 justify-start": isRTL, "order-1 justify-end": !isRTL,
"order-1 justify-end": !isRTL, }
}
)} )}
> >
{isLoading && ( {isLoading && (
<div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 animate-fade-in"> <div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 animate-fade-in">
<div className="relative"> <div className="relative">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-primary/20"></div> <div className="animate-spin rounded-full h-16 w-16 border-4 border-primary/20"></div>
<div className="absolute inset-0 animate-spin rounded-full h-16 w-16 border-4 border-transparent border-t-primary" style={{ animationDuration: '0.8s' }}></div> <div
className="absolute inset-0 animate-spin rounded-full h-16 w-16 border-4 border-transparent border-t-primary"
style={{ animationDuration: "0.8s" }}
></div>
</div> </div>
</div> </div>
)} )}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ''}`} key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`}
src={imageBlobUrls[currentPage] || getPageUrl(currentPage)} src={imageBlobUrls[currentPage] || getPageUrl(currentPage)}
alt={`Page ${currentPage}`} alt={`Page ${currentPage}`}
className={cn( className={cn(
@@ -85,25 +87,25 @@ export function PageDisplay({
{/* Page 2 (double page) */} {/* Page 2 (double page) */}
{isDoublePage && shouldShowDoublePage(currentPage) && ( {isDoublePage && shouldShowDoublePage(currentPage) && (
<div <div
className={cn( className={cn("relative h-full w-1/2 flex items-center", {
"relative h-full w-1/2 flex items-center", "order-1 justify-end": isRTL,
{ "order-2 justify-start": !isRTL,
"order-1 justify-end": isRTL, })}
"order-2 justify-start": !isRTL,
}
)}
> >
{secondPageLoading && ( {secondPageLoading && (
<div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 animate-fade-in"> <div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 animate-fade-in">
<div className="relative"> <div className="relative">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-primary/20"></div> <div className="animate-spin rounded-full h-16 w-16 border-4 border-primary/20"></div>
<div className="absolute inset-0 animate-spin rounded-full h-16 w-16 border-4 border-transparent border-t-primary" style={{ animationDuration: '0.8s' }}></div> <div
className="absolute inset-0 animate-spin rounded-full h-16 w-16 border-4 border-transparent border-t-primary"
style={{ animationDuration: "0.8s" }}
></div>
</div> </div>
</div> </div>
)} )}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ''}`} key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`}
src={imageBlobUrls[currentPage + 1] || getPageUrl(currentPage + 1)} src={imageBlobUrls[currentPage + 1] || getPageUrl(currentPage + 1)}
alt={`Page ${currentPage + 1}`} alt={`Page ${currentPage + 1}`}
className={cn( className={cn(

View File

@@ -8,9 +8,12 @@ interface ReaderContainerProps {
export function ReaderContainer({ children, onContainerClick }: ReaderContainerProps) { export function ReaderContainer({ children, onContainerClick }: ReaderContainerProps) {
const readerRef = useRef<HTMLDivElement>(null); const readerRef = useRef<HTMLDivElement>(null);
const handleContainerClick = useCallback((e: React.MouseEvent) => { const handleContainerClick = useCallback(
onContainerClick(e); (e: React.MouseEvent) => {
}, [onContainerClick]); onContainerClick(e);
},
[onContainerClick]
);
return ( return (
<div <div
@@ -18,9 +21,7 @@ export function ReaderContainer({ children, onContainerClick }: ReaderContainerP
className="reader-zoom-enabled fixed inset-0 bg-background/95 backdrop-blur-sm z-50 overflow-hidden" className="reader-zoom-enabled fixed inset-0 bg-background/95 backdrop-blur-sm z-50 overflow-hidden"
onClick={handleContainerClick} onClick={handleContainerClick}
> >
<div className="relative h-full flex flex-col items-center justify-center"> <div className="relative h-full flex flex-col items-center justify-center">{children}</div>
{children}
</div>
</div> </div>
); );
} }

View File

@@ -97,9 +97,9 @@ export const Thumbnail = forwardRef<HTMLButtonElement, ThumbnailProps>(
setImageUrl((prev) => { setImageUrl((prev) => {
if (!prev) return null; if (!prev) return null;
// Utiliser & si l'URL contient déjà des query params // Utiliser & si l'URL contient déjà des query params
const separator = prev.includes('?') ? '&' : '?'; const separator = prev.includes("?") ? "&" : "?";
// Supprimer l'ancien retry param si présent // Supprimer l'ancien retry param si présent
const baseUrl = prev.replace(/[?&]retry=\d+/g, ''); const baseUrl = prev.replace(/[?&]retry=\d+/g, "");
return `${baseUrl}${separator}retry=${loadAttempts.current}`; return `${baseUrl}${separator}retry=${loadAttempts.current}`;
}); });
}, delay); }, delay);

View File

@@ -22,7 +22,7 @@ export function useDoublePageMode() {
); );
const toggleDoublePage = useCallback(() => { const toggleDoublePage = useCallback(() => {
setIsDoublePage(prev => !prev); setIsDoublePage((prev) => !prev);
}, []); }, []);
return { return {

View File

@@ -14,7 +14,9 @@ export const useFullscreen = () => {
return () => { return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange); document.removeEventListener("fullscreenchange", handleFullscreenChange);
if (document.fullscreenElement) { if (document.fullscreenElement) {
document.exitFullscreen().catch(err => logger.error({ err }, "Erreur lors de la sortie du mode plein écran")); document
.exitFullscreen()
.catch((err) => logger.error({ err }, "Erreur lors de la sortie du mode plein écran"));
} }
}; };
}, []); }, []);

View File

@@ -15,7 +15,12 @@ interface UseImageLoaderProps {
nextBook?: { id: string; pages: number[] } | null; // Livre suivant pour prefetch nextBook?: { id: string; pages: number[] } | null; // Livre suivant pour prefetch
} }
export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextBook }: UseImageLoaderProps) { export function useImageLoader({
bookId,
pages: _pages,
prefetchCount = 5,
nextBook,
}: UseImageLoaderProps) {
const [loadedImages, setLoadedImages] = useState<Record<ImageKey, ImageDimensions>>({}); const [loadedImages, setLoadedImages] = useState<Record<ImageKey, ImageDimensions>>({});
const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({}); const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({});
const loadedImagesRef = useRef(loadedImages); const loadedImagesRef = useRef(loadedImages);
@@ -32,217 +37,238 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
imageBlobUrlsRef.current = imageBlobUrls; imageBlobUrlsRef.current = imageBlobUrls;
}, [imageBlobUrls]); }, [imageBlobUrls]);
const getPageUrl = useCallback((pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`, [bookId]); const getPageUrl = useCallback(
(pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`,
[bookId]
);
// Prefetch image and store dimensions // Prefetch image and store dimensions
const prefetchImage = useCallback(async (pageNum: number) => { const prefetchImage = useCallback(
// Check if we already have both dimensions and blob URL async (pageNum: number) => {
const hasDimensions = loadedImagesRef.current[pageNum]; // Check if we already have both dimensions and blob URL
const hasBlobUrl = imageBlobUrlsRef.current[pageNum]; const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
if (hasDimensions && hasBlobUrl) {
return; if (hasDimensions && hasBlobUrl) {
}
// Check if this page is already being fetched
if (pendingFetchesRef.current.has(pageNum)) {
return;
}
// Mark as pending
pendingFetchesRef.current.add(pageNum);
try {
// Use browser cache if available - the server sets Cache-Control headers
const response = await fetch(getPageUrl(pageNum), {
cache: 'default', // Respect Cache-Control headers from server
});
if (!response.ok) {
return; return;
} }
const blob = await response.blob(); // Check if this page is already being fetched
const blobUrl = URL.createObjectURL(blob); if (pendingFetchesRef.current.has(pageNum)) {
return;
// Create image to get dimensions }
const img = new Image();
img.onload = () => { // Mark as pending
setLoadedImages(prev => ({ pendingFetchesRef.current.add(pageNum);
...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight } try {
})); // Use browser cache if available - the server sets Cache-Control headers
const response = await fetch(getPageUrl(pageNum), {
// Store the blob URL for immediate use cache: "default", // Respect Cache-Control headers from server
setImageBlobUrls(prev => ({ });
...prev, if (!response.ok) {
[pageNum]: blobUrl return;
})); }
};
img.src = blobUrl; const blob = await response.blob();
} catch { const blobUrl = URL.createObjectURL(blob);
// Silently fail prefetch
} finally { // Create image to get dimensions
// Remove from pending set const img = new Image();
pendingFetchesRef.current.delete(pageNum); img.onload = () => {
} setLoadedImages((prev) => ({
}, [getPageUrl]); ...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
}));
// Store the blob URL for immediate use
setImageBlobUrls((prev) => ({
...prev,
[pageNum]: blobUrl,
}));
};
img.src = blobUrl;
} catch {
// Silently fail prefetch
} finally {
// Remove from pending set
pendingFetchesRef.current.delete(pageNum);
}
},
[getPageUrl]
);
// Prefetch multiple pages starting from a given page // Prefetch multiple pages starting from a given page
// The server-side queue (RequestQueueService) handles concurrency limits // The server-side queue (RequestQueueService) handles concurrency limits
// We only deduplicate to avoid redundant HTTP requests // We only deduplicate to avoid redundant HTTP requests
const prefetchPages = useCallback(async (startPage: number, count: number = prefetchCount) => { const prefetchPages = useCallback(
const pagesToPrefetch = []; async (startPage: number, count: number = prefetchCount) => {
const pagesToPrefetch = [];
for (let i = 0; i < count; i++) {
const pageNum = startPage + i; for (let i = 0; i < count; i++) {
if (pageNum <= _pages.length) { const pageNum = startPage + i;
const hasDimensions = loadedImagesRef.current[pageNum]; if (pageNum <= _pages.length) {
const hasBlobUrl = imageBlobUrlsRef.current[pageNum]; const hasDimensions = loadedImagesRef.current[pageNum];
const isPending = pendingFetchesRef.current.has(pageNum); const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
const isPending = pendingFetchesRef.current.has(pageNum);
// Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
if ((!hasDimensions || !hasBlobUrl) && !isPending) { // Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
pagesToPrefetch.push(pageNum); if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push(pageNum);
}
} }
} }
}
// Let all prefetch requests run - the server queue will manage concurrency
// Let all prefetch requests run - the server queue will manage concurrency // The browser cache and our deduplication prevent redundant requests
// The browser cache and our deduplication prevent redundant requests if (pagesToPrefetch.length > 0) {
if (pagesToPrefetch.length > 0) { // Fire all requests in parallel - server queue handles throttling
// Fire all requests in parallel - server queue handles throttling Promise.all(pagesToPrefetch.map((pageNum) => prefetchImage(pageNum))).catch(() => {
Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum))).catch(() => { // Silently fail - prefetch is non-critical
// Silently fail - prefetch is non-critical });
}); }
} },
}, [prefetchImage, prefetchCount, _pages.length]); [prefetchImage, prefetchCount, _pages.length]
);
// Prefetch pages from next book // Prefetch pages from next book
const prefetchNextBook = useCallback(async (count: number = prefetchCount) => { const prefetchNextBook = useCallback(
if (!nextBook) { async (count: number = prefetchCount) => {
return; if (!nextBook) {
} return;
const pagesToPrefetch = [];
for (let i = 0; i < count; i++) {
const pageNum = i + 1; // Pages du livre suivant commencent à 1
// Pour le livre suivant, on utilise une clé différente pour éviter les conflits
const nextBookPageKey = `next-${pageNum}`;
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
const isPending = pendingFetchesRef.current.has(nextBookPageKey);
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push({ pageNum, nextBookPageKey });
} }
}
const pagesToPrefetch = [];
// Let all prefetch requests run - server queue handles concurrency
if (pagesToPrefetch.length > 0) { for (let i = 0; i < count; i++) {
Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => { const pageNum = i + 1; // Pages du livre suivant commencent à 1
// Mark as pending // Pour le livre suivant, on utilise une clé différente pour éviter les conflits
pendingFetchesRef.current.add(nextBookPageKey); const nextBookPageKey = `next-${pageNum}`;
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
try { const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, { const isPending = pendingFetchesRef.current.has(nextBookPageKey);
cache: 'default', // Respect Cache-Control headers from server
}); if ((!hasDimensions || !hasBlobUrl) && !isPending) {
if (!response.ok) { pagesToPrefetch.push({ pageNum, nextBookPageKey });
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Create image to get dimensions
const img = new Image();
img.onload = () => {
setLoadedImages(prev => ({
...prev,
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight }
}));
// Store the blob URL for immediate use
setImageBlobUrls(prev => ({
...prev,
[nextBookPageKey]: blobUrl
}));
};
img.src = blobUrl;
} catch {
// Silently fail prefetch
} finally {
pendingFetchesRef.current.delete(nextBookPageKey);
} }
})).catch(() => { }
// Silently fail - prefetch is non-critical
}); // Let all prefetch requests run - server queue handles concurrency
} if (pagesToPrefetch.length > 0) {
}, [nextBook, prefetchCount]); Promise.all(
pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
// Mark as pending
pendingFetchesRef.current.add(nextBookPageKey);
try {
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
cache: "default", // Respect Cache-Control headers from server
});
if (!response.ok) {
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Create image to get dimensions
const img = new Image();
img.onload = () => {
setLoadedImages((prev) => ({
...prev,
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight },
}));
// Store the blob URL for immediate use
setImageBlobUrls((prev) => ({
...prev,
[nextBookPageKey]: blobUrl,
}));
};
img.src = blobUrl;
} catch {
// Silently fail prefetch
} finally {
pendingFetchesRef.current.delete(nextBookPageKey);
}
})
).catch(() => {
// Silently fail - prefetch is non-critical
});
}
},
[nextBook, prefetchCount]
);
// Force reload handler // Force reload handler
const handleForceReload = useCallback(async (currentPage: number, isDoublePage: boolean, shouldShowDoublePage: (page: number) => boolean) => { const handleForceReload = useCallback(
// Révoquer les anciennes URLs blob async (
if (imageBlobUrls[currentPage]) { currentPage: number,
URL.revokeObjectURL(imageBlobUrls[currentPage]); isDoublePage: boolean,
} shouldShowDoublePage: (page: number) => boolean
if (imageBlobUrls[currentPage + 1]) { ) => {
URL.revokeObjectURL(imageBlobUrls[currentPage + 1]); // Révoquer les anciennes URLs blob
} if (imageBlobUrls[currentPage]) {
URL.revokeObjectURL(imageBlobUrls[currentPage]);
try {
// Fetch page 1 avec cache: reload
const response1 = await fetch(getPageUrl(currentPage), {
cache: 'reload',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
if (!response1.ok) {
throw new Error(`HTTP ${response1.status}`);
} }
if (imageBlobUrls[currentPage + 1]) {
const blob1 = await response1.blob(); URL.revokeObjectURL(imageBlobUrls[currentPage + 1]);
const blobUrl1 = URL.createObjectURL(blob1); }
const newUrls: Record<number, string> = { try {
...imageBlobUrls, // Fetch page 1 avec cache: reload
[currentPage]: blobUrl1 const response1 = await fetch(getPageUrl(currentPage), {
}; cache: "reload",
// Fetch page 2 si double page
if (isDoublePage && shouldShowDoublePage(currentPage)) {
const response2 = await fetch(getPageUrl(currentPage + 1), {
cache: 'reload',
headers: { headers: {
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
'Pragma': 'no-cache' Pragma: "no-cache",
} },
}); });
if (!response2.ok) { if (!response1.ok) {
throw new Error(`HTTP ${response2.status}`); throw new Error(`HTTP ${response1.status}`);
} }
const blob2 = await response2.blob(); const blob1 = await response1.blob();
const blobUrl2 = URL.createObjectURL(blob2); const blobUrl1 = URL.createObjectURL(blob1);
newUrls[currentPage + 1] = blobUrl2;
const newUrls: Record<number, string> = {
...imageBlobUrls,
[currentPage]: blobUrl1,
};
// Fetch page 2 si double page
if (isDoublePage && shouldShowDoublePage(currentPage)) {
const response2 = await fetch(getPageUrl(currentPage + 1), {
cache: "reload",
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
if (!response2.ok) {
throw new Error(`HTTP ${response2.status}`);
}
const blob2 = await response2.blob();
const blobUrl2 = URL.createObjectURL(blob2);
newUrls[currentPage + 1] = blobUrl2;
}
setImageBlobUrls(newUrls);
} catch (error) {
logger.error({ err: error }, "Error reloading images:");
throw error;
} }
},
setImageBlobUrls(newUrls); [imageBlobUrls, getPageUrl]
} catch (error) { );
logger.error({ err: error }, 'Error reloading images:');
throw error;
}
}, [imageBlobUrls, getPageUrl]);
// Cleanup blob URLs on unmount only // Cleanup blob URLs on unmount only
useEffect(() => { useEffect(() => {
return () => { return () => {
Object.values(imageBlobUrlsRef.current).forEach(url => { Object.values(imageBlobUrlsRef.current).forEach((url) => {
if (url) URL.revokeObjectURL(url); if (url) URL.revokeObjectURL(url);
}); });
}; };

View File

@@ -100,7 +100,15 @@ export function usePageNavigation({
} }
const step = isDoublePage && shouldShowDoublePage(currentPage) ? 2 : 1; const step = isDoublePage && shouldShowDoublePage(currentPage) ? 2 : 1;
navigateToPage(Math.min(pages.length, currentPage + step)); navigateToPage(Math.min(pages.length, currentPage + step));
}, [currentPage, pages.length, isDoublePage, shouldShowDoublePage, navigateToPage, nextBook, router]); }, [
currentPage,
pages.length,
isDoublePage,
shouldShowDoublePage,
navigateToPage,
nextBook,
router,
]);
// Cleanup - Sync final sans debounce // Cleanup - Sync final sans debounce
useEffect(() => { useEffect(() => {
@@ -122,4 +130,4 @@ export function usePageNavigation({
handlePreviousPage, handlePreviousPage,
handleNextPage, handleNextPage,
}; };
} }

View File

@@ -19,12 +19,14 @@ export function usePhotoSwipeZoom({
const dims = loadedImages[currentPage]; const dims = loadedImages[currentPage];
if (!dims) return; if (!dims) return;
const dataSource = [{ const dataSource = [
src: getPageUrl(currentPage), {
width: dims.width, src: getPageUrl(currentPage),
height: dims.height, width: dims.width,
alt: `Page ${currentPage}` height: dims.height,
}]; alt: `Page ${currentPage}`,
},
];
// Close any existing instance // Close any existing instance
if (pswpRef.current) { if (pswpRef.current) {
@@ -36,12 +38,12 @@ export function usePhotoSwipeZoom({
dataSource, dataSource,
index: 0, index: 0,
bgOpacity: 0.9, bgOpacity: 0.9,
showHideAnimationType: 'fade', showHideAnimationType: "fade",
initialZoomLevel: 0.25, initialZoomLevel: 0.25,
secondaryZoomLevel: 0.5, // Niveau de zoom au double-clic secondaryZoomLevel: 0.5, // Niveau de zoom au double-clic
maxZoomLevel: 4, maxZoomLevel: 4,
clickToCloseNonZoomable: true, // Ferme au clic simple clickToCloseNonZoomable: true, // Ferme au clic simple
tapAction: 'zoom', // Ferme au tap tapAction: "zoom", // Ferme au tap
wheelToZoom: true, wheelToZoom: true,
pinchToClose: false, // Pinch pour fermer pinchToClose: false, // Pinch pour fermer
closeOnVerticalDrag: true, // Swipe vertical pour fermer closeOnVerticalDrag: true, // Swipe vertical pour fermer
@@ -53,7 +55,7 @@ export function usePhotoSwipeZoom({
pswp.init(); pswp.init();
// Clean up on close // Clean up on close
pswp.on('close', () => { pswp.on("close", () => {
pswpRef.current = null; pswpRef.current = null;
}); });
}, [loadedImages, currentPage, getPageUrl]); }, [loadedImages, currentPage, getPageUrl]);

View File

@@ -30,28 +30,31 @@ export function useTouchNavigation({
}, []); }, []);
// Touch handlers for swipe navigation // Touch handlers for swipe navigation
const handleTouchStart = useCallback((e: TouchEvent) => { const handleTouchStart = useCallback(
// Ne pas gérer si Photoswipe est ouvert (e: TouchEvent) => {
if (pswpRef.current) return; // Ne pas gérer si Photoswipe est ouvert
// Ne pas gérer si la page est zoomée (zoom natif) if (pswpRef.current) return;
if (isZoomed()) return; // Ne pas gérer si la page est zoomée (zoom natif)
if (isZoomed()) return;
// Détecter si c'est un pinch (2+ doigts)
if (e.touches.length > 1) { // Détecter si c'est un pinch (2+ doigts)
isPinchingRef.current = true; if (e.touches.length > 1) {
touchStartXRef.current = null; isPinchingRef.current = true;
touchStartYRef.current = null; touchStartXRef.current = null;
return; touchStartYRef.current = null;
} return;
}
// Un seul doigt - seulement si on n'était pas en train de pinch
// On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt // Un seul doigt - seulement si on n'était pas en train de pinch
if (e.touches.length === 1) { // On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt
isPinchingRef.current = false; if (e.touches.length === 1) {
touchStartXRef.current = e.touches[0].clientX; isPinchingRef.current = false;
touchStartYRef.current = e.touches[0].clientY; touchStartXRef.current = e.touches[0].clientX;
} touchStartYRef.current = e.touches[0].clientY;
}, [pswpRef, isZoomed]); }
},
[pswpRef, isZoomed]
);
const handleTouchMove = useCallback((e: TouchEvent) => { const handleTouchMove = useCallback((e: TouchEvent) => {
// Détecter le pinch pendant le mouvement // Détecter le pinch pendant le mouvement
@@ -62,63 +65,66 @@ export function useTouchNavigation({
} }
}, []); }, []);
const handleTouchEnd = useCallback((e: TouchEvent) => { const handleTouchEnd = useCallback(
// Si on était en mode pinch, ne JAMAIS traiter le swipe (e: TouchEvent) => {
if (isPinchingRef.current) { // Si on était en mode pinch, ne JAMAIS traiter le swipe
touchStartXRef.current = null; if (isPinchingRef.current) {
touchStartYRef.current = null; touchStartXRef.current = null;
// Ne PAS réinitialiser isPinchingRef ici, on le fera au prochain touchstart touchStartYRef.current = null;
return; // Ne PAS réinitialiser isPinchingRef ici, on le fera au prochain touchstart
} return;
}
// Vérifier qu'on a bien des coordonnées de départ
if (touchStartXRef.current === null || touchStartYRef.current === null) return;
// Ne pas gérer si Photoswipe est ouvert
if (pswpRef.current) return;
// Ne pas gérer si la page est zoomée (zoom natif)
if (isZoomed()) return;
const touchEndX = e.changedTouches[0].clientX; // Vérifier qu'on a bien des coordonnées de départ
const touchEndY = e.changedTouches[0].clientY; if (touchStartXRef.current === null || touchStartYRef.current === null) return;
const deltaX = touchEndX - touchStartXRef.current; // Ne pas gérer si Photoswipe est ouvert
const deltaY = touchEndY - touchStartYRef.current; if (pswpRef.current) return;
// Ne pas gérer si la page est zoomée (zoom natif)
if (isZoomed()) return;
// Si le déplacement vertical est plus important, on ignore (scroll) const touchEndX = e.changedTouches[0].clientX;
if (Math.abs(deltaY) > Math.abs(deltaX)) { const touchEndY = e.changedTouches[0].clientY;
touchStartXRef.current = null; const deltaX = touchEndX - touchStartXRef.current;
touchStartYRef.current = null; const deltaY = touchEndY - touchStartYRef.current;
return;
}
// Seuil de 50px pour changer de page // Si le déplacement vertical est plus important, on ignore (scroll)
if (Math.abs(deltaX) > 50) { if (Math.abs(deltaY) > Math.abs(deltaX)) {
if (deltaX > 0) { touchStartXRef.current = null;
// Swipe vers la droite touchStartYRef.current = null;
if (isRTL) { return;
onNextPage(); }
// Seuil de 50px pour changer de page
if (Math.abs(deltaX) > 50) {
if (deltaX > 0) {
// Swipe vers la droite
if (isRTL) {
onNextPage();
} else {
onPreviousPage();
}
} else { } else {
onPreviousPage(); // Swipe vers la gauche
} if (isRTL) {
} else { onPreviousPage();
// Swipe vers la gauche } else {
if (isRTL) { onNextPage();
onPreviousPage(); }
} else {
onNextPage();
} }
} }
}
touchStartXRef.current = null; touchStartXRef.current = null;
touchStartYRef.current = null; touchStartYRef.current = null;
}, [onNextPage, onPreviousPage, isRTL, pswpRef, isZoomed]); },
[onNextPage, onPreviousPage, isRTL, pswpRef, isZoomed]
);
// Setup touch event listeners // Setup touch event listeners
useEffect(() => { useEffect(() => {
window.addEventListener("touchstart", handleTouchStart); window.addEventListener("touchstart", handleTouchStart);
window.addEventListener("touchmove", handleTouchMove); window.addEventListener("touchmove", handleTouchMove);
window.addEventListener("touchend", handleTouchEnd); window.addEventListener("touchend", handleTouchEnd);
return () => { return () => {
window.removeEventListener("touchstart", handleTouchStart); window.removeEventListener("touchstart", handleTouchStart);
window.removeEventListener("touchmove", handleTouchMove); window.removeEventListener("touchmove", handleTouchMove);

View File

@@ -48,8 +48,9 @@ function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) {
<BookCover <BookCover
book={book} book={book}
alt={t("books.coverAlt", { alt={t("books.coverAlt", {
title: book.metadata.title || title:
(book.metadata.number book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number }) ? t("navigation.volume", { number: book.metadata.number })
: ""), : ""),
})} })}

View File

@@ -75,10 +75,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
}; };
const statusInfo = getStatusInfo(); const statusInfo = getStatusInfo();
const title = book.metadata.title || const title =
(book.metadata.number book.metadata.title ||
? t("navigation.volume", { number: book.metadata.number }) (book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name);
: book.name);
if (isCompact) { if (isCompact) {
return ( return (
@@ -118,7 +117,12 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
> >
{title} {title}
</h3> </h3>
<span className={cn("px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}> <span
className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{statusInfo.label} {statusInfo.label}
</span> </span>
</div> </div>
@@ -137,9 +141,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{book.metadata.authors && book.metadata.authors.length > 0 && ( {book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex"> <div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
<span className="line-clamp-1"> <span className="line-clamp-1">{book.metadata.authors[0].name}</span>
{book.metadata.authors[0].name}
</span>
</div> </div>
)} )}
</div> </div>
@@ -192,9 +194,14 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
</p> </p>
)} )}
</div> </div>
{/* Badge de statut */} {/* Badge de statut */}
<span className={cn("px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}> <span
className={cn(
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{statusInfo.label} {statusInfo.label}
</span> </span>
</div> </div>
@@ -221,7 +228,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
<span className="line-clamp-1"> <span className="line-clamp-1">
{book.metadata.authors.map(a => a.name).join(", ")} {book.metadata.authors.map((a) => a.name).join(", ")}
</span> </span>
</div> </div>
)} )}
@@ -343,4 +350,3 @@ export function BookList({ books, onBookClick, isCompact = false }: BookListProp
</div> </div>
); );
} }

View File

@@ -37,26 +37,26 @@ export function PaginatedBookGrid({
const { isCompact, itemsPerPage, viewMode } = useDisplayPreferences(); const { isCompact, itemsPerPage, viewMode } = useDisplayPreferences();
const { t } = useTranslate(); const { t } = useTranslate();
const updateUrlParams = useCallback(async ( const updateUrlParams = useCallback(
updates: Record<string, string | null>, async (updates: Record<string, string | null>, replace: boolean = false) => {
replace: boolean = false const params = new URLSearchParams(searchParams.toString());
) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => { Object.entries(updates).forEach(([key, value]) => {
if (value === null) { if (value === null) {
params.delete(key); params.delete(key);
} else {
params.set(key, value);
}
});
if (replace) {
await router.replace(`${pathname}?${params.toString()}`);
} else { } else {
params.set(key, value); await router.push(`${pathname}?${params.toString()}`);
} }
}); },
[router, pathname, searchParams]
if (replace) { );
await router.replace(`${pathname}?${params.toString()}`);
} else {
await router.push(`${pathname}?${params.toString()}`);
}
}, [router, pathname, searchParams]);
// Update local state when prop changes // Update local state when prop changes
useEffect(() => { useEffect(() => {

View File

@@ -157,17 +157,18 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
{statusInfo.label} {statusInfo.label}
</StatusBadge> </StatusBadge>
<span className="text-sm text-white/80"> <span className="text-sm text-white/80">
{series.booksCount === 1 {series.booksCount === 1
? t("series.header.books", { count: series.booksCount }) ? t("series.header.books", { count: series.booksCount })
: t("series.header.books_plural", { count: series.booksCount }) : t("series.header.books_plural", { count: series.booksCount })}
}
</span> </span>
<IconButton <IconButton
variant="ghost" variant="ghost"
size="icon" size="icon"
icon={isFavorite ? Star : StarOff} icon={isFavorite ? Star : StarOff}
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
tooltip={t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add")} tooltip={t(
isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"
)}
className="text-white hover:text-white" className="text-white hover:text-white"
iconClassName={isFavorite ? "fill-yellow-400 text-yellow-400" : ""} iconClassName={isFavorite ? "fill-yellow-400 text-yellow-400" : ""}
/> />

View File

@@ -116,9 +116,7 @@ export function AdvancedSettings() {
<Shield className="h-5 w-5 text-primary" /> <Shield className="h-5 w-5 text-primary" />
<CardTitle className="text-lg">{t("settings.advanced.circuitBreaker.title")}</CardTitle> <CardTitle className="text-lg">{t("settings.advanced.circuitBreaker.title")}</CardTitle>
</div> </div>
<CardDescription> <CardDescription>{t("settings.advanced.circuitBreaker.description")}</CardDescription>
{t("settings.advanced.circuitBreaker.description")}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<SliderControl <SliderControl

View File

@@ -147,7 +147,6 @@ export function BackgroundSettings() {
} }
}; };
const handleLibraryToggle = async (libraryId: string) => { const handleLibraryToggle = async (libraryId: string) => {
const newSelection = selectedLibraries.includes(libraryId) const newSelection = selectedLibraries.includes(libraryId)
? selectedLibraries.filter((id) => id !== libraryId) ? selectedLibraries.filter((id) => id !== libraryId)
@@ -174,7 +173,6 @@ export function BackgroundSettings() {
<CardDescription>{t("settings.background.description")}</CardDescription> <CardDescription>{t("settings.background.description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-6"> <div className="space-y-6">
{/* Type de background */} {/* Type de background */}
<div className="space-y-3"> <div className="space-y-3">
@@ -258,7 +256,9 @@ export function BackgroundSettings() {
onChange={(e) => setCustomImageUrl(e.target.value)} onChange={(e) => setCustomImageUrl(e.target.value)}
className="flex-1" className="flex-1"
/> />
<Button onClick={handleCustomImageSave}>{t("settings.background.image.save")}</Button> <Button onClick={handleCustomImageSave}>
{t("settings.background.image.save")}
</Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("settings.background.image.description")} {t("settings.background.image.description")}
@@ -326,4 +326,3 @@ export function BackgroundSettings() {
</Card> </Card>
); );
} }

View File

@@ -129,7 +129,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if (response) { if (response) {
const blob = await response.clone().blob(); const blob = await response.clone().blob();
totalSize += blob.size; totalSize += blob.size;
// Calculer la taille du cache API séparément // Calculer la taille du cache API séparément
if (cacheName.includes("api")) { if (cacheName.includes("api")) {
apiSize += blob.size; apiSize += blob.size;
@@ -214,19 +214,24 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
const path = urlObj.pathname; const path = urlObj.pathname;
const segments = path.split('/').filter(Boolean); const segments = path.split("/").filter(Boolean);
if (segments.length === 0) return '/'; if (segments.length === 0) return "/";
// Pour /api/komga/images, grouper par type (series/books) // Pour /api/komga/images, grouper par type (series/books)
if (segments[0] === 'api' && segments[1] === 'komga' && segments[2] === 'images' && segments[3]) { if (
segments[0] === "api" &&
segments[1] === "komga" &&
segments[2] === "images" &&
segments[3]
) {
return `/${segments[0]}/${segments[1]}/${segments[2]}/${segments[3]}`; return `/${segments[0]}/${segments[1]}/${segments[2]}/${segments[3]}`;
} }
// Pour les autres, garder juste le premier segment // Pour les autres, garder juste le premier segment
return `/${segments[0]}`; return `/${segments[0]}`;
} catch { } catch {
return 'Autres'; return "Autres";
} }
}; };
@@ -255,8 +260,8 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
// Trier par date (le plus récent en premier) basé sur le paramètre v // Trier par date (le plus récent en premier) basé sur le paramètre v
Object.keys(grouped).forEach((key) => { Object.keys(grouped).forEach((key) => {
grouped[key].sort((a, b) => { grouped[key].sort((a, b) => {
const aVersion = new URL(a.url).searchParams.get('v') || '0'; const aVersion = new URL(a.url).searchParams.get("v") || "0";
const bVersion = new URL(b.url).searchParams.get('v') || '0'; const bVersion = new URL(b.url).searchParams.get("v") || "0";
return Number(bVersion) - Number(aVersion); return Number(bVersion) - Number(aVersion);
}); });
}); });
@@ -363,13 +368,13 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if ("serviceWorker" in navigator && "caches" in window) { if ("serviceWorker" in navigator && "caches" in window) {
const cacheNames = await caches.keys(); const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName))); await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
// Forcer la mise à jour du service worker // Forcer la mise à jour du service worker
const registrations = await navigator.serviceWorker.getRegistrations(); const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) { for (const registration of registrations) {
await registration.unregister(); await registration.unregister();
} }
toast({ toast({
title: t("settings.cache.title"), title: t("settings.cache.title"),
description: t("settings.cache.messages.serviceWorkerCleared"), description: t("settings.cache.messages.serviceWorkerCleared"),
@@ -383,7 +388,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if (showSwEntries) { if (showSwEntries) {
await fetchSwCacheEntries(); await fetchSwCacheEntries();
} }
// Recharger la page après 1 seconde pour réenregistrer le SW // Recharger la page après 1 seconde pour réenregistrer le SW
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
@@ -458,7 +463,6 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<CardDescription>{t("settings.cache.description")}</CardDescription> <CardDescription>{t("settings.cache.description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="cache-mode">{t("settings.cache.mode.label")}</Label> <Label htmlFor="cache-mode">{t("settings.cache.mode.label")}</Label>
@@ -488,7 +492,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div> <div className="text-sm text-muted-foreground">
{t("settings.cache.size.error")}
</div>
)} )}
</div> </div>
@@ -497,7 +503,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
{swCacheSize !== null ? ( {swCacheSize !== null ? (
<div className="text-sm text-muted-foreground">{formatBytes(swCacheSize)}</div> <div className="text-sm text-muted-foreground">{formatBytes(swCacheSize)}</div>
) : ( ) : (
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div> <div className="text-sm text-muted-foreground">
{t("settings.cache.size.error")}
</div>
)} )}
</div> </div>
@@ -506,7 +514,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
{apiCacheSize !== null ? ( {apiCacheSize !== null ? (
<div className="text-sm text-muted-foreground">{formatBytes(apiCacheSize)}</div> <div className="text-sm text-muted-foreground">{formatBytes(apiCacheSize)}</div>
) : ( ) : (
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div> <div className="text-sm text-muted-foreground">
{t("settings.cache.size.error")}
</div>
)} )}
</div> </div>
</div> </div>
@@ -525,11 +535,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<List className="h-4 w-4" /> <List className="h-4 w-4" />
{t("settings.cache.entries.serverTitle")} {t("settings.cache.entries.serverTitle")}
</span> </span>
{showEntries ? ( {showEntries ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button> </Button>
{showEntries && ( {showEntries && (
@@ -569,7 +575,10 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
> >
{getTimeRemaining(entry.expiry)} {getTimeRemaining(entry.expiry)}
</div> </div>
<div className="text-muted-foreground/70" title={formatDate(entry.expiry)}> <div
className="text-muted-foreground/70"
title={formatDate(entry.expiry)}
>
{new Date(entry.expiry).toLocaleDateString()} {new Date(entry.expiry).toLocaleDateString()}
</div> </div>
</div> </div>
@@ -649,72 +658,90 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<div className="space-y-1 pl-2"> <div className="space-y-1 pl-2">
{(() => { {(() => {
const versionGroups = groupVersions(entries); const versionGroups = groupVersions(entries);
return Object.entries(versionGroups).map(([baseUrl, versions]) => { return Object.entries(versionGroups).map(
const hasMultipleVersions = versions.length > 1; ([baseUrl, versions]) => {
const isVersionExpanded = expandedVersions[baseUrl]; const hasMultipleVersions = versions.length > 1;
const totalSize = versions.reduce((sum, v) => sum + v.size, 0); const isVersionExpanded = expandedVersions[baseUrl];
const totalSize = versions.reduce(
(sum, v) => sum + v.size,
0
);
if (!hasMultipleVersions) { if (!hasMultipleVersions) {
const entry = versions[0]; const entry = versions[0];
return ( return (
<div key={baseUrl} className="py-1"> <div key={baseUrl} className="py-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate text-muted-foreground" title={entry.url}> <div
{entry.url.replace(/^https?:\/\/[^/]+/, "")} className="font-mono text-xs truncate text-muted-foreground"
title={entry.url}
>
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
</div>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{formatBytes(entry.size)}
</div> </div>
</div> </div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{formatBytes(entry.size)}
</div>
</div> </div>
);
}
return (
<div key={baseUrl} className="py-1">
<button
type="button"
onClick={() => toggleVersions(baseUrl)}
className="w-full flex items-start justify-between gap-2 hover:bg-muted/30 rounded p-1 -m-1 transition-colors"
>
<div className="flex-1 min-w-0 flex items-center gap-1">
{isVersionExpanded ? (
<ChevronDown className="h-3 w-3 flex-shrink-0" />
) : (
<ChevronUp className="h-3 w-3 flex-shrink-0" />
)}
<div
className="font-mono text-xs truncate text-muted-foreground"
title={baseUrl}
>
{baseUrl}
</div>
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-1.5 py-0.5 text-xs font-medium text-orange-600 dark:text-orange-400 flex-shrink-0">
{versions.length} versions
</span>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap font-medium">
{formatBytes(totalSize)}
</div>
</button>
{isVersionExpanded && (
<div className="pl-4 mt-1 space-y-1">
{versions.map((version, vIdx) => (
<div
key={vIdx}
className="py-0.5 flex items-start justify-between gap-2"
>
<div className="flex-1 min-w-0">
<div
className="font-mono text-xs truncate text-muted-foreground/70"
title={version.url}
>
{new URL(version.url).search ||
"(no version)"}
</div>
</div>
<div className="text-xs text-muted-foreground/70 whitespace-nowrap">
{formatBytes(version.size)}
</div>
</div>
))}
</div>
)}
</div> </div>
); );
} }
);
return (
<div key={baseUrl} className="py-1">
<button
type="button"
onClick={() => toggleVersions(baseUrl)}
className="w-full flex items-start justify-between gap-2 hover:bg-muted/30 rounded p-1 -m-1 transition-colors"
>
<div className="flex-1 min-w-0 flex items-center gap-1">
{isVersionExpanded ? (
<ChevronDown className="h-3 w-3 flex-shrink-0" />
) : (
<ChevronUp className="h-3 w-3 flex-shrink-0" />
)}
<div className="font-mono text-xs truncate text-muted-foreground" title={baseUrl}>
{baseUrl}
</div>
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-1.5 py-0.5 text-xs font-medium text-orange-600 dark:text-orange-400 flex-shrink-0">
{versions.length} versions
</span>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap font-medium">
{formatBytes(totalSize)}
</div>
</button>
{isVersionExpanded && (
<div className="pl-4 mt-1 space-y-1">
{versions.map((version, vIdx) => (
<div key={vIdx} className="py-0.5 flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate text-muted-foreground/70" title={version.url}>
{new URL(version.url).search || "(no version)"}
</div>
</div>
<div className="text-xs text-muted-foreground/70 whitespace-nowrap">
{formatBytes(version.size)}
</div>
</div>
))}
</div>
)}
</div>
);
});
})()} })()}
</div> </div>
)} )}
@@ -833,12 +860,24 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
onChange={handleTTLChange} onChange={handleTTLChange}
className="flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
> >
<option value="0">{t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}</option> <option value="0">
<option value="3600">{t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}</option> {t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}
<option value="86400">{t("settings.cache.ttl.imageCacheMaxAge.options.oneDay")}</option> </option>
<option value="604800">{t("settings.cache.ttl.imageCacheMaxAge.options.oneWeek")}</option> <option value="3600">
<option value="2592000">{t("settings.cache.ttl.imageCacheMaxAge.options.oneMonth")}</option> {t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}
<option value="31536000">{t("settings.cache.ttl.imageCacheMaxAge.options.oneYear")}</option> </option>
<option value="86400">
{t("settings.cache.ttl.imageCacheMaxAge.options.oneDay")}
</option>
<option value="604800">
{t("settings.cache.ttl.imageCacheMaxAge.options.oneWeek")}
</option>
<option value="2592000">
{t("settings.cache.ttl.imageCacheMaxAge.options.oneMonth")}
</option>
<option value="31536000">
{t("settings.cache.ttl.imageCacheMaxAge.options.oneYear")}
</option>
</select> </select>
</div> </div>
</div> </div>

View File

@@ -21,7 +21,7 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
return ( return (
<div className="container mx-auto px-4 py-8 space-y-6"> <div className="container mx-auto px-4 py-8 space-y-6">
<h1 className="text-3xl font-bold">{t("settings.title")}</h1> <h1 className="text-3xl font-bold">{t("settings.title")}</h1>
<Tabs defaultValue="display" className="w-full"> <Tabs defaultValue="display" className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="display" className="flex items-center gap-2"> <TabsTrigger value="display" className="flex items-center gap-2">

View File

@@ -155,7 +155,6 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
<CardDescription>{t("settings.komga.description")}</CardDescription> <CardDescription>{t("settings.komga.description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{!shouldShowForm ? ( {!shouldShowForm ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -8,16 +8,7 @@ interface OptimizedSkeletonProps {
} }
export function OptimizedSkeleton({ className, children }: OptimizedSkeletonProps) { export function OptimizedSkeleton({ className, children }: OptimizedSkeletonProps) {
return ( return <div className={cn("animate-pulse rounded-md bg-muted/50", className)}>{children}</div>;
<div
className={cn(
"animate-pulse rounded-md bg-muted/50",
className
)}
>
{children}
</div>
);
} }
export function HomePageSkeleton() { export function HomePageSkeleton() {

View File

@@ -13,9 +13,9 @@ interface ErrorMessageProps {
retryLabel?: string; retryLabel?: string;
} }
export const ErrorMessage = ({ export const ErrorMessage = ({
errorCode, errorCode,
error, error,
variant = "default", variant = "default",
onRetry, onRetry,
retryLabel, retryLabel,
@@ -37,12 +37,7 @@ export const ErrorMessage = ({
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<p>{message}</p> <p>{message}</p>
{onRetry && ( {onRetry && (
<Button <Button onClick={onRetry} variant="ghost" size="sm" className="ml-auto">
onClick={onRetry}
variant="ghost"
size="sm"
className="ml-auto"
>
<RefreshCw className="h-3 w-3" /> <RefreshCw className="h-3 w-3" />
</Button> </Button>
)} )}
@@ -68,11 +63,11 @@ export const ErrorMessage = ({
{t("errors.GENERIC_ERROR")} {t("errors.GENERIC_ERROR")}
</h3> </h3>
<p className="text-sm text-destructive/90 dark:text-red-300/90">{message}</p> <p className="text-sm text-destructive/90 dark:text-red-300/90">{message}</p>
{onRetry && ( {onRetry && (
<Button <Button
onClick={onRetry} onClick={onRetry}
variant="outline" variant="outline"
size="sm" size="sm"
className="mt-4 border-destructive/30 hover:bg-destructive/10" className="mt-4 border-destructive/30 hover:bg-destructive/10"
> >

View File

@@ -24,13 +24,10 @@ const badgeVariants = cva(
); );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
} }
export { Badge, badgeVariants }; export { Badge, badgeVariants };

View File

@@ -138,8 +138,8 @@ export function BookCover({
{showOverlay && overlayVariant === "default" && ( {showOverlay && overlayVariant === "default" && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200"> <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
<p className="text-sm font-medium text-white text-left line-clamp-2"> <p className="text-sm font-medium text-white text-left line-clamp-2">
{book.metadata.title || {book.metadata.title ||
(book.metadata.number (book.metadata.number
? t("navigation.volume", { number: book.metadata.number }) ? t("navigation.volume", { number: book.metadata.number })
: "")} : "")}
</p> </p>
@@ -155,8 +155,8 @@ export function BookCover({
{showOverlay && overlayVariant === "home" && ( {showOverlay && overlayVariant === "home" && (
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3"> <div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
<h3 className="font-medium text-sm text-white line-clamp-2"> <h3 className="font-medium text-sm text-white line-clamp-2">
{book.metadata.title || {book.metadata.title ||
(book.metadata.number (book.metadata.number
? t("navigation.volume", { number: book.metadata.number }) ? t("navigation.volume", { number: book.metadata.number })
: "")} : "")}
</h3> </h3>

View File

@@ -273,8 +273,8 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
const buttonTitle = isLoading const buttonTitle = isLoading
? `Téléchargement en cours (${Math.round(downloadProgress)}%)` ? `Téléchargement en cours (${Math.round(downloadProgress)}%)`
: isAvailableOffline : isAvailableOffline
? "Supprimer hors ligne" ? "Supprimer hors ligne"
: "Disponible hors ligne"; : "Disponible hors ligne";
return ( return (
<Button <Button

View File

@@ -9,9 +9,12 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-primary/90 backdrop-blur-md text-primary-foreground hover:bg-primary/80", default: "bg-primary/90 backdrop-blur-md text-primary-foreground hover:bg-primary/80",
destructive: "bg-destructive/90 backdrop-blur-md text-destructive-foreground hover:bg-destructive/80", destructive:
outline: "border border-input bg-background/70 backdrop-blur-md hover:bg-accent/80 hover:text-accent-foreground", "bg-destructive/90 backdrop-blur-md text-destructive-foreground hover:bg-destructive/80",
secondary: "bg-secondary/80 backdrop-blur-md text-secondary-foreground hover:bg-secondary/70", outline:
"border border-input bg-background/70 backdrop-blur-md hover:bg-accent/80 hover:text-accent-foreground",
secondary:
"bg-secondary/80 backdrop-blur-md text-secondary-foreground hover:bg-secondary/70",
ghost: "hover:bg-accent/80 hover:backdrop-blur-md hover:text-accent-foreground", ghost: "hover:bg-accent/80 hover:backdrop-blur-md hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
@@ -30,8 +33,7 @@ const buttonVariants = cva(
); );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
} }

View File

@@ -6,7 +6,10 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm", className)} className={cn(
"rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm",
className
)}
{...props} {...props}
/> />
) )

View File

@@ -26,4 +26,3 @@ const Checkbox = React.forwardRef<
Checkbox.displayName = CheckboxPrimitive.Root.displayName; Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox }; export { Checkbox };

View File

@@ -24,8 +24,7 @@ const containerVariants = cva("mx-auto px-2 sm:px-6 lg:px-8", {
}); });
export interface ContainerProps export interface ContainerProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof containerVariants> {
VariantProps<typeof containerVariants> {
as?: React.ElementType; as?: React.ElementType;
} }
@@ -44,4 +43,3 @@ const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
Container.displayName = "Container"; Container.displayName = "Container";
export { Container, containerVariants }; export { Container, containerVariants };

View File

@@ -56,7 +56,7 @@ export const CoverClient = ({
const timer = setTimeout(() => { const timer = setTimeout(() => {
setImageError(false); setImageError(false);
setIsLoading(true); setIsLoading(true);
setRetryCount(prev => prev + 1); setRetryCount((prev) => prev + 1);
}, 2000); }, 2000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
@@ -80,9 +80,10 @@ export const CoverClient = ({
}; };
// Ajouter un timestamp pour forcer le rechargement en cas de retry // Ajouter un timestamp pour forcer le rechargement en cas de retry
const imageUrlWithRetry = retryCount > 0 const imageUrlWithRetry =
? `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}retry=${retryCount}` retryCount > 0
: imageUrl; ? `${imageUrl}${imageUrl.includes("?") ? "&" : "?"}retry=${retryCount}`
: imageUrl;
if (imageError) { if (imageError) {
return ( return (

View File

@@ -30,4 +30,3 @@ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
IconButton.displayName = "IconButton"; IconButton.displayName = "IconButton";
export { IconButton }; export { IconButton };

View File

@@ -8,18 +8,12 @@ const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
); );
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement>, interface LabelProps
VariantProps<typeof labelVariants> {} extends React.LabelHTMLAttributes<HTMLLabelElement>, VariantProps<typeof labelVariants> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>( const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => (
({ className, ...props }, ref) => ( <label ref={ref} className={cn(labelVariants(), className)} {...props} />
<label ));
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
)
);
Label.displayName = "Label"; Label.displayName = "Label";
export { Label }; export { Label };

Some files were not shown because too many files have changed in this diff Show More