chore: update various components and services for improved functionality and consistency, including formatting adjustments and minor refactors
This commit is contained in:
3
ENV.md
3
ENV.md
@@ -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`.
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ CACHE_DEBUG=true
|
|||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
#### Développement (docker-compose.dev.yml)
|
#### Développement (docker-compose.dev.yml)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- CACHE_DEBUG=true
|
- CACHE_DEBUG=true
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Production (.env)
|
#### Production (.env)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
CACHE_DEBUG=true
|
CACHE_DEBUG=true
|
||||||
```
|
```
|
||||||
@@ -30,49 +32,61 @@ CACHE_DEBUG=true
|
|||||||
Les logs de cache apparaissent dans la console serveur avec le format suivant :
|
Les logs de cache apparaissent dans la console serveur avec le format suivant :
|
||||||
|
|
||||||
### Cache HIT (donnée valide)
|
### Cache HIT (donnée valide)
|
||||||
|
|
||||||
```
|
```
|
||||||
[CACHE HIT] home-ongoing | HOME | 0.45ms
|
[CACHE HIT] home-ongoing | HOME | 0.45ms
|
||||||
```
|
```
|
||||||
|
|
||||||
- ✅ Donnée trouvée en cache
|
- ✅ Donnée trouvée en cache
|
||||||
- ✅ Donnée encore valide (pas expirée)
|
- ✅ Donnée encore valide (pas expirée)
|
||||||
- ⚡ Retour immédiat (très rapide)
|
- ⚡ Retour immédiat (très rapide)
|
||||||
|
|
||||||
### Cache STALE (donnée expirée)
|
### Cache STALE (donnée expirée)
|
||||||
|
|
||||||
```
|
```
|
||||||
[CACHE STALE] home-ongoing | HOME | 0.52ms
|
[CACHE STALE] home-ongoing | HOME | 0.52ms
|
||||||
```
|
```
|
||||||
|
|
||||||
- ✅ Donnée trouvée en cache
|
- ✅ Donnée trouvée en cache
|
||||||
- ⚠️ Donnée expirée mais toujours retournée
|
- ⚠️ Donnée expirée mais toujours retournée
|
||||||
- 🔄 Revalidation lancée en background
|
- 🔄 Revalidation lancée en background
|
||||||
|
|
||||||
### Cache MISS (pas de donnée)
|
### Cache MISS (pas de donnée)
|
||||||
|
|
||||||
```
|
```
|
||||||
[CACHE MISS] home-ongoing | HOME
|
[CACHE MISS] home-ongoing | HOME
|
||||||
```
|
```
|
||||||
|
|
||||||
- ❌ Aucune donnée en cache
|
- ❌ Aucune donnée en cache
|
||||||
- 🌐 Fetch normal depuis Komga
|
- 🌐 Fetch normal depuis Komga
|
||||||
- 💾 Mise en cache automatique
|
- 💾 Mise en cache automatique
|
||||||
|
|
||||||
### Cache SET (mise en cache)
|
### Cache SET (mise en cache)
|
||||||
|
|
||||||
```
|
```
|
||||||
[CACHE SET] home-ongoing | HOME | 324.18ms
|
[CACHE SET] home-ongoing | HOME | 324.18ms
|
||||||
```
|
```
|
||||||
|
|
||||||
- 💾 Donnée mise en cache après fetch
|
- 💾 Donnée mise en cache après fetch
|
||||||
- 📊 Temps total incluant le fetch Komga
|
- 📊 Temps total incluant le fetch Komga
|
||||||
- ✅ Prochaines requêtes seront rapides
|
- ✅ Prochaines requêtes seront rapides
|
||||||
|
|
||||||
### Cache REVALIDATE (revalidation background)
|
### Cache REVALIDATE (revalidation background)
|
||||||
|
|
||||||
```
|
```
|
||||||
[CACHE REVALIDATE] home-ongoing | HOME | 287.45ms
|
[CACHE REVALIDATE] home-ongoing | HOME | 287.45ms
|
||||||
```
|
```
|
||||||
|
|
||||||
- 🔄 Revalidation en background (après STALE)
|
- 🔄 Revalidation en background (après STALE)
|
||||||
- 🌐 Nouvelle donnée fetched depuis Komga
|
- 🌐 Nouvelle donnée fetched depuis Komga
|
||||||
- 💾 Cache mis à jour pour les prochaines requêtes
|
- 💾 Cache mis à jour pour les prochaines requêtes
|
||||||
|
|
||||||
### Erreur de revalidation
|
### Erreur de revalidation
|
||||||
|
|
||||||
```
|
```
|
||||||
[CACHE REVALIDATE ERROR] home-ongoing: Error: ...
|
[CACHE REVALIDATE ERROR] home-ongoing: Error: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
- ❌ Échec de la revalidation background
|
- ❌ Échec de la revalidation background
|
||||||
- ⚠️ Cache ancien conservé
|
- ⚠️ Cache ancien conservé
|
||||||
- 🔄 Retry au prochain STALE
|
- 🔄 Retry au prochain STALE
|
||||||
@@ -81,14 +95,14 @@ Les logs de cache apparaissent dans la console serveur avec le format suivant :
|
|||||||
|
|
||||||
Les logs affichent le type de TTL utilisé :
|
Les logs affichent le type de TTL utilisé :
|
||||||
|
|
||||||
| Type | TTL | Usage |
|
| Type | TTL | Usage |
|
||||||
|------|-----|-------|
|
| ----------- | ------- | ------------------ |
|
||||||
| `DEFAULT` | 5 min | Données génériques |
|
| `DEFAULT` | 5 min | Données génériques |
|
||||||
| `HOME` | 10 min | Page d'accueil |
|
| `HOME` | 10 min | Page d'accueil |
|
||||||
| `LIBRARIES` | 24h | Bibliothèques |
|
| `LIBRARIES` | 24h | Bibliothèques |
|
||||||
| `SERIES` | 5 min | Séries |
|
| `SERIES` | 5 min | Séries |
|
||||||
| `BOOKS` | 5 min | Livres |
|
| `BOOKS` | 5 min | Livres |
|
||||||
| `IMAGES` | 7 jours | Images |
|
| `IMAGES` | 7 jours | Images |
|
||||||
|
|
||||||
## Exemple de session complète
|
## Exemple de session complète
|
||||||
|
|
||||||
@@ -113,22 +127,27 @@ Les logs affichent le type de TTL utilisé :
|
|||||||
### 1. DevTools du navigateur
|
### 1. DevTools du navigateur
|
||||||
|
|
||||||
#### Network Tab
|
#### Network Tab
|
||||||
|
|
||||||
- Temps de réponse < 50ms = probablement du cache serveur
|
- Temps de réponse < 50ms = probablement du cache serveur
|
||||||
- Headers `X-Cache` si configurés
|
- Headers `X-Cache` si configurés
|
||||||
- Onglet "Timing" pour détails
|
- Onglet "Timing" pour détails
|
||||||
|
|
||||||
#### Application → Cache Storage
|
#### Application → Cache Storage
|
||||||
|
|
||||||
Inspectez le cache du Service Worker :
|
Inspectez le cache du Service Worker :
|
||||||
|
|
||||||
- `stripstream-cache-v1` : Ressources statiques
|
- `stripstream-cache-v1` : Ressources statiques
|
||||||
- `stripstream-images-v1` : Images (covers + pages)
|
- `stripstream-images-v1` : Images (covers + pages)
|
||||||
|
|
||||||
Actions disponibles :
|
Actions disponibles :
|
||||||
|
|
||||||
- ✅ Voir le contenu de chaque cache
|
- ✅ Voir le contenu de chaque cache
|
||||||
- 🔍 Chercher une URL spécifique
|
- 🔍 Chercher une URL spécifique
|
||||||
- 🗑️ Supprimer des entrées
|
- 🗑️ Supprimer des entrées
|
||||||
- 🧹 Vider complètement un cache
|
- 🧹 Vider complètement un cache
|
||||||
|
|
||||||
#### Application → Service Workers
|
#### Application → Service Workers
|
||||||
|
|
||||||
- État du Service Worker
|
- État du Service Worker
|
||||||
- "Unregister" pour le désactiver
|
- "Unregister" pour le désactiver
|
||||||
- "Update" pour forcer une mise à jour
|
- "Update" pour forcer une mise à jour
|
||||||
@@ -137,10 +156,13 @@ Actions disponibles :
|
|||||||
### 2. API de monitoring
|
### 2. API de monitoring
|
||||||
|
|
||||||
#### Taille du cache
|
#### Taille du cache
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3000/api/komga/cache/size
|
curl http://localhost:3000/api/komga/cache/size
|
||||||
```
|
```
|
||||||
|
|
||||||
Response :
|
Response :
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"sizeInBytes": 15728640,
|
"sizeInBytes": 15728640,
|
||||||
@@ -149,10 +171,13 @@ Response :
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Mode actuel
|
#### Mode actuel
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3000/api/komga/cache/mode
|
curl http://localhost:3000/api/komga/cache/mode
|
||||||
```
|
```
|
||||||
|
|
||||||
Response :
|
Response :
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mode": "memory"
|
"mode": "memory"
|
||||||
@@ -160,11 +185,13 @@ Response :
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Vider le cache
|
#### Vider le cache
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3000/api/komga/cache/clear
|
curl -X POST http://localhost:3000/api/komga/cache/clear
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Changer de mode
|
#### Changer de mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3000/api/komga/cache/mode \
|
curl -X POST http://localhost:3000/api/komga/cache/mode \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
@@ -190,6 +217,7 @@ cat .cache/user-id/home-ongoing.json | jq
|
|||||||
```
|
```
|
||||||
|
|
||||||
Exemple de contenu :
|
Exemple de contenu :
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
@@ -206,6 +234,7 @@ Exemple de contenu :
|
|||||||
### Identifier un problème de cache
|
### Identifier un problème de cache
|
||||||
|
|
||||||
**Symptôme** : Les données ne se rafraîchissent pas
|
**Symptôme** : Les données ne se rafraîchissent pas
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Vérifier si STALE + REVALIDATE se produisent
|
# 1. Vérifier si STALE + REVALIDATE se produisent
|
||||||
CACHE_DEBUG=true
|
CACHE_DEBUG=true
|
||||||
@@ -222,6 +251,7 @@ CACHE_DEBUG=true
|
|||||||
### Optimiser les performances
|
### Optimiser les performances
|
||||||
|
|
||||||
**Objectif** : Identifier les requêtes lentes
|
**Objectif** : Identifier les requêtes lentes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Activer les logs
|
# Activer les logs
|
||||||
CACHE_DEBUG=true
|
CACHE_DEBUG=true
|
||||||
@@ -231,7 +261,8 @@ CACHE_DEBUG=true
|
|||||||
[CACHE SET] library-456-all-series | SERIES | 2847.32ms # ⚠️ Très lent !
|
[CACHE SET] library-456-all-series | SERIES | 2847.32ms # ⚠️ Très lent !
|
||||||
```
|
```
|
||||||
|
|
||||||
**Solution** :
|
**Solution** :
|
||||||
|
|
||||||
- Vérifier la taille des bibliothèques
|
- Vérifier la taille des bibliothèques
|
||||||
- Augmenter le TTL pour ces données
|
- Augmenter le TTL pour ces données
|
||||||
- Considérer la pagination
|
- Considérer la pagination
|
||||||
@@ -251,29 +282,33 @@ En mode `file` : les caches survivent au redémarrage
|
|||||||
|
|
||||||
### Temps de réponse normaux
|
### Temps de réponse normaux
|
||||||
|
|
||||||
| Scénario | Temps attendu | Log |
|
| Scénario | Temps attendu | Log |
|
||||||
|----------|---------------|-----|
|
| ----------------------- | ------------- | ------------------------------------ |
|
||||||
| Cache HIT | < 1ms | `[CACHE HIT] ... \| 0.45ms` |
|
| Cache HIT | < 1ms | `[CACHE HIT] ... \| 0.45ms` |
|
||||||
| Cache STALE | < 1ms | `[CACHE STALE] ... \| 0.52ms` |
|
| Cache STALE | < 1ms | `[CACHE STALE] ... \| 0.52ms` |
|
||||||
| Cache MISS (petit) | 50-200ms | `[CACHE SET] ... \| 124.18ms` |
|
| Cache MISS (petit) | 50-200ms | `[CACHE SET] ... \| 124.18ms` |
|
||||||
| Cache MISS (gros) | 200-1000ms | `[CACHE SET] ... \| 847.32ms` |
|
| Cache MISS (gros) | 200-1000ms | `[CACHE SET] ... \| 847.32ms` |
|
||||||
| Revalidate (background) | Variable | `[CACHE REVALIDATE] ... \| 287.45ms` |
|
| Revalidate (background) | Variable | `[CACHE REVALIDATE] ... \| 287.45ms` |
|
||||||
|
|
||||||
### Signaux d'alerte
|
### Signaux d'alerte
|
||||||
|
|
||||||
⚠️ **Cache HIT > 10ms**
|
⚠️ **Cache HIT > 10ms**
|
||||||
|
|
||||||
- Problème : Disque lent (mode file)
|
- Problème : Disque lent (mode file)
|
||||||
- Solution : Vérifier les I/O, passer en mode memory
|
- Solution : Vérifier les I/O, passer en mode memory
|
||||||
|
|
||||||
⚠️ **Cache MISS > 2000ms**
|
⚠️ **Cache MISS > 2000ms**
|
||||||
|
|
||||||
- Problème : Komga très lent ou données énormes
|
- Problème : Komga très lent ou données énormes
|
||||||
- Solution : Vérifier Komga, optimiser la requête
|
- Solution : Vérifier Komga, optimiser la requête
|
||||||
|
|
||||||
⚠️ **REVALIDATE ERROR fréquents**
|
⚠️ **REVALIDATE ERROR fréquents**
|
||||||
|
|
||||||
- Problème : Komga instable ou réseau
|
- Problème : Komga instable ou réseau
|
||||||
- Solution : Augmenter les timeouts, vérifier la connectivité
|
- Solution : Augmenter les timeouts, vérifier la connectivité
|
||||||
|
|
||||||
⚠️ **Trop de MISS successifs**
|
⚠️ **Trop de MISS successifs**
|
||||||
|
|
||||||
- Problème : Cache pas conservé ou TTL trop court
|
- Problème : Cache pas conservé ou TTL trop court
|
||||||
- Solution : Vérifier le mode, augmenter les TTL
|
- Solution : Vérifier le mode, augmenter les TTL
|
||||||
|
|
||||||
@@ -294,12 +329,14 @@ Les logs sont **automatiquement désactivés** si la variable n'est pas définie
|
|||||||
## Logs et performance
|
## Logs et performance
|
||||||
|
|
||||||
**Impact sur les performances** :
|
**Impact sur les performances** :
|
||||||
|
|
||||||
- Overhead : < 0.1ms par opération
|
- Overhead : < 0.1ms par opération
|
||||||
- Pas d'écriture disque (juste console)
|
- Pas d'écriture disque (juste console)
|
||||||
- Pas d'accumulation en mémoire
|
- Pas d'accumulation en mémoire
|
||||||
- Safe pour la production
|
- Safe pour la production
|
||||||
|
|
||||||
**Recommandations** :
|
**Recommandations** :
|
||||||
|
|
||||||
- ✅ Activé en développement
|
- ✅ Activé en développement
|
||||||
- ✅ Activé temporairement en production pour diagnostics
|
- ✅ Activé temporairement en production pour diagnostics
|
||||||
- ❌ Pas nécessaire en production normale
|
- ❌ Pas nécessaire en production normale
|
||||||
@@ -307,6 +344,7 @@ Les logs sont **automatiquement désactivés** si la variable n'est pas définie
|
|||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
Le système de logs de cache est conçu pour être :
|
Le système de logs de cache est conçu pour être :
|
||||||
|
|
||||||
- 🎯 **Simple** : Format clair et concis
|
- 🎯 **Simple** : Format clair et concis
|
||||||
- ⚡ **Rapide** : Impact négligeable sur les performances
|
- ⚡ **Rapide** : Impact négligeable sur les performances
|
||||||
- 🔧 **Utile** : Informations essentielles pour le debug
|
- 🔧 **Utile** : Informations essentielles pour le debug
|
||||||
@@ -314,4 +352,3 @@ Le système de logs de cache est conçu pour être :
|
|||||||
|
|
||||||
Pour la plupart des besoins de debug, les DevTools du navigateur suffisent.
|
Pour la plupart des besoins de debug, les DevTools du navigateur suffisent.
|
||||||
Les logs serveur sont utiles pour comprendre le comportement du cache côté backend.
|
Les logs serveur sont utiles pour comprendre le comportement du cache côté backend.
|
||||||
|
|
||||||
|
|||||||
117
docs/caching.md
117
docs/caching.md
@@ -34,9 +34,11 @@ Le système de caching est organisé en **3 couches indépendantes** avec des re
|
|||||||
## Couche 1 : Service Worker (Client)
|
## Couche 1 : Service Worker (Client)
|
||||||
|
|
||||||
### Fichier
|
### Fichier
|
||||||
|
|
||||||
`public/sw.js`
|
`public/sw.js`
|
||||||
|
|
||||||
### Responsabilité
|
### Responsabilité
|
||||||
|
|
||||||
- Support offline de l'application
|
- Support offline de l'application
|
||||||
- Cache persistant des images (couvertures et pages de livres)
|
- Cache persistant des images (couvertures et pages de livres)
|
||||||
- Cache des ressources statiques Next.js
|
- Cache des ressources statiques Next.js
|
||||||
@@ -44,20 +46,22 @@ Le système de caching est organisé en **3 couches indépendantes** avec des re
|
|||||||
### Stratégies
|
### Stratégies
|
||||||
|
|
||||||
#### Images : Cache-First
|
#### Images : Cache-First
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Pour toutes les images (covers + pages)
|
// Pour toutes les images (covers + pages)
|
||||||
const isImageResource = (url) => {
|
const isImageResource = (url) => {
|
||||||
return (
|
return (
|
||||||
(url.includes("/api/v1/books/") &&
|
(url.includes("/api/v1/books/") &&
|
||||||
(url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) ||
|
(url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) ||
|
||||||
(url.includes("/api/komga/images/") &&
|
(url.includes("/api/komga/images/") &&
|
||||||
(url.includes("/series/") || url.includes("/books/")) &&
|
(url.includes("/series/") || url.includes("/books/")) &&
|
||||||
url.includes("/thumbnail"))
|
url.includes("/thumbnail"))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
**Comportement** :
|
**Comportement** :
|
||||||
|
|
||||||
1. Vérifier si l'image est dans le cache
|
1. Vérifier si l'image est dans le cache
|
||||||
2. Si oui → retourner depuis le cache
|
2. Si oui → retourner depuis le cache
|
||||||
3. Si non → fetch depuis le réseau
|
3. Si non → fetch depuis le réseau
|
||||||
@@ -65,11 +69,13 @@ const isImageResource = (url) => {
|
|||||||
5. Si échec → retourner 404
|
5. Si échec → retourner 404
|
||||||
|
|
||||||
**Avantages** :
|
**Avantages** :
|
||||||
|
|
||||||
- Performance maximale (lecture instantanée depuis le cache)
|
- Performance maximale (lecture instantanée depuis le cache)
|
||||||
- Fonctionne offline une fois les images chargées
|
- Fonctionne offline une fois les images chargées
|
||||||
- Économise la bande passante
|
- Économise la bande passante
|
||||||
|
|
||||||
#### Navigation et ressources statiques : Network-First
|
#### Navigation et ressources statiques : Network-First
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Pour les pages et ressources _next/static
|
// Pour les pages et ressources _next/static
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
@@ -85,7 +91,7 @@ event.respondWith(
|
|||||||
// Fallback sur le cache si offline
|
// Fallback sur le cache si offline
|
||||||
const cachedResponse = await cache.match(request);
|
const cachedResponse = await cache.match(request);
|
||||||
if (cachedResponse) return cachedResponse;
|
if (cachedResponse) return cachedResponse;
|
||||||
|
|
||||||
// Page offline si navigation
|
// Page offline si navigation
|
||||||
if (request.mode === "navigate") {
|
if (request.mode === "navigate") {
|
||||||
return cache.match("/offline.html");
|
return cache.match("/offline.html");
|
||||||
@@ -95,18 +101,20 @@ event.respondWith(
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Avantages** :
|
**Avantages** :
|
||||||
|
|
||||||
- Toujours la dernière version quand online
|
- Toujours la dernière version quand online
|
||||||
- Fallback offline si nécessaire
|
- Fallback offline si nécessaire
|
||||||
- Navigation fluide même sans connexion
|
- Navigation fluide même sans connexion
|
||||||
|
|
||||||
### Caches
|
### Caches
|
||||||
|
|
||||||
| Cache | Usage | Stratégie | Taille |
|
| Cache | Usage | Stratégie | Taille |
|
||||||
|-------|-------|-----------|--------|
|
| ----------------------- | ---------------------------- | ------------- | -------- |
|
||||||
| `stripstream-cache-v1` | Ressources statiques + pages | Network-First | ~5 MB |
|
| `stripstream-cache-v1` | Ressources statiques + pages | Network-First | ~5 MB |
|
||||||
| `stripstream-images-v1` | Images (covers + pages) | Cache-First | Illimité |
|
| `stripstream-images-v1` | Images (covers + pages) | Cache-First | Illimité |
|
||||||
|
|
||||||
### Nettoyage
|
### Nettoyage
|
||||||
|
|
||||||
- Automatique lors de l'activation du Service Worker
|
- Automatique lors de l'activation du Service Worker
|
||||||
- Suppression des anciennes versions de cache
|
- Suppression des anciennes versions de cache
|
||||||
- Pas d'expiration (contrôlé par l'utilisateur via les paramètres du navigateur)
|
- Pas d'expiration (contrôlé par l'utilisateur via les paramètres du navigateur)
|
||||||
@@ -114,9 +122,11 @@ event.respondWith(
|
|||||||
## Couche 2 : ServerCacheService (Serveur)
|
## Couche 2 : ServerCacheService (Serveur)
|
||||||
|
|
||||||
### Fichier
|
### Fichier
|
||||||
|
|
||||||
`src/lib/services/server-cache.service.ts`
|
`src/lib/services/server-cache.service.ts`
|
||||||
|
|
||||||
### Responsabilité
|
### Responsabilité
|
||||||
|
|
||||||
- Cache des réponses API Komga côté serveur
|
- Cache des réponses API Komga côté serveur
|
||||||
- Optimisation des temps de réponse
|
- Optimisation des temps de réponse
|
||||||
- Réduction de la charge sur Komga
|
- Réduction de la charge sur Komga
|
||||||
@@ -126,6 +136,7 @@ event.respondWith(
|
|||||||
Cette stratégie est **la clé de la performance** de l'application.
|
Cette stratégie est **la clé de la performance** de l'application.
|
||||||
|
|
||||||
#### Principe
|
#### Principe
|
||||||
|
|
||||||
```
|
```
|
||||||
Requête → Cache existe ?
|
Requête → Cache existe ?
|
||||||
├─ Non → Fetch normal + mise en cache
|
├─ Non → Fetch normal + mise en cache
|
||||||
@@ -136,6 +147,7 @@ Requête → Cache existe ?
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Implémentation
|
#### Implémentation
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async getOrSet<T>(
|
async getOrSet<T>(
|
||||||
key: string,
|
key: string,
|
||||||
@@ -144,7 +156,7 @@ async getOrSet<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cacheKey = `${user.id}-${key}`;
|
const cacheKey = `${user.id}-${key}`;
|
||||||
const cachedResult = this.getStale(cacheKey);
|
const cachedResult = this.getStale(cacheKey);
|
||||||
|
|
||||||
if (cachedResult !== null) {
|
if (cachedResult !== null) {
|
||||||
const { data, isStale } = cachedResult;
|
const { data, isStale } = cachedResult;
|
||||||
|
|
||||||
@@ -164,12 +176,14 @@ async getOrSet<T>(
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Avantages
|
#### Avantages
|
||||||
|
|
||||||
✅ **Temps de réponse constant** : Le cache expiré est retourné instantanément
|
✅ **Temps de réponse constant** : Le cache expiré est retourné instantanément
|
||||||
✅ **Données fraîches** : Revalidation en background pour la prochaine requête
|
✅ **Données fraîches** : Revalidation en background pour la prochaine requête
|
||||||
✅ **Pas de délai** : L'utilisateur ne subit jamais l'attente de revalidation
|
✅ **Pas de délai** : L'utilisateur ne subit jamais l'attente de revalidation
|
||||||
✅ **Résilience** : Même si Komga est lent, l'app reste rapide
|
✅ **Résilience** : Même si Komga est lent, l'app reste rapide
|
||||||
|
|
||||||
#### Inconvénients
|
#### Inconvénients
|
||||||
|
|
||||||
⚠️ Les données peuvent être légèrement obsolètes (jusqu'au prochain refresh)
|
⚠️ Les données peuvent être légèrement obsolètes (jusqu'au prochain refresh)
|
||||||
⚠️ Nécessite un cache initialisé (première requête toujours lente)
|
⚠️ Nécessite un cache initialisé (première requête toujours lente)
|
||||||
|
|
||||||
@@ -178,9 +192,11 @@ async getOrSet<T>(
|
|||||||
L'utilisateur peut choisir entre deux modes :
|
L'utilisateur peut choisir entre deux modes :
|
||||||
|
|
||||||
#### Mode Mémoire (par défaut)
|
#### Mode Mémoire (par défaut)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
cacheMode: "memory"
|
cacheMode: "memory";
|
||||||
```
|
```
|
||||||
|
|
||||||
- Cache stocké en RAM
|
- Cache stocké en RAM
|
||||||
- **Performances** : Très rapide (lecture < 1ms)
|
- **Performances** : Très rapide (lecture < 1ms)
|
||||||
- **Persistance** : Perdu au redémarrage du serveur
|
- **Persistance** : Perdu au redémarrage du serveur
|
||||||
@@ -188,9 +204,11 @@ cacheMode: "memory"
|
|||||||
- **Idéal pour** : Développement, faible charge
|
- **Idéal pour** : Développement, faible charge
|
||||||
|
|
||||||
#### Mode Fichier
|
#### Mode Fichier
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
cacheMode: "file"
|
cacheMode: "file";
|
||||||
```
|
```
|
||||||
|
|
||||||
- Cache stocké sur disque (`.cache/`)
|
- Cache stocké sur disque (`.cache/`)
|
||||||
- **Performances** : Rapide (lecture 5-10ms)
|
- **Performances** : Rapide (lecture 5-10ms)
|
||||||
- **Persistance** : Survit aux redémarrages
|
- **Persistance** : Survit aux redémarrages
|
||||||
@@ -201,14 +219,14 @@ cacheMode: "file"
|
|||||||
|
|
||||||
Chaque type de données a un TTL configuré :
|
Chaque type de données a un TTL configuré :
|
||||||
|
|
||||||
| Type | TTL par défaut | Justification |
|
| Type | TTL par défaut | Justification |
|
||||||
|------|----------------|---------------|
|
| ----------- | -------------- | ---------------------------------- |
|
||||||
| `DEFAULT` | 5 minutes | Données génériques |
|
| `DEFAULT` | 5 minutes | Données génériques |
|
||||||
| `HOME` | 10 minutes | Page d'accueil (données agrégées) |
|
| `HOME` | 10 minutes | Page d'accueil (données agrégées) |
|
||||||
| `LIBRARIES` | 24 heures | Bibliothèques (rarement modifiées) |
|
| `LIBRARIES` | 24 heures | Bibliothèques (rarement modifiées) |
|
||||||
| `SERIES` | 5 minutes | Séries (métadonnées + progression) |
|
| `SERIES` | 5 minutes | Séries (métadonnées + progression) |
|
||||||
| `BOOKS` | 5 minutes | Livres (métadonnées + progression) |
|
| `BOOKS` | 5 minutes | Livres (métadonnées + progression) |
|
||||||
| `IMAGES` | 7 jours | Images (immuables) |
|
| `IMAGES` | 7 jours | Images (immuables) |
|
||||||
|
|
||||||
#### Configuration personnalisée
|
#### Configuration personnalisée
|
||||||
|
|
||||||
@@ -235,6 +253,7 @@ const cacheKey = `${user.id}-${key}`;
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Avantages** :
|
**Avantages** :
|
||||||
|
|
||||||
- Pas de collision entre utilisateurs
|
- Pas de collision entre utilisateurs
|
||||||
- Progression de lecture individuelle
|
- Progression de lecture individuelle
|
||||||
- Préférences personnalisées
|
- Préférences personnalisées
|
||||||
@@ -244,18 +263,21 @@ const cacheKey = `${user.id}-${key}`;
|
|||||||
Le cache peut être invalidé :
|
Le cache peut être invalidé :
|
||||||
|
|
||||||
#### Manuellement
|
#### Manuellement
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await cacheService.delete(key); // Une clé
|
await cacheService.delete(key); // Une clé
|
||||||
await cacheService.deleteAll(prefix); // Toutes les clés avec préfixe
|
await cacheService.deleteAll(prefix); // Toutes les clés avec préfixe
|
||||||
await cacheService.clear(); // Tout le cache
|
await cacheService.clear(); // Tout le cache
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Automatiquement
|
#### Automatiquement
|
||||||
|
|
||||||
- Lors d'une mise à jour de progression
|
- Lors d'une mise à jour de progression
|
||||||
- Lors d'un changement de favoris
|
- Lors d'un changement de favoris
|
||||||
- Lors de la suppression d'une série
|
- Lors de la suppression d'une série
|
||||||
|
|
||||||
#### API
|
#### API
|
||||||
|
|
||||||
```
|
```
|
||||||
DELETE /api/komga/cache/clear // Vider tout le cache
|
DELETE /api/komga/cache/clear // Vider tout le cache
|
||||||
DELETE /api/komga/home // Invalider le cache home
|
DELETE /api/komga/home // Invalider le cache home
|
||||||
@@ -264,12 +286,14 @@ DELETE /api/komga/home // Invalider le cache home
|
|||||||
## Couche 3 : Cache HTTP (Navigateur)
|
## Couche 3 : Cache HTTP (Navigateur)
|
||||||
|
|
||||||
### Responsabilité
|
### Responsabilité
|
||||||
|
|
||||||
- Cache basique géré par le navigateur
|
- Cache basique géré par le navigateur
|
||||||
- Headers HTTP standard
|
- Headers HTTP standard
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
#### Next.js ISR (Incremental Static Regeneration)
|
#### Next.js ISR (Incremental Static Regeneration)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export const revalidate = 60; // Revalidation toutes les 60 secondes
|
export const revalidate = 60; // Revalidation toutes les 60 secondes
|
||||||
```
|
```
|
||||||
@@ -279,20 +303,23 @@ Utilisé uniquement pour les routes avec rendu statique.
|
|||||||
#### Headers explicites (désactivé)
|
#### Headers explicites (désactivé)
|
||||||
|
|
||||||
Les headers HTTP explicites ont été **supprimés** car :
|
Les headers HTTP explicites ont été **supprimés** car :
|
||||||
|
|
||||||
- Le ServerCacheService gère déjà le caching efficacement
|
- Le ServerCacheService gère déjà le caching efficacement
|
||||||
- Évite la confusion entre plusieurs couches de cache
|
- Évite la confusion entre plusieurs couches de cache
|
||||||
- Simplifie le debugging
|
- Simplifie le debugging
|
||||||
|
|
||||||
Avant (supprimé) :
|
Avant (supprimé) :
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
NextResponse.json(data, {
|
NextResponse.json(data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
|
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Maintenant :
|
Maintenant :
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
NextResponse.json(data); // Pas de headers
|
NextResponse.json(data); // Pas de headers
|
||||||
```
|
```
|
||||||
@@ -322,29 +349,32 @@ Exemple : Chargement de la page d'accueil
|
|||||||
|
|
||||||
### Temps de réponse typiques
|
### Temps de réponse typiques
|
||||||
|
|
||||||
| Scénario | Temps | Détails |
|
| Scénario | Temps | Détails |
|
||||||
|----------|-------|---------|
|
| ----------------------------- | ----------- | -------------------------- |
|
||||||
| Cache ServerCache valide + SW | ~50ms | Optimal |
|
| Cache ServerCache valide + SW | ~50ms | Optimal |
|
||||||
| Cache ServerCache expiré + SW | ~50ms | Revalidation en background |
|
| Cache ServerCache expiré + SW | ~50ms | Revalidation en background |
|
||||||
| Pas de cache ServerCache + SW | ~200-500ms | Première requête |
|
| Pas de cache ServerCache + SW | ~200-500ms | Première requête |
|
||||||
| Cache SW uniquement | ~10ms | Images seulement |
|
| Cache SW uniquement | ~10ms | Images seulement |
|
||||||
| Tout à froid | ~500-1000ms | Pire cas |
|
| Tout à froid | ~500-1000ms | Pire cas |
|
||||||
|
|
||||||
## Cas d'usage
|
## Cas d'usage
|
||||||
|
|
||||||
### 1. Première visite
|
### 1. Première visite
|
||||||
|
|
||||||
```
|
```
|
||||||
User → App → Komga (tous les caches vides)
|
User → App → Komga (tous les caches vides)
|
||||||
Temps : ~500-1000ms
|
Temps : ~500-1000ms
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Visite suivante (online)
|
### 2. Visite suivante (online)
|
||||||
|
|
||||||
```
|
```
|
||||||
User → ServerCache (valide) → Images SW
|
User → ServerCache (valide) → Images SW
|
||||||
Temps : ~50ms
|
Temps : ~50ms
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Cache expiré (online)
|
### 3. Cache expiré (online)
|
||||||
|
|
||||||
```
|
```
|
||||||
User → ServerCache (stale) → Retour immédiat
|
User → ServerCache (stale) → Retour immédiat
|
||||||
↓
|
↓
|
||||||
@@ -353,6 +383,7 @@ Temps ressenti : ~50ms (aucun délai)
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4. Mode offline
|
### 4. Mode offline
|
||||||
|
|
||||||
```
|
```
|
||||||
User → Service Worker cache uniquement
|
User → Service Worker cache uniquement
|
||||||
Fonctionnalités :
|
Fonctionnalités :
|
||||||
@@ -373,6 +404,7 @@ CACHE_DEBUG=true
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Format des logs** :
|
**Format des logs** :
|
||||||
|
|
||||||
```
|
```
|
||||||
[CACHE HIT] home-ongoing | HOME | 0.45ms # Cache valide
|
[CACHE HIT] home-ongoing | HOME | 0.45ms # Cache valide
|
||||||
[CACHE STALE] home-ongoing | HOME | 0.52ms # Cache expiré (retourné + revalidation)
|
[CACHE STALE] home-ongoing | HOME | 0.52ms # Cache expiré (retourné + revalidation)
|
||||||
@@ -386,24 +418,28 @@ CACHE_DEBUG=true
|
|||||||
### API de monitoring
|
### API de monitoring
|
||||||
|
|
||||||
#### Taille du cache serveur
|
#### Taille du cache serveur
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET /api/komga/cache/size
|
GET /api/komga/cache/size
|
||||||
Response: { sizeInBytes: 15728640, itemCount: 234 }
|
Response: { sizeInBytes: 15728640, itemCount: 234 }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Mode de cache actuel
|
#### Mode de cache actuel
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET /api/komga/cache/mode
|
GET /api/komga/cache/mode
|
||||||
Response: { mode: "memory" }
|
Response: { mode: "memory" }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Changer le mode
|
#### Changer le mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/komga/cache/mode
|
POST /api/komga/cache/mode
|
||||||
Body: { mode: "file" }
|
Body: { mode: "file" }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Vider le cache
|
#### Vider le cache
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/komga/cache/clear
|
POST /api/komga/cache/clear
|
||||||
```
|
```
|
||||||
@@ -411,21 +447,26 @@ POST /api/komga/cache/clear
|
|||||||
### DevTools du navigateur
|
### DevTools du navigateur
|
||||||
|
|
||||||
#### Network Tab
|
#### Network Tab
|
||||||
|
|
||||||
- Temps de réponse < 50ms = cache serveur
|
- Temps de réponse < 50ms = cache serveur
|
||||||
- Headers `X-Cache` si configurés
|
- Headers `X-Cache` si configurés
|
||||||
- Onglet "Timing" pour détails
|
- Onglet "Timing" pour détails
|
||||||
|
|
||||||
#### Application → Cache Storage
|
#### Application → Cache Storage
|
||||||
|
|
||||||
Inspecter le Service Worker :
|
Inspecter le Service Worker :
|
||||||
|
|
||||||
- `stripstream-cache-v1` : Ressources statiques
|
- `stripstream-cache-v1` : Ressources statiques
|
||||||
- `stripstream-images-v1` : Images
|
- `stripstream-images-v1` : Images
|
||||||
|
|
||||||
Actions disponibles :
|
Actions disponibles :
|
||||||
|
|
||||||
- Voir le contenu
|
- Voir le contenu
|
||||||
- Supprimer des entrées
|
- Supprimer des entrées
|
||||||
- Vider complètement
|
- Vider complètement
|
||||||
|
|
||||||
#### Application → Service Workers
|
#### Application → Service Workers
|
||||||
|
|
||||||
- État du Service Worker
|
- État du Service Worker
|
||||||
- "Unregister" pour le désactiver
|
- "Unregister" pour le désactiver
|
||||||
- "Update" pour forcer une mise à jour
|
- "Update" pour forcer une mise à jour
|
||||||
@@ -433,21 +474,25 @@ Actions disponibles :
|
|||||||
## Optimisations futures possibles
|
## Optimisations futures possibles
|
||||||
|
|
||||||
### 1. Cache Redis (optionnel)
|
### 1. Cache Redis (optionnel)
|
||||||
|
|
||||||
- Pour un déploiement multi-instances
|
- Pour un déploiement multi-instances
|
||||||
- Cache partagé entre plusieurs serveurs
|
- Cache partagé entre plusieurs serveurs
|
||||||
- TTL natif Redis
|
- TTL natif Redis
|
||||||
|
|
||||||
### 2. Compression
|
### 2. Compression
|
||||||
|
|
||||||
- Compresser les données en cache (Brotli/Gzip)
|
- Compresser les données en cache (Brotli/Gzip)
|
||||||
- Économie d'espace disque/mémoire
|
- Économie d'espace disque/mémoire
|
||||||
- Trade-off CPU vs espace
|
- Trade-off CPU vs espace
|
||||||
|
|
||||||
### 3. Prefetching intelligent
|
### 3. Prefetching intelligent
|
||||||
|
|
||||||
- Précharger les séries en cours de lecture
|
- Précharger les séries en cours de lecture
|
||||||
- Précharger les pages suivantes dans le reader
|
- Précharger les pages suivantes dans le reader
|
||||||
- Basé sur l'historique utilisateur
|
- Basé sur l'historique utilisateur
|
||||||
|
|
||||||
### 4. Cache Analytics
|
### 4. Cache Analytics
|
||||||
|
|
||||||
- Ratio hit/miss
|
- Ratio hit/miss
|
||||||
- Temps de réponse moyens
|
- Temps de réponse moyens
|
||||||
- Identification des données les plus consultées
|
- Identification des données les plus consultées
|
||||||
@@ -456,7 +501,8 @@ Actions disponibles :
|
|||||||
|
|
||||||
### Pour les développeurs
|
### Pour les développeurs
|
||||||
|
|
||||||
✅ **Utiliser BaseApiService.fetchWithCache()**
|
✅ **Utiliser BaseApiService.fetchWithCache()**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await this.fetchWithCache<T>(
|
await this.fetchWithCache<T>(
|
||||||
"cache-key",
|
"cache-key",
|
||||||
@@ -465,12 +511,14 @@ await this.fetchWithCache<T>(
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ **Invalider le cache après modification**
|
✅ **Invalider le cache après modification**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await HomeService.invalidateHomeCache();
|
await HomeService.invalidateHomeCache();
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ **Choisir le bon TTL**
|
✅ **Choisir le bon TTL**
|
||||||
|
|
||||||
- Court (1-5 min) : Données qui changent souvent
|
- Court (1-5 min) : Données qui changent souvent
|
||||||
- Moyen (10-30 min) : Données agrégées
|
- Moyen (10-30 min) : Données agrégées
|
||||||
- Long (24h+) : Données quasi-statiques
|
- Long (24h+) : Données quasi-statiques
|
||||||
@@ -499,4 +547,3 @@ Le système de caching de StripStream est conçu pour :
|
|||||||
🧹 **Simplicité** : 3 couches bien définies, pas de redondance
|
🧹 **Simplicité** : 3 couches bien définies, pas de redondance
|
||||||
|
|
||||||
Le système est maintenu simple avec des responsabilités claires pour chaque couche, facilitant la maintenance et l'évolution future.
|
Le système est maintenu simple avec des responsabilités claires pour chaque couche, facilitant la maintenance et l'évolution future.
|
||||||
|
|
||||||
|
|||||||
@@ -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>`
|
||||||
|
|||||||
@@ -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
5821
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const prisma = new PrismaClient();
|
|||||||
async function checkDatabase() {
|
async function checkDatabase() {
|
||||||
try {
|
try {
|
||||||
console.log("🔍 Checking database content...");
|
console.log("🔍 Checking database content...");
|
||||||
|
|
||||||
// Vérifier les utilisateurs
|
// Vérifier les utilisateurs
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
select: {
|
select: {
|
||||||
@@ -20,22 +20,23 @@ async function checkDatabase() {
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`📊 Found ${users.length} users:`);
|
console.log(`📊 Found ${users.length} users:`);
|
||||||
users.forEach(user => {
|
users.forEach((user) => {
|
||||||
console.log(` - ID: ${user.id}, Email: ${user.email}, Roles: ${JSON.stringify(user.roles)}, Created: ${user.createdAt}`);
|
console.log(
|
||||||
|
` - ID: ${user.id}, Email: ${user.email}, Roles: ${JSON.stringify(user.roles)}, Created: ${user.createdAt}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Vérifier les configurations
|
// Vérifier les configurations
|
||||||
const komgaConfigs = await prisma.komgaConfig.count();
|
const komgaConfigs = await prisma.komgaConfig.count();
|
||||||
const preferences = await prisma.preferences.count();
|
const preferences = await prisma.preferences.count();
|
||||||
const favorites = await prisma.favorite.count();
|
const favorites = await prisma.favorite.count();
|
||||||
|
|
||||||
console.log(`📊 Database stats:`);
|
console.log(`📊 Database stats:`);
|
||||||
console.log(` - KomgaConfigs: ${komgaConfigs}`);
|
console.log(` - KomgaConfigs: ${komgaConfigs}`);
|
||||||
console.log(` - Preferences: ${preferences}`);
|
console.log(` - Preferences: ${preferences}`);
|
||||||
console.log(` - Favorites: ${favorites}`);
|
console.log(` - Favorites: ${favorites}`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Error checking database:", error);
|
console.error("❌ Error checking database:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -44,4 +45,3 @@ async function checkDatabase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkDatabase();
|
checkDatabase();
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ async function initializeAdminUser() {
|
|||||||
if (existingAdmin) {
|
if (existingAdmin) {
|
||||||
// Vérifier si l'utilisateur a le rôle admin
|
// Vérifier si l'utilisateur a le rôle admin
|
||||||
const hasAdminRole = existingAdmin.roles.includes("ROLE_ADMIN");
|
const hasAdminRole = existingAdmin.roles.includes("ROLE_ADMIN");
|
||||||
|
|
||||||
if (hasAdminRole) {
|
if (hasAdminRole) {
|
||||||
console.log(`✅ Admin user ${ADMIN_EMAIL} already exists with admin role`);
|
console.log(`✅ Admin user ${ADMIN_EMAIL} already exists with admin role`);
|
||||||
} else {
|
} else {
|
||||||
@@ -60,7 +60,7 @@ async function initializeAdminUser() {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("🔧 Initializing SQLite database...");
|
console.log("🔧 Initializing SQLite database...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initializeAdminUser();
|
await initializeAdminUser();
|
||||||
console.log("✅ Database initialization completed");
|
console.log("✅ Database initialization completed");
|
||||||
@@ -72,4 +72,3 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
/**
|
/**
|
||||||
* Script de réinitialisation forcée du mot de passe admin
|
* Script de réinitialisation forcée du mot de passe admin
|
||||||
* Force la mise à jour du mot de passe du compte admin
|
* Force la mise à jour du mot de passe du compte admin
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* pnpm reset-admin-password [nouveau-mot-de-passe]
|
* pnpm reset-admin-password [nouveau-mot-de-passe]
|
||||||
* pnpm reset-admin-password [email] [nouveau-mot-de-passe]
|
* pnpm reset-admin-password [email] [nouveau-mot-de-passe]
|
||||||
* docker compose exec app pnpm reset-admin-password [nouveau-mot-de-passe]
|
* docker compose exec app pnpm reset-admin-password [nouveau-mot-de-passe]
|
||||||
@@ -71,7 +71,7 @@ async function resetAdminPassword() {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("🔧 Starting admin password reset...");
|
console.log("🔧 Starting admin password reset...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resetAdminPassword();
|
await resetAdminPassword();
|
||||||
console.log("✅ Admin password reset completed");
|
console.log("✅ Admin password reset completed");
|
||||||
@@ -83,4 +83,3 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { isAdmin } from "@/lib/auth-utils";
|
|||||||
import { AdminContent } from "@/components/admin/AdminContent";
|
import { AdminContent } from "@/components/admin/AdminContent";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
try {
|
try {
|
||||||
const hasAdminAccess = await isAdmin();
|
const hasAdminAccess = await isAdmin();
|
||||||
|
|
||||||
if (!hasAdminAccess) {
|
if (!hasAdminAccess) {
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ export async function GET() {
|
|||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_FORBIDDEN" ? 403 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500
|
error.code === "AUTH_FORBIDDEN"
|
||||||
|
? 403
|
||||||
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,4 +30,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,17 +14,14 @@ export async function PUT(
|
|||||||
const { newPassword } = body;
|
const { newPassword } = body;
|
||||||
|
|
||||||
if (!newPassword) {
|
if (!newPassword) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Nouveau mot de passe manquant" }, { status: 400 });
|
||||||
{ error: "Nouveau mot de passe manquant" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier que le mot de passe est fort
|
// Vérifier que le mot de passe est fort
|
||||||
if (!AuthServerService.isPasswordStrong(newPassword)) {
|
if (!AuthServerService.isPasswordStrong(newPassword)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: "Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre"
|
error: "Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre",
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
@@ -39,11 +36,17 @@ export async function PUT(
|
|||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_FORBIDDEN" ? 403 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 :
|
error.code === "AUTH_FORBIDDEN"
|
||||||
error.code === "AUTH_USER_NOT_FOUND" ? 404 :
|
? 403
|
||||||
error.code === "ADMIN_CANNOT_RESET_OWN_PASSWORD" ? 400 : 500
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: error.code === "AUTH_USER_NOT_FOUND"
|
||||||
|
? 404
|
||||||
|
: error.code === "ADMIN_CANNOT_RESET_OWN_PASSWORD"
|
||||||
|
? 400
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,4 +57,3 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ export async function PATCH(
|
|||||||
const { roles } = body;
|
const { roles } = body;
|
||||||
|
|
||||||
if (!roles || !Array.isArray(roles)) {
|
if (!roles || !Array.isArray(roles)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Rôles invalides" }, { status: 400 });
|
||||||
{ error: "Rôles invalides" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await AdminService.updateUserRoles(userId, roles);
|
await AdminService.updateUserRoles(userId, roles);
|
||||||
@@ -28,10 +25,15 @@ export async function PATCH(
|
|||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_FORBIDDEN" ? 403 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 :
|
error.code === "AUTH_FORBIDDEN"
|
||||||
error.code === "AUTH_USER_NOT_FOUND" ? 404 : 500
|
? 403
|
||||||
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: error.code === "AUTH_USER_NOT_FOUND"
|
||||||
|
? 404
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,11 +60,17 @@ export async function DELETE(
|
|||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_FORBIDDEN" ? 403 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 :
|
error.code === "AUTH_FORBIDDEN"
|
||||||
error.code === "AUTH_USER_NOT_FOUND" ? 404 :
|
? 403
|
||||||
error.code === "ADMIN_CANNOT_DELETE_SELF" ? 400 : 500
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: error.code === "AUTH_USER_NOT_FOUND"
|
||||||
|
? 404
|
||||||
|
: error.code === "ADMIN_CANNOT_DELETE_SELF"
|
||||||
|
? 400
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -73,4 +81,3 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ export async function GET() {
|
|||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_FORBIDDEN" ? 403 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500
|
error.code === "AUTH_FORBIDDEN"
|
||||||
|
? 403
|
||||||
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { handlers } from "@/lib/auth";
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
export const { GET, POST } = handlers;
|
export const { GET, POST } = handlers;
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ export async function GET(
|
|||||||
return { buffer, contentType };
|
return { buffer, contentType };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cloner le buffer pour cette requête pour éviter tout partage de référence
|
// Cloner le buffer pour cette requête pour éviter tout partage de référence
|
||||||
const clonedBuffer = buffer.slice(0);
|
const clonedBuffer = buffer.slice(0);
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.set("Content-Type", contentType);
|
headers.set("Content-Type", contentType);
|
||||||
headers.set("Cache-Control", "public, max-age=31536000"); // Cache for 1 year
|
headers.set("Cache-Control", "public, max-age=31536000"); // Cache for 1 year
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function GET(
|
|||||||
|
|
||||||
const data: KomgaBookWithPages = await BookService.getBook(bookId);
|
const data: KomgaBookWithPages = await BookService.getBook(bookId);
|
||||||
const nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
|
const nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
|
||||||
|
|
||||||
return NextResponse.json({ ...data, nextBook });
|
return NextResponse.json({ ...data, nextBook });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "API Books - Erreur:");
|
logger.error({ err: error }, "API Books - Erreur:");
|
||||||
|
|||||||
4
src/app/api/komga/cache/clear/route.ts
vendored
4
src/app/api/komga/cache/clear/route.ts
vendored
@@ -10,13 +10,13 @@ export async function POST() {
|
|||||||
try {
|
try {
|
||||||
const cacheService: ServerCacheService = await getServerCacheService();
|
const cacheService: ServerCacheService = await getServerCacheService();
|
||||||
await cacheService.clear();
|
await cacheService.clear();
|
||||||
|
|
||||||
// Revalider toutes les pages importantes après le vidage du cache
|
// Revalider toutes les pages importantes après le vidage du cache
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
revalidatePath("/libraries");
|
revalidatePath("/libraries");
|
||||||
revalidatePath("/series");
|
revalidatePath("/series");
|
||||||
revalidatePath("/books");
|
revalidatePath("/books");
|
||||||
|
|
||||||
return NextResponse.json({ message: "🧹 Cache vidé avec succès" });
|
return NextResponse.json({ message: "🧹 Cache vidé avec succès" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la suppression du cache:");
|
logger.error({ err: error }, "Erreur lors de la suppression du cache:");
|
||||||
|
|||||||
3
src/app/api/komga/cache/entries/route.ts
vendored
3
src/app/api/komga/cache/entries/route.ts
vendored
@@ -9,7 +9,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const cacheService: ServerCacheService = await getServerCacheService();
|
const cacheService: ServerCacheService = await getServerCacheService();
|
||||||
const entries = await cacheService.getCacheEntries();
|
const entries = await cacheService.getCacheEntries();
|
||||||
|
|
||||||
return NextResponse.json({ entries });
|
return NextResponse.json({ entries });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération des entrées du cache");
|
logger.error({ err: error }, "Erreur lors de la récupération des entrées du cache");
|
||||||
@@ -25,4 +25,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
src/app/api/komga/cache/size/route.ts
vendored
9
src/app/api/komga/cache/size/route.ts
vendored
@@ -9,11 +9,11 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const cacheService: ServerCacheService = await getServerCacheService();
|
const cacheService: ServerCacheService = await getServerCacheService();
|
||||||
const { sizeInBytes, itemCount } = await cacheService.getCacheSize();
|
const { sizeInBytes, itemCount } = await cacheService.getCacheSize();
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
sizeInBytes,
|
sizeInBytes,
|
||||||
itemCount,
|
itemCount,
|
||||||
mode: cacheService.getCacheMode()
|
mode: cacheService.getCacheMode(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la taille du cache:");
|
logger.error({ err: error }, "Erreur lors de la récupération de la taille du cache:");
|
||||||
@@ -29,4 +29,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import logger from "@/lib/logger";
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const favoriteIds: string[] = await FavoriteService.getAllFavoriteIds();
|
const favoriteIds: string[] = await FavoriteService.getAllFavoriteIds();
|
||||||
|
|
||||||
// Valider que chaque série existe encore dans Komga
|
// Valider que chaque série existe encore dans Komga
|
||||||
const validFavoriteIds: string[] = [];
|
const validFavoriteIds: string[] = [];
|
||||||
|
|
||||||
for (const seriesId of favoriteIds) {
|
for (const seriesId of favoriteIds) {
|
||||||
try {
|
try {
|
||||||
await SeriesService.getSeries(seriesId);
|
await SeriesService.getSeries(seriesId);
|
||||||
@@ -27,7 +27,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(validFavoriteIds);
|
return NextResponse.json(validFavoriteIds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
|
|||||||
@@ -67,4 +67,3 @@ export async function DELETE() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export async function GET(
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la page du livre:");
|
logger.error({ err: error }, "Erreur lors de la récupération de la page du livre:");
|
||||||
|
|
||||||
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
||||||
const httpStatus = findHttpStatus(error);
|
const httpStatus = findHttpStatus(error);
|
||||||
|
|
||||||
if (httpStatus === 404) {
|
if (httpStatus === 404) {
|
||||||
const { bookId, pageNumber } = await params;
|
const { bookId, pageNumber } = await params;
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -39,7 +39,7 @@ export async function GET(
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ export async function GET(
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la miniature de la page:");
|
logger.error({ err: error }, "Erreur lors de la récupération de la miniature de la page:");
|
||||||
|
|
||||||
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
||||||
const httpStatus = findHttpStatus(error);
|
const httpStatus = findHttpStatus(error);
|
||||||
|
|
||||||
if (httpStatus === 404) {
|
if (httpStatus === 404) {
|
||||||
const { bookId, pageNumber: pageNumberParam } = await params;
|
const { bookId, pageNumber: pageNumberParam } = await params;
|
||||||
const pageNumber: number = parseInt(pageNumberParam);
|
const pageNumber: number = parseInt(pageNumberParam);
|
||||||
@@ -54,7 +54,7 @@ export async function GET(
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export async function GET(
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la miniature du livre:");
|
logger.error({ err: error }, "Erreur lors de la récupération de la miniature du livre:");
|
||||||
|
|
||||||
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
||||||
const httpStatus = findHttpStatus(error);
|
const httpStatus = findHttpStatus(error);
|
||||||
|
|
||||||
if (httpStatus === 404) {
|
if (httpStatus === 404) {
|
||||||
const bookId: string = (await params).bookId;
|
const bookId: string = (await params).bookId;
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -37,7 +37,7 @@ export async function GET(
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export async function GET(
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la couverture de la série:");
|
logger.error({ err: error }, "Erreur lors de la récupération de la couverture de la série:");
|
||||||
|
|
||||||
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
||||||
const httpStatus = findHttpStatus(error);
|
const httpStatus = findHttpStatus(error);
|
||||||
|
|
||||||
if (httpStatus === 404) {
|
if (httpStatus === 404) {
|
||||||
const seriesId: string = (await params).seriesId;
|
const seriesId: string = (await params).seriesId;
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -39,7 +39,7 @@ export async function GET(
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export async function GET(
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la miniature de la série");
|
logger.error({ err: error }, "Erreur lors de la récupération de la miniature de la série");
|
||||||
|
|
||||||
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
||||||
const httpStatus = findHttpStatus(error);
|
const httpStatus = findHttpStatus(error);
|
||||||
|
|
||||||
if (httpStatus === 404) {
|
if (httpStatus === 404) {
|
||||||
const seriesId: string = (await params).seriesId;
|
const seriesId: string = (await params).seriesId;
|
||||||
logger.info(`📷 Image not found for series: ${seriesId}`);
|
logger.info(`📷 Image not found for series: ${seriesId}`);
|
||||||
@@ -35,7 +35,7 @@ export async function GET(
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function POST(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const libraryId: string = (await params).libraryId;
|
const libraryId: string = (await params).libraryId;
|
||||||
|
|
||||||
// Scan library with deep=false
|
// Scan library with deep=false
|
||||||
await LibraryService.scanLibrary(libraryId, false);
|
await LibraryService.scanLibrary(libraryId, false);
|
||||||
|
|
||||||
@@ -43,4 +43,3 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const libraryId: string = (await params).libraryId;
|
const libraryId: string = (await params).libraryId;
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
|
||||||
const page = parseInt(searchParams.get("page") || "0");
|
const page = parseInt(searchParams.get("page") || "0");
|
||||||
const size = parseInt(searchParams.get("size") || String(DEFAULT_PAGE_SIZE));
|
const size = parseInt(searchParams.get("size") || String(DEFAULT_PAGE_SIZE));
|
||||||
const unreadOnly = searchParams.get("unread") === "true";
|
const unreadOnly = searchParams.get("unread") === "true";
|
||||||
@@ -24,15 +24,15 @@ export async function GET(
|
|||||||
|
|
||||||
const [series, library] = await Promise.all([
|
const [series, library] = await Promise.all([
|
||||||
LibraryService.getLibrarySeries(libraryId, page, size, unreadOnly, search),
|
LibraryService.getLibrarySeries(libraryId, page, size, unreadOnly, search),
|
||||||
LibraryService.getLibrary(libraryId)
|
LibraryService.getLibrary(libraryId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ series, library },
|
{ series, library },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
|
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -68,7 +68,7 @@ export async function DELETE(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const libraryId: string = (await params).libraryId;
|
const libraryId: string = (await params).libraryId;
|
||||||
|
|
||||||
await LibraryService.invalidateLibrarySeriesCache(libraryId);
|
await LibraryService.invalidateLibrarySeriesCache(libraryId);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
@@ -98,4 +98,3 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,4 +51,3 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,22 +16,22 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const seriesId: string = (await params).seriesId;
|
const seriesId: string = (await params).seriesId;
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
|
||||||
const page = parseInt(searchParams.get("page") || "0");
|
const page = parseInt(searchParams.get("page") || "0");
|
||||||
const size = parseInt(searchParams.get("size") || String(DEFAULT_PAGE_SIZE));
|
const size = parseInt(searchParams.get("size") || String(DEFAULT_PAGE_SIZE));
|
||||||
const unreadOnly = searchParams.get("unread") === "true";
|
const unreadOnly = searchParams.get("unread") === "true";
|
||||||
|
|
||||||
const [books, series] = await Promise.all([
|
const [books, series] = await Promise.all([
|
||||||
SeriesService.getSeriesBooks(seriesId, page, size, unreadOnly),
|
SeriesService.getSeriesBooks(seriesId, page, size, unreadOnly),
|
||||||
SeriesService.getSeries(seriesId)
|
SeriesService.getSeries(seriesId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ books, series },
|
{ books, series },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
|
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -67,10 +67,10 @@ export async function DELETE(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const seriesId: string = (await params).seriesId;
|
const seriesId: string = (await params).seriesId;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
SeriesService.invalidateSeriesBooksCache(seriesId),
|
SeriesService.invalidateSeriesBooksCache(seriesId),
|
||||||
SeriesService.invalidateSeriesCache(seriesId)
|
SeriesService.invalidateSeriesCache(seriesId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
@@ -100,4 +100,3 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:");
|
||||||
|
|||||||
@@ -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:");
|
||||||
|
|||||||
@@ -10,17 +10,15 @@ export async function PUT(request: NextRequest) {
|
|||||||
const { currentPassword, newPassword } = body;
|
const { currentPassword, newPassword } = body;
|
||||||
|
|
||||||
if (!currentPassword || !newPassword) {
|
if (!currentPassword || !newPassword) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Mots de passe manquants" }, { status: 400 });
|
||||||
{ error: "Mots de passe manquants" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier que le nouveau mot de passe est fort
|
// Vérifier que le nouveau mot de passe est fort
|
||||||
if (!AuthServerService.isPasswordStrong(newPassword)) {
|
if (!AuthServerService.isPasswordStrong(newPassword)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: "Le nouveau mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre"
|
error:
|
||||||
|
"Le nouveau mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre",
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
@@ -35,9 +33,13 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_INVALID_PASSWORD" ? 400 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500
|
error.code === "AUTH_INVALID_PASSWORD"
|
||||||
|
? 400
|
||||||
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
export default function BookReaderLayout({
|
export default function BookReaderLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
|
|||||||
|
|
||||||
export default async function BookPage({ params }: { params: Promise<{ bookId: string }> }) {
|
export default async function BookPage({ params }: { params: Promise<{ bookId: string }> }) {
|
||||||
const { bookId } = await params;
|
const { bookId } = await params;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<BookSkeleton />}>
|
<Suspense fallback={<BookSkeleton />}>
|
||||||
<ClientBookPage bookId={bookId} />
|
<ClientBookPage bookId={bookId} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -47,22 +47,22 @@ export function ClientLibraryPage({
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: String(currentPage - 1),
|
page: String(currentPage - 1),
|
||||||
size: String(effectivePageSize),
|
size: String(effectivePageSize),
|
||||||
unread: String(unreadOnly),
|
unread: String(unreadOnly),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
params.append("search", search);
|
params.append("search", search);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||||
cache: 'default' // Utilise le cache HTTP du navigateur
|
cache: "default", // Utilise le cache HTTP du navigateur
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
|
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
|
||||||
@@ -86,28 +86,28 @@ export function ClientLibraryPage({
|
|||||||
try {
|
try {
|
||||||
// Invalidate cache via API
|
// Invalidate cache via API
|
||||||
const cacheResponse = await fetch(`/api/komga/libraries/${libraryId}/series`, {
|
const cacheResponse = await fetch(`/api/komga/libraries/${libraryId}/series`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cacheResponse.ok) {
|
if (!cacheResponse.ok) {
|
||||||
throw new Error("Error invalidating cache");
|
throw new Error("Error invalidating cache");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recharger les données
|
// Recharger les données
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: String(currentPage - 1),
|
page: String(currentPage - 1),
|
||||||
size: String(effectivePageSize),
|
size: String(effectivePageSize),
|
||||||
unread: String(unreadOnly),
|
unread: String(unreadOnly),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
params.append("search", search);
|
params.append("search", search);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||||
cache: 'reload' // Force un nouveau fetch après invalidation
|
cache: "reload", // Force un nouveau fetch après invalidation
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Error refreshing library");
|
throw new Error("Error refreshing library");
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ export function ClientLibraryPage({
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setLibrary(data.library);
|
setLibrary(data.library);
|
||||||
setSeries(data.series);
|
setSeries(data.series);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Error during refresh:");
|
logger.error({ err: error }, "Error during refresh:");
|
||||||
@@ -133,15 +133,15 @@ export function ClientLibraryPage({
|
|||||||
size: String(effectivePageSize),
|
size: String(effectivePageSize),
|
||||||
unread: String(unreadOnly),
|
unread: String(unreadOnly),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
params.append("search", search);
|
params.append("search", search);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||||
cache: 'reload' // Force un nouveau fetch lors du retry
|
cache: "reload", // Force un nouveau fetch lors du retry
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
|
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
|
||||||
|
|||||||
@@ -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({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,4 +53,3 @@ export default function SeriesLoading() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'utiliser un mot de passe fort (8 caractères minimum, une majuscule et un chiffre)
|
Assurez-vous d'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,4 +60,3 @@ export function StatsCards({ stats }: StatsCardsProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export function PullToRefreshIndicator({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-0 left-1/2 transform -translate-x-1/2 z-50 transition-all",
|
"fixed top-0 left-1/2 transform -translate-x-1/2 z-50 transition-all",
|
||||||
isHiding ? "duration-300 ease-out" : "duration-200",
|
isHiding ? "duration-300 ease-out" : "duration-200",
|
||||||
(isPulling || isRefreshing) && !isHiding ? "translate-y-0 opacity-100" : "-translate-y-full opacity-0"
|
(isPulling || isRefreshing) && !isHiding
|
||||||
|
? "translate-y-0 opacity-100"
|
||||||
|
: "-translate-y-full opacity-0"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, ${(isPulling || isRefreshing) && !isHiding ? (isRefreshing ? 60 : progress * 60) : -100}px)`,
|
transform: `translate(-50%, ${(isPulling || isRefreshing) && !isHiding ? (isRefreshing ? 60 : progress * 60) : -100}px)`,
|
||||||
@@ -40,27 +42,26 @@ export function PullToRefreshIndicator({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full transition-all duration-200 rounded-full",
|
"h-full transition-all duration-200 rounded-full",
|
||||||
(canRefresh || isRefreshing) ? "bg-primary" : "bg-muted-foreground"
|
canRefresh || isRefreshing ? "bg-primary" : "bg-muted-foreground"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: `${isRefreshing ? 200 : barWidth}px`,
|
width: `${isRefreshing ? 200 : barWidth}px`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Icône centrée */}
|
{/* Icône centrée */}
|
||||||
<div className="flex justify-center mt-2">
|
<div className="flex justify-center mt-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200",
|
"flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200",
|
||||||
(canRefresh || isRefreshing) ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
canRefresh || isRefreshing
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={cn(
|
className={cn("h-4 w-4 transition-all duration-200", isRefreshing && "animate-spin")}
|
||||||
"h-4 w-4 transition-all duration-200",
|
|
||||||
isRefreshing && "animate-spin"
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
transform: isRefreshing ? "rotate(0deg)" : `rotate(${rotation}deg)`,
|
transform: isRefreshing ? "rotate(0deg)" : `rotate(${rotation}deg)`,
|
||||||
animationDuration: isRefreshing ? "2s" : undefined,
|
animationDuration: isRefreshing ? "2s" : undefined,
|
||||||
@@ -68,15 +69,21 @@ export function PullToRefreshIndicator({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-2 text-center text-xs transition-opacity duration-200",
|
"mt-2 text-center text-xs transition-opacity duration-200",
|
||||||
(canRefresh || isRefreshing) ? "text-primary opacity-100" : "text-muted-foreground opacity-70"
|
canRefresh || isRefreshing
|
||||||
|
? "text-primary opacity-100"
|
||||||
|
: "text-muted-foreground opacity-70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isRefreshing ? "Actualisation..." : canRefresh ? "Relâchez pour actualiser" : "Tirez pour actualiser"}
|
{isRefreshing
|
||||||
|
? "Actualisation..."
|
||||||
|
: canRefresh
|
||||||
|
? "Relâchez pour actualiser"
|
||||||
|
: "Tirez pour actualiser"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,4 +33,3 @@ export function ViewModeButton({ onToggle }: ViewModeButtonProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,19 +23,19 @@ export function ClientHomePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/komga/home", {
|
const response = await fetch("/api/komga/home", {
|
||||||
cache: 'default' // Utilise le cache HTTP du navigateur
|
cache: "default", // Utilise le cache HTTP du navigateur
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
|
const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
|
||||||
|
|
||||||
// Si la config Komga est manquante, rediriger vers les settings
|
// Si la config Komga est manquante, rediriger vers les settings
|
||||||
if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
||||||
router.push("/settings");
|
router.push("/settings");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(errorCode);
|
throw new Error(errorCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export function ClientHomePage() {
|
|||||||
|
|
||||||
// Récupérer les nouvelles données
|
// Récupérer les nouvelles données
|
||||||
const response = await fetch("/api/komga/home", {
|
const response = await fetch("/api/komga/home", {
|
||||||
cache: 'reload' // Force un nouveau fetch après invalidation
|
cache: "reload", // Force un nouveau fetch après invalidation
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -128,4 +128,3 @@ export function ClientHomePage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -79,13 +79,11 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const isSeries = "booksCount" in item;
|
const isSeries = "booksCount" in item;
|
||||||
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
|
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
|
||||||
|
|
||||||
const title = isSeries
|
const title = isSeries
|
||||||
? item.metadata.title
|
? item.metadata.title
|
||||||
: item.metadata.title ||
|
: item.metadata.title ||
|
||||||
(item.metadata.number
|
(item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : "");
|
||||||
? t("navigation.volume", { number: item.metadata.number })
|
|
||||||
: "");
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
// Pour les séries, toujours autoriser le clic
|
// Pour les séries, toujours autoriser le clic
|
||||||
@@ -100,7 +98,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden",
|
"flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden",
|
||||||
(!isSeries && !isAccessible) ? "cursor-not-allowed" : "cursor-pointer"
|
!isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[2/3] bg-muted">
|
<div className="relative aspect-[2/3] bg-muted">
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ interface ClientLayoutProps {
|
|||||||
userIsAdmin?: boolean;
|
userIsAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClientLayout({ children, initialLibraries = [], initialFavorites = [], userIsAdmin = false }: ClientLayoutProps) {
|
export default function ClientLayout({
|
||||||
|
children,
|
||||||
|
initialLibraries = [],
|
||||||
|
initialFavorites = [],
|
||||||
|
userIsAdmin = false,
|
||||||
|
}: ClientLayoutProps) {
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const [randomBookId, setRandomBookId] = useState<string | null>(null);
|
const [randomBookId, setRandomBookId] = useState<string | null>(null);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -67,14 +72,14 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
|
|||||||
const backgroundStyle = useMemo(() => {
|
const backgroundStyle = useMemo(() => {
|
||||||
const bg = preferences.background;
|
const bg = preferences.background;
|
||||||
const blur = bg.blur || 0;
|
const blur = bg.blur || 0;
|
||||||
|
|
||||||
if (bg.type === "gradient" && bg.gradient) {
|
if (bg.type === "gradient" && bg.gradient) {
|
||||||
return {
|
return {
|
||||||
backgroundImage: bg.gradient,
|
backgroundImage: bg.gradient,
|
||||||
filter: blur > 0 ? `blur(${blur}px)` : undefined,
|
filter: blur > 0 ? `blur(${blur}px)` : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bg.type === "image" && bg.imageUrl) {
|
if (bg.type === "image" && bg.imageUrl) {
|
||||||
return {
|
return {
|
||||||
backgroundImage: `url(${bg.imageUrl})`,
|
backgroundImage: `url(${bg.imageUrl})`,
|
||||||
@@ -94,7 +99,7 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
|
|||||||
filter: blur > 0 ? `blur(${blur}px)` : undefined,
|
filter: blur > 0 ? `blur(${blur}px)` : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}, [preferences.background, randomBookId]);
|
}, [preferences.background, randomBookId]);
|
||||||
|
|
||||||
@@ -137,10 +142,10 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
|
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
|
||||||
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith('/books/');
|
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
|
||||||
|
|
||||||
const hasCustomBackground =
|
const hasCustomBackground =
|
||||||
preferences.background.type === "gradient" ||
|
preferences.background.type === "gradient" ||
|
||||||
preferences.background.type === "image" ||
|
preferences.background.type === "image" ||
|
||||||
(preferences.background.type === "komga-random" && randomBookId);
|
(preferences.background.type === "komga-random" && randomBookId);
|
||||||
const contentOpacity = (preferences.background.opacity || 100) / 100;
|
const contentOpacity = (preferences.background.opacity || 100) / 100;
|
||||||
@@ -149,28 +154,27 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
|
|||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<ImageCacheProvider>
|
<ImageCacheProvider>
|
||||||
{/* Background fixe pour les images et gradients */}
|
{/* Background fixe pour les images et gradients */}
|
||||||
{hasCustomBackground && (
|
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 -z-10"
|
|
||||||
style={backgroundStyle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
|
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
|
||||||
style={hasCustomBackground ? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` } : undefined}
|
style={
|
||||||
|
hasCustomBackground
|
||||||
|
? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{!isPublicRoute && (
|
{!isPublicRoute && (
|
||||||
<Header
|
<Header
|
||||||
onToggleSidebar={handleToggleSidebar}
|
onToggleSidebar={handleToggleSidebar}
|
||||||
onRefreshBackground={fetchRandomBook}
|
onRefreshBackground={fetchRandomBook}
|
||||||
showRefreshBackground={preferences.background.type === "komga-random"}
|
showRefreshBackground={preferences.background.type === "komga-random"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isPublicRoute && (
|
{!isPublicRoute && (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isOpen={isSidebarOpen}
|
isOpen={isSidebarOpen}
|
||||||
onClose={handleCloseSidebar}
|
onClose={handleCloseSidebar}
|
||||||
initialLibraries={initialLibraries}
|
initialLibraries={initialLibraries}
|
||||||
initialFavorites={initialFavorites}
|
initialFavorites={initialFavorites}
|
||||||
userIsAdmin={userIsAdmin}
|
userIsAdmin={userIsAdmin}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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'image de fond</span>
|
<span className="sr-only">Rafraîchir l'image de fond</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -16,19 +16,25 @@ interface LibraryHeaderProps {
|
|||||||
refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
|
refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }: LibraryHeaderProps) => {
|
export const LibraryHeader = ({
|
||||||
|
library,
|
||||||
|
seriesCount,
|
||||||
|
series,
|
||||||
|
refreshLibrary,
|
||||||
|
}: LibraryHeaderProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
// Mémoriser la sélection des séries pour éviter les rerenders inutiles
|
// Mémoriser la sélection des séries pour éviter les rerenders inutiles
|
||||||
const { randomSeries, backgroundSeries } = useMemo(() => {
|
const { randomSeries, backgroundSeries } = useMemo(() => {
|
||||||
// Sélectionner une série aléatoire pour l'image centrale
|
// Sélectionner une série aléatoire pour l'image centrale
|
||||||
const random = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null;
|
const random = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null;
|
||||||
|
|
||||||
// Sélectionner une autre série aléatoire pour le fond (différente de celle du centre)
|
// Sélectionner une autre série aléatoire pour le fond (différente de celle du centre)
|
||||||
const background = series.length > 1
|
const background =
|
||||||
? series.filter(s => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))]
|
series.length > 1
|
||||||
: random;
|
? series.filter((s) => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))]
|
||||||
|
: random;
|
||||||
|
|
||||||
return { randomSeries: random, backgroundSeries: background };
|
return { randomSeries: random, backgroundSeries: background };
|
||||||
}, [series]);
|
}, [series]);
|
||||||
|
|
||||||
@@ -76,23 +82,20 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
|
|||||||
{/* Informations */}
|
{/* Informations */}
|
||||||
<div className="flex-1 space-y-3 text-center md:text-left">
|
<div className="flex-1 space-y-3 text-center md:text-left">
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-foreground">{library.name}</h1>
|
<h1 className="text-3xl md:text-4xl font-bold text-foreground">{library.name}</h1>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 justify-center md:justify-start flex-wrap">
|
<div className="flex items-center gap-4 justify-center md:justify-start flex-wrap">
|
||||||
<StatusBadge status="unread" icon={Library}>
|
<StatusBadge status="unread" icon={Library}>
|
||||||
{seriesCount === 1
|
{seriesCount === 1
|
||||||
? t("library.header.series", { count: seriesCount })
|
? t("library.header.series", { count: seriesCount })
|
||||||
: t("library.header.series_plural", { count: seriesCount })
|
: t("library.header.series_plural", { count: seriesCount })}
|
||||||
}
|
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
|
|
||||||
<RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} />
|
<RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} />
|
||||||
<ScanButton libraryId={library.id} />
|
<ScanButton libraryId={library.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{library.unavailable && (
|
{library.unavailable && (
|
||||||
<p className="text-sm text-destructive mt-2">
|
<p className="text-sm text-destructive mt-2">{t("library.header.unavailable")}</p>
|
||||||
{t("library.header.unavailable")}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,4 +103,3 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,31 +38,31 @@ export function PaginatedSeriesGrid({
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
|
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
|
||||||
const { isCompact, itemsPerPage: displayItemsPerPage, viewMode } = useDisplayPreferences();
|
const { isCompact, itemsPerPage: displayItemsPerPage, viewMode } = useDisplayPreferences();
|
||||||
|
|
||||||
// Utiliser la taille de page effective (depuis l'URL ou les préférences)
|
// Utiliser la taille de page effective (depuis l'URL ou les préférences)
|
||||||
const effectivePageSize = pageSize || displayItemsPerPage;
|
const effectivePageSize = pageSize || displayItemsPerPage;
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const updateUrlParams = useCallback(async (
|
const updateUrlParams = useCallback(
|
||||||
updates: Record<string, string | null>,
|
async (updates: Record<string, string | null>, replace: boolean = false) => {
|
||||||
replace: boolean = false
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
|
|
||||||
Object.entries(updates).forEach(([key, value]) => {
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
params.delete(key);
|
params.delete(key);
|
||||||
|
} else {
|
||||||
|
params.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
await router.replace(`${pathname}?${params.toString()}`);
|
||||||
} else {
|
} else {
|
||||||
params.set(key, value);
|
await router.push(`${pathname}?${params.toString()}`);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
[router, pathname, searchParams]
|
||||||
if (replace) {
|
);
|
||||||
await router.replace(`${pathname}?${params.toString()}`);
|
|
||||||
} else {
|
|
||||||
await router.push(`${pathname}?${params.toString()}`);
|
|
||||||
}
|
|
||||||
}, [router, pathname, searchParams]);
|
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,7 +89,6 @@ export function PaginatedSeriesGrid({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handlePageSizeChange = async (size: number) => {
|
const handlePageSizeChange = async (size: number) => {
|
||||||
await updateUrlParams({
|
await updateUrlParams({
|
||||||
page: "1",
|
page: "1",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,9 +60,8 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isCompleted = series.booksCount === series.booksReadCount;
|
const isCompleted = series.booksCount === series.booksReadCount;
|
||||||
const progressPercentage = series.booksCount > 0
|
const progressPercentage =
|
||||||
? (series.booksReadCount / series.booksCount) * 100
|
series.booksCount > 0 ? (series.booksReadCount / series.booksCount) * 100 : 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
const statusInfo = getReadingStatusInfo(series, t);
|
const statusInfo = getReadingStatusInfo(series, t);
|
||||||
|
|
||||||
@@ -91,7 +90,12 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
|
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
|
||||||
{series.metadata.title}
|
{series.metadata.title}
|
||||||
</h3>
|
</h3>
|
||||||
<span className={cn("px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
|
statusInfo.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +105,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BookOpen className="h-3 w-3" />
|
<BookOpen className="h-3 w-3" />
|
||||||
<span>
|
<span>
|
||||||
{series.booksCount === 1
|
{series.booksCount === 1
|
||||||
? t("series.book", { count: 1 })
|
? t("series.book", { count: 1 })
|
||||||
: t("series.books", { count: series.booksCount })}
|
: t("series.books", { count: series.booksCount })}
|
||||||
</span>
|
</span>
|
||||||
@@ -109,9 +113,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
|
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
|
||||||
<div className="flex items-center gap-1 hidden sm:flex">
|
<div className="flex items-center gap-1 hidden sm:flex">
|
||||||
<User className="h-3 w-3" />
|
<User className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">{series.booksMetadata.authors[0].name}</span>
|
||||||
{series.booksMetadata.authors[0].name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -146,9 +148,14 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
{series.metadata.title}
|
{series.metadata.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badge de statut */}
|
{/* Badge de statut */}
|
||||||
<span className={cn("px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
|
statusInfo.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +173,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BookOpen className="h-3 w-3" />
|
<BookOpen className="h-3 w-3" />
|
||||||
<span>
|
<span>
|
||||||
{series.booksCount === 1
|
{series.booksCount === 1
|
||||||
? t("series.book", { count: 1 })
|
? t("series.book", { count: 1 })
|
||||||
: t("series.books", { count: series.booksCount })}
|
: t("series.books", { count: series.booksCount })}
|
||||||
</span>
|
</span>
|
||||||
@@ -177,7 +184,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User className="h-3 w-3" />
|
<User className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">
|
||||||
{series.booksMetadata.authors.map(a => a.name).join(", ")}
|
{series.booksMetadata.authors.map((a) => a.name).join(", ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -246,4 +253,3 @@ export function SeriesList({ series, isCompact = false }: SeriesListProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function ClientBookPage({ bookId }: ClientBookPageProps) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/books/${bookId}`);
|
const response = await fetch(`/api/komga/books/${bookId}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
|
throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
|
||||||
@@ -74,4 +74,3 @@ export function ClientBookPage({ bookId }: ClientBookPageProps) {
|
|||||||
|
|
||||||
return <ClientBookWrapper book={data.book} pages={data.pages} nextBook={data.nextBook} />;
|
return <ClientBookWrapper book={data.book} pages={data.pages} nextBook={data.nextBook} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,20 +29,29 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
const { direction, toggleDirection, isRTL } = useReadingDirection();
|
const { direction, toggleDirection, isRTL } = useReadingDirection();
|
||||||
const { isFullscreen, toggleFullscreen } = useFullscreen();
|
const { isFullscreen, toggleFullscreen } = useFullscreen();
|
||||||
const { isDoublePage, shouldShowDoublePage, toggleDoublePage } = useDoublePageMode();
|
const { isDoublePage, shouldShowDoublePage, toggleDoublePage } = useDoublePageMode();
|
||||||
const { loadedImages, imageBlobUrls, prefetchPages, prefetchNextBook, handleForceReload, getPageUrl, prefetchCount } = useImageLoader({
|
const {
|
||||||
bookId: book.id,
|
loadedImages,
|
||||||
pages,
|
imageBlobUrls,
|
||||||
prefetchCount: preferences.readerPrefetchCount,
|
prefetchPages,
|
||||||
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null
|
prefetchNextBook,
|
||||||
});
|
handleForceReload,
|
||||||
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } = usePageNavigation({
|
getPageUrl,
|
||||||
book,
|
prefetchCount,
|
||||||
|
} = useImageLoader({
|
||||||
|
bookId: book.id,
|
||||||
pages,
|
pages,
|
||||||
isDoublePage,
|
prefetchCount: preferences.readerPrefetchCount,
|
||||||
shouldShowDoublePage: (page) => shouldShowDoublePage(page, pages.length),
|
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null,
|
||||||
onClose,
|
|
||||||
nextBook,
|
|
||||||
});
|
});
|
||||||
|
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
|
||||||
|
usePageNavigation({
|
||||||
|
book,
|
||||||
|
pages,
|
||||||
|
isDoublePage,
|
||||||
|
shouldShowDoublePage: (page) => shouldShowDoublePage(page, pages.length),
|
||||||
|
onClose,
|
||||||
|
nextBook,
|
||||||
|
});
|
||||||
const { pswpRef, handleZoom } = usePhotoSwipeZoom({
|
const { pswpRef, handleZoom } = usePhotoSwipeZoom({
|
||||||
loadedImages,
|
loadedImages,
|
||||||
currentPage,
|
currentPage,
|
||||||
@@ -58,32 +67,44 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
|
|
||||||
// Activer le zoom dans le reader en enlevant la classe no-pinch-zoom
|
// Activer le zoom dans le reader en enlevant la classe no-pinch-zoom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.remove('no-pinch-zoom');
|
document.body.classList.remove("no-pinch-zoom");
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.classList.add('no-pinch-zoom');
|
document.body.classList.add("no-pinch-zoom");
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// Prefetch current and next pages
|
// Prefetch current and next pages
|
||||||
// Deduplication in useImageLoader prevents redundant requests
|
// Deduplication in useImageLoader prevents redundant requests
|
||||||
// Server queue (RequestQueueService) handles concurrency limits
|
// Server queue (RequestQueueService) handles concurrency limits
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prefetch pages starting from current page
|
// Prefetch pages starting from current page
|
||||||
prefetchPages(currentPage, prefetchCount);
|
prefetchPages(currentPage, prefetchCount);
|
||||||
|
|
||||||
// If double page mode, also prefetch additional pages for smooth double page navigation
|
// If double page mode, also prefetch additional pages for smooth double page navigation
|
||||||
if (isDoublePage && shouldShowDoublePage(currentPage, pages.length) && currentPage + prefetchCount < pages.length) {
|
if (
|
||||||
|
isDoublePage &&
|
||||||
|
shouldShowDoublePage(currentPage, pages.length) &&
|
||||||
|
currentPage + prefetchCount < pages.length
|
||||||
|
) {
|
||||||
prefetchPages(currentPage + prefetchCount, 1);
|
prefetchPages(currentPage + prefetchCount, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're near the end of the book, prefetch the next book
|
// If we're near the end of the book, prefetch the next book
|
||||||
const pagesFromEnd = pages.length - currentPage;
|
const pagesFromEnd = pages.length - currentPage;
|
||||||
if (pagesFromEnd <= prefetchCount && nextBook) {
|
if (pagesFromEnd <= prefetchCount && nextBook) {
|
||||||
prefetchNextBook(prefetchCount);
|
prefetchNextBook(prefetchCount);
|
||||||
}
|
}
|
||||||
}, [currentPage, isDoublePage, shouldShowDoublePage, prefetchPages, prefetchNextBook, prefetchCount, pages.length, nextBook]);
|
}, [
|
||||||
|
currentPage,
|
||||||
|
isDoublePage,
|
||||||
|
shouldShowDoublePage,
|
||||||
|
prefetchPages,
|
||||||
|
prefetchNextBook,
|
||||||
|
prefetchCount,
|
||||||
|
pages.length,
|
||||||
|
nextBook,
|
||||||
|
]);
|
||||||
|
|
||||||
// Keyboard events
|
// Keyboard events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -109,43 +130,46 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [handleNextPage, handlePreviousPage, onClose, isRTL, currentPage]);
|
}, [handleNextPage, handlePreviousPage, onClose, isRTL, currentPage]);
|
||||||
|
|
||||||
const handleContainerClick = useCallback((e: React.MouseEvent) => {
|
const handleContainerClick = useCallback(
|
||||||
// Vérifier si c'est un double-clic sur une image
|
(e: React.MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
// Vérifier si c'est un double-clic sur une image
|
||||||
const now = Date.now();
|
const target = e.target as HTMLElement;
|
||||||
const timeSinceLastClick = now - lastClickTimeRef.current;
|
const now = Date.now();
|
||||||
|
const timeSinceLastClick = now - lastClickTimeRef.current;
|
||||||
if (target.tagName === 'IMG' && timeSinceLastClick < 300) {
|
|
||||||
// Double-clic sur une image
|
if (target.tagName === "IMG" && timeSinceLastClick < 300) {
|
||||||
if (clickTimeoutRef.current) {
|
// Double-clic sur une image
|
||||||
clearTimeout(clickTimeoutRef.current);
|
if (clickTimeoutRef.current) {
|
||||||
clickTimeoutRef.current = null;
|
clearTimeout(clickTimeoutRef.current);
|
||||||
|
clickTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
handleZoom();
|
||||||
|
lastClickTimeRef.current = 0;
|
||||||
|
} else if (target.tagName === "IMG") {
|
||||||
|
// Premier clic sur une image - attendre pour voir si c'est un double-clic
|
||||||
|
lastClickTimeRef.current = now;
|
||||||
|
if (clickTimeoutRef.current) {
|
||||||
|
clearTimeout(clickTimeoutRef.current);
|
||||||
|
}
|
||||||
|
clickTimeoutRef.current = setTimeout(() => {
|
||||||
|
setShowControls((prev) => !prev);
|
||||||
|
clickTimeoutRef.current = null;
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
// Clic ailleurs - toggle les contrôles immédiatement
|
||||||
|
setShowControls(!showControls);
|
||||||
|
lastClickTimeRef.current = 0;
|
||||||
}
|
}
|
||||||
e.stopPropagation();
|
},
|
||||||
handleZoom();
|
[showControls, handleZoom]
|
||||||
lastClickTimeRef.current = 0;
|
);
|
||||||
} else if (target.tagName === 'IMG') {
|
|
||||||
// Premier clic sur une image - attendre pour voir si c'est un double-clic
|
|
||||||
lastClickTimeRef.current = now;
|
|
||||||
if (clickTimeoutRef.current) {
|
|
||||||
clearTimeout(clickTimeoutRef.current);
|
|
||||||
}
|
|
||||||
clickTimeoutRef.current = setTimeout(() => {
|
|
||||||
setShowControls(prev => !prev);
|
|
||||||
clickTimeoutRef.current = null;
|
|
||||||
}, 300);
|
|
||||||
} else {
|
|
||||||
// Clic ailleurs - toggle les contrôles immédiatement
|
|
||||||
setShowControls(!showControls);
|
|
||||||
lastClickTimeRef.current = 0;
|
|
||||||
}
|
|
||||||
}, [showControls, handleZoom]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReaderContainer onContainerClick={handleContainerClick}>
|
<ReaderContainer onContainerClick={handleContainerClick}>
|
||||||
@@ -173,7 +197,11 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
showThumbnails={showThumbnails}
|
showThumbnails={showThumbnails}
|
||||||
onToggleThumbnails={() => setShowThumbnails(!showThumbnails)}
|
onToggleThumbnails={() => setShowThumbnails(!showThumbnails)}
|
||||||
onZoom={handleZoom}
|
onZoom={handleZoom}
|
||||||
onForceReload={() => handleForceReload(currentPage, isDoublePage, (page) => shouldShowDoublePage(page, pages.length))}
|
onForceReload={() =>
|
||||||
|
handleForceReload(currentPage, isDoublePage, (page) =>
|
||||||
|
shouldShowDoublePage(page, pages.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PageDisplay
|
<PageDisplay
|
||||||
@@ -196,4 +224,3 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
</ReaderContainer>
|
</ReaderContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function useDoublePageMode() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toggleDoublePage = useCallback(() => {
|
const toggleDoublePage = useCallback(() => {
|
||||||
setIsDoublePage(prev => !prev);
|
setIsDoublePage((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ interface UseImageLoaderProps {
|
|||||||
nextBook?: { id: string; pages: number[] } | null; // Livre suivant pour prefetch
|
nextBook?: { id: string; pages: number[] } | null; // Livre suivant pour prefetch
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextBook }: UseImageLoaderProps) {
|
export function useImageLoader({
|
||||||
|
bookId,
|
||||||
|
pages: _pages,
|
||||||
|
prefetchCount = 5,
|
||||||
|
nextBook,
|
||||||
|
}: UseImageLoaderProps) {
|
||||||
const [loadedImages, setLoadedImages] = useState<Record<ImageKey, ImageDimensions>>({});
|
const [loadedImages, setLoadedImages] = useState<Record<ImageKey, ImageDimensions>>({});
|
||||||
const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({});
|
const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({});
|
||||||
const loadedImagesRef = useRef(loadedImages);
|
const loadedImagesRef = useRef(loadedImages);
|
||||||
@@ -32,217 +37,238 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
|
|||||||
imageBlobUrlsRef.current = imageBlobUrls;
|
imageBlobUrlsRef.current = imageBlobUrls;
|
||||||
}, [imageBlobUrls]);
|
}, [imageBlobUrls]);
|
||||||
|
|
||||||
const getPageUrl = useCallback((pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`, [bookId]);
|
const getPageUrl = useCallback(
|
||||||
|
(pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`,
|
||||||
|
[bookId]
|
||||||
|
);
|
||||||
|
|
||||||
// Prefetch image and store dimensions
|
// Prefetch image and store dimensions
|
||||||
const prefetchImage = useCallback(async (pageNum: number) => {
|
const prefetchImage = useCallback(
|
||||||
// Check if we already have both dimensions and blob URL
|
async (pageNum: number) => {
|
||||||
const hasDimensions = loadedImagesRef.current[pageNum];
|
// Check if we already have both dimensions and blob URL
|
||||||
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
const hasDimensions = loadedImagesRef.current[pageNum];
|
||||||
|
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
||||||
if (hasDimensions && hasBlobUrl) {
|
|
||||||
return;
|
if (hasDimensions && hasBlobUrl) {
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this page is already being fetched
|
|
||||||
if (pendingFetchesRef.current.has(pageNum)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as pending
|
|
||||||
pendingFetchesRef.current.add(pageNum);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use browser cache if available - the server sets Cache-Control headers
|
|
||||||
const response = await fetch(getPageUrl(pageNum), {
|
|
||||||
cache: 'default', // Respect Cache-Control headers from server
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
// Check if this page is already being fetched
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
if (pendingFetchesRef.current.has(pageNum)) {
|
||||||
|
return;
|
||||||
// Create image to get dimensions
|
}
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
// Mark as pending
|
||||||
setLoadedImages(prev => ({
|
pendingFetchesRef.current.add(pageNum);
|
||||||
...prev,
|
|
||||||
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight }
|
try {
|
||||||
}));
|
// Use browser cache if available - the server sets Cache-Control headers
|
||||||
|
const response = await fetch(getPageUrl(pageNum), {
|
||||||
// Store the blob URL for immediate use
|
cache: "default", // Respect Cache-Control headers from server
|
||||||
setImageBlobUrls(prev => ({
|
});
|
||||||
...prev,
|
if (!response.ok) {
|
||||||
[pageNum]: blobUrl
|
return;
|
||||||
}));
|
}
|
||||||
};
|
|
||||||
img.src = blobUrl;
|
const blob = await response.blob();
|
||||||
} catch {
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
// Silently fail prefetch
|
|
||||||
} finally {
|
// Create image to get dimensions
|
||||||
// Remove from pending set
|
const img = new Image();
|
||||||
pendingFetchesRef.current.delete(pageNum);
|
img.onload = () => {
|
||||||
}
|
setLoadedImages((prev) => ({
|
||||||
}, [getPageUrl]);
|
...prev,
|
||||||
|
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Store the blob URL for immediate use
|
||||||
|
setImageBlobUrls((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[pageNum]: blobUrl,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
img.src = blobUrl;
|
||||||
|
} catch {
|
||||||
|
// Silently fail prefetch
|
||||||
|
} finally {
|
||||||
|
// Remove from pending set
|
||||||
|
pendingFetchesRef.current.delete(pageNum);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getPageUrl]
|
||||||
|
);
|
||||||
|
|
||||||
// Prefetch multiple pages starting from a given page
|
// Prefetch multiple pages starting from a given page
|
||||||
// The server-side queue (RequestQueueService) handles concurrency limits
|
// The server-side queue (RequestQueueService) handles concurrency limits
|
||||||
// We only deduplicate to avoid redundant HTTP requests
|
// We only deduplicate to avoid redundant HTTP requests
|
||||||
const prefetchPages = useCallback(async (startPage: number, count: number = prefetchCount) => {
|
const prefetchPages = useCallback(
|
||||||
const pagesToPrefetch = [];
|
async (startPage: number, count: number = prefetchCount) => {
|
||||||
|
const pagesToPrefetch = [];
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const pageNum = startPage + i;
|
for (let i = 0; i < count; i++) {
|
||||||
if (pageNum <= _pages.length) {
|
const pageNum = startPage + i;
|
||||||
const hasDimensions = loadedImagesRef.current[pageNum];
|
if (pageNum <= _pages.length) {
|
||||||
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
const hasDimensions = loadedImagesRef.current[pageNum];
|
||||||
const isPending = pendingFetchesRef.current.has(pageNum);
|
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
||||||
|
const isPending = pendingFetchesRef.current.has(pageNum);
|
||||||
// Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
|
|
||||||
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
|
// Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
|
||||||
pagesToPrefetch.push(pageNum);
|
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
|
||||||
|
pagesToPrefetch.push(pageNum);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Let all prefetch requests run - the server queue will manage concurrency
|
||||||
// Let all prefetch requests run - the server queue will manage concurrency
|
// The browser cache and our deduplication prevent redundant requests
|
||||||
// The browser cache and our deduplication prevent redundant requests
|
if (pagesToPrefetch.length > 0) {
|
||||||
if (pagesToPrefetch.length > 0) {
|
// Fire all requests in parallel - server queue handles throttling
|
||||||
// Fire all requests in parallel - server queue handles throttling
|
Promise.all(pagesToPrefetch.map((pageNum) => prefetchImage(pageNum))).catch(() => {
|
||||||
Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum))).catch(() => {
|
// Silently fail - prefetch is non-critical
|
||||||
// Silently fail - prefetch is non-critical
|
});
|
||||||
});
|
}
|
||||||
}
|
},
|
||||||
}, [prefetchImage, prefetchCount, _pages.length]);
|
[prefetchImage, prefetchCount, _pages.length]
|
||||||
|
);
|
||||||
|
|
||||||
// Prefetch pages from next book
|
// Prefetch pages from next book
|
||||||
const prefetchNextBook = useCallback(async (count: number = prefetchCount) => {
|
const prefetchNextBook = useCallback(
|
||||||
if (!nextBook) {
|
async (count: number = prefetchCount) => {
|
||||||
return;
|
if (!nextBook) {
|
||||||
}
|
return;
|
||||||
|
|
||||||
const pagesToPrefetch = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const pageNum = i + 1; // Pages du livre suivant commencent à 1
|
|
||||||
// Pour le livre suivant, on utilise une clé différente pour éviter les conflits
|
|
||||||
const nextBookPageKey = `next-${pageNum}`;
|
|
||||||
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
|
|
||||||
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
|
|
||||||
const isPending = pendingFetchesRef.current.has(nextBookPageKey);
|
|
||||||
|
|
||||||
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
|
|
||||||
pagesToPrefetch.push({ pageNum, nextBookPageKey });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const pagesToPrefetch = [];
|
||||||
// Let all prefetch requests run - server queue handles concurrency
|
|
||||||
if (pagesToPrefetch.length > 0) {
|
for (let i = 0; i < count; i++) {
|
||||||
Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
|
const pageNum = i + 1; // Pages du livre suivant commencent à 1
|
||||||
// Mark as pending
|
// Pour le livre suivant, on utilise une clé différente pour éviter les conflits
|
||||||
pendingFetchesRef.current.add(nextBookPageKey);
|
const nextBookPageKey = `next-${pageNum}`;
|
||||||
|
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
|
||||||
try {
|
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
|
||||||
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
|
const isPending = pendingFetchesRef.current.has(nextBookPageKey);
|
||||||
cache: 'default', // Respect Cache-Control headers from server
|
|
||||||
});
|
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
|
||||||
if (!response.ok) {
|
pagesToPrefetch.push({ pageNum, nextBookPageKey });
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// Create image to get dimensions
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
setLoadedImages(prev => ({
|
|
||||||
...prev,
|
|
||||||
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight }
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Store the blob URL for immediate use
|
|
||||||
setImageBlobUrls(prev => ({
|
|
||||||
...prev,
|
|
||||||
[nextBookPageKey]: blobUrl
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
img.src = blobUrl;
|
|
||||||
} catch {
|
|
||||||
// Silently fail prefetch
|
|
||||||
} finally {
|
|
||||||
pendingFetchesRef.current.delete(nextBookPageKey);
|
|
||||||
}
|
}
|
||||||
})).catch(() => {
|
}
|
||||||
// Silently fail - prefetch is non-critical
|
|
||||||
});
|
// Let all prefetch requests run - server queue handles concurrency
|
||||||
}
|
if (pagesToPrefetch.length > 0) {
|
||||||
}, [nextBook, prefetchCount]);
|
Promise.all(
|
||||||
|
pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
|
||||||
|
// Mark as pending
|
||||||
|
pendingFetchesRef.current.add(nextBookPageKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
|
||||||
|
cache: "default", // Respect Cache-Control headers from server
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Create image to get dimensions
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
setLoadedImages((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Store the blob URL for immediate use
|
||||||
|
setImageBlobUrls((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nextBookPageKey]: blobUrl,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
img.src = blobUrl;
|
||||||
|
} catch {
|
||||||
|
// Silently fail prefetch
|
||||||
|
} finally {
|
||||||
|
pendingFetchesRef.current.delete(nextBookPageKey);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).catch(() => {
|
||||||
|
// Silently fail - prefetch is non-critical
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nextBook, prefetchCount]
|
||||||
|
);
|
||||||
|
|
||||||
// Force reload handler
|
// Force reload handler
|
||||||
const handleForceReload = useCallback(async (currentPage: number, isDoublePage: boolean, shouldShowDoublePage: (page: number) => boolean) => {
|
const handleForceReload = useCallback(
|
||||||
// Révoquer les anciennes URLs blob
|
async (
|
||||||
if (imageBlobUrls[currentPage]) {
|
currentPage: number,
|
||||||
URL.revokeObjectURL(imageBlobUrls[currentPage]);
|
isDoublePage: boolean,
|
||||||
}
|
shouldShowDoublePage: (page: number) => boolean
|
||||||
if (imageBlobUrls[currentPage + 1]) {
|
) => {
|
||||||
URL.revokeObjectURL(imageBlobUrls[currentPage + 1]);
|
// Révoquer les anciennes URLs blob
|
||||||
}
|
if (imageBlobUrls[currentPage]) {
|
||||||
|
URL.revokeObjectURL(imageBlobUrls[currentPage]);
|
||||||
try {
|
|
||||||
// Fetch page 1 avec cache: reload
|
|
||||||
const response1 = await fetch(getPageUrl(currentPage), {
|
|
||||||
cache: 'reload',
|
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Pragma': 'no-cache'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response1.ok) {
|
|
||||||
throw new Error(`HTTP ${response1.status}`);
|
|
||||||
}
|
}
|
||||||
|
if (imageBlobUrls[currentPage + 1]) {
|
||||||
const blob1 = await response1.blob();
|
URL.revokeObjectURL(imageBlobUrls[currentPage + 1]);
|
||||||
const blobUrl1 = URL.createObjectURL(blob1);
|
}
|
||||||
|
|
||||||
const newUrls: Record<number, string> = {
|
try {
|
||||||
...imageBlobUrls,
|
// Fetch page 1 avec cache: reload
|
||||||
[currentPage]: blobUrl1
|
const response1 = await fetch(getPageUrl(currentPage), {
|
||||||
};
|
cache: "reload",
|
||||||
|
|
||||||
// Fetch page 2 si double page
|
|
||||||
if (isDoublePage && shouldShowDoublePage(currentPage)) {
|
|
||||||
const response2 = await fetch(getPageUrl(currentPage + 1), {
|
|
||||||
cache: 'reload',
|
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-cache',
|
"Cache-Control": "no-cache",
|
||||||
'Pragma': 'no-cache'
|
Pragma: "no-cache",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response2.ok) {
|
if (!response1.ok) {
|
||||||
throw new Error(`HTTP ${response2.status}`);
|
throw new Error(`HTTP ${response1.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob2 = await response2.blob();
|
const blob1 = await response1.blob();
|
||||||
const blobUrl2 = URL.createObjectURL(blob2);
|
const blobUrl1 = URL.createObjectURL(blob1);
|
||||||
newUrls[currentPage + 1] = blobUrl2;
|
|
||||||
|
const newUrls: Record<number, string> = {
|
||||||
|
...imageBlobUrls,
|
||||||
|
[currentPage]: blobUrl1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch page 2 si double page
|
||||||
|
if (isDoublePage && shouldShowDoublePage(currentPage)) {
|
||||||
|
const response2 = await fetch(getPageUrl(currentPage + 1), {
|
||||||
|
cache: "reload",
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Pragma: "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response2.ok) {
|
||||||
|
throw new Error(`HTTP ${response2.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob2 = await response2.blob();
|
||||||
|
const blobUrl2 = URL.createObjectURL(blob2);
|
||||||
|
newUrls[currentPage + 1] = blobUrl2;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageBlobUrls(newUrls);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Error reloading images:");
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
setImageBlobUrls(newUrls);
|
[imageBlobUrls, getPageUrl]
|
||||||
} catch (error) {
|
);
|
||||||
logger.error({ err: error }, 'Error reloading images:');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [imageBlobUrls, getPageUrl]);
|
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount only
|
// Cleanup blob URLs on unmount only
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
Object.values(imageBlobUrlsRef.current).forEach(url => {
|
Object.values(imageBlobUrlsRef.current).forEach((url) => {
|
||||||
if (url) URL.revokeObjectURL(url);
|
if (url) URL.revokeObjectURL(url);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -100,7 +100,15 @@ export function usePageNavigation({
|
|||||||
}
|
}
|
||||||
const step = isDoublePage && shouldShowDoublePage(currentPage) ? 2 : 1;
|
const step = isDoublePage && shouldShowDoublePage(currentPage) ? 2 : 1;
|
||||||
navigateToPage(Math.min(pages.length, currentPage + step));
|
navigateToPage(Math.min(pages.length, currentPage + step));
|
||||||
}, [currentPage, pages.length, isDoublePage, shouldShowDoublePage, navigateToPage, nextBook, router]);
|
}, [
|
||||||
|
currentPage,
|
||||||
|
pages.length,
|
||||||
|
isDoublePage,
|
||||||
|
shouldShowDoublePage,
|
||||||
|
navigateToPage,
|
||||||
|
nextBook,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
// Cleanup - Sync final sans debounce
|
// Cleanup - Sync final sans debounce
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,4 +130,4 @@ export function usePageNavigation({
|
|||||||
handlePreviousPage,
|
handlePreviousPage,
|
||||||
handleNextPage,
|
handleNextPage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -30,28 +30,31 @@ export function useTouchNavigation({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Touch handlers for swipe navigation
|
// Touch handlers for swipe navigation
|
||||||
const handleTouchStart = useCallback((e: TouchEvent) => {
|
const handleTouchStart = useCallback(
|
||||||
// Ne pas gérer si Photoswipe est ouvert
|
(e: TouchEvent) => {
|
||||||
if (pswpRef.current) return;
|
// Ne pas gérer si Photoswipe est ouvert
|
||||||
// Ne pas gérer si la page est zoomée (zoom natif)
|
if (pswpRef.current) return;
|
||||||
if (isZoomed()) return;
|
// Ne pas gérer si la page est zoomée (zoom natif)
|
||||||
|
if (isZoomed()) return;
|
||||||
// Détecter si c'est un pinch (2+ doigts)
|
|
||||||
if (e.touches.length > 1) {
|
// Détecter si c'est un pinch (2+ doigts)
|
||||||
isPinchingRef.current = true;
|
if (e.touches.length > 1) {
|
||||||
touchStartXRef.current = null;
|
isPinchingRef.current = true;
|
||||||
touchStartYRef.current = null;
|
touchStartXRef.current = null;
|
||||||
return;
|
touchStartYRef.current = null;
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
// Un seul doigt - seulement si on n'était pas en train de pinch
|
|
||||||
// On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt
|
// Un seul doigt - seulement si on n'était pas en train de pinch
|
||||||
if (e.touches.length === 1) {
|
// On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt
|
||||||
isPinchingRef.current = false;
|
if (e.touches.length === 1) {
|
||||||
touchStartXRef.current = e.touches[0].clientX;
|
isPinchingRef.current = false;
|
||||||
touchStartYRef.current = e.touches[0].clientY;
|
touchStartXRef.current = e.touches[0].clientX;
|
||||||
}
|
touchStartYRef.current = e.touches[0].clientY;
|
||||||
}, [pswpRef, isZoomed]);
|
}
|
||||||
|
},
|
||||||
|
[pswpRef, isZoomed]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTouchMove = useCallback((e: TouchEvent) => {
|
const handleTouchMove = useCallback((e: TouchEvent) => {
|
||||||
// Détecter le pinch pendant le mouvement
|
// Détecter le pinch pendant le mouvement
|
||||||
@@ -62,63 +65,66 @@ export function useTouchNavigation({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback((e: TouchEvent) => {
|
const handleTouchEnd = useCallback(
|
||||||
// Si on était en mode pinch, ne JAMAIS traiter le swipe
|
(e: TouchEvent) => {
|
||||||
if (isPinchingRef.current) {
|
// Si on était en mode pinch, ne JAMAIS traiter le swipe
|
||||||
touchStartXRef.current = null;
|
if (isPinchingRef.current) {
|
||||||
touchStartYRef.current = null;
|
touchStartXRef.current = null;
|
||||||
// Ne PAS réinitialiser isPinchingRef ici, on le fera au prochain touchstart
|
touchStartYRef.current = null;
|
||||||
return;
|
// Ne PAS réinitialiser isPinchingRef ici, on le fera au prochain touchstart
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
// Vérifier qu'on a bien des coordonnées de départ
|
|
||||||
if (touchStartXRef.current === null || touchStartYRef.current === null) return;
|
|
||||||
// Ne pas gérer si Photoswipe est ouvert
|
|
||||||
if (pswpRef.current) return;
|
|
||||||
// Ne pas gérer si la page est zoomée (zoom natif)
|
|
||||||
if (isZoomed()) return;
|
|
||||||
|
|
||||||
const touchEndX = e.changedTouches[0].clientX;
|
// Vérifier qu'on a bien des coordonnées de départ
|
||||||
const touchEndY = e.changedTouches[0].clientY;
|
if (touchStartXRef.current === null || touchStartYRef.current === null) return;
|
||||||
const deltaX = touchEndX - touchStartXRef.current;
|
// Ne pas gérer si Photoswipe est ouvert
|
||||||
const deltaY = touchEndY - touchStartYRef.current;
|
if (pswpRef.current) return;
|
||||||
|
// Ne pas gérer si la page est zoomée (zoom natif)
|
||||||
|
if (isZoomed()) return;
|
||||||
|
|
||||||
// Si le déplacement vertical est plus important, on ignore (scroll)
|
const touchEndX = e.changedTouches[0].clientX;
|
||||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
const touchEndY = e.changedTouches[0].clientY;
|
||||||
touchStartXRef.current = null;
|
const deltaX = touchEndX - touchStartXRef.current;
|
||||||
touchStartYRef.current = null;
|
const deltaY = touchEndY - touchStartYRef.current;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seuil de 50px pour changer de page
|
// Si le déplacement vertical est plus important, on ignore (scroll)
|
||||||
if (Math.abs(deltaX) > 50) {
|
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||||
if (deltaX > 0) {
|
touchStartXRef.current = null;
|
||||||
// Swipe vers la droite
|
touchStartYRef.current = null;
|
||||||
if (isRTL) {
|
return;
|
||||||
onNextPage();
|
}
|
||||||
|
|
||||||
|
// Seuil de 50px pour changer de page
|
||||||
|
if (Math.abs(deltaX) > 50) {
|
||||||
|
if (deltaX > 0) {
|
||||||
|
// Swipe vers la droite
|
||||||
|
if (isRTL) {
|
||||||
|
onNextPage();
|
||||||
|
} else {
|
||||||
|
onPreviousPage();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
onPreviousPage();
|
// Swipe vers la gauche
|
||||||
}
|
if (isRTL) {
|
||||||
} else {
|
onPreviousPage();
|
||||||
// Swipe vers la gauche
|
} else {
|
||||||
if (isRTL) {
|
onNextPage();
|
||||||
onPreviousPage();
|
}
|
||||||
} else {
|
|
||||||
onNextPage();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
touchStartXRef.current = null;
|
touchStartXRef.current = null;
|
||||||
touchStartYRef.current = null;
|
touchStartYRef.current = null;
|
||||||
}, [onNextPage, onPreviousPage, isRTL, pswpRef, isZoomed]);
|
},
|
||||||
|
[onNextPage, onPreviousPage, isRTL, pswpRef, isZoomed]
|
||||||
|
);
|
||||||
|
|
||||||
// Setup touch event listeners
|
// Setup touch event listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("touchstart", handleTouchStart);
|
window.addEventListener("touchstart", handleTouchStart);
|
||||||
window.addEventListener("touchmove", handleTouchMove);
|
window.addEventListener("touchmove", handleTouchMove);
|
||||||
window.addEventListener("touchend", handleTouchEnd);
|
window.addEventListener("touchend", handleTouchEnd);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("touchstart", handleTouchStart);
|
window.removeEventListener("touchstart", handleTouchStart);
|
||||||
window.removeEventListener("touchmove", handleTouchMove);
|
window.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) {
|
|||||||
<BookCover
|
<BookCover
|
||||||
book={book}
|
book={book}
|
||||||
alt={t("books.coverAlt", {
|
alt={t("books.coverAlt", {
|
||||||
title: book.metadata.title ||
|
title:
|
||||||
(book.metadata.number
|
book.metadata.title ||
|
||||||
|
(book.metadata.number
|
||||||
? t("navigation.volume", { number: book.metadata.number })
|
? t("navigation.volume", { number: book.metadata.number })
|
||||||
: ""),
|
: ""),
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -75,10 +75,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusInfo = getStatusInfo();
|
const statusInfo = getStatusInfo();
|
||||||
const title = book.metadata.title ||
|
const title =
|
||||||
(book.metadata.number
|
book.metadata.title ||
|
||||||
? t("navigation.volume", { number: book.metadata.number })
|
(book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name);
|
||||||
: book.name);
|
|
||||||
|
|
||||||
if (isCompact) {
|
if (isCompact) {
|
||||||
return (
|
return (
|
||||||
@@ -118,7 +117,12 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<span className={cn("px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
|
statusInfo.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,9 +141,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
{book.metadata.authors && book.metadata.authors.length > 0 && (
|
{book.metadata.authors && book.metadata.authors.length > 0 && (
|
||||||
<div className="flex items-center gap-1 hidden sm:flex">
|
<div className="flex items-center gap-1 hidden sm:flex">
|
||||||
<User className="h-3 w-3" />
|
<User className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">{book.metadata.authors[0].name}</span>
|
||||||
{book.metadata.authors[0].name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -192,9 +194,14 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badge de statut */}
|
{/* Badge de statut */}
|
||||||
<span className={cn("px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
|
statusInfo.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +228,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User className="h-3 w-3" />
|
<User className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">
|
||||||
{book.metadata.authors.map(a => a.name).join(", ")}
|
{book.metadata.authors.map((a) => a.name).join(", ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -343,4 +350,3 @@ export function BookList({ books, onBookClick, isCompact = false }: BookListProp
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -157,17 +157,18 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
|
|||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<span className="text-sm text-white/80">
|
<span className="text-sm text-white/80">
|
||||||
{series.booksCount === 1
|
{series.booksCount === 1
|
||||||
? t("series.header.books", { count: series.booksCount })
|
? t("series.header.books", { count: series.booksCount })
|
||||||
: t("series.header.books_plural", { count: series.booksCount })
|
: t("series.header.books_plural", { count: series.booksCount })}
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
icon={isFavorite ? Star : StarOff}
|
icon={isFavorite ? Star : StarOff}
|
||||||
onClick={handleToggleFavorite}
|
onClick={handleToggleFavorite}
|
||||||
tooltip={t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add")}
|
tooltip={t(
|
||||||
|
isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"
|
||||||
|
)}
|
||||||
className="text-white hover:text-white"
|
className="text-white hover:text-white"
|
||||||
iconClassName={isFavorite ? "fill-yellow-400 text-yellow-400" : ""}
|
iconClassName={isFavorite ? "fill-yellow-400 text-yellow-400" : ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
if (response) {
|
if (response) {
|
||||||
const blob = await response.clone().blob();
|
const blob = await response.clone().blob();
|
||||||
totalSize += blob.size;
|
totalSize += blob.size;
|
||||||
|
|
||||||
// Calculer la taille du cache API séparément
|
// Calculer la taille du cache API séparément
|
||||||
if (cacheName.includes("api")) {
|
if (cacheName.includes("api")) {
|
||||||
apiSize += blob.size;
|
apiSize += blob.size;
|
||||||
@@ -214,19 +214,24 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const path = urlObj.pathname;
|
const path = urlObj.pathname;
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split("/").filter(Boolean);
|
||||||
|
|
||||||
if (segments.length === 0) return '/';
|
if (segments.length === 0) return "/";
|
||||||
|
|
||||||
// Pour /api/komga/images, grouper par type (series/books)
|
// Pour /api/komga/images, grouper par type (series/books)
|
||||||
if (segments[0] === 'api' && segments[1] === 'komga' && segments[2] === 'images' && segments[3]) {
|
if (
|
||||||
|
segments[0] === "api" &&
|
||||||
|
segments[1] === "komga" &&
|
||||||
|
segments[2] === "images" &&
|
||||||
|
segments[3]
|
||||||
|
) {
|
||||||
return `/${segments[0]}/${segments[1]}/${segments[2]}/${segments[3]}`;
|
return `/${segments[0]}/${segments[1]}/${segments[2]}/${segments[3]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pour les autres, garder juste le premier segment
|
// Pour les autres, garder juste le premier segment
|
||||||
return `/${segments[0]}`;
|
return `/${segments[0]}`;
|
||||||
} catch {
|
} catch {
|
||||||
return 'Autres';
|
return "Autres";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -255,8 +260,8 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
// Trier par date (le plus récent en premier) basé sur le paramètre v
|
// Trier par date (le plus récent en premier) basé sur le paramètre v
|
||||||
Object.keys(grouped).forEach((key) => {
|
Object.keys(grouped).forEach((key) => {
|
||||||
grouped[key].sort((a, b) => {
|
grouped[key].sort((a, b) => {
|
||||||
const aVersion = new URL(a.url).searchParams.get('v') || '0';
|
const aVersion = new URL(a.url).searchParams.get("v") || "0";
|
||||||
const bVersion = new URL(b.url).searchParams.get('v') || '0';
|
const bVersion = new URL(b.url).searchParams.get("v") || "0";
|
||||||
return Number(bVersion) - Number(aVersion);
|
return Number(bVersion) - Number(aVersion);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -363,13 +368,13 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
if ("serviceWorker" in navigator && "caches" in window) {
|
if ("serviceWorker" in navigator && "caches" in window) {
|
||||||
const cacheNames = await caches.keys();
|
const cacheNames = await caches.keys();
|
||||||
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
|
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
|
||||||
|
|
||||||
// Forcer la mise à jour du service worker
|
// Forcer la mise à jour du service worker
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
for (const registration of registrations) {
|
for (const registration of registrations) {
|
||||||
await registration.unregister();
|
await registration.unregister();
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("settings.cache.title"),
|
title: t("settings.cache.title"),
|
||||||
description: t("settings.cache.messages.serviceWorkerCleared"),
|
description: t("settings.cache.messages.serviceWorkerCleared"),
|
||||||
@@ -383,7 +388,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
if (showSwEntries) {
|
if (showSwEntries) {
|
||||||
await fetchSwCacheEntries();
|
await fetchSwCacheEntries();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recharger la page après 1 seconde pour réenregistrer le SW
|
// Recharger la page après 1 seconde pour réenregistrer le SW
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -458,7 +463,6 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
<CardDescription>{t("settings.cache.description")}</CardDescription>
|
<CardDescription>{t("settings.cache.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="cache-mode">{t("settings.cache.mode.label")}</Label>
|
<Label htmlFor="cache-mode">{t("settings.cache.mode.label")}</Label>
|
||||||
@@ -488,7 +492,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.cache.size.error")}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -497,7 +503,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
{swCacheSize !== null ? (
|
{swCacheSize !== null ? (
|
||||||
<div className="text-sm text-muted-foreground">{formatBytes(swCacheSize)}</div>
|
<div className="text-sm text-muted-foreground">{formatBytes(swCacheSize)}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.cache.size.error")}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -506,7 +514,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
{apiCacheSize !== null ? (
|
{apiCacheSize !== null ? (
|
||||||
<div className="text-sm text-muted-foreground">{formatBytes(apiCacheSize)}</div>
|
<div className="text-sm text-muted-foreground">{formatBytes(apiCacheSize)}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.cache.size.error")}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -525,11 +535,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
{t("settings.cache.entries.serverTitle")}
|
{t("settings.cache.entries.serverTitle")}
|
||||||
</span>
|
</span>
|
||||||
{showEntries ? (
|
{showEntries ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showEntries && (
|
{showEntries && (
|
||||||
@@ -569,7 +575,10 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
>
|
>
|
||||||
{getTimeRemaining(entry.expiry)}
|
{getTimeRemaining(entry.expiry)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground/70" title={formatDate(entry.expiry)}>
|
<div
|
||||||
|
className="text-muted-foreground/70"
|
||||||
|
title={formatDate(entry.expiry)}
|
||||||
|
>
|
||||||
{new Date(entry.expiry).toLocaleDateString()}
|
{new Date(entry.expiry).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -649,72 +658,90 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
<div className="space-y-1 pl-2">
|
<div className="space-y-1 pl-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const versionGroups = groupVersions(entries);
|
const versionGroups = groupVersions(entries);
|
||||||
return Object.entries(versionGroups).map(([baseUrl, versions]) => {
|
return Object.entries(versionGroups).map(
|
||||||
const hasMultipleVersions = versions.length > 1;
|
([baseUrl, versions]) => {
|
||||||
const isVersionExpanded = expandedVersions[baseUrl];
|
const hasMultipleVersions = versions.length > 1;
|
||||||
const totalSize = versions.reduce((sum, v) => sum + v.size, 0);
|
const isVersionExpanded = expandedVersions[baseUrl];
|
||||||
|
const totalSize = versions.reduce(
|
||||||
|
(sum, v) => sum + v.size,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasMultipleVersions) {
|
if (!hasMultipleVersions) {
|
||||||
const entry = versions[0];
|
const entry = versions[0];
|
||||||
return (
|
return (
|
||||||
<div key={baseUrl} className="py-1">
|
<div key={baseUrl} className="py-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-mono text-xs truncate text-muted-foreground" title={entry.url}>
|
<div
|
||||||
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
|
className="font-mono text-xs truncate text-muted-foreground"
|
||||||
|
title={entry.url}
|
||||||
|
>
|
||||||
|
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatBytes(entry.size)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
{formatBytes(entry.size)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={baseUrl} className="py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleVersions(baseUrl)}
|
||||||
|
className="w-full flex items-start justify-between gap-2 hover:bg-muted/30 rounded p-1 -m-1 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-1">
|
||||||
|
{isVersionExpanded ? (
|
||||||
|
<ChevronDown className="h-3 w-3 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="h-3 w-3 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="font-mono text-xs truncate text-muted-foreground"
|
||||||
|
title={baseUrl}
|
||||||
|
>
|
||||||
|
{baseUrl}
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-1.5 py-0.5 text-xs font-medium text-orange-600 dark:text-orange-400 flex-shrink-0">
|
||||||
|
{versions.length} versions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground whitespace-nowrap font-medium">
|
||||||
|
{formatBytes(totalSize)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isVersionExpanded && (
|
||||||
|
<div className="pl-4 mt-1 space-y-1">
|
||||||
|
{versions.map((version, vIdx) => (
|
||||||
|
<div
|
||||||
|
key={vIdx}
|
||||||
|
className="py-0.5 flex items-start justify-between gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="font-mono text-xs truncate text-muted-foreground/70"
|
||||||
|
title={version.url}
|
||||||
|
>
|
||||||
|
{new URL(version.url).search ||
|
||||||
|
"(no version)"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground/70 whitespace-nowrap">
|
||||||
|
{formatBytes(version.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return (
|
|
||||||
<div key={baseUrl} className="py-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleVersions(baseUrl)}
|
|
||||||
className="w-full flex items-start justify-between gap-2 hover:bg-muted/30 rounded p-1 -m-1 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-1">
|
|
||||||
{isVersionExpanded ? (
|
|
||||||
<ChevronDown className="h-3 w-3 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronUp className="h-3 w-3 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="font-mono text-xs truncate text-muted-foreground" title={baseUrl}>
|
|
||||||
{baseUrl}
|
|
||||||
</div>
|
|
||||||
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-1.5 py-0.5 text-xs font-medium text-orange-600 dark:text-orange-400 flex-shrink-0">
|
|
||||||
{versions.length} versions
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground whitespace-nowrap font-medium">
|
|
||||||
{formatBytes(totalSize)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{isVersionExpanded && (
|
|
||||||
<div className="pl-4 mt-1 space-y-1">
|
|
||||||
{versions.map((version, vIdx) => (
|
|
||||||
<div key={vIdx} className="py-0.5 flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-mono text-xs truncate text-muted-foreground/70" title={version.url}>
|
|
||||||
{new URL(version.url).search || "(no version)"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground/70 whitespace-nowrap">
|
|
||||||
{formatBytes(version.size)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -833,12 +860,24 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
onChange={handleTTLChange}
|
onChange={handleTTLChange}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="0">{t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}</option>
|
<option value="0">
|
||||||
<option value="3600">{t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}</option>
|
{t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}
|
||||||
<option value="86400">{t("settings.cache.ttl.imageCacheMaxAge.options.oneDay")}</option>
|
</option>
|
||||||
<option value="604800">{t("settings.cache.ttl.imageCacheMaxAge.options.oneWeek")}</option>
|
<option value="3600">
|
||||||
<option value="2592000">{t("settings.cache.ttl.imageCacheMaxAge.options.oneMonth")}</option>
|
{t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}
|
||||||
<option value="31536000">{t("settings.cache.ttl.imageCacheMaxAge.options.oneYear")}</option>
|
</option>
|
||||||
|
<option value="86400">
|
||||||
|
{t("settings.cache.ttl.imageCacheMaxAge.options.oneDay")}
|
||||||
|
</option>
|
||||||
|
<option value="604800">
|
||||||
|
{t("settings.cache.ttl.imageCacheMaxAge.options.oneWeek")}
|
||||||
|
</option>
|
||||||
|
<option value="2592000">
|
||||||
|
{t("settings.cache.ttl.imageCacheMaxAge.options.oneMonth")}
|
||||||
|
</option>
|
||||||
|
<option value="31536000">
|
||||||
|
{t("settings.cache.ttl.imageCacheMaxAge.options.oneYear")}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 space-y-6">
|
<div className="container mx-auto px-4 py-8 space-y-6">
|
||||||
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
||||||
|
|
||||||
<Tabs defaultValue="display" className="w-full">
|
<Tabs defaultValue="display" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="display" className="flex items-center gap-2">
|
<TabsTrigger value="display" className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ interface ErrorMessageProps {
|
|||||||
retryLabel?: string;
|
retryLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorMessage = ({
|
export const ErrorMessage = ({
|
||||||
errorCode,
|
errorCode,
|
||||||
error,
|
error,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
onRetry,
|
onRetry,
|
||||||
retryLabel,
|
retryLabel,
|
||||||
@@ -37,12 +37,7 @@ export const ErrorMessage = ({
|
|||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<p>{message}</p>
|
<p>{message}</p>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button
|
<Button onClick={onRetry} variant="ghost" size="sm" className="ml-auto">
|
||||||
onClick={onRetry}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="ml-auto"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3" />
|
<RefreshCw className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -68,11 +63,11 @@ export const ErrorMessage = ({
|
|||||||
{t("errors.GENERIC_ERROR")}
|
{t("errors.GENERIC_ERROR")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-destructive/90 dark:text-red-300/90">{message}</p>
|
<p className="text-sm text-destructive/90 dark:text-red-300/90">{message}</p>
|
||||||
|
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button
|
<Button
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-4 border-destructive/30 hover:bg-destructive/10"
|
className="mt-4 border-destructive/30 hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ export function BookCover({
|
|||||||
{showOverlay && overlayVariant === "default" && (
|
{showOverlay && overlayVariant === "default" && (
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
|
||||||
<p className="text-sm font-medium text-white text-left line-clamp-2">
|
<p className="text-sm font-medium text-white text-left line-clamp-2">
|
||||||
{book.metadata.title ||
|
{book.metadata.title ||
|
||||||
(book.metadata.number
|
(book.metadata.number
|
||||||
? t("navigation.volume", { number: book.metadata.number })
|
? t("navigation.volume", { number: book.metadata.number })
|
||||||
: "")}
|
: "")}
|
||||||
</p>
|
</p>
|
||||||
@@ -155,8 +155,8 @@ export function BookCover({
|
|||||||
{showOverlay && overlayVariant === "home" && (
|
{showOverlay && overlayVariant === "home" && (
|
||||||
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
|
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
|
||||||
<h3 className="font-medium text-sm text-white line-clamp-2">
|
<h3 className="font-medium text-sm text-white line-clamp-2">
|
||||||
{book.metadata.title ||
|
{book.metadata.title ||
|
||||||
(book.metadata.number
|
(book.metadata.number
|
||||||
? t("navigation.volume", { number: book.metadata.number })
|
? t("navigation.volume", { number: book.metadata.number })
|
||||||
: "")}
|
: "")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,4 +26,3 @@ const Checkbox = React.forwardRef<
|
|||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Checkbox };
|
export { Checkbox };
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -30,4 +30,3 @@ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
|
|||||||
IconButton.displayName = "IconButton";
|
IconButton.displayName = "IconButton";
|
||||||
|
|
||||||
export { IconButton };
|
export { IconButton };
|
||||||
|
|
||||||
|
|||||||
@@ -8,18 +8,12 @@ const labelVariants = cva(
|
|||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
);
|
);
|
||||||
|
|
||||||
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement>,
|
interface LabelProps
|
||||||
VariantProps<typeof labelVariants> {}
|
extends React.LabelHTMLAttributes<HTMLLabelElement>, VariantProps<typeof labelVariants> {}
|
||||||
|
|
||||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => (
|
||||||
({ className, ...props }, ref) => (
|
<label ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
<label
|
));
|
||||||
ref={ref}
|
|
||||||
className={cn(labelVariants(), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
Label.displayName = "Label";
|
Label.displayName = "Label";
|
||||||
|
|
||||||
export { Label };
|
export { Label };
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user