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
@@ -232,6 +262,7 @@ CACHE_DEBUG=true
``` ```
**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,6 +46,7 @@ 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) => {
@@ -58,6 +61,7 @@ const isImageResource = (url) => {
``` ```
**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(
@@ -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,
@@ -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
@@ -457,6 +502,7 @@ 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",
@@ -466,11 +512,13 @@ 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

@@ -22,8 +22,10 @@ async function checkDatabase() {
}); });
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
@@ -35,7 +37,6 @@ async function checkDatabase() {
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

@@ -72,4 +72,3 @@ async function main() {
} }
main(); main();

View File

@@ -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,7 +4,7 @@ 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 {

View File

@@ -14,8 +14,12 @@ export async function GET() {
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 }
); );
@@ -40,10 +37,16 @@ export async function PUT(
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);
@@ -29,9 +26,14 @@ export async function PATCH(
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,
} }
); );
} }
@@ -59,10 +61,16 @@ export async function DELETE(
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

@@ -14,8 +14,12 @@ export async function GET() {
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

@@ -25,4 +25,3 @@ export async function GET() {
); );
} }
} }

View File

@@ -13,7 +13,7 @@ export async function GET() {
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

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

View File

@@ -43,4 +43,3 @@ export async function POST(
); );
} }
} }

View File

@@ -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) {
@@ -98,4 +98,3 @@ export async function DELETE(
); );
} }
} }

View File

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

View File

