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

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

3
ENV.md
View File

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

View File

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

View File

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

View File

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

View File

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

5821
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -22,8 +22,10 @@ async function checkDatabase() {
});
console.log(`📊 Found ${users.length} users:`);
users.forEach(user => {
console.log(` - ID: ${user.id}, Email: ${user.email}, Roles: ${JSON.stringify(user.roles)}, Created: ${user.createdAt}`);
users.forEach((user) => {
console.log(
` - ID: ${user.id}, Email: ${user.email}, Roles: ${JSON.stringify(user.roles)}, Created: ${user.createdAt}`
);
});
// Vérifier les configurations
@@ -35,7 +37,6 @@ async function checkDatabase() {
console.log(` - KomgaConfigs: ${komgaConfigs}`);
console.log(` - Preferences: ${preferences}`);
console.log(` - Favorites: ${favorites}`);
} catch (error) {
console.error("❌ Error checking database:", error);
} finally {
@@ -44,4 +45,3 @@ async function checkDatabase() {
}
checkDatabase();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,17 +14,14 @@ export async function PUT(
const { newPassword } = body;
if (!newPassword) {
return NextResponse.json(
{ error: "Nouveau mot de passe manquant" },
{ status: 400 }
);
return NextResponse.json({ error: "Nouveau mot de passe manquant" }, { status: 400 });
}
// Vérifier que le mot de passe est fort
if (!AuthServerService.isPasswordStrong(newPassword)) {
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 }
);
@@ -40,10 +37,16 @@ export async function PUT(
return NextResponse.json(
{ error: error.message, code: error.code },
{
status: error.code === "AUTH_FORBIDDEN" ? 403 :
error.code === "AUTH_UNAUTHENTICATED" ? 401 :
error.code === "AUTH_USER_NOT_FOUND" ? 404 :
error.code === "ADMIN_CANNOT_RESET_OWN_PASSWORD" ? 400 : 500
status:
error.code === "AUTH_FORBIDDEN"
? 403
: error.code === "AUTH_UNAUTHENTICATED"
? 401
: error.code === "AUTH_USER_NOT_FOUND"
? 404
: error.code === "ADMIN_CANNOT_RESET_OWN_PASSWORD"
? 400
: 500,
}
);
}
@@ -54,4 +57,3 @@ export async function PUT(
);
}
}

View File

@@ -13,10 +13,7 @@ export async function PATCH(
const { roles } = body;
if (!roles || !Array.isArray(roles)) {
return NextResponse.json(
{ error: "Rôles invalides" },
{ status: 400 }
);
return NextResponse.json({ error: "Rôles invalides" }, { status: 400 });
}
await AdminService.updateUserRoles(userId, roles);
@@ -29,9 +26,14 @@ export async function PATCH(
return NextResponse.json(
{ error: error.message, code: error.code },
{
status: error.code === "AUTH_FORBIDDEN" ? 403 :
error.code === "AUTH_UNAUTHENTICATED" ? 401 :
error.code === "AUTH_USER_NOT_FOUND" ? 404 : 500
status:
error.code === "AUTH_FORBIDDEN"
? 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(
{ error: error.message, code: error.code },
{
status: error.code === "AUTH_FORBIDDEN" ? 403 :
error.code === "AUTH_UNAUTHENTICATED" ? 401 :
error.code === "AUTH_USER_NOT_FOUND" ? 404 :
error.code === "ADMIN_CANNOT_DELETE_SELF" ? 400 : 500
status:
error.code === "AUTH_FORBIDDEN"
? 403
: error.code === "AUTH_UNAUTHENTICATED"
? 401
: error.code === "AUTH_USER_NOT_FOUND"
? 404
: error.code === "ADMIN_CANNOT_DELETE_SELF"
? 400
: 500,
}
);
}
@@ -73,4 +81,3 @@ export async function DELETE(
);
}
}

View File

@@ -14,8 +14,12 @@ export async function GET() {
return NextResponse.json(
{ error: error.message, code: error.code },
{
status: error.code === "AUTH_FORBIDDEN" ? 403 :
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500
status:
error.code === "AUTH_FORBIDDEN"
? 403
: error.code === "AUTH_UNAUTHENTICATED"
? 401
: 500,
}
);
}

View File

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

View File

@@ -13,7 +13,7 @@ export async function GET() {
return NextResponse.json({
sizeInBytes,
itemCount,
mode: cacheService.getCacheMode()
mode: cacheService.getCacheMode(),
});
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la taille du cache:");
@@ -29,4 +29,3 @@ export async function GET() {
);
}
}

View File

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

View File

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

View File

@@ -24,15 +24,15 @@ export async function GET(
const [series, library] = await Promise.all([
LibraryService.getLibrarySeries(libraryId, page, size, unreadOnly, search),
LibraryService.getLibrary(libraryId)
LibraryService.getLibrary(libraryId),
]);
return NextResponse.json(
{ series, library },
{
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
}
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
}
);
} catch (error) {
@@ -98,4 +98,3 @@ export async function DELETE(
);
}
}

