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
|
||||||
@@ -232,6 +262,7 @@ CACHE_DEBUG=true
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Solution** :
|
**Solution** :
|
||||||
|
|
||||||
- Vérifier la taille des bibliothèques
|
- Vérifier la taille des bibliothèques
|
||||||
- Augmenter le TTL pour ces données
|
- Augmenter le TTL pour ces données
|
||||||
- Considérer la pagination
|
- Considérer la pagination
|
||||||
@@ -251,29 +282,33 @@ En mode `file` : les caches survivent au redémarrage
|
|||||||
|
|
||||||
### Temps de réponse normaux
|
### Temps de réponse normaux
|
||||||
|
|
||||||
| Scénario | Temps attendu | Log |
|
| Scénario | Temps attendu | Log |
|
||||||
|----------|---------------|-----|
|
| ----------------------- | ------------- | ------------------------------------ |
|
||||||
| Cache HIT | < 1ms | `[CACHE HIT] ... \| 0.45ms` |
|
| Cache HIT | < 1ms | `[CACHE HIT] ... \| 0.45ms` |
|
||||||
| Cache STALE | < 1ms | `[CACHE STALE] ... \| 0.52ms` |
|
| Cache STALE | < 1ms | `[CACHE STALE] ... \| 0.52ms` |
|
||||||
| Cache MISS (petit) | 50-200ms | `[CACHE SET] ... \| 124.18ms` |
|
| Cache MISS (petit) | 50-200ms | `[CACHE SET] ... \| 124.18ms` |
|
||||||
| Cache MISS (gros) | 200-1000ms | `[CACHE SET] ... \| 847.32ms` |
|
| Cache MISS (gros) | 200-1000ms | `[CACHE SET] ... \| 847.32ms` |
|
||||||
| Revalidate (background) | Variable | `[CACHE REVALIDATE] ... \| 287.45ms` |
|
| Revalidate (background) | Variable | `[CACHE REVALIDATE] ... \| 287.45ms` |
|
||||||
|
|
||||||
### Signaux d'alerte
|
### Signaux d'alerte
|
||||||
|
|
||||||
⚠️ **Cache HIT > 10ms**
|
⚠️ **Cache HIT > 10ms**
|
||||||
|
|
||||||
- Problème : Disque lent (mode file)
|
- Problème : Disque lent (mode file)
|
||||||
- Solution : Vérifier les I/O, passer en mode memory
|
- Solution : Vérifier les I/O, passer en mode memory
|
||||||
|
|
||||||
⚠️ **Cache MISS > 2000ms**
|
⚠️ **Cache MISS > 2000ms**
|
||||||
|
|
||||||
- Problème : Komga très lent ou données énormes
|
- Problème : Komga très lent ou données énormes
|
||||||
- Solution : Vérifier Komga, optimiser la requête
|
- Solution : Vérifier Komga, optimiser la requête
|
||||||
|
|
||||||
⚠️ **REVALIDATE ERROR fréquents**
|
⚠️ **REVALIDATE ERROR fréquents**
|
||||||
|
|
||||||
- Problème : Komga instable ou réseau
|
- Problème : Komga instable ou réseau
|
||||||
- Solution : Augmenter les timeouts, vérifier la connectivité
|
- Solution : Augmenter les timeouts, vérifier la connectivité
|
||||||
|
|
||||||
⚠️ **Trop de MISS successifs**
|
⚠️ **Trop de MISS successifs**
|
||||||
|
|
||||||
- Problème : Cache pas conservé ou TTL trop court
|
- Problème : Cache pas conservé ou TTL trop court
|
||||||
- Solution : Vérifier le mode, augmenter les TTL
|
- Solution : Vérifier le mode, augmenter les TTL
|
||||||
|
|
||||||
@@ -294,12 +329,14 @@ Les logs sont **automatiquement désactivés** si la variable n'est pas définie
|
|||||||
## Logs et performance
|
## Logs et performance
|
||||||
|
|
||||||
**Impact sur les performances** :
|
**Impact sur les performances** :
|
||||||
|
|
||||||
- Overhead : < 0.1ms par opération
|
- Overhead : < 0.1ms par opération
|
||||||
- Pas d'écriture disque (juste console)
|
- Pas d'écriture disque (juste console)
|
||||||
- Pas d'accumulation en mémoire
|
- Pas d'accumulation en mémoire
|
||||||
- Safe pour la production
|
- Safe pour la production
|
||||||
|
|
||||||
**Recommandations** :
|
**Recommandations** :
|
||||||
|
|
||||||
- ✅ Activé en développement
|
- ✅ Activé en développement
|
||||||
- ✅ Activé temporairement en production pour diagnostics
|
- ✅ Activé temporairement en production pour diagnostics
|
||||||
- ❌ Pas nécessaire en production normale
|
- ❌ Pas nécessaire en production normale
|
||||||
@@ -307,6 +344,7 @@ Les logs sont **automatiquement désactivés** si la variable n'est pas définie
|
|||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
Le système de logs de cache est conçu pour être :
|
Le système de logs de cache est conçu pour être :
|
||||||
|
|
||||||
- 🎯 **Simple** : Format clair et concis
|
- 🎯 **Simple** : Format clair et concis
|
||||||
- ⚡ **Rapide** : Impact négligeable sur les performances
|
- ⚡ **Rapide** : Impact négligeable sur les performances
|
||||||
- 🔧 **Utile** : Informations essentielles pour le debug
|
- 🔧 **Utile** : Informations essentielles pour le debug
|
||||||
@@ -314,4 +352,3 @@ Le système de logs de cache est conçu pour être :
|
|||||||
|
|
||||||
Pour la plupart des besoins de debug, les DevTools du navigateur suffisent.
|
Pour la plupart des besoins de debug, les DevTools du navigateur suffisent.
|
||||||
Les logs serveur sont utiles pour comprendre le comportement du cache côté backend.
|
Les logs serveur sont utiles pour comprendre le comportement du cache côté backend.
|
||||||
|
|
||||||
|
|||||||
101
docs/caching.md
101
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,6 +46,7 @@ Le système de caching est organisé en **3 couches indépendantes** avec des re
|
|||||||
### Stratégies
|
### Stratégies
|
||||||
|
|
||||||
#### Images : Cache-First
|
#### Images : Cache-First
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Pour toutes les images (covers + pages)
|
// Pour toutes les images (covers + pages)
|
||||||
const isImageResource = (url) => {
|
const isImageResource = (url) => {
|
||||||
@@ -58,6 +61,7 @@ const isImageResource = (url) => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Comportement** :
|
**Comportement** :
|
||||||
|
|
||||||
1. Vérifier si l'image est dans le cache
|
1. Vérifier si l'image est dans le cache
|
||||||
2. Si oui → retourner depuis le cache
|
2. Si oui → retourner depuis le cache
|
||||||
3. Si non → fetch depuis le réseau
|
3. Si non → fetch depuis le réseau
|
||||||
@@ -65,11 +69,13 @@ const isImageResource = (url) => {
|
|||||||
5. Si échec → retourner 404
|
5. Si échec → retourner 404
|
||||||
|
|
||||||
**Avantages** :
|
**Avantages** :
|
||||||
|
|
||||||
- Performance maximale (lecture instantanée depuis le cache)
|
- Performance maximale (lecture instantanée depuis le cache)
|
||||||
- Fonctionne offline une fois les images chargées
|
- Fonctionne offline une fois les images chargées
|
||||||
- Économise la bande passante
|
- Économise la bande passante
|
||||||
|
|
||||||
#### Navigation et ressources statiques : Network-First
|
#### Navigation et ressources statiques : Network-First
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Pour les pages et ressources _next/static
|
// Pour les pages et ressources _next/static
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
@@ -95,18 +101,20 @@ event.respondWith(
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Avantages** :
|
**Avantages** :
|
||||||
|
|
||||||
- Toujours la dernière version quand online
|
- Toujours la dernière version quand online
|
||||||
- Fallback offline si nécessaire
|
- Fallback offline si nécessaire
|
||||||
- Navigation fluide même sans connexion
|
- Navigation fluide même sans connexion
|
||||||
|
|
||||||
### Caches
|
### Caches
|
||||||
|
|
||||||
| Cache | Usage | Stratégie | Taille |
|
| Cache | Usage | Stratégie | Taille |
|
||||||
|-------|-------|-----------|--------|
|
| ----------------------- | ---------------------------- | ------------- | -------- |
|
||||||
| `stripstream-cache-v1` | Ressources statiques + pages | Network-First | ~5 MB |
|
| `stripstream-cache-v1` | Ressources statiques + pages | Network-First | ~5 MB |
|
||||||
| `stripstream-images-v1` | Images (covers + pages) | Cache-First | Illimité |
|
| `stripstream-images-v1` | Images (covers + pages) | Cache-First | Illimité |
|
||||||
|
|
||||||
### Nettoyage
|
### Nettoyage
|
||||||
|
|
||||||
- Automatique lors de l'activation du Service Worker
|
- Automatique lors de l'activation du Service Worker
|
||||||
- Suppression des anciennes versions de cache
|
- Suppression des anciennes versions de cache
|
||||||
- Pas d'expiration (contrôlé par l'utilisateur via les paramètres du navigateur)
|
- Pas d'expiration (contrôlé par l'utilisateur via les paramètres du navigateur)
|
||||||
@@ -114,9 +122,11 @@ event.respondWith(
|
|||||||
## Couche 2 : ServerCacheService (Serveur)
|
## Couche 2 : ServerCacheService (Serveur)
|
||||||
|
|
||||||
### Fichier
|
### Fichier
|
||||||
|
|
||||||
`src/lib/services/server-cache.service.ts`
|
`src/lib/services/server-cache.service.ts`
|
||||||
|
|
||||||
### Responsabilité
|
### Responsabilité
|
||||||
|
|
||||||
- Cache des réponses API Komga côté serveur
|
- Cache des réponses API Komga côté serveur
|
||||||
- Optimisation des temps de réponse
|
- Optimisation des temps de réponse
|
||||||
- Réduction de la charge sur Komga
|
- Réduction de la charge sur Komga
|
||||||
@@ -126,6 +136,7 @@ event.respondWith(
|
|||||||
Cette stratégie est **la clé de la performance** de l'application.
|
Cette stratégie est **la clé de la performance** de l'application.
|
||||||
|
|
||||||
#### Principe
|
#### Principe
|
||||||
|
|
||||||
```
|
```
|
||||||
Requête → Cache existe ?
|
Requête → Cache existe ?
|
||||||
├─ Non → Fetch normal + mise en cache
|
├─ Non → Fetch normal + mise en cache
|
||||||
@@ -136,6 +147,7 @@ Requête → Cache existe ?
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Implémentation
|
#### Implémentation
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async getOrSet<T>(
|
async getOrSet<T>(
|
||||||
key: string,
|
key: string,
|
||||||
@@ -164,12 +176,14 @@ async getOrSet<T>(
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Avantages
|
#### Avantages
|
||||||
|
|
||||||
✅ **Temps de réponse constant** : Le cache expiré est retourné instantanément
|
✅ **Temps de réponse constant** : Le cache expiré est retourné instantanément
|
||||||
✅ **Données fraîches** : Revalidation en background pour la prochaine requête
|
✅ **Données fraîches** : Revalidation en background pour la prochaine requête
|
||||||
✅ **Pas de délai** : L'utilisateur ne subit jamais l'attente de revalidation
|
✅ **Pas de délai** : L'utilisateur ne subit jamais l'attente de revalidation
|
||||||
✅ **Résilience** : Même si Komga est lent, l'app reste rapide
|
✅ **Résilience** : Même si Komga est lent, l'app reste rapide
|
||||||
|
|
||||||
#### Inconvénients
|
#### Inconvénients
|
||||||
|
|
||||||
⚠️ Les données peuvent être légèrement obsolètes (jusqu'au prochain refresh)
|
⚠️ Les données peuvent être légèrement obsolètes (jusqu'au prochain refresh)
|
||||||
⚠️ Nécessite un cache initialisé (première requête toujours lente)
|
⚠️ Nécessite un cache initialisé (première requête toujours lente)
|
||||||
|
|
||||||
@@ -178,9 +192,11 @@ async getOrSet<T>(
|
|||||||
L'utilisateur peut choisir entre deux modes :
|
L'utilisateur peut choisir entre deux modes :
|
||||||
|
|
||||||
#### Mode Mémoire (par défaut)
|
#### Mode Mémoire (par défaut)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
cacheMode: "memory"
|
cacheMode: "memory";
|
||||||
```
|
```
|
||||||
|
|
||||||
- Cache stocké en RAM
|
- Cache stocké en RAM
|
||||||
- **Performances** : Très rapide (lecture < 1ms)
|
- **Performances** : Très rapide (lecture < 1ms)
|
||||||
- **Persistance** : Perdu au redémarrage du serveur
|
- **Persistance** : Perdu au redémarrage du serveur
|
||||||
@@ -188,9 +204,11 @@ cacheMode: "memory"
|
|||||||
- **Idéal pour** : Développement, faible charge
|
- **Idéal pour** : Développement, faible charge
|
||||||
|
|
||||||
#### Mode Fichier
|
#### Mode Fichier
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
cacheMode: "file"
|
cacheMode: "file";
|
||||||
```
|
```
|
||||||
|
|
||||||
- Cache stocké sur disque (`.cache/`)
|
- Cache stocké sur disque (`.cache/`)
|
||||||
- **Performances** : Rapide (lecture 5-10ms)
|
- **Performances** : Rapide (lecture 5-10ms)
|
||||||
- **Persistance** : Survit aux redémarrages
|
- **Persistance** : Survit aux redémarrages
|
||||||
@@ -201,14 +219,14 @@ cacheMode: "file"
|
|||||||
|
|
||||||
Chaque type de données a un TTL configuré :
|
Chaque type de données a un TTL configuré :
|
||||||
|
|
||||||
| Type | TTL par défaut | Justification |
|
| Type | TTL par défaut | Justification |
|
||||||
|------|----------------|---------------|
|
| ----------- | -------------- | ---------------------------------- |
|
||||||
| `DEFAULT` | 5 minutes | Données génériques |
|
| `DEFAULT` | 5 minutes | Données génériques |
|
||||||
| `HOME` | 10 minutes | Page d'accueil (données agrégées) |
|
| `HOME` | 10 minutes | Page d'accueil (données agrégées) |
|
||||||
| `LIBRARIES` | 24 heures | Bibliothèques (rarement modifiées) |
|
| `LIBRARIES` | 24 heures | Bibliothèques (rarement modifiées) |
|
||||||
| `SERIES` | 5 minutes | Séries (métadonnées + progression) |
|
| `SERIES` | 5 minutes | Séries (métadonnées + progression) |
|
||||||
| `BOOKS` | 5 minutes | Livres (métadonnées + progression) |
|
| `BOOKS` | 5 minutes | Livres (métadonnées + progression) |
|
||||||
| `IMAGES` | 7 jours | Images (immuables) |
|
| `IMAGES` | 7 jours | Images (immuables) |
|
||||||
|
|
||||||
#### Configuration personnalisée
|
#### Configuration personnalisée
|
||||||
|
|
||||||
@@ -235,6 +253,7 @@ const cacheKey = `${user.id}-${key}`;
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Avantages** :
|
**Avantages** :
|
||||||
|
|
||||||
- Pas de collision entre utilisateurs
|
- Pas de collision entre utilisateurs
|
||||||
- Progression de lecture individuelle
|
- Progression de lecture individuelle
|
||||||
- Préférences personnalisées
|
- Préférences personnalisées
|
||||||
@@ -244,18 +263,21 @@ const cacheKey = `${user.id}-${key}`;
|
|||||||
Le cache peut être invalidé :
|
Le cache peut être invalidé :
|
||||||
|
|
||||||
#### Manuellement
|
#### Manuellement
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await cacheService.delete(key); // Une clé
|
await cacheService.delete(key); // Une clé
|
||||||
await cacheService.deleteAll(prefix); // Toutes les clés avec préfixe
|
await cacheService.deleteAll(prefix); // Toutes les clés avec préfixe
|
||||||
await cacheService.clear(); // Tout le cache
|
await cacheService.clear(); // Tout le cache
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Automatiquement
|
#### Automatiquement
|
||||||
|
|
||||||
- Lors d'une mise à jour de progression
|
- Lors d'une mise à jour de progression
|
||||||
- Lors d'un changement de favoris
|
- Lors d'un changement de favoris
|
||||||
- Lors de la suppression d'une série
|
- Lors de la suppression d'une série
|
||||||
|
|
||||||
#### API
|
#### API
|
||||||
|
|
||||||
```
|
```
|
||||||
DELETE /api/komga/cache/clear // Vider tout le cache
|
DELETE /api/komga/cache/clear // Vider tout le cache
|
||||||
DELETE /api/komga/home // Invalider le cache home
|
DELETE /api/komga/home // Invalider le cache home
|
||||||
@@ -264,12 +286,14 @@ DELETE /api/komga/home // Invalider le cache home
|
|||||||
## Couche 3 : Cache HTTP (Navigateur)
|
## Couche 3 : Cache HTTP (Navigateur)
|
||||||
|
|
||||||
### Responsabilité
|
### Responsabilité
|
||||||
|
|
||||||
- Cache basique géré par le navigateur
|
- Cache basique géré par le navigateur
|
||||||
- Headers HTTP standard
|
- Headers HTTP standard
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
#### Next.js ISR (Incremental Static Regeneration)
|
#### Next.js ISR (Incremental Static Regeneration)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export const revalidate = 60; // Revalidation toutes les 60 secondes
|
export const revalidate = 60; // Revalidation toutes les 60 secondes
|
||||||
```
|
```
|
||||||
@@ -279,20 +303,23 @@ Utilisé uniquement pour les routes avec rendu statique.
|
|||||||
#### Headers explicites (désactivé)
|
#### Headers explicites (désactivé)
|
||||||
|
|
||||||
Les headers HTTP explicites ont été **supprimés** car :
|
Les headers HTTP explicites ont été **supprimés** car :
|
||||||
|
|
||||||
- Le ServerCacheService gère déjà le caching efficacement
|
- Le ServerCacheService gère déjà le caching efficacement
|
||||||
- Évite la confusion entre plusieurs couches de cache
|
- Évite la confusion entre plusieurs couches de cache
|
||||||
- Simplifie le debugging
|
- Simplifie le debugging
|
||||||
|
|
||||||
Avant (supprimé) :
|
Avant (supprimé) :
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
NextResponse.json(data, {
|
NextResponse.json(data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
|
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Maintenant :
|
Maintenant :
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
NextResponse.json(data); // Pas de headers
|
NextResponse.json(data); // Pas de headers
|
||||||
```
|
```
|
||||||
@@ -322,29 +349,32 @@ Exemple : Chargement de la page d'accueil
|
|||||||
|
|
||||||
### Temps de réponse typiques
|
### Temps de réponse typiques
|
||||||
|
|
||||||
| Scénario | Temps | Détails |
|
| Scénario | Temps | Détails |
|
||||||
|----------|-------|---------|
|
| ----------------------------- | ----------- | -------------------------- |
|
||||||
| Cache ServerCache valide + SW | ~50ms | Optimal |
|
| Cache ServerCache valide + SW | ~50ms | Optimal |
|
||||||
| Cache ServerCache expiré + SW | ~50ms | Revalidation en background |
|
| Cache ServerCache expiré + SW | ~50ms | Revalidation en background |
|
||||||
| Pas de cache ServerCache + SW | ~200-500ms | Première requête |
|
| Pas de cache ServerCache + SW | ~200-500ms | Première requête |
|
||||||
| Cache SW uniquement | ~10ms | Images seulement |
|
| Cache SW uniquement | ~10ms | Images seulement |
|
||||||
| Tout à froid | ~500-1000ms | Pire cas |
|
| Tout à froid | ~500-1000ms | Pire cas |
|
||||||
|
|
||||||
## Cas d'usage
|
## Cas d'usage
|
||||||
|
|
||||||
### 1. Première visite
|
### 1. Première visite
|
||||||
|
|
||||||
```
|
```
|
||||||
User → App → Komga (tous les caches vides)
|
User → App → Komga (tous les caches vides)
|
||||||
Temps : ~500-1000ms
|
Temps : ~500-1000ms
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Visite suivante (online)
|
### 2. Visite suivante (online)
|
||||||
|
|
||||||
```
|
```
|
||||||
User → ServerCache (valide) → Images SW
|
User → ServerCache (valide) → Images SW
|
||||||
Temps : ~50ms
|
Temps : ~50ms
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Cache expiré (online)
|
### 3. Cache expiré (online)
|
||||||
|
|
||||||
```
|
```
|
||||||
User → ServerCache (stale) → Retour immédiat
|
User → ServerCache (stale) → Retour immédiat
|
||||||
↓
|
↓
|
||||||
@@ -353,6 +383,7 @@ Temps ressenti : ~50ms (aucun délai)
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4. Mode offline
|
### 4. Mode offline
|
||||||
|
|
||||||
```
|
```
|
||||||
User → Service Worker cache uniquement
|
User → Service Worker cache uniquement
|
||||||
Fonctionnalités :
|
Fonctionnalités :
|
||||||
@@ -373,6 +404,7 @@ CACHE_DEBUG=true
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Format des logs** :
|
**Format des logs** :
|
||||||
|
|
||||||
```
|
```
|
||||||
[CACHE HIT] home-ongoing | HOME | 0.45ms # Cache valide
|
[CACHE HIT] home-ongoing | HOME | 0.45ms # Cache valide
|
||||||
[CACHE STALE] home-ongoing | HOME | 0.52ms # Cache expiré (retourné + revalidation)
|
[CACHE STALE] home-ongoing | HOME | 0.52ms # Cache expiré (retourné + revalidation)
|
||||||
@@ -386,24 +418,28 @@ CACHE_DEBUG=true
|
|||||||
### API de monitoring
|
### API de monitoring
|
||||||
|
|
||||||
#### Taille du cache serveur
|
#### Taille du cache serveur
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET /api/komga/cache/size
|
GET /api/komga/cache/size
|
||||||
Response: { sizeInBytes: 15728640, itemCount: 234 }
|
Response: { sizeInBytes: 15728640, itemCount: 234 }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Mode de cache actuel
|
#### Mode de cache actuel
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET /api/komga/cache/mode
|
GET /api/komga/cache/mode
|
||||||
Response: { mode: "memory" }
|
Response: { mode: "memory" }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Changer le mode
|
#### Changer le mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/komga/cache/mode
|
POST /api/komga/cache/mode
|
||||||
Body: { mode: "file" }
|
Body: { mode: "file" }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Vider le cache
|
#### Vider le cache
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/komga/cache/clear
|
POST /api/komga/cache/clear
|
||||||
```
|
```
|
||||||
@@ -411,21 +447,26 @@ POST /api/komga/cache/clear
|
|||||||
### DevTools du navigateur
|
### DevTools du navigateur
|
||||||
|
|
||||||
#### Network Tab
|
#### Network Tab
|
||||||
|
|
||||||
- Temps de réponse < 50ms = cache serveur
|
- Temps de réponse < 50ms = cache serveur
|
||||||
- Headers `X-Cache` si configurés
|
- Headers `X-Cache` si configurés
|
||||||
- Onglet "Timing" pour détails
|
- Onglet "Timing" pour détails
|
||||||
|
|
||||||
#### Application → Cache Storage
|
#### Application → Cache Storage
|
||||||
|
|
||||||
Inspecter le Service Worker :
|
Inspecter le Service Worker :
|
||||||
|
|
||||||
- `stripstream-cache-v1` : Ressources statiques
|
- `stripstream-cache-v1` : Ressources statiques
|
||||||
- `stripstream-images-v1` : Images
|
- `stripstream-images-v1` : Images
|
||||||
|
|
||||||
Actions disponibles :
|
Actions disponibles :
|
||||||
|
|
||||||
- Voir le contenu
|
- Voir le contenu
|
||||||
- Supprimer des entrées
|
- Supprimer des entrées
|
||||||
- Vider complètement
|
- Vider complètement
|
||||||
|
|
||||||
#### Application → Service Workers
|
#### Application → Service Workers
|
||||||
|
|
||||||
- État du Service Worker
|
- État du Service Worker
|
||||||
- "Unregister" pour le désactiver
|
- "Unregister" pour le désactiver
|
||||||
- "Update" pour forcer une mise à jour
|
- "Update" pour forcer une mise à jour
|
||||||
@@ -433,21 +474,25 @@ Actions disponibles :
|
|||||||
## Optimisations futures possibles
|
## Optimisations futures possibles
|
||||||
|
|
||||||
### 1. Cache Redis (optionnel)
|
### 1. Cache Redis (optionnel)
|
||||||
|
|
||||||
- Pour un déploiement multi-instances
|
- Pour un déploiement multi-instances
|
||||||
- Cache partagé entre plusieurs serveurs
|
- Cache partagé entre plusieurs serveurs
|
||||||
- TTL natif Redis
|
- TTL natif Redis
|
||||||
|
|
||||||
### 2. Compression
|
### 2. Compression
|
||||||
|
|
||||||
- Compresser les données en cache (Brotli/Gzip)
|
- Compresser les données en cache (Brotli/Gzip)
|
||||||
- Économie d'espace disque/mémoire
|
- Économie d'espace disque/mémoire
|
||||||
- Trade-off CPU vs espace
|
- Trade-off CPU vs espace
|
||||||
|
|
||||||
### 3. Prefetching intelligent
|
### 3. Prefetching intelligent
|
||||||
|
|
||||||
- Précharger les séries en cours de lecture
|
- Précharger les séries en cours de lecture
|
||||||
- Précharger les pages suivantes dans le reader
|
- Précharger les pages suivantes dans le reader
|
||||||
- Basé sur l'historique utilisateur
|
- Basé sur l'historique utilisateur
|
||||||
|
|
||||||
### 4. Cache Analytics
|
### 4. Cache Analytics
|
||||||
|
|
||||||
- Ratio hit/miss
|
- Ratio hit/miss
|
||||||
- Temps de réponse moyens
|
- Temps de réponse moyens
|
||||||
- Identification des données les plus consultées
|
- Identification des données les plus consultées
|
||||||
@@ -457,6 +502,7 @@ Actions disponibles :
|
|||||||
### Pour les développeurs
|
### Pour les développeurs
|
||||||
|
|
||||||
✅ **Utiliser BaseApiService.fetchWithCache()**
|
✅ **Utiliser BaseApiService.fetchWithCache()**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await this.fetchWithCache<T>(
|
await this.fetchWithCache<T>(
|
||||||
"cache-key",
|
"cache-key",
|
||||||
@@ -466,11 +512,13 @@ await this.fetchWithCache<T>(
|
|||||||
```
|
```
|
||||||
|
|
||||||
✅ **Invalider le cache après modification**
|
✅ **Invalider le cache après modification**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await HomeService.invalidateHomeCache();
|
await HomeService.invalidateHomeCache();
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ **Choisir le bon TTL**
|
✅ **Choisir le bon TTL**
|
||||||
|
|
||||||
- Court (1-5 min) : Données qui changent souvent
|
- Court (1-5 min) : Données qui changent souvent
|
||||||
- Moyen (10-30 min) : Données agrégées
|
- Moyen (10-30 min) : Données agrégées
|
||||||
- Long (24h+) : Données quasi-statiques
|
- Long (24h+) : Données quasi-statiques
|
||||||
@@ -499,4 +547,3 @@ Le système de caching de StripStream est conçu pour :
|
|||||||
🧹 **Simplicité** : 3 couches bien définies, pas de redondance
|
🧹 **Simplicité** : 3 couches bien définies, pas de redondance
|
||||||
|
|
||||||
Le système est maintenu simple avec des responsabilités claires pour chaque couche, facilitant la maintenance et l'évolution future.
|
Le système est maintenu simple avec des responsabilités claires pour chaque couche, facilitant la maintenance et l'évolution future.
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ async function checkDatabase() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`📊 Found ${users.length} users:`);
|
console.log(`📊 Found ${users.length} users:`);
|
||||||
users.forEach(user => {
|
users.forEach((user) => {
|
||||||
console.log(` - ID: ${user.id}, Email: ${user.email}, Roles: ${JSON.stringify(user.roles)}, Created: ${user.createdAt}`);
|
console.log(
|
||||||
|
` - ID: ${user.id}, Email: ${user.email}, Roles: ${JSON.stringify(user.roles)}, Created: ${user.createdAt}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Vérifier les configurations
|
// Vérifier les configurations
|
||||||
@@ -35,7 +37,6 @@ async function checkDatabase() {
|
|||||||
console.log(` - KomgaConfigs: ${komgaConfigs}`);
|
console.log(` - KomgaConfigs: ${komgaConfigs}`);
|
||||||
console.log(` - Preferences: ${preferences}`);
|
console.log(` - Preferences: ${preferences}`);
|
||||||
console.log(` - Favorites: ${favorites}`);
|
console.log(` - Favorites: ${favorites}`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Error checking database:", error);
|
console.error("❌ Error checking database:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -44,4 +45,3 @@ async function checkDatabase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkDatabase();
|
checkDatabase();
|
||||||
|
|
||||||
|
|||||||
@@ -72,4 +72,3 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +4,7 @@ import { isAdmin } from "@/lib/auth-utils";
|
|||||||
import { AdminContent } from "@/components/admin/AdminContent";
|
import { AdminContent } from "@/components/admin/AdminContent";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ export async function GET() {
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_FORBIDDEN" ? 403 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500
|
error.code === "AUTH_FORBIDDEN"
|
||||||
|
? 403
|
||||||
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,4 +30,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,17 +14,14 @@ export async function PUT(
|
|||||||
const { newPassword } = body;
|
const { newPassword } = body;
|
||||||
|
|
||||||
if (!newPassword) {
|
if (!newPassword) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Nouveau mot de passe manquant" }, { status: 400 });
|
||||||
{ error: "Nouveau mot de passe manquant" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier que le mot de passe est fort
|
// Vérifier que le mot de passe est fort
|
||||||
if (!AuthServerService.isPasswordStrong(newPassword)) {
|
if (!AuthServerService.isPasswordStrong(newPassword)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: "Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre"
|
error: "Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre",
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
@@ -40,10 +37,16 @@ export async function PUT(
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_FORBIDDEN" ? 403 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 :
|
error.code === "AUTH_FORBIDDEN"
|
||||||
error.code === "AUTH_USER_NOT_FOUND" ? 404 :
|
? 403
|
||||||
error.code === "ADMIN_CANNOT_RESET_OWN_PASSWORD" ? 400 : 500
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: error.code === "AUTH_USER_NOT_FOUND"
|
||||||
|
? 404
|
||||||
|
: error.code === "ADMIN_CANNOT_RESET_OWN_PASSWORD"
|
||||||
|
? 400
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,4 +57,3 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ export async function PATCH(
|
|||||||
const { roles } = body;
|
const { roles } = body;
|
||||||
|
|
||||||
if (!roles || !Array.isArray(roles)) {
|
if (!roles || !Array.isArray(roles)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Rôles invalides" }, { status: 400 });
|
||||||
{ error: "Rôles invalides" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await AdminService.updateUserRoles(userId, roles);
|
await AdminService.updateUserRoles(userId, roles);
|
||||||
@@ -29,9 +26,14 @@ export async function PATCH(
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_FORBIDDEN" ? 403 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 :
|
error.code === "AUTH_FORBIDDEN"
|
||||||
error.code === "AUTH_USER_NOT_FOUND" ? 404 : 500
|
? 403
|
||||||
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: error.code === "AUTH_USER_NOT_FOUND"
|
||||||
|
? 404
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,10 +61,16 @@ export async function DELETE(
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_FORBIDDEN" ? 403 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 :
|
error.code === "AUTH_FORBIDDEN"
|
||||||
error.code === "AUTH_USER_NOT_FOUND" ? 404 :
|
? 403
|
||||||
error.code === "ADMIN_CANNOT_DELETE_SELF" ? 400 : 500
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: error.code === "AUTH_USER_NOT_FOUND"
|
||||||
|
? 404
|
||||||
|
: error.code === "ADMIN_CANNOT_DELETE_SELF"
|
||||||
|
? 400
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -73,4 +81,3 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ export async function GET() {
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_FORBIDDEN" ? 403 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500
|
error.code === "AUTH_FORBIDDEN"
|
||||||
|
? 403
|
||||||
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/app/api/komga/cache/entries/route.ts
vendored
1
src/app/api/komga/cache/entries/route.ts
vendored
@@ -25,4 +25,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
src/app/api/komga/cache/size/route.ts
vendored
3
src/app/api/komga/cache/size/route.ts
vendored
@@ -13,7 +13,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
sizeInBytes,
|
sizeInBytes,
|
||||||
itemCount,
|
itemCount,
|
||||||
mode: cacheService.getCacheMode()
|
mode: cacheService.getCacheMode(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la taille du cache:");
|
logger.error({ err: error }, "Erreur lors de la récupération de la taille du cache:");
|
||||||
@@ -29,4 +29,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,4 +67,3 @@ export async function DELETE() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,4 +43,3 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ export async function GET(
|
|||||||
|
|
||||||
const [series, library] = await Promise.all([
|
const [series, library] = await Promise.all([
|
||||||
LibraryService.getLibrarySeries(libraryId, page, size, unreadOnly, search),
|
LibraryService.getLibrarySeries(libraryId, page, size, unreadOnly, search),
|
||||||
LibraryService.getLibrary(libraryId)
|
LibraryService.getLibrary(libraryId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ series, library },
|
{ series, library },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
|
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -98,4 +98,3 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,4 +51,3 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ export async function GET(
|
|||||||
|
|
||||||
const [books, series] = await Promise.all([
|
const [books, series] = await Promise.all([
|
||||||
SeriesService.getSeriesBooks(seriesId, page, size, unreadOnly),
|
SeriesService.getSeriesBooks(seriesId, page, size, unreadOnly),
|
||||||
SeriesService.getSeries(seriesId)
|
SeriesService.getSeries(seriesId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ books, series },
|
{ books, series },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
|
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -70,7 +70,7 @@ export async function DELETE(
|
|||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
SeriesService.invalidateSeriesBooksCache(seriesId),
|
SeriesService.invalidateSeriesBooksCache(seriesId),
|
||||||
SeriesService.invalidateSeriesCache(seriesId)
|
SeriesService.invalidateSeriesCache(seriesId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
@@ -100,4 +100,3 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
@@ -36,8 +34,12 @@ export async function PUT(request: NextRequest) {
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message, code: error.code },
|
{ error: error.message, code: error.code },
|
||||||
{
|
{
|
||||||
status: error.code === "AUTH_INVALID_PASSWORD" ? 400 :
|
status:
|
||||||
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500
|
error.code === "AUTH_INVALID_PASSWORD"
|
||||||
|
? 400
|
||||||
|
: error.code === "AUTH_UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: 500,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
export default function BookReaderLayout({
|
export default function BookReaderLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function ClientLibraryPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||||
cache: 'default' // Utilise le cache HTTP du navigateur
|
cache: "default", // Utilise le cache HTTP du navigateur
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -86,7 +86,7 @@ export function ClientLibraryPage({
|
|||||||
try {
|
try {
|
||||||
// Invalidate cache via API
|
// Invalidate cache via API
|
||||||
const cacheResponse = await fetch(`/api/komga/libraries/${libraryId}/series`, {
|
const cacheResponse = await fetch(`/api/komga/libraries/${libraryId}/series`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cacheResponse.ok) {
|
if (!cacheResponse.ok) {
|
||||||
@@ -105,7 +105,7 @@ export function ClientLibraryPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||||
cache: 'reload' // Force un nouveau fetch après invalidation
|
cache: "reload", // Force un nouveau fetch après invalidation
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -139,7 +139,7 @@ export function ClientLibraryPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||||
cache: 'reload' // Force un nouveau fetch lors du retry
|
cache: "reload", // Force un nouveau fetch lors du retry
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -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,7 +42,7 @@ export function PullToRefreshIndicator({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full transition-all duration-200 rounded-full",
|
"h-full transition-all duration-200 rounded-full",
|
||||||
(canRefresh || isRefreshing) ? "bg-primary" : "bg-muted-foreground"
|
canRefresh || isRefreshing ? "bg-primary" : "bg-muted-foreground"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: `${isRefreshing ? 200 : barWidth}px`,
|
width: `${isRefreshing ? 200 : barWidth}px`,
|
||||||
@@ -53,14 +55,13 @@ export function PullToRefreshIndicator({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200",
|
"flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200",
|
||||||
(canRefresh || isRefreshing) ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
canRefresh || isRefreshing
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={cn(
|
className={cn("h-4 w-4 transition-all duration-200", isRefreshing && "animate-spin")}
|
||||||
"h-4 w-4 transition-all duration-200",
|
|
||||||
isRefreshing && "animate-spin"
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
transform: isRefreshing ? "rotate(0deg)" : `rotate(${rotation}deg)`,
|
transform: isRefreshing ? "rotate(0deg)" : `rotate(${rotation}deg)`,
|
||||||
animationDuration: isRefreshing ? "2s" : undefined,
|
animationDuration: isRefreshing ? "2s" : undefined,
|
||||||
@@ -73,10 +74,16 @@ export function PullToRefreshIndicator({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-2 text-center text-xs transition-opacity duration-200",
|
"mt-2 text-center text-xs transition-opacity duration-200",
|
||||||
(canRefresh || isRefreshing) ? "text-primary opacity-100" : "text-muted-foreground opacity-70"
|
canRefresh || isRefreshing
|
||||||
|
? "text-primary opacity-100"
|
||||||
|
: "text-muted-foreground opacity-70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isRefreshing ? "Actualisation..." : canRefresh ? "Relâchez pour actualiser" : "Tirez pour actualiser"}
|
{isRefreshing
|
||||||
|
? "Actualisation..."
|
||||||
|
: canRefresh
|
||||||
|
? "Relâchez pour actualiser"
|
||||||
|
: "Tirez pour actualiser"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,7 +23,7 @@ export function ClientHomePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/komga/home", {
|
const response = await fetch("/api/komga/home", {
|
||||||
cache: 'default' // Utilise le cache HTTP du navigateur
|
cache: "default", // Utilise le cache HTTP du navigateur
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -67,7 +67,7 @@ export function ClientHomePage() {
|
|||||||
|
|
||||||
// Récupérer les nouvelles données
|
// Récupérer les nouvelles données
|
||||||
const response = await fetch("/api/komga/home", {
|
const response = await fetch("/api/komga/home", {
|
||||||
cache: 'reload' // Force un nouveau fetch après invalidation
|
cache: "reload", // Force un nouveau fetch après invalidation
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -128,4 +128,3 @@ export function ClientHomePage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -83,9 +83,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
const title = isSeries
|
const title = isSeries
|
||||||
? item.metadata.title
|
? item.metadata.title
|
||||||
: item.metadata.title ||
|
: item.metadata.title ||
|
||||||
(item.metadata.number
|
(item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : "");
|
||||||
? t("navigation.volume", { number: item.metadata.number })
|
|
||||||
: "");
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
// Pour les séries, toujours autoriser le clic
|
// Pour les séries, toujours autoriser le clic
|
||||||
@@ -100,7 +98,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden",
|
"flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden",
|
||||||
(!isSeries && !isAccessible) ? "cursor-not-allowed" : "cursor-pointer"
|
!isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[2/3] bg-muted">
|
<div className="relative aspect-[2/3] bg-muted">
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ interface ClientLayoutProps {
|
|||||||
userIsAdmin?: boolean;
|
userIsAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClientLayout({ children, initialLibraries = [], initialFavorites = [], userIsAdmin = false }: ClientLayoutProps) {
|
export default function ClientLayout({
|
||||||
|
children,
|
||||||
|
initialLibraries = [],
|
||||||
|
initialFavorites = [],
|
||||||
|
userIsAdmin = false,
|
||||||
|
}: ClientLayoutProps) {
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const [randomBookId, setRandomBookId] = useState<string | null>(null);
|
const [randomBookId, setRandomBookId] = useState<string | null>(null);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -137,7 +142,7 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
|
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
|
||||||
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith('/books/');
|
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
|
||||||
|
|
||||||
const hasCustomBackground =
|
const hasCustomBackground =
|
||||||
preferences.background.type === "gradient" ||
|
preferences.background.type === "gradient" ||
|
||||||
@@ -149,15 +154,14 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
|
|||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<ImageCacheProvider>
|
<ImageCacheProvider>
|
||||||
{/* Background fixe pour les images et gradients */}
|
{/* Background fixe pour les images et gradients */}
|
||||||
{hasCustomBackground && (
|
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
|
||||||
<div
|
|
||||||
className="fixed inset-0 -z-10"
|
|
||||||
style={backgroundStyle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
|
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
|
||||||
style={hasCustomBackground ? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` } : undefined}
|
style={
|
||||||
|
hasCustomBackground
|
||||||
|
? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{!isPublicRoute && (
|
{!isPublicRoute && (
|
||||||
<Header
|
<Header
|
||||||
|
|||||||
@@ -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,7 +16,12 @@ interface LibraryHeaderProps {
|
|||||||
refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
|
refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }: LibraryHeaderProps) => {
|
export const LibraryHeader = ({
|
||||||
|
library,
|
||||||
|
seriesCount,
|
||||||
|
series,
|
||||||
|
refreshLibrary,
|
||||||
|
}: LibraryHeaderProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
// Mémoriser la sélection des séries pour éviter les rerenders inutiles
|
// Mémoriser la sélection des séries pour éviter les rerenders inutiles
|
||||||
@@ -25,9 +30,10 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
|
|||||||
const random = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null;
|
const random = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null;
|
||||||
|
|
||||||
// Sélectionner une autre série aléatoire pour le fond (différente de celle du centre)
|
// Sélectionner une autre série aléatoire pour le fond (différente de celle du centre)
|
||||||
const background = series.length > 1
|
const background =
|
||||||
? series.filter(s => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))]
|
series.length > 1
|
||||||
: random;
|
? series.filter((s) => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))]
|
||||||
|
: random;
|
||||||
|
|
||||||
return { randomSeries: random, backgroundSeries: background };
|
return { randomSeries: random, backgroundSeries: background };
|
||||||
}, [series]);
|
}, [series]);
|
||||||
@@ -81,8 +87,7 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
|
|||||||
<StatusBadge status="unread" icon={Library}>
|
<StatusBadge status="unread" icon={Library}>
|
||||||
{seriesCount === 1
|
{seriesCount === 1
|
||||||
? t("library.header.series", { count: seriesCount })
|
? t("library.header.series", { count: seriesCount })
|
||||||
: t("library.header.series_plural", { count: seriesCount })
|
: t("library.header.series_plural", { count: seriesCount })}
|
||||||
}
|
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
|
|
||||||
<RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} />
|
<RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} />
|
||||||
@@ -90,9 +95,7 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{library.unavailable && (
|
{library.unavailable && (
|
||||||
<p className="text-sm text-destructive mt-2">
|
<p className="text-sm text-destructive mt-2">{t("library.header.unavailable")}</p>
|
||||||
{t("library.header.unavailable")}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,4 +103,3 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,26 +43,26 @@ export function PaginatedSeriesGrid({
|
|||||||
const effectivePageSize = pageSize || displayItemsPerPage;
|
const effectivePageSize = pageSize || displayItemsPerPage;
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const updateUrlParams = useCallback(async (
|
const updateUrlParams = useCallback(
|
||||||
updates: Record<string, string | null>,
|
async (updates: Record<string, string | null>, replace: boolean = false) => {
|
||||||
replace: boolean = false
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
|
|
||||||
Object.entries(updates).forEach(([key, value]) => {
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
params.delete(key);
|
params.delete(key);
|
||||||
|
} else {
|
||||||
|
params.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
await router.replace(`${pathname}?${params.toString()}`);
|
||||||
} else {
|
} else {
|
||||||
params.set(key, value);
|
await router.push(`${pathname}?${params.toString()}`);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
[router, pathname, searchParams]
|
||||||
if (replace) {
|
);
|
||||||
await router.replace(`${pathname}?${params.toString()}`);
|
|
||||||
} else {
|
|
||||||
await router.push(`${pathname}?${params.toString()}`);
|
|
||||||
}
|
|
||||||
}, [router, pathname, searchParams]);
|
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,7 +89,6 @@ export function PaginatedSeriesGrid({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handlePageSizeChange = async (size: number) => {
|
const handlePageSizeChange = async (size: number) => {
|
||||||
await updateUrlParams({
|
await updateUrlParams({
|
||||||
page: "1",
|
page: "1",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -109,9 +113,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
|
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
|
||||||
<div className="flex items-center gap-1 hidden sm:flex">
|
<div className="flex items-center gap-1 hidden sm:flex">
|
||||||
<User className="h-3 w-3" />
|
<User className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">{series.booksMetadata.authors[0].name}</span>
|
||||||
{series.booksMetadata.authors[0].name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +150,12 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badge de statut */}
|
{/* Badge de statut */}
|
||||||
<span className={cn("px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
|
statusInfo.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +184,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User className="h-3 w-3" />
|
<User className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">
|
||||||
{series.booksMetadata.authors.map(a => a.name).join(", ")}
|
{series.booksMetadata.authors.map((a) => a.name).join(", ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -246,4 +253,3 @@ export function SeriesList({ series, isCompact = false }: SeriesListProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
loadedImages,
|
||||||
|
imageBlobUrls,
|
||||||
|
prefetchPages,
|
||||||
|
prefetchNextBook,
|
||||||
|
handleForceReload,
|
||||||
|
getPageUrl,
|
||||||
|
prefetchCount,
|
||||||
|
} = useImageLoader({
|
||||||
bookId: book.id,
|
bookId: book.id,
|
||||||
pages,
|
pages,
|
||||||
prefetchCount: preferences.readerPrefetchCount,
|
prefetchCount: preferences.readerPrefetchCount,
|
||||||
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null
|
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null,
|
||||||
});
|
|
||||||
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } = usePageNavigation({
|
|
||||||
book,
|
|
||||||
pages,
|
|
||||||
isDoublePage,
|
|
||||||
shouldShowDoublePage: (page) => shouldShowDoublePage(page, pages.length),
|
|
||||||
onClose,
|
|
||||||
nextBook,
|
|
||||||
});
|
});
|
||||||
|
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
|
||||||
|
usePageNavigation({
|
||||||
|
book,
|
||||||
|
pages,
|
||||||
|
isDoublePage,
|
||||||
|
shouldShowDoublePage: (page) => shouldShowDoublePage(page, pages.length),
|
||||||
|
onClose,
|
||||||
|
nextBook,
|
||||||
|
});
|
||||||
const { pswpRef, handleZoom } = usePhotoSwipeZoom({
|
const { pswpRef, handleZoom } = usePhotoSwipeZoom({
|
||||||
loadedImages,
|
loadedImages,
|
||||||
currentPage,
|
currentPage,
|
||||||
@@ -58,14 +67,13 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
|
|
||||||
// Activer le zoom dans le reader en enlevant la classe no-pinch-zoom
|
// Activer le zoom dans le reader en enlevant la classe no-pinch-zoom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.remove('no-pinch-zoom');
|
document.body.classList.remove("no-pinch-zoom");
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.classList.add('no-pinch-zoom');
|
document.body.classList.add("no-pinch-zoom");
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// Prefetch current and next pages
|
// Prefetch current and next pages
|
||||||
// Deduplication in useImageLoader prevents redundant requests
|
// Deduplication in useImageLoader prevents redundant requests
|
||||||
// Server queue (RequestQueueService) handles concurrency limits
|
// Server queue (RequestQueueService) handles concurrency limits
|
||||||
@@ -74,7 +82,11 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
prefetchPages(currentPage, prefetchCount);
|
prefetchPages(currentPage, prefetchCount);
|
||||||
|
|
||||||
// If double page mode, also prefetch additional pages for smooth double page navigation
|
// If double page mode, also prefetch additional pages for smooth double page navigation
|
||||||
if (isDoublePage && shouldShowDoublePage(currentPage, pages.length) && currentPage + prefetchCount < pages.length) {
|
if (
|
||||||
|
isDoublePage &&
|
||||||
|
shouldShowDoublePage(currentPage, pages.length) &&
|
||||||
|
currentPage + prefetchCount < pages.length
|
||||||
|
) {
|
||||||
prefetchPages(currentPage + prefetchCount, 1);
|
prefetchPages(currentPage + prefetchCount, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +95,16 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
if (pagesFromEnd <= prefetchCount && nextBook) {
|
if (pagesFromEnd <= prefetchCount && nextBook) {
|
||||||
prefetchNextBook(prefetchCount);
|
prefetchNextBook(prefetchCount);
|
||||||
}
|
}
|
||||||
}, [currentPage, isDoublePage, shouldShowDoublePage, prefetchPages, prefetchNextBook, prefetchCount, pages.length, nextBook]);
|
}, [
|
||||||
|
currentPage,
|
||||||
|
isDoublePage,
|
||||||
|
shouldShowDoublePage,
|
||||||
|
prefetchPages,
|
||||||
|
prefetchNextBook,
|
||||||
|
prefetchCount,
|
||||||
|
pages.length,
|
||||||
|
nextBook,
|
||||||
|
]);
|
||||||
|
|
||||||
// Keyboard events
|
// Keyboard events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,37 +136,40 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
};
|
};
|
||||||
}, [handleNextPage, handlePreviousPage, onClose, isRTL, currentPage]);
|
}, [handleNextPage, handlePreviousPage, onClose, isRTL, currentPage]);
|
||||||
|
|
||||||
const handleContainerClick = useCallback((e: React.MouseEvent) => {
|
const handleContainerClick = useCallback(
|
||||||
// Vérifier si c'est un double-clic sur une image
|
(e: React.MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
// Vérifier si c'est un double-clic sur une image
|
||||||
const now = Date.now();
|
const target = e.target as HTMLElement;
|
||||||
const timeSinceLastClick = now - lastClickTimeRef.current;
|
const now = Date.now();
|
||||||
|
const timeSinceLastClick = now - lastClickTimeRef.current;
|
||||||
|
|
||||||
if (target.tagName === 'IMG' && timeSinceLastClick < 300) {
|
if (target.tagName === "IMG" && timeSinceLastClick < 300) {
|
||||||
// Double-clic sur une image
|
// Double-clic sur une image
|
||||||
if (clickTimeoutRef.current) {
|
if (clickTimeoutRef.current) {
|
||||||
clearTimeout(clickTimeoutRef.current);
|
clearTimeout(clickTimeoutRef.current);
|
||||||
clickTimeoutRef.current = null;
|
clickTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
handleZoom();
|
||||||
|
lastClickTimeRef.current = 0;
|
||||||
|
} else if (target.tagName === "IMG") {
|
||||||
|
// Premier clic sur une image - attendre pour voir si c'est un double-clic
|
||||||
|
lastClickTimeRef.current = now;
|
||||||
|
if (clickTimeoutRef.current) {
|
||||||
|
clearTimeout(clickTimeoutRef.current);
|
||||||
|
}
|
||||||
|
clickTimeoutRef.current = setTimeout(() => {
|
||||||
|
setShowControls((prev) => !prev);
|
||||||
|
clickTimeoutRef.current = null;
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
// Clic ailleurs - toggle les contrôles immédiatement
|
||||||
|
setShowControls(!showControls);
|
||||||
|
lastClickTimeRef.current = 0;
|
||||||
}
|
}
|
||||||
e.stopPropagation();
|
},
|
||||||
handleZoom();
|
[showControls, handleZoom]
|
||||||
lastClickTimeRef.current = 0;
|
);
|
||||||
} else if (target.tagName === 'IMG') {
|
|
||||||
// Premier clic sur une image - attendre pour voir si c'est un double-clic
|
|
||||||
lastClickTimeRef.current = now;
|
|
||||||
if (clickTimeoutRef.current) {
|
|
||||||
clearTimeout(clickTimeoutRef.current);
|
|
||||||
}
|
|
||||||
clickTimeoutRef.current = setTimeout(() => {
|
|
||||||
setShowControls(prev => !prev);
|
|
||||||
clickTimeoutRef.current = null;
|
|
||||||
}, 300);
|
|
||||||
} else {
|
|
||||||
// Clic ailleurs - toggle les contrôles immédiatement
|
|
||||||
setShowControls(!showControls);
|
|
||||||
lastClickTimeRef.current = 0;
|
|
||||||
}
|
|
||||||
}, [showControls, handleZoom]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReaderContainer onContainerClick={handleContainerClick}>
|
<ReaderContainer onContainerClick={handleContainerClick}>
|
||||||
@@ -173,7 +197,11 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
showThumbnails={showThumbnails}
|
showThumbnails={showThumbnails}
|
||||||
onToggleThumbnails={() => setShowThumbnails(!showThumbnails)}
|
onToggleThumbnails={() => setShowThumbnails(!showThumbnails)}
|
||||||
onZoom={handleZoom}
|
onZoom={handleZoom}
|
||||||
onForceReload={() => handleForceReload(currentPage, isDoublePage, (page) => shouldShowDoublePage(page, pages.length))}
|
onForceReload={() =>
|
||||||
|
handleForceReload(currentPage, isDoublePage, (page) =>
|
||||||
|
shouldShowDoublePage(page, pages.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PageDisplay
|
<PageDisplay
|
||||||
@@ -196,4 +224,3 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
</ReaderContainer>
|
</ReaderContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
if (hasDimensions && hasBlobUrl) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this page is already being fetched
|
|
||||||
if (pendingFetchesRef.current.has(pageNum)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as pending
|
|
||||||
pendingFetchesRef.current.add(pageNum);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use browser cache if available - the server sets Cache-Control headers
|
|
||||||
const response = await fetch(getPageUrl(pageNum), {
|
|
||||||
cache: 'default', // Respect Cache-Control headers from server
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
// Check if this page is already being fetched
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
if (pendingFetchesRef.current.has(pageNum)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Create image to get dimensions
|
// Mark as pending
|
||||||
const img = new Image();
|
pendingFetchesRef.current.add(pageNum);
|
||||||
img.onload = () => {
|
|
||||||
setLoadedImages(prev => ({
|
|
||||||
...prev,
|
|
||||||
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight }
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Store the blob URL for immediate use
|
try {
|
||||||
setImageBlobUrls(prev => ({
|
// Use browser cache if available - the server sets Cache-Control headers
|
||||||
...prev,
|
const response = await fetch(getPageUrl(pageNum), {
|
||||||
[pageNum]: blobUrl
|
cache: "default", // Respect Cache-Control headers from server
|
||||||
}));
|
});
|
||||||
};
|
if (!response.ok) {
|
||||||
img.src = blobUrl;
|
return;
|
||||||
} catch {
|
}
|
||||||
// Silently fail prefetch
|
|
||||||
} finally {
|
const blob = await response.blob();
|
||||||
// Remove from pending set
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
pendingFetchesRef.current.delete(pageNum);
|
|
||||||
}
|
// Create image to get dimensions
|
||||||
}, [getPageUrl]);
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
setLoadedImages((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Store the blob URL for immediate use
|
||||||
|
setImageBlobUrls((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[pageNum]: blobUrl,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
img.src = blobUrl;
|
||||||
|
} catch {
|
||||||
|
// Silently fail prefetch
|
||||||
|
} finally {
|
||||||
|
// Remove from pending set
|
||||||
|
pendingFetchesRef.current.delete(pageNum);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getPageUrl]
|
||||||
|
);
|
||||||
|
|
||||||
// Prefetch multiple pages starting from a given page
|
// Prefetch multiple pages starting from a given page
|
||||||
// The server-side queue (RequestQueueService) handles concurrency limits
|
// The server-side queue (RequestQueueService) handles concurrency limits
|
||||||
// We only deduplicate to avoid redundant HTTP requests
|
// We only deduplicate to avoid redundant HTTP requests
|
||||||
const prefetchPages = useCallback(async (startPage: number, count: number = prefetchCount) => {
|
const prefetchPages = useCallback(
|
||||||
const pagesToPrefetch = [];
|
async (startPage: number, count: number = prefetchCount) => {
|
||||||
|
const pagesToPrefetch = [];
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const pageNum = startPage + i;
|
const pageNum = startPage + i;
|
||||||
if (pageNum <= _pages.length) {
|
if (pageNum <= _pages.length) {
|
||||||
const hasDimensions = loadedImagesRef.current[pageNum];
|
const hasDimensions = loadedImagesRef.current[pageNum];
|
||||||
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
||||||
const isPending = pendingFetchesRef.current.has(pageNum);
|
const isPending = pendingFetchesRef.current.has(pageNum);
|
||||||
|
|
||||||
// Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
|
// Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
|
||||||
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
|
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
|
||||||
pagesToPrefetch.push(pageNum);
|
pagesToPrefetch.push(pageNum);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Let all prefetch requests run - the server queue will manage concurrency
|
// Let all prefetch requests run - the server queue will manage concurrency
|
||||||
// The browser cache and our deduplication prevent redundant requests
|
// The browser cache and our deduplication prevent redundant requests
|
||||||
if (pagesToPrefetch.length > 0) {
|
if (pagesToPrefetch.length > 0) {
|
||||||
// Fire all requests in parallel - server queue handles throttling
|
// Fire all requests in parallel - server queue handles throttling
|
||||||
Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum))).catch(() => {
|
Promise.all(pagesToPrefetch.map((pageNum) => prefetchImage(pageNum))).catch(() => {
|
||||||
// Silently fail - prefetch is non-critical
|
// Silently fail - prefetch is non-critical
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [prefetchImage, prefetchCount, _pages.length]);
|
},
|
||||||
|
[prefetchImage, prefetchCount, _pages.length]
|
||||||
|
);
|
||||||
|
|
||||||
// Prefetch pages from next book
|
// Prefetch pages from next book
|
||||||
const prefetchNextBook = useCallback(async (count: number = prefetchCount) => {
|
const prefetchNextBook = useCallback(
|
||||||
if (!nextBook) {
|
async (count: number = prefetchCount) => {
|
||||||
return;
|
if (!nextBook) {
|
||||||
}
|
return;
|
||||||
|
|
||||||
const pagesToPrefetch = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const pageNum = i + 1; // Pages du livre suivant commencent à 1
|
|
||||||
// Pour le livre suivant, on utilise une clé différente pour éviter les conflits
|
|
||||||
const nextBookPageKey = `next-${pageNum}`;
|
|
||||||
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
|
|
||||||
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
|
|
||||||
const isPending = pendingFetchesRef.current.has(nextBookPageKey);
|
|
||||||
|
|
||||||
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
|
|
||||||
pagesToPrefetch.push({ pageNum, nextBookPageKey });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Let all prefetch requests run - server queue handles concurrency
|
const pagesToPrefetch = [];
|
||||||
if (pagesToPrefetch.length > 0) {
|
|
||||||
Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
|
|
||||||
// Mark as pending
|
|
||||||
pendingFetchesRef.current.add(nextBookPageKey);
|
|
||||||
|
|
||||||
try {
|
for (let i = 0; i < count; i++) {
|
||||||
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
|
const pageNum = i + 1; // Pages du livre suivant commencent à 1
|
||||||
cache: 'default', // Respect Cache-Control headers from server
|
// Pour le livre suivant, on utilise une clé différente pour éviter les conflits
|
||||||
});
|
const nextBookPageKey = `next-${pageNum}`;
|
||||||
if (!response.ok) {
|
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
|
||||||
return;
|
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
|
||||||
}
|
const isPending = pendingFetchesRef.current.has(nextBookPageKey);
|
||||||
|
|
||||||
const blob = await response.blob();
|
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
pagesToPrefetch.push({ pageNum, nextBookPageKey });
|
||||||
|
|
||||||
// Create image to get dimensions
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
setLoadedImages(prev => ({
|
|
||||||
...prev,
|
|
||||||
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight }
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Store the blob URL for immediate use
|
|
||||||
setImageBlobUrls(prev => ({
|
|
||||||
...prev,
|
|
||||||
[nextBookPageKey]: blobUrl
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
img.src = blobUrl;
|
|
||||||
} catch {
|
|
||||||
// Silently fail prefetch
|
|
||||||
} finally {
|
|
||||||
pendingFetchesRef.current.delete(nextBookPageKey);
|
|
||||||
}
|
}
|
||||||
})).catch(() => {
|
}
|
||||||
// Silently fail - prefetch is non-critical
|
|
||||||
});
|
// Let all prefetch requests run - server queue handles concurrency
|
||||||
}
|
if (pagesToPrefetch.length > 0) {
|
||||||
}, [nextBook, prefetchCount]);
|
Promise.all(
|
||||||
|
pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
|
||||||
|
// Mark as pending
|
||||||
|
pendingFetchesRef.current.add(nextBookPageKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
|
||||||
|
cache: "default", // Respect Cache-Control headers from server
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Create image to get dimensions
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
setLoadedImages((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Store the blob URL for immediate use
|
||||||
|
setImageBlobUrls((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nextBookPageKey]: blobUrl,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
img.src = blobUrl;
|
||||||
|
} catch {
|
||||||
|
// Silently fail prefetch
|
||||||
|
} finally {
|
||||||
|
pendingFetchesRef.current.delete(nextBookPageKey);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).catch(() => {
|
||||||
|
// Silently fail - prefetch is non-critical
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nextBook, prefetchCount]
|
||||||
|
);
|
||||||
|
|
||||||
// Force reload handler
|
// Force reload handler
|
||||||
const handleForceReload = useCallback(async (currentPage: number, isDoublePage: boolean, shouldShowDoublePage: (page: number) => boolean) => {
|
const handleForceReload = useCallback(
|
||||||
// Révoquer les anciennes URLs blob
|
async (
|
||||||
if (imageBlobUrls[currentPage]) {
|
currentPage: number,
|
||||||
URL.revokeObjectURL(imageBlobUrls[currentPage]);
|
isDoublePage: boolean,
|
||||||
}
|
shouldShowDoublePage: (page: number) => boolean
|
||||||
if (imageBlobUrls[currentPage + 1]) {
|
) => {
|
||||||
URL.revokeObjectURL(imageBlobUrls[currentPage + 1]);
|
// Révoquer les anciennes URLs blob
|
||||||
}
|
if (imageBlobUrls[currentPage]) {
|
||||||
|
URL.revokeObjectURL(imageBlobUrls[currentPage]);
|
||||||
try {
|
}
|
||||||
// Fetch page 1 avec cache: reload
|
if (imageBlobUrls[currentPage + 1]) {
|
||||||
const response1 = await fetch(getPageUrl(currentPage), {
|
URL.revokeObjectURL(imageBlobUrls[currentPage + 1]);
|
||||||
cache: 'reload',
|
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Pragma': 'no-cache'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response1.ok) {
|
|
||||||
throw new Error(`HTTP ${response1.status}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob1 = await response1.blob();
|
try {
|
||||||
const blobUrl1 = URL.createObjectURL(blob1);
|
// Fetch page 1 avec cache: reload
|
||||||
|
const response1 = await fetch(getPageUrl(currentPage), {
|
||||||
const newUrls: Record<number, string> = {
|
cache: "reload",
|
||||||
...imageBlobUrls,
|
|
||||||
[currentPage]: blobUrl1
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch page 2 si double page
|
|
||||||
if (isDoublePage && shouldShowDoublePage(currentPage)) {
|
|
||||||
const response2 = await fetch(getPageUrl(currentPage + 1), {
|
|
||||||
cache: 'reload',
|
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-cache',
|
"Cache-Control": "no-cache",
|
||||||
'Pragma': 'no-cache'
|
Pragma: "no-cache",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response2.ok) {
|
if (!response1.ok) {
|
||||||
throw new Error(`HTTP ${response2.status}`);
|
throw new Error(`HTTP ${response1.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob2 = await response2.blob();
|
const blob1 = await response1.blob();
|
||||||
const blobUrl2 = URL.createObjectURL(blob2);
|
const blobUrl1 = URL.createObjectURL(blob1);
|
||||||
newUrls[currentPage + 1] = blobUrl2;
|
|
||||||
}
|
|
||||||
|
|
||||||
setImageBlobUrls(newUrls);
|
const newUrls: Record<number, string> = {
|
||||||
} catch (error) {
|
...imageBlobUrls,
|
||||||
logger.error({ err: error }, 'Error reloading images:');
|
[currentPage]: blobUrl1,
|
||||||
throw error;
|
};
|
||||||
}
|
|
||||||
}, [imageBlobUrls, getPageUrl]);
|
// Fetch page 2 si double page
|
||||||
|
if (isDoublePage && shouldShowDoublePage(currentPage)) {
|
||||||
|
const response2 = await fetch(getPageUrl(currentPage + 1), {
|
||||||
|
cache: "reload",
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Pragma: "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response2.ok) {
|
||||||
|
throw new Error(`HTTP ${response2.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob2 = await response2.blob();
|
||||||
|
const blobUrl2 = URL.createObjectURL(blob2);
|
||||||
|
newUrls[currentPage + 1] = blobUrl2;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageBlobUrls(newUrls);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Error reloading images:");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[imageBlobUrls, getPageUrl]
|
||||||
|
);
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount only
|
// Cleanup blob URLs on unmount only
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
Object.values(imageBlobUrlsRef.current).forEach(url => {
|
Object.values(imageBlobUrlsRef.current).forEach((url) => {
|
||||||
if (url) URL.revokeObjectURL(url);
|
if (url) URL.revokeObjectURL(url);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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)
|
// Détecter si c'est un pinch (2+ doigts)
|
||||||
if (e.touches.length > 1) {
|
if (e.touches.length > 1) {
|
||||||
isPinchingRef.current = true;
|
isPinchingRef.current = true;
|
||||||
touchStartXRef.current = null;
|
touchStartXRef.current = null;
|
||||||
touchStartYRef.current = null;
|
touchStartYRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Un seul doigt - seulement si on n'était pas en train de pinch
|
// Un seul doigt - seulement si on n'était pas en train de pinch
|
||||||
// On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt
|
// On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt
|
||||||
if (e.touches.length === 1) {
|
if (e.touches.length === 1) {
|
||||||
isPinchingRef.current = false;
|
isPinchingRef.current = false;
|
||||||
touchStartXRef.current = e.touches[0].clientX;
|
touchStartXRef.current = e.touches[0].clientX;
|
||||||
touchStartYRef.current = e.touches[0].clientY;
|
touchStartYRef.current = e.touches[0].clientY;
|
||||||
}
|
}
|
||||||
}, [pswpRef, isZoomed]);
|
},
|
||||||
|
[pswpRef, isZoomed]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTouchMove = useCallback((e: TouchEvent) => {
|
const handleTouchMove = useCallback((e: TouchEvent) => {
|
||||||
// Détecter le pinch pendant le mouvement
|
// Détecter le pinch pendant le mouvement
|
||||||
@@ -62,56 +65,59 @@ export function useTouchNavigation({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback((e: TouchEvent) => {
|
const handleTouchEnd = useCallback(
|
||||||
// Si on était en mode pinch, ne JAMAIS traiter le swipe
|
(e: TouchEvent) => {
|
||||||
if (isPinchingRef.current) {
|
// Si on était en mode pinch, ne JAMAIS traiter le swipe
|
||||||
touchStartXRef.current = null;
|
if (isPinchingRef.current) {
|
||||||
touchStartYRef.current = null;
|
touchStartXRef.current = null;
|
||||||
// Ne PAS réinitialiser isPinchingRef ici, on le fera au prochain touchstart
|
touchStartYRef.current = null;
|
||||||
return;
|
// Ne PAS réinitialiser isPinchingRef ici, on le fera au prochain touchstart
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Vérifier qu'on a bien des coordonnées de départ
|
// Vérifier qu'on a bien des coordonnées de départ
|
||||||
if (touchStartXRef.current === null || touchStartYRef.current === null) return;
|
if (touchStartXRef.current === null || touchStartYRef.current === null) return;
|
||||||
// Ne pas gérer si Photoswipe est ouvert
|
// Ne pas gérer si Photoswipe est ouvert
|
||||||
if (pswpRef.current) return;
|
if (pswpRef.current) return;
|
||||||
// Ne pas gérer si la page est zoomée (zoom natif)
|
// Ne pas gérer si la page est zoomée (zoom natif)
|
||||||
if (isZoomed()) return;
|
if (isZoomed()) return;
|
||||||
|
|
||||||
const touchEndX = e.changedTouches[0].clientX;
|
const touchEndX = e.changedTouches[0].clientX;
|
||||||
const touchEndY = e.changedTouches[0].clientY;
|
const touchEndY = e.changedTouches[0].clientY;
|
||||||
const deltaX = touchEndX - touchStartXRef.current;
|
const deltaX = touchEndX - touchStartXRef.current;
|
||||||
const deltaY = touchEndY - touchStartYRef.current;
|
const deltaY = touchEndY - touchStartYRef.current;
|
||||||
|
|
||||||
// Si le déplacement vertical est plus important, on ignore (scroll)
|
// Si le déplacement vertical est plus important, on ignore (scroll)
|
||||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||||
touchStartXRef.current = null;
|
touchStartXRef.current = null;
|
||||||
touchStartYRef.current = null;
|
touchStartYRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seuil de 50px pour changer de page
|
// Seuil de 50px pour changer de page
|
||||||
if (Math.abs(deltaX) > 50) {
|
if (Math.abs(deltaX) > 50) {
|
||||||
if (deltaX > 0) {
|
if (deltaX > 0) {
|
||||||
// Swipe vers la droite
|
// Swipe vers la droite
|
||||||
if (isRTL) {
|
if (isRTL) {
|
||||||
onNextPage();
|
onNextPage();
|
||||||
|
} else {
|
||||||
|
onPreviousPage();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
onPreviousPage();
|
// Swipe vers la gauche
|
||||||
}
|
if (isRTL) {
|
||||||
} else {
|
onPreviousPage();
|
||||||
// Swipe vers la gauche
|
} else {
|
||||||
if (isRTL) {
|
onNextPage();
|
||||||
onPreviousPage();
|
}
|
||||||
} else {
|
|
||||||
onNextPage();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
touchStartXRef.current = null;
|
touchStartXRef.current = null;
|
||||||
touchStartYRef.current = null;
|
touchStartYRef.current = null;
|
||||||
}, [onNextPage, onPreviousPage, isRTL, pswpRef, isZoomed]);
|
},
|
||||||
|
[onNextPage, onPreviousPage, isRTL, pswpRef, isZoomed]
|
||||||
|
);
|
||||||
|
|
||||||
// Setup touch event listeners
|
// Setup touch event listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) {
|
|||||||
<BookCover
|
<BookCover
|
||||||
book={book}
|
book={book}
|
||||||
alt={t("books.coverAlt", {
|
alt={t("books.coverAlt", {
|
||||||
title: book.metadata.title ||
|
title:
|
||||||
|
book.metadata.title ||
|
||||||
(book.metadata.number
|
(book.metadata.number
|
||||||
? t("navigation.volume", { number: book.metadata.number })
|
? t("navigation.volume", { number: book.metadata.number })
|
||||||
: ""),
|
: ""),
|
||||||
|
|||||||
@@ -75,10 +75,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusInfo = getStatusInfo();
|
const statusInfo = getStatusInfo();
|
||||||
const title = book.metadata.title ||
|
const title =
|
||||||
(book.metadata.number
|
book.metadata.title ||
|
||||||
? t("navigation.volume", { number: book.metadata.number })
|
(book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name);
|
||||||
: book.name);
|
|
||||||
|
|
||||||
if (isCompact) {
|
if (isCompact) {
|
||||||
return (
|
return (
|
||||||
@@ -118,7 +117,12 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<span className={cn("px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
|
statusInfo.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,9 +141,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
{book.metadata.authors && book.metadata.authors.length > 0 && (
|
{book.metadata.authors && book.metadata.authors.length > 0 && (
|
||||||
<div className="flex items-center gap-1 hidden sm:flex">
|
<div className="flex items-center gap-1 hidden sm:flex">
|
||||||
<User className="h-3 w-3" />
|
<User className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">{book.metadata.authors[0].name}</span>
|
||||||
{book.metadata.authors[0].name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +196,12 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badge de statut */}
|
{/* Badge de statut */}
|
||||||
<span className={cn("px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
|
statusInfo.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +228,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User className="h-3 w-3" />
|
<User className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">
|
||||||
{book.metadata.authors.map(a => a.name).join(", ")}
|
{book.metadata.authors.map((a) => a.name).join(", ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -343,4 +350,3 @@ export function BookList({ books, onBookClick, isCompact = false }: BookListProp
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -159,15 +159,16 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
|
|||||||
<span className="text-sm text-white/80">
|
<span className="text-sm text-white/80">
|
||||||
{series.booksCount === 1
|
{series.booksCount === 1
|
||||||
? t("series.header.books", { count: series.booksCount })
|
? t("series.header.books", { count: series.booksCount })
|
||||||
: t("series.header.books_plural", { count: series.booksCount })
|
: t("series.header.books_plural", { count: series.booksCount })}
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
icon={isFavorite ? Star : StarOff}
|
icon={isFavorite ? Star : StarOff}
|
||||||
onClick={handleToggleFavorite}
|
onClick={handleToggleFavorite}
|
||||||
tooltip={t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add")}
|
tooltip={t(
|
||||||
|
isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"
|
||||||
|
)}
|
||||||
className="text-white hover:text-white"
|
className="text-white hover:text-white"
|
||||||
iconClassName={isFavorite ? "fill-yellow-400 text-yellow-400" : ""}
|
iconClassName={isFavorite ? "fill-yellow-400 text-yellow-400" : ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,19 +214,24 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const path = urlObj.pathname;
|
const path = urlObj.pathname;
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split("/").filter(Boolean);
|
||||||
|
|
||||||
if (segments.length === 0) return '/';
|
if (segments.length === 0) return "/";
|
||||||
|
|
||||||
// Pour /api/komga/images, grouper par type (series/books)
|
// Pour /api/komga/images, grouper par type (series/books)
|
||||||
if (segments[0] === 'api' && segments[1] === 'komga' && segments[2] === 'images' && segments[3]) {
|
if (
|
||||||
|
segments[0] === "api" &&
|
||||||
|
segments[1] === "komga" &&
|
||||||
|
segments[2] === "images" &&
|
||||||
|
segments[3]
|
||||||
|
) {
|
||||||
return `/${segments[0]}/${segments[1]}/${segments[2]}/${segments[3]}`;
|
return `/${segments[0]}/${segments[1]}/${segments[2]}/${segments[3]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pour les autres, garder juste le premier segment
|
// Pour les autres, garder juste le premier segment
|
||||||
return `/${segments[0]}`;
|
return `/${segments[0]}`;
|
||||||
} catch {
|
} catch {
|
||||||
return 'Autres';
|
return "Autres";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -255,8 +260,8 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
// Trier par date (le plus récent en premier) basé sur le paramètre v
|
// Trier par date (le plus récent en premier) basé sur le paramètre v
|
||||||
Object.keys(grouped).forEach((key) => {
|
Object.keys(grouped).forEach((key) => {
|
||||||
grouped[key].sort((a, b) => {
|
grouped[key].sort((a, b) => {
|
||||||
const aVersion = new URL(a.url).searchParams.get('v') || '0';
|
const aVersion = new URL(a.url).searchParams.get("v") || "0";
|
||||||
const bVersion = new URL(b.url).searchParams.get('v') || '0';
|
const bVersion = new URL(b.url).searchParams.get("v") || "0";
|
||||||
return Number(bVersion) - Number(aVersion);
|
return Number(bVersion) - Number(aVersion);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -458,7 +463,6 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
<CardDescription>{t("settings.cache.description")}</CardDescription>
|
<CardDescription>{t("settings.cache.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="cache-mode">{t("settings.cache.mode.label")}</Label>
|
<Label htmlFor="cache-mode">{t("settings.cache.mode.label")}</Label>
|
||||||
@@ -488,7 +492,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.cache.size.error")}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -497,7 +503,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
{swCacheSize !== null ? (
|
{swCacheSize !== null ? (
|
||||||
<div className="text-sm text-muted-foreground">{formatBytes(swCacheSize)}</div>
|
<div className="text-sm text-muted-foreground">{formatBytes(swCacheSize)}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.cache.size.error")}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -506,7 +514,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
{apiCacheSize !== null ? (
|
{apiCacheSize !== null ? (
|
||||||
<div className="text-sm text-muted-foreground">{formatBytes(apiCacheSize)}</div>
|
<div className="text-sm text-muted-foreground">{formatBytes(apiCacheSize)}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.cache.size.error")}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -525,11 +535,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
{t("settings.cache.entries.serverTitle")}
|
{t("settings.cache.entries.serverTitle")}
|
||||||
</span>
|
</span>
|
||||||
{showEntries ? (
|
{showEntries ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showEntries && (
|
{showEntries && (
|
||||||
@@ -569,7 +575,10 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
>
|
>
|
||||||
{getTimeRemaining(entry.expiry)}
|
{getTimeRemaining(entry.expiry)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground/70" title={formatDate(entry.expiry)}>
|
<div
|
||||||
|
className="text-muted-foreground/70"
|
||||||
|
title={formatDate(entry.expiry)}
|
||||||
|
>
|
||||||
{new Date(entry.expiry).toLocaleDateString()}
|
{new Date(entry.expiry).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -649,72 +658,90 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
<div className="space-y-1 pl-2">
|
<div className="space-y-1 pl-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const versionGroups = groupVersions(entries);
|
const versionGroups = groupVersions(entries);
|
||||||
return Object.entries(versionGroups).map(([baseUrl, versions]) => {
|
return Object.entries(versionGroups).map(
|
||||||
const hasMultipleVersions = versions.length > 1;
|
([baseUrl, versions]) => {
|
||||||
const isVersionExpanded = expandedVersions[baseUrl];
|
const hasMultipleVersions = versions.length > 1;
|
||||||
const totalSize = versions.reduce((sum, v) => sum + v.size, 0);
|
const isVersionExpanded = expandedVersions[baseUrl];
|
||||||
|
const totalSize = versions.reduce(
|
||||||
|
(sum, v) => sum + v.size,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasMultipleVersions) {
|
if (!hasMultipleVersions) {
|
||||||
const entry = versions[0];
|
const entry = versions[0];
|
||||||
return (
|
return (
|
||||||
<div key={baseUrl} className="py-1">
|
<div key={baseUrl} className="py-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-mono text-xs truncate text-muted-foreground" title={entry.url}>
|
<div
|
||||||
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
|
className="font-mono text-xs truncate text-muted-foreground"
|
||||||
|
title={entry.url}
|
||||||
|
>
|
||||||
|
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatBytes(entry.size)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
{formatBytes(entry.size)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={baseUrl} className="py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleVersions(baseUrl)}
|
||||||
|
className="w-full flex items-start justify-between gap-2 hover:bg-muted/30 rounded p-1 -m-1 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-1">
|
||||||
|
{isVersionExpanded ? (
|
||||||
|
<ChevronDown className="h-3 w-3 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="h-3 w-3 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="font-mono text-xs truncate text-muted-foreground"
|
||||||
|
title={baseUrl}
|
||||||
|
>
|
||||||
|
{baseUrl}
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-1.5 py-0.5 text-xs font-medium text-orange-600 dark:text-orange-400 flex-shrink-0">
|
||||||
|
{versions.length} versions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground whitespace-nowrap font-medium">
|
||||||
|
{formatBytes(totalSize)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isVersionExpanded && (
|
||||||
|
<div className="pl-4 mt-1 space-y-1">
|
||||||
|
{versions.map((version, vIdx) => (
|
||||||
|
<div
|
||||||
|
key={vIdx}
|
||||||
|
className="py-0.5 flex items-start justify-between gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="font-mono text-xs truncate text-muted-foreground/70"
|
||||||
|
title={version.url}
|
||||||
|
>
|
||||||
|
{new URL(version.url).search ||
|
||||||
|
"(no version)"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground/70 whitespace-nowrap">
|
||||||
|
{formatBytes(version.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return (
|
|
||||||
<div key={baseUrl} className="py-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleVersions(baseUrl)}
|
|
||||||
className="w-full flex items-start justify-between gap-2 hover:bg-muted/30 rounded p-1 -m-1 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-1">
|
|
||||||
{isVersionExpanded ? (
|
|
||||||
<ChevronDown className="h-3 w-3 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronUp className="h-3 w-3 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="font-mono text-xs truncate text-muted-foreground" title={baseUrl}>
|
|
||||||
{baseUrl}
|
|
||||||
</div>
|
|
||||||
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-1.5 py-0.5 text-xs font-medium text-orange-600 dark:text-orange-400 flex-shrink-0">
|
|
||||||
{versions.length} versions
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground whitespace-nowrap font-medium">
|
|
||||||
{formatBytes(totalSize)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{isVersionExpanded && (
|
|
||||||
<div className="pl-4 mt-1 space-y-1">
|
|
||||||
{versions.map((version, vIdx) => (
|
|
||||||
<div key={vIdx} className="py-0.5 flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-mono text-xs truncate text-muted-foreground/70" title={version.url}>
|
|
||||||
{new URL(version.url).search || "(no version)"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground/70 whitespace-nowrap">
|
|
||||||
{formatBytes(version.size)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -833,12 +860,24 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
onChange={handleTTLChange}
|
onChange={handleTTLChange}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="0">{t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}</option>
|
<option value="0">
|
||||||
<option value="3600">{t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}</option>
|
{t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}
|
||||||
<option value="86400">{t("settings.cache.ttl.imageCacheMaxAge.options.oneDay")}</option>
|
</option>
|
||||||
<option value="604800">{t("settings.cache.ttl.imageCacheMaxAge.options.oneWeek")}</option>
|
<option value="3600">
|
||||||
<option value="2592000">{t("settings.cache.ttl.imageCacheMaxAge.options.oneMonth")}</option>
|
{t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}
|
||||||
<option value="31536000">{t("settings.cache.ttl.imageCacheMaxAge.options.oneYear")}</option>
|
</option>
|
||||||
|
<option value="86400">
|
||||||
|
{t("settings.cache.ttl.imageCacheMaxAge.options.oneDay")}
|
||||||
|
</option>
|
||||||
|
<option value="604800">
|
||||||
|
{t("settings.cache.ttl.imageCacheMaxAge.options.oneWeek")}
|
||||||
|
</option>
|
||||||
|
<option value="2592000">
|
||||||
|
{t("settings.cache.ttl.imageCacheMaxAge.options.oneMonth")}
|
||||||
|
</option>
|
||||||
|
<option value="31536000">
|
||||||
|
{t("settings.cache.ttl.imageCacheMaxAge.options.oneYear")}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ const NavButton = React.forwardRef<HTMLButtonElement, NavButtonProps>(
|
|||||||
<Icon className="mr-2 h-4 w-4" />
|
<Icon className="mr-2 h-4 w-4" />
|
||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{count !== undefined && (
|
{count !== undefined && <span className="text-xs text-muted-foreground">{count}</span>}
|
||||||
<span className="text-xs text-muted-foreground">{count}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,4 +34,3 @@ const NavButton = React.forwardRef<HTMLButtonElement, NavButtonProps>(
|
|||||||
NavButton.displayName = "NavButton";
|
NavButton.displayName = "NavButton";
|
||||||
|
|
||||||
export { NavButton };
|
export { NavButton };
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ interface ProgressBarProps {
|
|||||||
export function ProgressBar({ progress, total, type }: ProgressBarProps) {
|
export function ProgressBar({ progress, total, type }: ProgressBarProps) {
|
||||||
const percentage = Math.round((progress / total) * 100);
|
const percentage = Math.round((progress / total) * 100);
|
||||||
|
|
||||||
const barColor = type === "series"
|
const barColor =
|
||||||
? "bg-gradient-to-r from-purple-500 to-pink-500"
|
type === "series"
|
||||||
: "bg-gradient-to-r from-blue-500 to-cyan-500";
|
? "bg-gradient-to-r from-purple-500 to-pink-500"
|
||||||
|
: "bg-gradient-to-r from-blue-500 to-cyan-500";
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-black/70 backdrop-blur-md border-t border-white/10">
|
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-black/70 backdrop-blur-md border-t border-white/10">
|
||||||
<div className="h-2 bg-white/30 rounded-full overflow-hidden">
|
<div className="h-2 bg-white/30 rounded-full overflow-hidden">
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ const RadioGroup = React.forwardRef<
|
|||||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
|
||||||
<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||||
|
|
||||||
@@ -38,4 +36,3 @@ const RadioGroupItem = React.forwardRef<
|
|||||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||||
|
|
||||||
export { RadioGroup, RadioGroupItem };
|
export { RadioGroup, RadioGroupItem };
|
||||||
|
|
||||||
|
|||||||
@@ -76,10 +76,7 @@ const ScrollContainer = React.forwardRef<HTMLDivElement, ScrollContainerProps>(
|
|||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className={cn(
|
className={cn("flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-4", className)}
|
||||||
"flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -102,4 +99,3 @@ const ScrollContainer = React.forwardRef<HTMLDivElement, ScrollContainerProps>(
|
|||||||
ScrollContainer.displayName = "ScrollContainer";
|
ScrollContainer.displayName = "ScrollContainer";
|
||||||
|
|
||||||
export { ScrollContainer };
|
export { ScrollContainer };
|
||||||
|
|
||||||
|
|||||||
@@ -42,4 +42,3 @@ const Section = React.forwardRef<HTMLElement, SectionProps>(
|
|||||||
Section.displayName = "Section";
|
Section.displayName = "Section";
|
||||||
|
|
||||||
export { Section };
|
export { Section };
|
||||||
|
|
||||||
|
|||||||
@@ -24,4 +24,3 @@ const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
|
|||||||
Separator.displayName = "Separator";
|
Separator.displayName = "Separator";
|
||||||
|
|
||||||
export { Separator };
|
export { Separator };
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Skeleton({
|
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
className,
|
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton };
|
export { Skeleton };
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,7 @@ export function SliderControl({
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Slider = React.forwardRef<
|
const Slider = React.forwardRef<
|
||||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
@@ -11,11 +11,8 @@ const Slider = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SliderPrimitive.Root
|
<SliderPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn("relative flex w-full touch-auto select-none items-center", className)}
|
||||||
"relative flex w-full touch-auto select-none items-center",
|
style={{ touchAction: "pan-x" }}
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={{ touchAction: 'pan-x' }}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-secondary">
|
<SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||||
@@ -23,8 +20,7 @@ const Slider = React.forwardRef<
|
|||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
<SliderPrimitive.Thumb className="block h-6 w-6 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:scale-110 active:scale-105 touch-manipulation cursor-pointer" />
|
<SliderPrimitive.Thumb className="block h-6 w-6 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:scale-110 active:scale-105 touch-manipulation cursor-pointer" />
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
))
|
));
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Slider }
|
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
|
|||||||
@@ -21,19 +21,14 @@ const statusBadgeVariants = cva("flex items-center gap-1", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export interface StatusBadgeProps
|
export interface StatusBadgeProps
|
||||||
extends Omit<BadgeProps, "variant">,
|
extends Omit<BadgeProps, "variant">, VariantProps<typeof statusBadgeVariants> {
|
||||||
VariantProps<typeof statusBadgeVariants> {
|
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusBadge = ({ status, icon: Icon, children, className, ...props }: StatusBadgeProps) => {
|
const StatusBadge = ({ status, icon: Icon, children, className, ...props }: StatusBadgeProps) => {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge variant="outline" className={cn(statusBadgeVariants({ status }), className)} {...props}>
|
||||||
variant="outline"
|
|
||||||
className={cn(statusBadgeVariants({ status }), className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="w-4 h-4" />}
|
{Icon && <Icon className="w-4 h-4" />}
|
||||||
{children}
|
{children}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -41,4 +36,3 @@ const StatusBadge = ({ status, icon: Icon, children, className, ...props }: Stat
|
|||||||
};
|
};
|
||||||
|
|
||||||
export { StatusBadge, statusBadgeVariants };
|
export { StatusBadge, statusBadgeVariants };
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface SwitchProps
|
interface SwitchProps extends Omit<
|
||||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "role" | "aria-checked"> {
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
"type" | "role" | "aria-checked"
|
||||||
|
> {
|
||||||
onCheckedChange?: (checked: boolean) => void;
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import { cn } from "@/lib/utils";
|
|||||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div className="relative w-full overflow-auto">
|
<div className="relative w-full overflow-auto">
|
||||||
<table
|
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -37,7 +33,10 @@ const TableFooter = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tfoot
|
<tfoot
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("border-t bg-muted/50 backdrop-blur-md font-medium [&>tr]:last:border-b-0", className)}
|
className={cn(
|
||||||
|
"border-t bg-muted/50 backdrop-blur-md font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -93,4 +92,3 @@ const TableCaption = React.forwardRef<
|
|||||||
TableCaption.displayName = "TableCaption";
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const toastVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border border-border/40 bg-background/70 backdrop-blur-md text-foreground shadow-lg",
|
default:
|
||||||
|
"border border-border/40 bg-background/70 backdrop-blur-md text-foreground shadow-lg",
|
||||||
destructive:
|
destructive:
|
||||||
"destructive group border-destructive/20 bg-destructive/70 backdrop-blur-md text-destructive-foreground font-medium",
|
"destructive group border-destructive/20 bg-destructive/70 backdrop-blur-md text-destructive-foreground font-medium",
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user