@@ -23,15 +23,15 @@ export async function GET(
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) {
@@ -70,7 +70,7 @@ export async function DELETE(
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 }
); );
@@ -36,8 +34,12 @@ export async function PUT(request: NextRequest) {
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

@@ -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

@@ -60,7 +60,7 @@ export function ClientLibraryPage({
} }
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) {
@@ -86,7 +86,7 @@ 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) {
@@ -105,7 +105,7 @@ export function ClientLibraryPage({
} }
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) {
@@ -139,7 +139,7 @@ export function ClientLibraryPage({
} }
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) {

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,7 +42,7 @@ 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`,
@@ -53,14 +55,13 @@ export function PullToRefreshIndicator({
<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,
@@ -73,10 +74,16 @@ export function PullToRefreshIndicator({
<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,7 +23,7 @@ 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) {
@@ -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

@@ -83,9 +83,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
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();
@@ -137,7 +142,7 @@ 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" ||
@@ -149,15 +154,14 @@ 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
className="fixed inset-0 -z-10"
style={backgroundStyle}
/>
)}
<div <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

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,7 +16,12 @@ 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
@@ -25,9 +30,10 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
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]);
@@ -81,8 +87,7 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
<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} />
@@ -90,9 +95,7 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
</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

@@ -43,26 +43,26 @@ export function PaginatedSeriesGrid({
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>
@@ -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>
@@ -148,7 +150,12 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
</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>
@@ -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

@@ -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 {
loadedImages,
imageBlobUrls,
prefetchPages,
prefetchNextBook,
handleForceReload,
getPageUrl,
prefetchCount,
} = useImageLoader({
bookId: book.id, bookId: book.id,
pages, pages,
prefetchCount: preferences.readerPrefetchCount, prefetchCount: preferences.readerPrefetchCount,
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null nextBook: nextBook ? { id: nextBook.id, pages: [] } : null,
});
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } = usePageNavigation({
book,
pages,
isDoublePage,
shouldShowDoublePage: (page) => shouldShowDoublePage(page, pages.length),
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,14 +67,13 @@ 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
@@ -74,7 +82,11 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
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);
} }
@@ -83,7 +95,16 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
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(() => {
@@ -115,37 +136,40 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
}; };
}, [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) { if (target.tagName === "IMG" && timeSinceLastClick < 300) {
// Double-clic sur une image // Double-clic sur une image
if (clickTimeoutRef.current) { if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current); clearTimeout(clickTimeoutRef.current);
clickTimeoutRef.current = null; 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) { if (hasDimensions && hasBlobUrl) {
return;
}
// 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 // Mark as pending
const img = new Image(); pendingFetchesRef.current.add(pageNum);
img.onload = () => {
setLoadedImages(prev => ({
...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight }
}));
// Store the blob URL for immediate use try {
setImageBlobUrls(prev => ({ // Use browser cache if available - the server sets Cache-Control headers
...prev, const response = await fetch(getPageUrl(pageNum), {
[pageNum]: blobUrl cache: "default", // Respect Cache-Control headers from server
})); });
}; if (!response.ok) {
img.src = blobUrl; return;
} catch { }
// Silently fail prefetch
} finally { const blob = await response.blob();
// Remove from pending set const blobUrl = URL.createObjectURL(blob);
pendingFetchesRef.current.delete(pageNum);
} // Create image to get dimensions
}, [getPageUrl]); const img = new Image();
img.onload = () => {
setLoadedImages((prev) => ({
...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++) { for (let i = 0; i < count; i++) {
const pageNum = startPage + i; const pageNum = startPage + i;
if (pageNum <= _pages.length) { if (pageNum <= _pages.length) {
const hasDimensions = loadedImagesRef.current[pageNum]; const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum]; const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
const isPending = pendingFetchesRef.current.has(pageNum); const isPending = pendingFetchesRef.current.has(pageNum);
// Prefetch if we don't have both dimensions AND blob URL AND it's not already pending // Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
if ((!hasDimensions || !hasBlobUrl) && !isPending) { if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push(pageNum); 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 });
} }
}
// Let all prefetch requests run - server queue handles concurrency const pagesToPrefetch = [];
if (pagesToPrefetch.length > 0) {
Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
// Mark as pending
pendingFetchesRef.current.add(nextBookPageKey);
try { for (let i = 0; i < count; i++) {
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, { const pageNum = i + 1; // Pages du livre suivant commencent à 1
cache: 'default', // Respect Cache-Control headers from server // Pour le livre suivant, on utilise une clé différente pour éviter les conflits
}); const nextBookPageKey = `next-${pageNum}`;
if (!response.ok) { const hasDimensions = loadedImagesRef.current[nextBookPageKey];
return; const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
} const isPending = pendingFetchesRef.current.has(nextBookPageKey);
const blob = await response.blob(); if ((!hasDimensions || !hasBlobUrl) && !isPending) {
const blobUrl = URL.createObjectURL(blob); pagesToPrefetch.push({ pageNum, nextBookPageKey });
// 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 if (imageBlobUrls[currentPage + 1]) {
const response1 = await fetch(getPageUrl(currentPage), { URL.revokeObjectURL(imageBlobUrls[currentPage + 1]);
cache: 'reload',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
if (!response1.ok) {
throw new Error(`HTTP ${response1.status}`);
} }
const blob1 = await response1.blob(); try {
const blobUrl1 = URL.createObjectURL(blob1); // Fetch page 1 avec cache: reload
const response1 = await fetch(getPageUrl(currentPage), {
const newUrls: Record<number, string> = { cache: "reload",
...imageBlobUrls,
[currentPage]: blobUrl1
};
// 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;
}
setImageBlobUrls(newUrls); const newUrls: Record<number, string> = {
} catch (error) { ...imageBlobUrls,
logger.error({ err: error }, 'Error reloading images:'); [currentPage]: blobUrl1,
throw error; };
}
}, [imageBlobUrls, getPageUrl]); // 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;
}
},
[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(() => {

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) // Détecter si c'est un pinch (2+ doigts)
if (e.touches.length > 1) { if (e.touches.length > 1) {
isPinchingRef.current = true; isPinchingRef.current = true;
touchStartXRef.current = null; touchStartXRef.current = null;
touchStartYRef.current = null; touchStartYRef.current = null;
return; return;
} }
// Un seul doigt - seulement si on n'était pas en train de pinch // 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 // On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt
if (e.touches.length === 1) { if (e.touches.length === 1) {
isPinchingRef.current = false; isPinchingRef.current = false;
touchStartXRef.current = e.touches[0].clientX; touchStartXRef.current = e.touches[0].clientX;
touchStartYRef.current = e.touches[0].clientY; 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,56 +65,59 @@ 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 // Vérifier qu'on a bien des coordonnées de départ
if (touchStartXRef.current === null || touchStartYRef.current === null) return; if (touchStartXRef.current === null || touchStartYRef.current === null) return;
// Ne pas gérer si Photoswipe est ouvert // Ne pas gérer si Photoswipe est ouvert
if (pswpRef.current) return; if (pswpRef.current) return;
// Ne pas gérer si la page est zoomée (zoom natif) // Ne pas gérer si la page est zoomée (zoom natif)
if (isZoomed()) return; if (isZoomed()) return;
const touchEndX = e.changedTouches[0].clientX; const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY; const touchEndY = e.changedTouches[0].clientY;
const deltaX = touchEndX - touchStartXRef.current; const deltaX = touchEndX - touchStartXRef.current;
const deltaY = touchEndY - touchStartYRef.current; const deltaY = touchEndY - touchStartYRef.current;
// Si le déplacement vertical est plus important, on ignore (scroll) // Si le déplacement vertical est plus important, on ignore (scroll)
if (Math.abs(deltaY) > Math.abs(deltaX)) { if (Math.abs(deltaY) > Math.abs(deltaX)) {
touchStartXRef.current = null; touchStartXRef.current = null;
touchStartYRef.current = null; touchStartYRef.current = null;
return; return;
} }
// Seuil de 50px pour changer de page // Seuil de 50px pour changer de page
if (Math.abs(deltaX) > 50) { if (Math.abs(deltaX) > 50) {
if (deltaX > 0) { if (deltaX > 0) {
// Swipe vers la droite // Swipe vers la droite
if (isRTL) { if (isRTL) {
onNextPage(); 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(() => {

View File

@@ -48,7 +48,8 @@ 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.title ||
(book.metadata.number (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>
@@ -194,7 +196,12 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
</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

@@ -159,15 +159,16 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
<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

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

@@ -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

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

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

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

View File

@@ -25,9 +25,7 @@ const NavButton = React.forwardRef<HTMLButtonElement, NavButtonProps>(
<Icon className="mr-2 h-4 w-4" /> <Icon className="mr-2 h-4 w-4" />
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</div> </div>
{count !== undefined && ( {count !== undefined && <span className="text-xs text-muted-foreground">{count}</span>}
<span className="text-xs text-muted-foreground">{count}</span>
)}
</button> </button>
); );
} }
@@ -36,4 +34,3 @@ const NavButton = React.forwardRef<HTMLButtonElement, NavButtonProps>(
NavButton.displayName = "NavButton"; NavButton.displayName = "NavButton";
export { NavButton }; export { NavButton };

View File

@@ -7,9 +7,10 @@ interface ProgressBarProps {
export function ProgressBar({ progress, total, type }: ProgressBarProps) { export function ProgressBar({ progress, total, type }: ProgressBarProps) {
const percentage = Math.round((progress / total) * 100); const percentage = Math.round((progress / total) * 100);
const barColor = type === "series" const barColor =
? "bg-gradient-to-r from-purple-500 to-pink-500" type === "series"
: "bg-gradient-to-r from-blue-500 to-cyan-500"; ? "bg-gradient-to-r from-purple-500 to-pink-500"
: "bg-gradient-to-r from-blue-500 to-cyan-500";
return ( return (
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-black/70 backdrop-blur-md border-t border-white/10"> <div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-black/70 backdrop-blur-md border-t border-white/10">
<div className="h-2 bg-white/30 rounded-full overflow-hidden"> <div className="h-2 bg-white/30 rounded-full overflow-hidden">

View File

@@ -10,9 +10,7 @@ const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>, React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
return ( return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />
);
}); });
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
@@ -38,4 +36,3 @@ const RadioGroupItem = React.forwardRef<
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem }; export { RadioGroup, RadioGroupItem };

View File

@@ -76,10 +76,7 @@ const ScrollContainer = React.forwardRef<HTMLDivElement, ScrollContainerProps>(
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
onScroll={handleScroll} onScroll={handleScroll}
className={cn( className={cn("flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-4", className)}
"flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-4",
className
)}
{...props} {...props}
> >
{children} {children}
@@ -102,4 +99,3 @@ const ScrollContainer = React.forwardRef<HTMLDivElement, ScrollContainerProps>(
ScrollContainer.displayName = "ScrollContainer"; ScrollContainer.displayName = "ScrollContainer";
export { ScrollContainer }; export { ScrollContainer };

View File

@@ -42,4 +42,3 @@ const Section = React.forwardRef<HTMLElement, SectionProps>(
Section.displayName = "Section"; Section.displayName = "Section";
export { Section }; export { Section };

View File

@@ -24,4 +24,3 @@ const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
Separator.displayName = "Separator"; Separator.displayName = "Separator";
export { Separator }; export { Separator };

View File

@@ -1,16 +1,7 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Skeleton({ function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
className, return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
} }
export { Skeleton }; export { Skeleton };

View File

@@ -69,10 +69,7 @@ export function SliderControl({
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
{description && ( {description && <p className="text-xs text-muted-foreground">{description}</p>}
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div> </div>
); );
} }

View File

@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider" import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Slider = React.forwardRef< const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>, React.ElementRef<typeof SliderPrimitive.Root>,
@@ -11,11 +11,8 @@ const Slider = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SliderPrimitive.Root <SliderPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn("relative flex w-full touch-auto select-none items-center", className)}
"relative flex w-full touch-auto select-none items-center", style={{ touchAction: "pan-x" }}
className
)}
style={{ touchAction: 'pan-x' }}
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-secondary"> <SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-secondary">
@@ -23,8 +20,7 @@ const Slider = React.forwardRef<
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-6 w-6 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:scale-110 active:scale-105 touch-manipulation cursor-pointer" /> <SliderPrimitive.Thumb className="block h-6 w-6 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:scale-110 active:scale-105 touch-manipulation cursor-pointer" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)) ));
Slider.displayName = SliderPrimitive.Root.displayName Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider }
export { Slider };

View File

@@ -21,19 +21,14 @@ const statusBadgeVariants = cva("flex items-center gap-1", {
}); });
export interface StatusBadgeProps export interface StatusBadgeProps
extends Omit<BadgeProps, "variant">, extends Omit<BadgeProps, "variant">, VariantProps<typeof statusBadgeVariants> {
VariantProps<typeof statusBadgeVariants> {
icon?: LucideIcon; icon?: LucideIcon;
children: React.ReactNode; children: React.ReactNode;
} }
const StatusBadge = ({ status, icon: Icon, children, className, ...props }: StatusBadgeProps) => { const StatusBadge = ({ status, icon: Icon, children, className, ...props }: StatusBadgeProps) => {
return ( return (
<Badge <Badge variant="outline" className={cn(statusBadgeVariants({ status }), className)} {...props}>
variant="outline"
className={cn(statusBadgeVariants({ status }), className)}
{...props}
>
{Icon && <Icon className="w-4 h-4" />} {Icon && <Icon className="w-4 h-4" />}
{children} {children}
</Badge> </Badge>
@@ -41,4 +36,3 @@ const StatusBadge = ({ status, icon: Icon, children, className, ...props }: Stat
}; };
export { StatusBadge, statusBadgeVariants }; export { StatusBadge, statusBadgeVariants };

View File

@@ -3,8 +3,10 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface SwitchProps interface SwitchProps extends Omit<
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "role" | "aria-checked"> { React.InputHTMLAttributes<HTMLInputElement>,
"type" | "role" | "aria-checked"
> {
onCheckedChange?: (checked: boolean) => void; onCheckedChange?: (checked: boolean) => void;
} }

View File

@@ -5,11 +5,7 @@ import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>( const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto"> <div className="relative w-full overflow-auto">
<table <table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div> </div>
) )
); );
@@ -37,7 +33,10 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tfoot <tfoot
ref={ref} ref={ref}
className={cn("border-t bg-muted/50 backdrop-blur-md font-medium [&>tr]:last:border-b-0", className)} className={cn(
"border-t bg-muted/50 backdrop-blur-md font-medium [&>tr]:last:border-b-0",
className
)}
{...props} {...props}
/> />
)); ));
@@ -93,4 +92,3 @@ const TableCaption = React.forwardRef<
TableCaption.displayName = "TableCaption"; TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View File

@@ -27,7 +27,8 @@ const toastVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: "border border-border/40 bg-background/70 backdrop-blur-md text-foreground shadow-lg", default:
"border border-border/40 bg-background/70 backdrop-blur-md text-foreground shadow-lg",
destructive: destructive:
"destructive group border-destructive/20 bg-destructive/70 backdrop-blur-md text-destructive-foreground font-medium", "destructive group border-destructive/20 bg-destructive/70 backdrop-blur-md text-destructive-foreground font-medium",
}, },

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