View File

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

View File

@@ -23,15 +23,15 @@ export async function GET(
const [books, series] = await Promise.all([
SeriesService.getSeriesBooks(seriesId, page, size, unreadOnly),
SeriesService.getSeries(seriesId)
SeriesService.getSeries(seriesId),
]);
return NextResponse.json(
{ books, series },
{
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
}
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
}
);
} catch (error) {
@@ -70,7 +70,7 @@ export async function DELETE(
await Promise.all([
SeriesService.invalidateSeriesBooksCache(seriesId),
SeriesService.invalidateSeriesCache(seriesId)
SeriesService.invalidateSeriesCache(seriesId),
]);
return NextResponse.json({ success: true });
@@ -100,4 +100,3 @@ export async function DELETE(
);
}
}

View File

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

View File

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

View File

@@ -10,17 +10,15 @@ export async function PUT(request: NextRequest) {
const { currentPassword, newPassword } = body;
if (!currentPassword || !newPassword) {
return NextResponse.json(
{ error: "Mots de passe manquants" },
{ status: 400 }
);
return NextResponse.json({ error: "Mots de passe manquants" }, { status: 400 });
}
// Vérifier que le nouveau mot de passe est fort
if (!AuthServerService.isPasswordStrong(newPassword)) {
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 }
);
@@ -36,8 +34,12 @@ export async function PUT(request: NextRequest) {
return NextResponse.json(
{ error: error.message, code: error.code },
{
status: error.code === "AUTH_INVALID_PASSWORD" ? 400 :
error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500
status:
error.code === "AUTH_INVALID_PASSWORD"
? 400
: error.code === "AUTH_UNAUTHENTICATED"
? 401
: 500,
}
);
}

View File

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

View File

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

View File

@@ -12,7 +12,12 @@ import { defaultPreferences } from "@/types/preferences";
import type { UserPreferences } from "@/types/preferences";
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 = {
title: {
@@ -90,10 +95,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang={locale} suppressHydrationWarning className="h-full">
<head>
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta 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-status-bar-style" content="black-translucent" />
<meta name="apple-touch-fullscreen" content="yes" />
@@ -145,16 +147,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
/>
</head>
<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>
<I18nProvider locale={locale}>
<PreferencesProvider initialPreferences={preferences}>
<ClientLayout
initialLibraries={[]}
initialFavorites={[]}
userIsAdmin={userIsAdmin}
>
<ClientLayout initialLibraries={[]} initialFavorites={[]} userIsAdmin={userIsAdmin}>
{children}
</ClientLayout>
</PreferencesProvider>

View File

@@ -60,7 +60,7 @@ export function ClientLibraryPage({
}
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) {
@@ -86,7 +86,7 @@ export function ClientLibraryPage({
try {
// Invalidate cache via API
const cacheResponse = await fetch(`/api/komga/libraries/${libraryId}/series`, {
method: 'DELETE',
method: "DELETE",
});
if (!cacheResponse.ok) {
@@ -105,7 +105,7 @@ export function ClientLibraryPage({
}
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) {
@@ -139,7 +139,7 @@ export function ClientLibraryPage({
}
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) {

View File

@@ -50,7 +50,7 @@ export function ClientSeriesPage({
});
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) {
@@ -76,7 +76,7 @@ export function ClientSeriesPage({
try {
// Invalidate cache via API
const cacheResponse = await fetch(`/api/komga/series/${seriesId}/books`, {
method: 'DELETE',
method: "DELETE",
});
if (!cacheResponse.ok) {
@@ -91,7 +91,7 @@ export function ClientSeriesPage({
});
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) {
@@ -121,7 +121,7 @@ export function ClientSeriesPage({
});
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) {
@@ -204,4 +204,3 @@ export function ClientSeriesPage({
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,9 @@ export function PullToRefreshIndicator({
className={cn(
"fixed top-0 left-1/2 transform -translate-x-1/2 z-50 transition-all",
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={{
transform: `translate(-50%, ${(isPulling || isRefreshing) && !isHiding ? (isRefreshing ? 60 : progress * 60) : -100}px)`,
@@ -40,7 +42,7 @@ export function PullToRefreshIndicator({
<div
className={cn(
"h-full transition-all duration-200 rounded-full",
(canRefresh || isRefreshing) ? "bg-primary" : "bg-muted-foreground"
canRefresh || isRefreshing ? "bg-primary" : "bg-muted-foreground"
)}
style={{
width: `${isRefreshing ? 200 : barWidth}px`,
@@ -53,14 +55,13 @@ export function PullToRefreshIndicator({
<div
className={cn(
"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
className={cn(
"h-4 w-4 transition-all duration-200",
isRefreshing && "animate-spin"
)}
className={cn("h-4 w-4 transition-all duration-200", isRefreshing && "animate-spin")}
style={{
transform: isRefreshing ? "rotate(0deg)" : `rotate(${rotation}deg)`,
animationDuration: isRefreshing ? "2s" : undefined,
@@ -73,10 +74,16 @@ export function PullToRefreshIndicator({
<div
className={cn(
"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>
);

View File

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

View File

@@ -23,7 +23,7 @@ export function ClientHomePage() {
try {
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) {
@@ -67,7 +67,7 @@ export function ClientHomePage() {
// Récupérer les nouvelles données
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) {
@@ -128,4 +128,3 @@ export function ClientHomePage() {
</>
);
}

View File

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

View File

@@ -83,9 +83,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
const title = isSeries
? item.metadata.title
: item.metadata.title ||
(item.metadata.number
? t("navigation.volume", { number: item.metadata.number })
: "");
(item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : "");
const handleClick = () => {
// Pour les séries, toujours autoriser le clic
@@ -100,7 +98,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
onClick={handleClick}
className={cn(
"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">

View File

@@ -24,7 +24,12 @@ interface ClientLayoutProps {
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 [randomBookId, setRandomBookId] = useState<string | null>(null);
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
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith('/books/');
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
const hasCustomBackground =
preferences.background.type === "gradient" ||
@@ -149,15 +154,14 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ImageCacheProvider>
{/* Background fixe pour les images et gradients */}
{hasCustomBackground && (
<div
className="fixed inset-0 -z-10"
style={backgroundStyle}
/>
)}
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
<div
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 && (
<Header

View File

@@ -11,7 +11,11 @@ interface HeaderProps {
showRefreshBackground?: boolean;
}
export function Header({ onToggleSidebar, onRefreshBackground, showRefreshBackground = false }: HeaderProps) {
export function Header({
onToggleSidebar,
onRefreshBackground,
showRefreshBackground = false,
}: HeaderProps) {
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
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"
aria-label="Rafraîchir l'image de fond"
>
<RefreshCw className={`h-[1.2rem] w-[1.2rem] ${isRefreshing ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-[1.2rem] w-[1.2rem] ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="sr-only">Rafraîchir l&apos;image de fond</span>
</button>
)}

View File

@@ -1,6 +1,16 @@
"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 { cn } from "@/lib/utils";
import { signOut } from "next-auth/react";
@@ -24,7 +34,13 @@ interface SidebarProps {
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 pathname = usePathname();
const router = useRouter();

View File

@@ -16,7 +16,12 @@ interface LibraryHeaderProps {
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();
// Mémoriser la sélection des séries pour éviter les rerenders inutiles
@@ -25,8 +30,9 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
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)
const background = series.length > 1
? series.filter(s => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))]
const background =
series.length > 1
? series.filter((s) => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))]
: random;
return { randomSeries: random, backgroundSeries: background };
@@ -81,8 +87,7 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
<StatusBadge status="unread" icon={Library}>
{seriesCount === 1
? t("library.header.series", { count: seriesCount })
: t("library.header.series_plural", { count: seriesCount })
}
: t("library.header.series_plural", { count: seriesCount })}
</StatusBadge>
<RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} />
@@ -90,9 +95,7 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
</div>
{library.unavailable && (
<p className="text-sm text-destructive mt-2">
{t("library.header.unavailable")}
</p>
<p className="text-sm text-destructive mt-2">{t("library.header.unavailable")}</p>
)}
</div>
</div>
@@ -100,4 +103,3 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
</div>
);
};

View File

@@ -43,10 +43,8 @@ export function PaginatedSeriesGrid({
const effectivePageSize = pageSize || displayItemsPerPage;
const { t } = useTranslate();
const updateUrlParams = useCallback(async (
updates: Record<string, string | null>,
replace: boolean = false
) => {
const updateUrlParams = useCallback(
async (updates: Record<string, string | null>, replace: boolean = false) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
@@ -62,7 +60,9 @@ export function PaginatedSeriesGrid({
} else {
await router.push(`${pathname}?${params.toString()}`);
}
}, [router, pathname, searchParams]);
},
[router, pathname, searchParams]
);
// Update local state when prop changes
useEffect(() => {
@@ -89,7 +89,6 @@ export function PaginatedSeriesGrid({
});
};
const handlePageSizeChange = async (size: number) => {
await updateUrlParams({
page: "1",

View File

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

View File

@@ -60,9 +60,8 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
};
const isCompleted = series.booksCount === series.booksReadCount;
const progressPercentage = series.booksCount > 0
? (series.booksReadCount / series.booksCount) * 100
: 0;
const progressPercentage =
series.booksCount > 0 ? (series.booksReadCount / series.booksCount) * 100 : 0;
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">
{series.metadata.title}
</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}
</span>
</div>
@@ -109,9 +113,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{series.booksMetadata.authors[0].name}
</span>
<span className="line-clamp-1">{series.booksMetadata.authors[0].name}</span>
</div>
)}
</div>
@@ -148,7 +150,12 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
</div>
{/* 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}
</span>
</div>
@@ -177,7 +184,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{series.booksMetadata.authors.map(a => a.name).join(", ")}
{series.booksMetadata.authors.map((a) => a.name).join(", ")}
</span>
</div>
)}
@@ -246,4 +253,3 @@ export function SeriesList({ series, isCompact = false }: SeriesListProps) {
</div>
);
}

View File

@@ -74,4 +74,3 @@ export function ClientBookPage({ bookId }: ClientBookPageProps) {
return <ClientBookWrapper book={data.book} pages={data.pages} nextBook={data.nextBook} />;
}

View File

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

View File

@@ -29,13 +29,22 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const { direction, toggleDirection, isRTL } = useReadingDirection();
const { isFullscreen, toggleFullscreen } = useFullscreen();
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,
pages,
prefetchCount: preferences.readerPrefetchCount,
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null,
});
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } = usePageNavigation({
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
usePageNavigation({
book,
pages,
isDoublePage,
@@ -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
useEffect(() => {
document.body.classList.remove('no-pinch-zoom');
document.body.classList.remove("no-pinch-zoom");
return () => {
document.body.classList.add('no-pinch-zoom');
document.body.classList.add("no-pinch-zoom");
};
}, []);
// Prefetch current and next pages
// Deduplication in useImageLoader prevents redundant requests
// Server queue (RequestQueueService) handles concurrency limits
@@ -74,7 +82,11 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
prefetchPages(currentPage, prefetchCount);
// 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);
}
@@ -83,7 +95,16 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
if (pagesFromEnd <= prefetchCount && nextBook) {
prefetchNextBook(prefetchCount);
}
}, [currentPage, isDoublePage, shouldShowDoublePage, prefetchPages, prefetchNextBook, prefetchCount, pages.length, nextBook]);
}, [
currentPage,
isDoublePage,
shouldShowDoublePage,
prefetchPages,
prefetchNextBook,
prefetchCount,
pages.length,
nextBook,
]);
// Keyboard events
useEffect(() => {
@@ -115,13 +136,14 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
};
}, [handleNextPage, handlePreviousPage, onClose, isRTL, currentPage]);
const handleContainerClick = useCallback((e: React.MouseEvent) => {
const handleContainerClick = useCallback(
(e: React.MouseEvent) => {
// Vérifier si c'est un double-clic sur une image
const target = e.target as HTMLElement;
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
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
@@ -130,14 +152,14 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
e.stopPropagation();
handleZoom();
lastClickTimeRef.current = 0;
} else if (target.tagName === 'IMG') {
} 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);
setShowControls((prev) => !prev);
clickTimeoutRef.current = null;
}, 300);
} else {
@@ -145,7 +167,9 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
setShowControls(!showControls);
lastClickTimeRef.current = 0;
}
}, [showControls, handleZoom]);
},
[showControls, handleZoom]
);
return (
<ReaderContainer onContainerClick={handleContainerClick}>
@@ -173,7 +197,11 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
showThumbnails={showThumbnails}
onToggleThumbnails={() => setShowThumbnails(!showThumbnails)}
onZoom={handleZoom}
onForceReload={() => handleForceReload(currentPage, isDoublePage, (page) => shouldShowDoublePage(page, pages.length))}
onForceReload={() =>
handleForceReload(currentPage, isDoublePage, (page) =>
shouldShowDoublePage(page, pages.length)
)
}
/>
<PageDisplay
@@ -196,4 +224,3 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
</ReaderContainer>
);
}

View File

@@ -44,10 +44,9 @@ export function PageDisplay({
<div
className={cn(
"relative h-full flex items-center",
isDoublePage && shouldShowDoublePage(currentPage)
? "w-1/2"
: "w-full justify-center",
isDoublePage && shouldShowDoublePage(currentPage) && {
isDoublePage && shouldShowDoublePage(currentPage) ? "w-1/2" : "w-full justify-center",
isDoublePage &&
shouldShowDoublePage(currentPage) && {
"order-2 justify-start": isRTL,
"order-1 justify-end": !isRTL,
}
@@ -57,13 +56,16 @@ export function PageDisplay({
<div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 animate-fade-in">
<div className="relative">
<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>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ''}`}
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`}
src={imageBlobUrls[currentPage] || getPageUrl(currentPage)}
alt={`Page ${currentPage}`}
className={cn(
@@ -85,25 +87,25 @@ export function PageDisplay({
{/* Page 2 (double page) */}
{isDoublePage && shouldShowDoublePage(currentPage) && (
<div
className={cn(
"relative h-full w-1/2 flex items-center",
{
className={cn("relative h-full w-1/2 flex items-center", {
"order-1 justify-end": isRTL,
"order-2 justify-start": !isRTL,
}
)}
})}
>
{secondPageLoading && (
<div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 animate-fade-in">
<div className="relative">
<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>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ''}`}
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`}
src={imageBlobUrls[currentPage + 1] || getPageUrl(currentPage + 1)}
alt={`Page ${currentPage + 1}`}
className={cn(

View File

@@ -8,9 +8,12 @@ interface ReaderContainerProps {
export function ReaderContainer({ children, onContainerClick }: ReaderContainerProps) {
const readerRef = useRef<HTMLDivElement>(null);
const handleContainerClick = useCallback((e: React.MouseEvent) => {
const handleContainerClick = useCallback(
(e: React.MouseEvent) => {
onContainerClick(e);
}, [onContainerClick]);
},
[onContainerClick]
);
return (
<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"
onClick={handleContainerClick}
>
<div className="relative h-full flex flex-col items-center justify-center">
{children}
</div>
<div className="relative h-full flex flex-col items-center justify-center">{children}</div>
</div>
);
}

View File

@@ -97,9 +97,9 @@ export const Thumbnail = forwardRef<HTMLButtonElement, ThumbnailProps>(
setImageUrl((prev) => {
if (!prev) return null;
// 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
const baseUrl = prev.replace(/[?&]retry=\d+/g, '');
const baseUrl = prev.replace(/[?&]retry=\d+/g, "");
return `${baseUrl}${separator}retry=${loadAttempts.current}`;
});
}, delay);

View File

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

View File

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

View File

@@ -15,7 +15,12 @@ interface UseImageLoaderProps {
nextBook?: { id: string; pages: number[] } | null; // Livre suivant pour prefetch
}
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 [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({});
const loadedImagesRef = useRef(loadedImages);
@@ -32,10 +37,14 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
imageBlobUrlsRef.current = 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
const prefetchImage = useCallback(async (pageNum: number) => {
const prefetchImage = useCallback(
async (pageNum: number) => {
// Check if we already have both dimensions and blob URL
const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
@@ -55,7 +64,7 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
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
cache: "default", // Respect Cache-Control headers from server
});
if (!response.ok) {
return;
@@ -67,15 +76,15 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
// Create image to get dimensions
const img = new Image();
img.onload = () => {
setLoadedImages(prev => ({
setLoadedImages((prev) => ({
...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight }
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
}));
// Store the blob URL for immediate use
setImageBlobUrls(prev => ({
setImageBlobUrls((prev) => ({
...prev,
[pageNum]: blobUrl
[pageNum]: blobUrl,
}));
};
img.src = blobUrl;
@@ -85,12 +94,15 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
// Remove from pending set
pendingFetchesRef.current.delete(pageNum);
}
}, [getPageUrl]);
},
[getPageUrl]
);
// Prefetch multiple pages starting from a given page
// The server-side queue (RequestQueueService) handles concurrency limits
// We only deduplicate to avoid redundant HTTP requests
const prefetchPages = useCallback(async (startPage: number, count: number = prefetchCount) => {
const prefetchPages = useCallback(
async (startPage: number, count: number = prefetchCount) => {
const pagesToPrefetch = [];
for (let i = 0; i < count; i++) {
@@ -111,14 +123,17 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
// The browser cache and our deduplication prevent redundant requests
if (pagesToPrefetch.length > 0) {
// 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
});
}
}, [prefetchImage, prefetchCount, _pages.length]);
},
[prefetchImage, prefetchCount, _pages.length]
);
// Prefetch pages from next book
const prefetchNextBook = useCallback(async (count: number = prefetchCount) => {
const prefetchNextBook = useCallback(
async (count: number = prefetchCount) => {
if (!nextBook) {
return;
}
@@ -140,13 +155,14 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
// Let all prefetch requests run - server queue handles concurrency
if (pagesToPrefetch.length > 0) {
Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
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
cache: "default", // Respect Cache-Control headers from server
});
if (!response.ok) {
return;
@@ -158,15 +174,15 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
// Create image to get dimensions
const img = new Image();
img.onload = () => {
setLoadedImages(prev => ({
setLoadedImages((prev) => ({
...prev,
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight }
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight },
}));
// Store the blob URL for immediate use
setImageBlobUrls(prev => ({
setImageBlobUrls((prev) => ({
...prev,
[nextBookPageKey]: blobUrl
[nextBookPageKey]: blobUrl,
}));
};
img.src = blobUrl;
@@ -175,14 +191,22 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
} finally {
pendingFetchesRef.current.delete(nextBookPageKey);
}
})).catch(() => {
})
).catch(() => {
// Silently fail - prefetch is non-critical
});
}
}, [nextBook, prefetchCount]);
},
[nextBook, prefetchCount]
);
// Force reload handler
const handleForceReload = useCallback(async (currentPage: number, isDoublePage: boolean, shouldShowDoublePage: (page: number) => boolean) => {
const handleForceReload = useCallback(
async (
currentPage: number,
isDoublePage: boolean,
shouldShowDoublePage: (page: number) => boolean
) => {
// Révoquer les anciennes URLs blob
if (imageBlobUrls[currentPage]) {
URL.revokeObjectURL(imageBlobUrls[currentPage]);
@@ -194,11 +218,11 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
try {
// Fetch page 1 avec cache: reload
const response1 = await fetch(getPageUrl(currentPage), {
cache: 'reload',
cache: "reload",
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
if (!response1.ok) {
@@ -210,17 +234,17 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
const newUrls: Record<number, string> = {
...imageBlobUrls,
[currentPage]: blobUrl1
[currentPage]: blobUrl1,
};
// Fetch page 2 si double page
if (isDoublePage && shouldShowDoublePage(currentPage)) {
const response2 = await fetch(getPageUrl(currentPage + 1), {
cache: 'reload',
cache: "reload",
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
if (!response2.ok) {
@@ -234,15 +258,17 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
setImageBlobUrls(newUrls);
} catch (error) {
logger.error({ err: error }, 'Error reloading images:');
logger.error({ err: error }, "Error reloading images:");
throw error;
}
}, [imageBlobUrls, getPageUrl]);
},
[imageBlobUrls, getPageUrl]
);
// Cleanup blob URLs on unmount only
useEffect(() => {
return () => {
Object.values(imageBlobUrlsRef.current).forEach(url => {
Object.values(imageBlobUrlsRef.current).forEach((url) => {
if (url) URL.revokeObjectURL(url);
});
};

View File

@@ -100,7 +100,15 @@ export function usePageNavigation({
}
const step = isDoublePage && shouldShowDoublePage(currentPage) ? 2 : 1;
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
useEffect(() => {

View File

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

View File

@@ -30,7 +30,8 @@ export function useTouchNavigation({
}, []);
// Touch handlers for swipe navigation
const handleTouchStart = useCallback((e: TouchEvent) => {
const handleTouchStart = useCallback(
(e: TouchEvent) => {
// Ne pas gérer si Photoswipe est ouvert
if (pswpRef.current) return;
// Ne pas gérer si la page est zoomée (zoom natif)
@@ -51,7 +52,9 @@ export function useTouchNavigation({
touchStartXRef.current = e.touches[0].clientX;
touchStartYRef.current = e.touches[0].clientY;
}
}, [pswpRef, isZoomed]);
},
[pswpRef, isZoomed]
);
const handleTouchMove = useCallback((e: TouchEvent) => {
// Détecter le pinch pendant le mouvement
@@ -62,7 +65,8 @@ export function useTouchNavigation({
}
}, []);
const handleTouchEnd = useCallback((e: TouchEvent) => {
const handleTouchEnd = useCallback(
(e: TouchEvent) => {
// Si on était en mode pinch, ne JAMAIS traiter le swipe
if (isPinchingRef.current) {
touchStartXRef.current = null;
@@ -111,7 +115,9 @@ export function useTouchNavigation({
touchStartXRef.current = null;
touchStartYRef.current = null;
}, [onNextPage, onPreviousPage, isRTL, pswpRef, isZoomed]);
},
[onNextPage, onPreviousPage, isRTL, pswpRef, isZoomed]
);
// Setup touch event listeners
useEffect(() => {

View File

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

View File

@@ -75,10 +75,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
};
const statusInfo = getStatusInfo();
const title = book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
: book.name);
const title =
book.metadata.title ||
(book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name);
if (isCompact) {
return (
@@ -118,7 +117,12 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
>
{title}
</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}
</span>
</div>
@@ -137,9 +141,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.authors[0].name}
</span>
<span className="line-clamp-1">{book.metadata.authors[0].name}</span>
</div>
)}
</div>
@@ -194,7 +196,12 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
</div>
{/* 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}
</span>
</div>
@@ -221,7 +228,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.authors.map(a => a.name).join(", ")}
{book.metadata.authors.map((a) => a.name).join(", ")}
</span>
</div>
)}
@@ -343,4 +350,3 @@ export function BookList({ books, onBookClick, isCompact = false }: BookListProp
</div>
);
}

View File

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

View File

@@ -159,15 +159,16 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
<span className="text-sm text-white/80">
{series.booksCount === 1
? t("series.header.books", { count: series.booksCount })
: t("series.header.books_plural", { count: series.booksCount })
}
: t("series.header.books_plural", { count: series.booksCount })}
</span>
<IconButton
variant="ghost"
size="icon"
icon={isFavorite ? Star : StarOff}
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"
iconClassName={isFavorite ? "fill-yellow-400 text-yellow-400" : ""}
/>

View File

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

View File

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

View File

@@ -214,19 +214,24 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
try {
const urlObj = new URL(url);
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)
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]}`;
}
// Pour les autres, garder juste le premier segment
return `/${segments[0]}`;
} 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
Object.keys(grouped).forEach((key) => {
grouped[key].sort((a, b) => {
const aVersion = new URL(a.url).searchParams.get('v') || '0';
const bVersion = new URL(b.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";
return Number(bVersion) - Number(aVersion);
});
});
@@ -458,7 +463,6 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<CardDescription>{t("settings.cache.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between mb-4">
<div className="space-y-0.5">
<Label htmlFor="cache-mode">{t("settings.cache.mode.label")}</Label>
@@ -488,7 +492,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
</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>
@@ -497,7 +503,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
{swCacheSize !== null ? (
<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>
@@ -506,7 +514,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
{apiCacheSize !== null ? (
<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>
@@ -525,11 +535,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<List className="h-4 w-4" />
{t("settings.cache.entries.serverTitle")}
</span>
{showEntries ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{showEntries ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
{showEntries && (
@@ -569,7 +575,10 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
>
{getTimeRemaining(entry.expiry)}
</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()}
</div>
</div>
@@ -649,10 +658,14 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<div className="space-y-1 pl-2">
{(() => {
const versionGroups = groupVersions(entries);
return Object.entries(versionGroups).map(([baseUrl, versions]) => {
return Object.entries(versionGroups).map(
([baseUrl, versions]) => {
const hasMultipleVersions = versions.length > 1;
const isVersionExpanded = expandedVersions[baseUrl];
const totalSize = versions.reduce((sum, v) => sum + v.size, 0);
const totalSize = versions.reduce(
(sum, v) => sum + v.size,
0
);
if (!hasMultipleVersions) {
const entry = versions[0];
@@ -660,7 +673,10 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<div key={baseUrl} className="py-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate text-muted-foreground" title={entry.url}>
<div
className="font-mono text-xs truncate text-muted-foreground"
title={entry.url}
>
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
</div>
</div>
@@ -685,7 +701,10 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
) : (
<ChevronUp className="h-3 w-3 flex-shrink-0" />
)}
<div className="font-mono text-xs truncate text-muted-foreground" title={baseUrl}>
<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">
@@ -699,10 +718,17 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
{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
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
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">
@@ -714,7 +740,8 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
)}
</div>
);
});
}
);
})()}
</div>
)}
@@ -833,12 +860,24 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
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"
>
<option value="0">{t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}</option>
<option value="3600">{t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}</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>
<option value="0">
{t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}
</option>
<option value="3600">
{t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}
</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>
</div>
</div>

View File

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

View File

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

View File

@@ -37,12 +37,7 @@ export const ErrorMessage = ({
<AlertCircle className="h-4 w-4" />
<p>{message}</p>
{onRetry && (
<Button
onClick={onRetry}
variant="ghost"
size="sm"
className="ml-auto"
>
<Button onClick={onRetry} variant="ghost" size="sm" className="ml-auto">
<RefreshCw className="h-3 w-3" />
</Button>
)}

View File

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

View File

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

View File

@@ -6,7 +6,10 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
({ className, ...props }, ref) => (
<div
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}
/>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ interface ProgressBarProps {
export function ProgressBar({ progress, total, type }: ProgressBarProps) {
const percentage = Math.round((progress / total) * 100);
const barColor = type === "series"
const barColor =
type === "series"
? "bg-gradient-to-r from-purple-500 to-pink-500"
: "bg-gradient-to-r from-blue-500 to-cyan-500";
return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
@@ -11,11 +11,8 @@ const Slider = React.forwardRef<
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-auto select-none items-center",
className
)}
style={{ touchAction: 'pan-x' }}
className={cn("relative flex w-full touch-auto select-none items-center", className)}
style={{ touchAction: "pan-x" }}
{...props}
>
<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.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>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

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

View File

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

View File

@@ -5,11 +5,7 @@ import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
)
);
@@ -37,7 +33,10 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<tfoot
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}
/>
));
@@ -93,4 +92,3 @@ const TableCaption = React.forwardRef<
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View File

@@ -27,7 +27,8 @@ const toastVariants = cva(
{
variants: {
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 group border-destructive/20 bg-destructive/70 backdrop-blur-md text-destructive-foreground font-medium",
},

View File

@@ -105,6 +105,10 @@ export const ERROR_CODES = {
} as const;
type Values<T> = T[keyof T];
type ErrorCodeValues<T> = T extends { [key: string]: infer U } ? U extends { [key: string]: string } ? Values<U> : never : never;
type ErrorCodeValues<T> = T extends { [key: string]: infer U }
? U extends { [key: string]: string }
? Values<U>
: never
: never;
export type ErrorCode = ErrorCodeValues<typeof ERROR_CODES>;

View File

@@ -56,4 +56,3 @@ export function useImageCache() {
}
return context;
}

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