Compare commits
19 Commits
style/simp
...
b40892b924
| Author | SHA1 | Date | |
|---|---|---|---|
| b40892b924 | |||
|
|
74f033f5d1 | ||
| be8a3c6de8 | |||
|
|
9c47c717d0 | ||
|
|
cc702cff19 | ||
|
|
b609fe0a45 | ||
|
|
10d10d2c2f | ||
| 74f903d78d | |||
|
|
b997b87f51 | ||
|
|
7fb73d3a69 | ||
|
|
9a4fb26b06 | ||
| 2cedd14f97 | |||
| bc0339646f | |||
|
|
7fba3c6fcb | ||
| 3791a58e3c | |||
| 798befd642 | |||
|
|
8e1c4637ba | ||
|
|
d219ed1b3b | ||
| 9a1d1954ad |
40
DONE.md
Normal file
40
DONE.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# DONE.md — Tâches terminées
|
||||||
|
|
||||||
|
## [UI] Passe sur le menu latéral (Sidebar) — 2026-03-14
|
||||||
|
|
||||||
|
> Branche : `style/sidebar-cleanup-and-ui-polish` | Commit : `d219ed1`
|
||||||
|
|
||||||
|
- [x] **`isActive` incorrect** : inclut désormais les sous-items dans le calcul (groupe Mangas actif sur `/import`)
|
||||||
|
- [x] **Double déclenchement toggle/navigation** : chevron déplacé dans un `<button>` séparé du `RouterLink`
|
||||||
|
- [x] **Parent items** (`MenuGroup.vue`) : ajout `hover:text-white` aligné avec le style SubMenuItem
|
||||||
|
- [x] **SubMenuItems** (`SubMenuItem.vue`) : ajout `hover:bg-gray-700` pour harmoniser avec le parent
|
||||||
|
- [x] **État actif vs hover** : logique couleur unifiée sur les deux niveaux
|
||||||
|
|
||||||
|
## [UI] Supprimer "Calendrier" du menu — 2026-03-14
|
||||||
|
|
||||||
|
> Branche : `style/sidebar-cleanup-and-ui-polish` | Commit : `d219ed1`
|
||||||
|
|
||||||
|
- [x] Retirer l'entrée "Calendrier" de la Sidebar
|
||||||
|
- [x] Supprimer la route Vue Router `/calendar`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [UI] Simplifier l'affichage table de la HomePage — 2026-03-14
|
||||||
|
|
||||||
|
> Branche : `style/simplifier-table-homepage` | Commit : `cc27fc4`
|
||||||
|
|
||||||
|
- [x] Supprimer le wrapper card (`bg-white shadow rounded-lg overflow-hidden`) — remplacer par un simple `border-t`
|
||||||
|
- [x] Lien du titre : passer le hover de bleu (`hover:text-blue-600`) à vert (`hover:text-green-500`)
|
||||||
|
- [x] Icône monitoring : remplacer `BellIcon` / `BellSlashIcon` par `BookmarkIcon` / `BookmarkSlashIcon`
|
||||||
|
- [x] Supprimer le padding du wrapper + `container mx-auto` pour tableau pleine largeur
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [UI] Restyling vue grille des mangas — 2026-03-14
|
||||||
|
|
||||||
|
> Branche : `style/restyling-manga-grid` | Commit : `9a4fb26`
|
||||||
|
|
||||||
|
- [x] **Réduire la taille des cards** : grille plus dense (cols-3/4/5/7/8 selon breakpoint, gap-2)
|
||||||
|
- [x] **Supprimer les arrondis** : retrait de `rounded-lg` et `hover:scale-105`
|
||||||
|
- [x] **Overlay icônes au survol** : gradient + 3 boutons (éditer, sources, rafraîchir) en bas à gauche de la cover, visibles au `group-hover`
|
||||||
|
- [x] MangaCard émet les événements, MangaGrid gère les modales (edit, sources, refresh)
|
||||||
129
TASK.md
Normal file
129
TASK.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# TASK.md — Tâches à venir
|
||||||
|
|
||||||
|
## [Feature] Découvrir — Suggestions de mangas via MangaDex
|
||||||
|
|
||||||
|
**Objectif :** Page "Découvrir" qui propose des mangas populaires/récents depuis l'API MangaDex, en excluant ceux déjà présents en base (comparaison via `externalId` = ID MangaDex).
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- [ ] **Consulter la doc API MangaDex** pour identifier le(s) endpoint(s) pertinents (mangas populaires, récemment mis à jour, tendances…) et les paramètres disponibles (filtres langue, statut, contentRating, etc.)
|
||||||
|
- [ ] **Étendre le client MangaDex existant** pour exposer le(s) nouvel(aux) endpoint(s) identifiés (nouveau(x) méthode(s) dans le client + adapter le contrat d'interface si besoin)
|
||||||
|
- [ ] Query `GetDiscoverMangaListQuery` + handler qui appelle le client MangaDex et filtre les résultats dont l'`externalId` est déjà en base
|
||||||
|
- [ ] Response DTO `DiscoverMangaListResponse` avec les champs nécessaires à l'affichage (id MangaDex, titre, couverture, genres, statut…)
|
||||||
|
- [ ] State Provider API Platform sur la route `GET /api/manga/discover`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- [ ] Page `DiscoverPage.vue` avec grille de cards (réutiliser `MangaCard.vue` ou créer `DiscoverMangaCard.vue`)
|
||||||
|
- [ ] Composable TanStack Query `useDiscoverMangaList`
|
||||||
|
- [ ] Route Vue Router `/discover`
|
||||||
|
- [ ] Entrée dans la Sidebar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Domain] Créer le domaine "System"
|
||||||
|
|
||||||
|
**Objectif :** Poser la structure DDD hexagonale du nouveau domaine `System` qui servira de socle aux fonctionnalités Status et Logs.
|
||||||
|
|
||||||
|
- [ ] Créer l'arborescence `src/Domain/System/Domain/`, `Application/`, `Infrastructure/`
|
||||||
|
- [ ] Créer l'arborescence frontend `assets/vue/app/domain/system/`
|
||||||
|
- [ ] Vérifier la conformité avec `phparkitect.php` (ajouter le domaine si nécessaire)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Feature] System — Page "Status"
|
||||||
|
|
||||||
|
**Objectif :** Page de monitoring affichant l'état général de l'application.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- [ ] Query `GetSystemStatusQuery` + handler qui agrège :
|
||||||
|
- Version de l'application (depuis `composer.json` ou variable d'env)
|
||||||
|
- Statut des services critiques (base de données, Messenger workers, stockage)
|
||||||
|
- Poids total des images (scan du dossier `IMAGE_DATA_PATH`)
|
||||||
|
- Poids total des CBZ (scan du dossier `MANGA_DATA_PATH`)
|
||||||
|
- Liens / chemins vers les dossiers de stockage configurés
|
||||||
|
- [ ] Response DTO `SystemStatusResponse`
|
||||||
|
- [ ] State Provider API Platform sur la route `GET /api/system/status`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- [ ] Page `StatusPage.vue` avec sections (Général, Stockage, Services)
|
||||||
|
- [ ] Composable TanStack Query `useSystemStatus`
|
||||||
|
- [ ] Route Vue Router `/system/status`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Feature] System — Page "Logs"
|
||||||
|
|
||||||
|
**Objectif :** Page de consultation des logs d'erreur des workers Messenger, avec filtres.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- [ ] Définir le contrat `WorkerLogRepositoryInterface` dans `System/Domain/Contract/Repository/`
|
||||||
|
- [ ] Implémenter `DoctrineWorkerLogRepository` (ou lecture des logs Monolog selon la stratégie retenue) dans `Infrastructure/`
|
||||||
|
- [ ] Query `GetWorkerLogsQuery` avec paramètres de filtrage (date début/fin, source, niveau, worker/transport) + handler
|
||||||
|
- [ ] Response DTO `WorkerLogListResponse` (liste paginée)
|
||||||
|
- [ ] State Provider API Platform sur la route `GET /api/system/logs`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- [ ] Page `LogsPage.vue` avec tableau paginé + panneau de filtres
|
||||||
|
- [ ] Filtres disponibles : plage de dates, source (transport Messenger), niveau d'erreur, manga associé (source préférée)
|
||||||
|
- [ ] Composable TanStack Query `useWorkerLogs` (avec paramètres de filtre réactifs)
|
||||||
|
- [ ] Route Vue Router `/system/logs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Perf] Reader — Lazy-loading des pages (InfiniteReader)
|
||||||
|
|
||||||
|
**Problème :** `readerStore.js` charge toutes les pages avec `itemsPerPage=9999`. `InfiniteReader.vue` monte tous les composants `ReaderPage` simultanément dans le DOM. Sur un chapitre de 200 pages, cela représente 200 composants actifs et autant d'images pré-chargées.
|
||||||
|
|
||||||
|
- [ ] Implémenter un `IntersectionObserver` sur les wrappers de page pour ne charger les images qu'au moment où elles entrent dans le viewport (`loading="lazy"` ou src conditionnel)
|
||||||
|
- [ ] Limiter le nombre de composants montés simultanément (virtualisation ou windowing) : ne rendre que les pages proches de la page courante (ex. fenêtre de ±3 pages)
|
||||||
|
- [ ] Adapter `readerStore.js` : remplacer `itemsPerPage=9999` par la vraie pagination côté API si la virtualisation le justifie, sinon conserver le fetch unique mais différer le rendu
|
||||||
|
- [ ] Vérifier que le mode `single` n'est pas impacté (il affiche déjà une seule page)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Bug] Reader — N+1 requêtes SQL dans `getChapterContext()`
|
||||||
|
|
||||||
|
**Problème :** `LegacyChapterRepository::getChapterContext()` émet 5 requêtes SQL pour un seul chargement : la requête principale + 2 doublons dans `getPreviousChapterId()` / `getNextChapterId()` (chacune re-fetche le chapitre courant) + les 2 requêtes de navigation.
|
||||||
|
|
||||||
|
- [ ] Refactorer `getPreviousChapterId()` et `getNextChapterId()` pour accepter l'entité `ChapterEntity` déjà chargée en paramètre (au lieu de re-fetcher par ID)
|
||||||
|
- [ ] Appeler ces méthodes depuis `getChapterContext()` en passant l'entité déjà disponible
|
||||||
|
- [ ] Résultat attendu : 3 requêtes maximum (1 pour le chapitre courant + 1 prev + 1 next), idéalement 1 seule avec une requête SQL combinée
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Bug] Reader — Division par zéro dans `ChapterPagesResponse::getTotalPages()`
|
||||||
|
|
||||||
|
**Problème :** `ceil($totalItems / $itemsPerPage)` crashe si `itemsPerPage = 0`. Le test existant documente le bug avec un TODO et assert un HTTP 500 au lieu de corriger.
|
||||||
|
|
||||||
|
- [ ] Ajouter une validation dans `ChapterPagesProvider` : rejeter la requête avec HTTP 400 si `itemsPerPage <= 0`
|
||||||
|
- [ ] Corriger le test `GetChapterPagesTest` pour vérifier HTTP 400 (et non 500)
|
||||||
|
- [ ] Supprimer le commentaire TODO du test une fois corrigé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Bug] Reader — `totalPages` toujours égal à 0 dans `ChapterContext`
|
||||||
|
|
||||||
|
**Problème :** `LegacyChapterRepository::getChapterContext()` hardcode `totalPages: 0`. La méthode `getTotalPagesForChapter()` existe mais n'est jamais appelée depuis `GetChapterContextHandler`.
|
||||||
|
|
||||||
|
- [ ] Appeler `getTotalPagesForChapter()` dans `getChapterContext()` (ou dans le handler) pour calculer le vrai nombre de pages
|
||||||
|
- [ ] Vérifier que la valeur est correctement sérialisée dans la réponse API Platform (`ChapterContextResponse`)
|
||||||
|
- [ ] Adapter les tests existants qui pourraient asserter `totalPages: 0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast
|
||||||
|
|
||||||
|
**Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application.
|
||||||
|
|
||||||
|
- [ ] Auditer le composant/template actuel de la page de conversion
|
||||||
|
- [ ] Simplifier la mise en page (réduire la complexité visuelle, harmoniser avec le reste de l'UI)
|
||||||
|
- [ ] Supprimer l'affichage inline "Conversion réussie"
|
||||||
|
- [ ] Brancher les notifications toast existantes pour signaler le succès (et l'échec) de la conversion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -20,7 +20,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
|
|
||||||
// État de l'interface
|
// État de l'interface
|
||||||
isDragOver: false,
|
isDragOver: false,
|
||||||
showSuccessMessage: false,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -86,7 +85,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
this.clearError();
|
this.clearError();
|
||||||
this.conversionSuccess = false;
|
this.conversionSuccess = false;
|
||||||
this.convertedFile = null;
|
this.convertedFile = null;
|
||||||
this.showSuccessMessage = false;
|
|
||||||
|
|
||||||
// Stockage du fichier
|
// Stockage du fichier
|
||||||
this.currentFile = file;
|
this.currentFile = file;
|
||||||
@@ -125,7 +123,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
// Stockage du fichier converti
|
// Stockage du fichier converti
|
||||||
this.convertedFile = convertedFileBlob;
|
this.convertedFile = convertedFileBlob;
|
||||||
this.conversionSuccess = true;
|
this.conversionSuccess = true;
|
||||||
this.showSuccessMessage = true;
|
|
||||||
|
|
||||||
// Ajout à l'historique
|
// Ajout à l'historique
|
||||||
this.addToHistory({
|
this.addToHistory({
|
||||||
@@ -171,7 +168,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
this.currentFile = null;
|
this.currentFile = null;
|
||||||
this.convertedFile = null;
|
this.convertedFile = null;
|
||||||
this.conversionSuccess = false;
|
this.conversionSuccess = false;
|
||||||
this.showSuccessMessage = false;
|
|
||||||
this.conversionProgress = 0;
|
this.conversionProgress = 0;
|
||||||
this.clearError();
|
this.clearError();
|
||||||
},
|
},
|
||||||
@@ -183,7 +179,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
setError(message) {
|
setError(message) {
|
||||||
this.conversionError = message;
|
this.conversionError = message;
|
||||||
this.conversionSuccess = false;
|
this.conversionSuccess = false;
|
||||||
this.showSuccessMessage = false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,13 +188,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
this.conversionError = null;
|
this.conversionError = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache le message de succès
|
|
||||||
*/
|
|
||||||
hideSuccessMessage() {
|
|
||||||
this.showSuccessMessage = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gère l'état du drag and drop
|
* Gère l'état du drag and drop
|
||||||
* @param {boolean} isDragOver - Indique si un fichier est survolé
|
* @param {boolean} isDragOver - Indique si un fichier est survolé
|
||||||
|
|||||||
@@ -1,33 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8 max-w-4xl">
|
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||||
<!-- En-tête -->
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="mb-8">
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="flex items-center space-x-3 mb-4">
|
|
||||||
<ArrowPathIcon class="w-8 h-8 text-green-600" />
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
Convertir CBR en CBZ
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p class="text-lg text-gray-600 dark:text-gray-400">
|
|
||||||
Convertissez vos fichiers CBR (Comic Book RAR) en CBZ (Comic Book ZIP) pour une meilleure compatibilité.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zone principale -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden">
|
|
||||||
<!-- En-tête de la carte -->
|
|
||||||
<div class="bg-gray-800 text-white p-6">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<ArchiveBoxIcon class="w-6 h-6" />
|
|
||||||
<h2 class="text-xl font-semibold">
|
|
||||||
Conversion de fichiers
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contenu de la carte -->
|
|
||||||
<div class="p-6 space-y-6">
|
|
||||||
<!-- Zone d'upload -->
|
|
||||||
<FileUploadArea
|
<FileUploadArea
|
||||||
:selected-file="conversionStore.currentFile"
|
:selected-file="conversionStore.currentFile"
|
||||||
:disabled="conversionStore.isProcessing"
|
:disabled="conversionStore.isProcessing"
|
||||||
@@ -35,33 +10,25 @@
|
|||||||
@file-cleared="handleFileClear"
|
@file-cleared="handleFileClear"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Bouton de conversion -->
|
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="mt-6 flex justify-center">
|
||||||
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="flex justify-center">
|
|
||||||
<button
|
<button
|
||||||
@click="handleConvert"
|
@click="handleConvert"
|
||||||
:disabled="conversionStore.isProcessing"
|
:disabled="conversionStore.isProcessing"
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center space-x-2 px-6 py-3 text-white font-medium rounded-lg transition-all duration-200',
|
'flex items-center gap-2 px-6 py-3 text-white font-medium rounded-lg transition-colors',
|
||||||
conversionStore.isProcessing
|
conversionStore.isProcessing
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
: 'bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2'
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<ArrowPathIcon
|
<ArrowPathIcon :class="['w-5 h-5', conversionStore.isProcessing && 'animate-spin']" />
|
||||||
:class="[
|
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
|
||||||
'w-5 h-5',
|
|
||||||
conversionStore.isProcessing && 'animate-spin'
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progression et résultat -->
|
|
||||||
<ConversionProgress
|
<ConversionProgress
|
||||||
v-if="showProgress"
|
v-if="showProgress"
|
||||||
|
class="mt-6"
|
||||||
:is-converting="conversionStore.isProcessing"
|
:is-converting="conversionStore.isProcessing"
|
||||||
:progress="conversionStore.conversionProgress"
|
:progress="conversionStore.conversionProgress"
|
||||||
:is-success="conversionStore.hasSucceeded"
|
:is-success="conversionStore.hasSucceeded"
|
||||||
@@ -74,212 +41,101 @@
|
|||||||
@reset="handleReset"
|
@reset="handleReset"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Message d'information -->
|
<div v-if="conversionStore.conversionCount > 0" class="mt-8">
|
||||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex">
|
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Historique</h3>
|
||||||
<InformationCircleIcon class="w-5 h-5 text-blue-500 flex-shrink-0" />
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
|
||||||
À propos de la conversion
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-400 space-y-1">
|
|
||||||
<p>• Les fichiers CBZ sont plus largement supportés par les lecteurs de bandes dessinées</p>
|
|
||||||
<p>• La compression ZIP permet généralement une meilleure accessibilité</p>
|
|
||||||
<p>• Aucune perte de qualité lors de la conversion</p>
|
|
||||||
<p>• Taille maximale supportée: 150MB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Historique des conversions -->
|
|
||||||
<div v-if="conversionStore.conversionCount > 0" class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Historique des conversions
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
@click="handleClearHistory"
|
@click="conversionStore.clearHistory()"
|
||||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||||
>
|
>
|
||||||
Effacer l'historique
|
Effacer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
<div
|
||||||
<div class="space-y-3">
|
v-for="(conversion, index) in conversionStore.conversionHistory"
|
||||||
<div
|
:key="index"
|
||||||
v-for="(conversion, index) in conversionStore.conversionHistory"
|
class="flex items-center justify-between py-3"
|
||||||
:key="index"
|
>
|
||||||
class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-600 last:border-b-0"
|
<div>
|
||||||
>
|
<p class="text-sm text-gray-900 dark:text-gray-100">{{ conversion.originalName }}</p>
|
||||||
<div class="flex-1">
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(conversion.timestamp) }}</p>
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
</div>
|
||||||
{{ conversion.originalName }}
|
<div class="text-right text-sm">
|
||||||
</p>
|
<p class="text-gray-600 dark:text-gray-300">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
||||||
{{ formatDate(conversion.timestamp) }}
|
</p>
|
||||||
</p>
|
<p class="text-xs text-green-600">{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}</p>
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-green-600">
|
|
||||||
{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Toast de notification -->
|
|
||||||
<div
|
|
||||||
v-if="conversionStore.showSuccessMessage"
|
|
||||||
class="fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-3 z-50"
|
|
||||||
>
|
|
||||||
<CheckCircleIcon class="w-5 h-5" />
|
|
||||||
<span class="font-medium">Conversion réussie !</span>
|
|
||||||
<button
|
|
||||||
@click="conversionStore.hideSuccessMessage()"
|
|
||||||
class="ml-2 text-green-100 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<XMarkIcon class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div></div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import {
|
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
|
||||||
ArchiveBoxIcon,
|
|
||||||
ArrowPathIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
InformationCircleIcon,
|
|
||||||
XMarkIcon,
|
|
||||||
} from '@heroicons/vue/24/outline';
|
|
||||||
import { computed, onMounted } from 'vue';
|
import { computed, onMounted } from 'vue';
|
||||||
import { useConversionStore } from '../../application/store/conversionStore';
|
import { useConversionStore } from '../../application/store/conversionStore';
|
||||||
|
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||||
import ConversionProgress from '../components/ConversionProgress.vue';
|
import ConversionProgress from '../components/ConversionProgress.vue';
|
||||||
import FileUploadArea from '../components/FileUploadArea.vue';
|
import FileUploadArea from '../components/FileUploadArea.vue';
|
||||||
|
|
||||||
export default {
|
const conversionStore = useConversionStore();
|
||||||
name: 'ConversionPage',
|
const { showSuccess, showError } = useNotifications();
|
||||||
|
|
||||||
components: {
|
const showProgress = computed(() =>
|
||||||
FileUploadArea,
|
conversionStore.hasSelectedFile &&
|
||||||
ConversionProgress,
|
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
|
||||||
ArrowPathIcon,
|
);
|
||||||
ArchiveBoxIcon,
|
|
||||||
InformationCircleIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
XMarkIcon,
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
const handleFileSelected = (file) => {
|
||||||
const conversionStore = useConversionStore();
|
conversionStore.selectFile(file);
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const showProgress = computed(() => {
|
|
||||||
return conversionStore.hasSelectedFile &&
|
|
||||||
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
const handleFileSelected = (file) => {
|
|
||||||
const success = conversionStore.selectFile(file);
|
|
||||||
if (!success) {
|
|
||||||
// L'erreur est déjà gérée par le store
|
|
||||||
console.warn('Fichier non valide:', file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileClear = () => {
|
|
||||||
conversionStore.resetConversion();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConvert = async () => {
|
|
||||||
if (!conversionStore.currentFile) return;
|
|
||||||
|
|
||||||
const success = await conversionStore.convertCurrentFile();
|
|
||||||
if (success) {
|
|
||||||
console.log('Conversion réussie');
|
|
||||||
} else {
|
|
||||||
console.error('Échec de la conversion');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
conversionStore.downloadConvertedFile();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
conversionStore.resetConversion();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearHistory = () => {
|
|
||||||
conversionStore.clearHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Utility functions
|
|
||||||
const formatFileSize = (bytes) => {
|
|
||||||
if (bytes === 0) return '0 octets';
|
|
||||||
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (isoString) => {
|
|
||||||
const date = new Date(isoString);
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateSaving = (originalSize, convertedSize) => {
|
|
||||||
if (!originalSize || !convertedSize) return '';
|
|
||||||
|
|
||||||
const saving = ((originalSize - convertedSize) / originalSize) * 100;
|
|
||||||
if (saving > 0) {
|
|
||||||
return `-${saving.toFixed(1)}%`;
|
|
||||||
} else if (saving < 0) {
|
|
||||||
return `+${Math.abs(saving).toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
return '0%';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(() => {
|
|
||||||
// Réinitialiser l'état au montage de la page
|
|
||||||
conversionStore.resetConversion();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
conversionStore,
|
|
||||||
showProgress,
|
|
||||||
handleFileSelected,
|
|
||||||
handleFileClear,
|
|
||||||
handleConvert,
|
|
||||||
handleDownload,
|
|
||||||
handleReset,
|
|
||||||
handleClearHistory,
|
|
||||||
formatFileSize,
|
|
||||||
formatDate,
|
|
||||||
calculateSaving,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
const handleFileClear = () => {
|
||||||
/* Styles spécifiques si nécessaires */
|
conversionStore.resetConversion();
|
||||||
</style>
|
};
|
||||||
|
|
||||||
|
const handleConvert = async () => {
|
||||||
|
if (!conversionStore.currentFile) return;
|
||||||
|
const success = await conversionStore.convertCurrentFile();
|
||||||
|
if (success) {
|
||||||
|
showSuccess('Conversion réussie !');
|
||||||
|
} else {
|
||||||
|
showError(conversionStore.conversionError ?? 'Échec de la conversion');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => conversionStore.downloadConvertedFile();
|
||||||
|
const handleReset = () => conversionStore.resetConversion();
|
||||||
|
|
||||||
|
const formatFileSize = (bytes) => {
|
||||||
|
if (bytes === 0) return '0 octets';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (isoString) =>
|
||||||
|
new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(isoString));
|
||||||
|
|
||||||
|
const calculateSaving = (originalSize, convertedSize) => {
|
||||||
|
if (!originalSize || !convertedSize) return '';
|
||||||
|
const saving = ((originalSize - convertedSize) / originalSize) * 100;
|
||||||
|
if (saving > 0) return `-${saving.toFixed(1)}%`;
|
||||||
|
if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`;
|
||||||
|
return '0%';
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => conversionStore.resetConversion());
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export class Manga {
|
|||||||
status = null,
|
status = null,
|
||||||
rating = null,
|
rating = null,
|
||||||
genres = [],
|
genres = [],
|
||||||
createdAt = new Date().toISOString()
|
createdAt = new Date().toISOString(),
|
||||||
|
monitored = false,
|
||||||
|
chaptersTotal = 0,
|
||||||
|
chaptersScraped = 0,
|
||||||
}) {
|
}) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.slug = slug;
|
this.slug = slug;
|
||||||
@@ -25,6 +28,9 @@ export class Manga {
|
|||||||
this.rating = rating;
|
this.rating = rating;
|
||||||
this.genres = genres;
|
this.genres = genres;
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
|
this.monitored = monitored;
|
||||||
|
this.chaptersTotal = chaptersTotal;
|
||||||
|
this.chaptersScraped = chaptersScraped;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(data) {
|
static create(data) {
|
||||||
|
|||||||
@@ -1,37 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterLink
|
<div class="group relative bg-white dark:bg-gray-800 overflow-hidden shadow-sm">
|
||||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
<!-- Cover avec overlay -->
|
||||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
|
<div class="relative pb-[140%]">
|
||||||
<div class="relative pb-[150%]">
|
<RouterLink
|
||||||
<img
|
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
class="absolute inset-0">
|
||||||
:alt="manga.title"
|
<img
|
||||||
class="absolute inset-0 w-full h-full object-cover bg-gray-100" />
|
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||||
</div>
|
:alt="manga.title"
|
||||||
<div class="p-2">
|
class="w-full h-full object-cover bg-gray-100" />
|
||||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">{{ manga.title }}</h3>
|
</RouterLink>
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
|
<!-- Gradient + actions au survol -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||||
|
<div class="absolute bottom-2 left-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||||
|
title="Éditer"
|
||||||
|
@click="$emit('edit', manga)">
|
||||||
|
<PencilIcon class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||||
|
title="Sources préférées"
|
||||||
|
@click="$emit('sources', manga)">
|
||||||
|
<Cog6ToothIcon class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||||
|
title="Rafraîchir"
|
||||||
|
@click="$emit('refresh', manga)">
|
||||||
|
<ArrowPathIcon class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400"> Added: {{ formatDate(manga.createdAt) }} </div>
|
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
|
||||||
|
<!-- Titre + année -->
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||||
|
class="block p-2">
|
||||||
|
<h3 class="text-xs font-medium text-gray-800 dark:text-gray-100 truncate">{{ manga.title }}</h3>
|
||||||
|
<span v-if="manga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||||
manga: {
|
import { RouterLink } from 'vue-router';
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = dateString => {
|
defineProps({
|
||||||
const date = new Date(dateString);
|
manga: {
|
||||||
return date.toLocaleDateString('en-US', {
|
type: Object,
|
||||||
month: 'short',
|
required: true
|
||||||
day: 'numeric',
|
}
|
||||||
year: 'numeric'
|
});
|
||||||
});
|
|
||||||
};
|
defineEmits(['edit', 'sources', 'refresh']);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,16 +1,96 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6">
|
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3 p-4">
|
||||||
<MangaCard v-for="manga in mangas" :key="manga.id" :manga="manga" />
|
<MangaCard
|
||||||
|
v-for="manga in mangas"
|
||||||
|
:key="manga.id"
|
||||||
|
:manga="manga"
|
||||||
|
@edit="openEdit"
|
||||||
|
@sources="openSources"
|
||||||
|
@refresh="doRefresh" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modales -->
|
||||||
|
<MangaEditModal
|
||||||
|
:is-open="isEditModalOpen"
|
||||||
|
:manga="selectedManga"
|
||||||
|
:is-saving="editIsLoading"
|
||||||
|
:error="editError"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@save="handleSaveEdit" />
|
||||||
|
|
||||||
|
<MangaPreferredSourcesModal
|
||||||
|
:is-open="isSourcesModalOpen"
|
||||||
|
:sources="preferredSources"
|
||||||
|
:is-loading="sourcesIsLoading"
|
||||||
|
:error="sourcesError"
|
||||||
|
:is-saving="sourcesIsSaving"
|
||||||
|
@close="isSourcesModalOpen = false"
|
||||||
|
@save="handleSaveSources" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import MangaCard from './MangaCard.vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||||
|
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||||
|
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||||
|
import MangaCard from './MangaCard.vue';
|
||||||
|
import MangaEditModal from './MangaEditModal.vue';
|
||||||
|
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
mangas: {
|
mangas: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedManga = ref(null);
|
||||||
|
const isSourcesModalOpen = ref(false);
|
||||||
|
|
||||||
|
// ── Edit ──────────────────────────────────────────────────
|
||||||
|
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||||
|
|
||||||
|
function openEdit(manga) {
|
||||||
|
selectedManga.value = manga;
|
||||||
|
openEditModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(data) {
|
||||||
|
if (!selectedManga.value) return;
|
||||||
|
await editManga(selectedManga.value.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sources préférées ─────────────────────────────────────
|
||||||
|
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||||
|
const {
|
||||||
|
sources: preferredSources,
|
||||||
|
isLoading: sourcesIsLoading,
|
||||||
|
error: sourcesError,
|
||||||
|
isSaving: sourcesIsSaving,
|
||||||
|
savePreferredSources
|
||||||
|
} = useMangaPreferredSources(selectedMangaId);
|
||||||
|
|
||||||
|
function openSources(manga) {
|
||||||
|
selectedManga.value = manga;
|
||||||
|
isSourcesModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveSources(sourceIds) {
|
||||||
|
savePreferredSources(sourceIds);
|
||||||
|
isSourcesModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refresh ───────────────────────────────────────────────
|
||||||
|
const { refreshMetadata } = useMangaRefresh();
|
||||||
|
const refreshingId = ref(null);
|
||||||
|
|
||||||
|
async function doRefresh(manga) {
|
||||||
|
if (refreshingId.value) return;
|
||||||
|
refreshingId.value = manga.id;
|
||||||
|
try {
|
||||||
|
await refreshMetadata(manga.id);
|
||||||
|
} finally {
|
||||||
|
refreshingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="manga in mangas"
|
|
||||||
:key="manga.id"
|
|
||||||
class="flex bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg p-4 space-x-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
||||||
@click="$emit('manga-click', manga)">
|
|
||||||
<!-- Cover Image -->
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" referrerpolicy="no-referrer" />
|
|
||||||
<!-- TODO: Add placeholder image -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manga Info -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h3 class="text-lg leading-7 font-medium text-gray-900 dark:text-gray-100 truncate">{{
|
|
||||||
manga.title
|
|
||||||
}}</h3>
|
|
||||||
<p v-if="manga.publicationYear" class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{
|
|
||||||
manga.publicationYear
|
|
||||||
}}</p>
|
|
||||||
<p v-if="manga.description" class="text-sm text-gray-700 dark:text-gray-300 mt-2">
|
|
||||||
{{ truncateDescription(manga.description) }}
|
|
||||||
</p>
|
|
||||||
<p v-if="manga.createdAt" class="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
|
||||||
Added: {{ formatDate(manga.createdAt) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { defineEmits, defineProps } from 'vue';
|
|
||||||
|
|
||||||
const emit = defineEmits(['manga-click']);
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
mangas: {
|
|
||||||
type: Array,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = dateString => {
|
|
||||||
if (!dateString) return '';
|
|
||||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
|
||||||
try {
|
|
||||||
return new Date(dateString).toLocaleDateString(undefined, options);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error formatting date:', e);
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const truncateDescription = description => {
|
|
||||||
if (!description) return '';
|
|
||||||
return description.length > 500 ? description.slice(0, 500) + '...' : description;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Pour s'assurer que line-clamp fonctionne */
|
|
||||||
@supports (-webkit-line-clamp: 3) {
|
|
||||||
.line-clamp-3 {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-truncate {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div
|
||||||
|
v-for="manga in mangas"
|
||||||
|
:key="manga.id"
|
||||||
|
class="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors border-b border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Cover -->
|
||||||
|
<img
|
||||||
|
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||||
|
alt=""
|
||||||
|
class="h-36 w-24 object-cover flex-shrink-0 self-start"
|
||||||
|
referrerpolicy="no-referrer" />
|
||||||
|
|
||||||
|
<!-- Titre + méta + résumé -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-start gap-2 flex-wrap">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||||
|
class="text-2xl font-semibold text-gray-900 dark:text-gray-100 hover:text-green-500 dark:hover:text-green-400 transition-colors"
|
||||||
|
@click.stop>
|
||||||
|
{{ manga.title }}
|
||||||
|
</RouterLink>
|
||||||
|
<span
|
||||||
|
v-if="manga.status"
|
||||||
|
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
|
||||||
|
:class="statusClass(manga.status)">
|
||||||
|
{{ manga.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">
|
||||||
|
{{ manga.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions verticales -->
|
||||||
|
<div class="flex flex-col items-center justify-center gap-0.5 flex-shrink-0 self-stretch">
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title="Éditer"
|
||||||
|
@click.stop="openEdit(manga)">
|
||||||
|
<PencilIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title="Sources préférées"
|
||||||
|
@click.stop="openSources(manga)">
|
||||||
|
<Cog6ToothIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md transition-colors"
|
||||||
|
:class="refreshingId === manga.id
|
||||||
|
? 'text-blue-400 cursor-not-allowed'
|
||||||
|
: 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600'"
|
||||||
|
title="Rafraîchir"
|
||||||
|
:disabled="refreshingId === manga.id"
|
||||||
|
@click.stop="doRefresh(manga)">
|
||||||
|
<ArrowPathIcon
|
||||||
|
class="w-4 h-4"
|
||||||
|
:class="{ 'animate-spin': refreshingId === manga.id }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modales -->
|
||||||
|
<MangaEditModal
|
||||||
|
:is-open="isEditModalOpen"
|
||||||
|
:manga="selectedManga"
|
||||||
|
:is-saving="editIsLoading"
|
||||||
|
:error="editError"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@save="handleSaveEdit" />
|
||||||
|
|
||||||
|
<MangaPreferredSourcesModal
|
||||||
|
:is-open="isSourcesModalOpen"
|
||||||
|
:sources="preferredSources"
|
||||||
|
:is-loading="sourcesIsLoading"
|
||||||
|
:error="sourcesError"
|
||||||
|
:is-saving="sourcesIsSaving"
|
||||||
|
@close="isSourcesModalOpen = false"
|
||||||
|
@save="handleSaveSources" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||||
|
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||||
|
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||||
|
import MangaEditModal from './MangaEditModal.vue';
|
||||||
|
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['manga-click']);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mangas: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
} catch (e) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status) {
|
||||||
|
if (status === 'ongoing') return 'text-blue-600 bg-blue-50 dark:bg-blue-900/20';
|
||||||
|
if (status === 'completed') return 'text-green-600 bg-green-50 dark:bg-green-900/20';
|
||||||
|
return 'text-gray-500 bg-gray-100 dark:bg-gray-700';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selected manga ────────────────────────────────────────
|
||||||
|
const selectedManga = ref(null);
|
||||||
|
const isSourcesModalOpen = ref(false);
|
||||||
|
|
||||||
|
// ── Edit ──────────────────────────────────────────────────
|
||||||
|
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||||
|
|
||||||
|
function openEdit(manga) {
|
||||||
|
selectedManga.value = manga;
|
||||||
|
openEditModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(data) {
|
||||||
|
if (!selectedManga.value) return;
|
||||||
|
await editManga(selectedManga.value.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sources préférées ─────────────────────────────────────
|
||||||
|
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||||
|
const {
|
||||||
|
sources: preferredSources,
|
||||||
|
isLoading: sourcesIsLoading,
|
||||||
|
error: sourcesError,
|
||||||
|
isSaving: sourcesIsSaving,
|
||||||
|
savePreferredSources
|
||||||
|
} = useMangaPreferredSources(selectedMangaId);
|
||||||
|
|
||||||
|
function openSources(manga) {
|
||||||
|
selectedManga.value = manga;
|
||||||
|
isSourcesModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveSources(sourceIds) {
|
||||||
|
savePreferredSources(sourceIds);
|
||||||
|
isSourcesModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refresh ───────────────────────────────────────────────
|
||||||
|
const { refreshMetadata } = useMangaRefresh();
|
||||||
|
const refreshingId = ref(null);
|
||||||
|
|
||||||
|
async function doRefresh(manga) {
|
||||||
|
if (refreshingId.value) return;
|
||||||
|
refreshingId.value = manga.id;
|
||||||
|
try {
|
||||||
|
await refreshMetadata(manga.id);
|
||||||
|
} finally {
|
||||||
|
refreshingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="isLoading" class="text-gray-400 dark:text-gray-600 text-xs">…</span>
|
||||||
|
<span v-else-if="sources.length" class="text-gray-700 dark:text-gray-300 truncate max-w-xs block">{{ sources[0].name }}</span>
|
||||||
|
<span v-else class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, toRef } from 'vue';
|
||||||
|
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mangaId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mangaIdRef = toRef(props, 'mangaId');
|
||||||
|
const { sources, isLoading } = useMangaPreferredSources(mangaIdRef);
|
||||||
|
</script>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="overflow-y-auto h-full">
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<!-- Barre de recherche -->
|
<!-- Barre de recherche -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
@@ -29,10 +30,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Résultats de recherche -->
|
<!-- Résultats de recherche -->
|
||||||
<div class="max-w-full overflow-hidden">
|
<div v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700">
|
||||||
<MangaList v-if="searchResults.length > 0" :mangas="searchResults" @manga-click="openMangaModal" />
|
<div
|
||||||
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600 dark:text-gray-400">Aucun résultat trouvé</p>
|
v-for="manga in searchResults"
|
||||||
|
:key="manga.externalId"
|
||||||
|
class="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors border-b border-gray-100 dark:border-gray-700 cursor-pointer"
|
||||||
|
@click="openMangaModal(manga)">
|
||||||
|
<img
|
||||||
|
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||||
|
alt=""
|
||||||
|
class="h-36 w-24 object-cover flex-shrink-0 self-start"
|
||||||
|
referrerpolicy="no-referrer" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
|
||||||
|
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600 dark:text-gray-400">Aucun résultat trouvé</p>
|
||||||
|
|
||||||
<!-- Modal de confirmation -->
|
<!-- Modal de confirmation -->
|
||||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||||
@@ -79,6 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -88,7 +104,6 @@ import { storeToRefs } from 'pinia';
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useMangaStore } from '../../application/store/mangaStore';
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
import MangaList from '../components/MangaList.vue';
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="overflow-y-auto flex-1">
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
||||||
<MangaList
|
<MangaOverview
|
||||||
v-else-if="viewMode === 'list'"
|
v-else-if="viewMode === 'list'"
|
||||||
:mangas="pagedItems"
|
:mangas="pagedItems"
|
||||||
@manga-click="handleMangaClick" />
|
@manga-click="handleMangaClick" />
|
||||||
@@ -45,7 +45,7 @@ import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
|||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useMangaStore } from '../../application/store/mangaStore';
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
import MangaGrid from '../components/MangaGrid.vue';
|
import MangaGrid from '../components/MangaGrid.vue';
|
||||||
import MangaList from '../components/MangaList.vue';
|
import MangaOverview from '../components/MangaOverview.vue';
|
||||||
import MangaTable from '../components/MangaTable.vue';
|
import MangaTable from '../components/MangaTable.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -9,19 +9,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="reader-content">
|
<div v-else class="reader-content">
|
||||||
<ReaderControls
|
|
||||||
v-if="store.readingMode === 'single'"
|
|
||||||
:current-page="store.currentPage"
|
|
||||||
:total-pages="store.totalPages"
|
|
||||||
:is-first-page="store.isFirstPage"
|
|
||||||
:is-last-page="store.isLastPage"
|
|
||||||
:available-chapters="availableChapters"
|
|
||||||
:settings-open="settingsOpen"
|
|
||||||
@previous="store.previousPage"
|
|
||||||
@next="store.nextPage"
|
|
||||||
@chapter-selected="handleChapterSelected"
|
|
||||||
@toggle-settings="toggleSettings" />
|
|
||||||
|
|
||||||
<template v-if="store.readingMode === 'single'">
|
<template v-if="store.readingMode === 'single'">
|
||||||
<SingleModeReader
|
<SingleModeReader
|
||||||
:page-data="store.currentPageData"
|
:page-data="store.currentPageData"
|
||||||
@@ -35,29 +22,10 @@
|
|||||||
:pages="store.pages"
|
:pages="store.pages"
|
||||||
:zoom="store.zoom"
|
:zoom="store.zoom"
|
||||||
:double-page-mode="store.effectiveDoublePageMode"
|
:double-page-mode="store.effectiveDoublePageMode"
|
||||||
|
:initial-page="store.currentPage"
|
||||||
@page-visible="store.handlePageVisible"
|
@page-visible="store.handlePageVisible"
|
||||||
@buttons-visibility-change="handleButtonsVisibilityChange"
|
|
||||||
ref="infiniteReaderRef" />
|
ref="infiniteReaderRef" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ReaderSettings
|
|
||||||
:reading-mode="store.readingMode"
|
|
||||||
:reading-direction="store.readingDirection"
|
|
||||||
:zoom="store.zoom"
|
|
||||||
:double-page-mode="store.effectiveDoublePageMode"
|
|
||||||
:double-page-settings="store.doublePageSettings"
|
|
||||||
:visible="showFloatingButtons"
|
|
||||||
:force-open="store.readingMode === 'single' ? settingsOpen : null"
|
|
||||||
@toggle-reading-mode="toggleReadingMode"
|
|
||||||
@toggle-reading-direction="toggleReadingDirection"
|
|
||||||
@zoom-in="zoomIn"
|
|
||||||
@zoom-out="zoomOut"
|
|
||||||
@zoom-change="handleZoomChange"
|
|
||||||
@double-page-mode-change="handleDoublePageModeChange"
|
|
||||||
@double-page-auto-detect-change="handleDoublePageAutoDetectChange"
|
|
||||||
@detection-threshold-change="handleDetectionThresholdChange"
|
|
||||||
@reset-preferences="handleResetPreferences"
|
|
||||||
@button-click="resetButtonsTimer" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -68,8 +36,6 @@ import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
|||||||
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||||
import { useReaderStore } from '../../application/store/readerStore';
|
import { useReaderStore } from '../../application/store/readerStore';
|
||||||
import InfiniteReader from './InfiniteReader.vue';
|
import InfiniteReader from './InfiniteReader.vue';
|
||||||
import ReaderControls from './ReaderControls.vue';
|
|
||||||
import ReaderSettings from './ReaderSettings.vue';
|
|
||||||
import SingleModeReader from './SingleModeReader.vue';
|
import SingleModeReader from './SingleModeReader.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -87,28 +53,20 @@ import SingleModeReader from './SingleModeReader.vue';
|
|||||||
const headerStore = useHeaderStore();
|
const headerStore = useHeaderStore();
|
||||||
const prefs = useUserPreferencesStore();
|
const prefs = useUserPreferencesStore();
|
||||||
|
|
||||||
// Référence vers InfiniteReader pour accéder à ses méthodes
|
|
||||||
const infiniteReaderRef = ref(null);
|
const infiniteReaderRef = ref(null);
|
||||||
|
|
||||||
// État pour la visibilité des boutons (géré par InfiniteReader en mode infini, localement en mode simple)
|
|
||||||
const showFloatingButtons = ref(false);
|
|
||||||
const settingsOpen = ref(false); // Nouvel état pour gérer l'ouverture des paramètres
|
|
||||||
let localButtonsTimer = null;
|
|
||||||
|
|
||||||
// Actions de l'interface lecteur
|
// Actions de l'interface lecteur
|
||||||
const toggleReadingMode = () => {
|
const toggleReadingMode = () => {
|
||||||
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
|
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
|
||||||
store.setReadingMode(newMode);
|
store.setReadingMode(newMode);
|
||||||
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
|
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
|
||||||
|
|
||||||
// Gérer la visibilité selon le mode
|
|
||||||
if (newMode === 'single') {
|
if (newMode === 'single') {
|
||||||
headerStore.disableAutoHide();
|
headerStore.disableAutoHide();
|
||||||
// En mode simple : toujours visible
|
headerStore.disableReaderToolbarAutoHide();
|
||||||
showFloatingButtons.value = true;
|
|
||||||
clearTimeout(localButtonsTimer); // Annuler tout timer local
|
|
||||||
} else {
|
} else {
|
||||||
// En mode infini : utiliser la logique d'InfiniteReader
|
headerStore.enableReaderToolbarAutoHide();
|
||||||
|
headerStore.enableAutoHide();
|
||||||
showButtonsWithTimer();
|
showButtonsWithTimer();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -117,100 +75,40 @@ import SingleModeReader from './SingleModeReader.vue';
|
|||||||
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
|
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
|
||||||
store.setReadingDirection(newDir);
|
store.setReadingDirection(newDir);
|
||||||
prefs.setReadingDirection(newDir);
|
prefs.setReadingDirection(newDir);
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoomIn = () => {
|
const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||||
store.setZoom(Math.min(store.zoom + 0.1, 2));
|
const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomOut = () => {
|
const handleZoomChange = (zoom) => store.setZoom(zoom);
|
||||||
store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleZoomChange = (zoom) => {
|
const handleDoublePageModeChange = (mode) => store.setDoublePageMode(mode);
|
||||||
store.setZoom(zoom);
|
const handleDoublePageAutoDetectChange = (enabled) => store.setDoublePageAutoDetect(enabled);
|
||||||
resetButtonsTimer();
|
const handleDetectionThresholdChange = (threshold) => store.setDoublePageDetectionThreshold(threshold);
|
||||||
};
|
const handleResetPreferences = () => store.resetPreferences();
|
||||||
|
|
||||||
// Fonctions pour les doubles pages
|
|
||||||
const handleDoublePageModeChange = (mode) => {
|
|
||||||
store.setDoublePageMode(mode);
|
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDoublePageAutoDetectChange = (enabled) => {
|
|
||||||
store.setDoublePageAutoDetect(enabled);
|
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDetectionThresholdChange = (threshold) => {
|
|
||||||
store.setDoublePageDetectionThreshold(threshold);
|
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetPreferences = () => {
|
|
||||||
store.resetPreferences();
|
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fonction pour afficher les boutons avec timer (avec fallback pour mode simple)
|
|
||||||
const showButtonsWithTimer = () => {
|
const showButtonsWithTimer = () => {
|
||||||
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
||||||
// Mode infini : utiliser la logique d'InfiniteReader
|
|
||||||
infiniteReaderRef.value.showButtonsWithTimer();
|
infiniteReaderRef.value.showButtonsWithTimer();
|
||||||
} else {
|
|
||||||
// Mode simple : toujours visible, pas de timer
|
|
||||||
showFloatingButtons.value = true;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fonction centralisée pour réinitialiser le timer
|
|
||||||
const resetButtonsTimer = () => {
|
const resetButtonsTimer = () => {
|
||||||
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
||||||
// Mode infini : utiliser la logique d'InfiniteReader
|
|
||||||
infiniteReaderRef.value.resetButtonsTimer();
|
infiniteReaderRef.value.resetButtonsTimer();
|
||||||
} else {
|
|
||||||
// Mode simple : toujours visible, pas de timer
|
|
||||||
showFloatingButtons.value = true;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gestionnaire pour les changements de visibilité des boutons
|
|
||||||
const handleButtonsVisibilityChange = (visible) => {
|
|
||||||
if (store.readingMode === 'infinite') {
|
|
||||||
showFloatingButtons.value = visible;
|
|
||||||
}
|
|
||||||
// En mode simple, on ignore les changements et on reste toujours visible
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = event => {
|
const handleKeyPress = event => {
|
||||||
if (store.readingMode === 'single') {
|
if (store.readingMode === 'single') {
|
||||||
if (event.key === 'ArrowRight') {
|
if (event.key === 'ArrowRight') {
|
||||||
store.nextPage();
|
store.nextPage();
|
||||||
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
|
|
||||||
} else if (event.key === 'ArrowLeft') {
|
} else if (event.key === 'ArrowLeft') {
|
||||||
store.previousPage();
|
store.previousPage();
|
||||||
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChapterSelected = (chapterId) => {
|
|
||||||
// La navigation est déjà gérée par le ChapterSelector via le store
|
|
||||||
// Cette fonction est là pour d'éventuelles actions supplémentaires
|
|
||||||
console.log('Chapitre sélectionné:', chapterId);
|
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gestion des paramètres via le bouton intégré
|
|
||||||
const toggleSettings = () => {
|
|
||||||
settingsOpen.value = !settingsOpen.value;
|
|
||||||
resetButtonsTimer(); // Réinitialiser le timer lors de l'interaction
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.chapterId,
|
() => props.chapterId,
|
||||||
newId => {
|
newId => {
|
||||||
@@ -222,38 +120,46 @@ import SingleModeReader from './SingleModeReader.vue';
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Charger les préférences sauvegardées
|
|
||||||
store.loadPreferences();
|
store.loadPreferences();
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyPress);
|
window.addEventListener('keydown', handleKeyPress);
|
||||||
|
|
||||||
// Auto-hide header si activé dans les préférences
|
|
||||||
if (prefs.autoHideHeaderReader) {
|
if (prefs.autoHideHeaderReader) {
|
||||||
headerStore.enableAutoHide();
|
headerStore.enableAutoHide();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fullscreen si activé dans les préférences
|
if (store.readingMode === 'infinite') {
|
||||||
|
headerStore.enableReaderToolbarAutoHide();
|
||||||
|
}
|
||||||
|
|
||||||
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
|
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
|
||||||
document.documentElement.requestFullscreen().catch(() => {});
|
document.documentElement.requestFullscreen().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Afficher les boutons au démarrage
|
|
||||||
showButtonsWithTimer();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', handleKeyPress);
|
window.removeEventListener('keydown', handleKeyPress);
|
||||||
// S'assurer que l'auto-hide est désactivé en quittant le lecteur
|
|
||||||
headerStore.disableAutoHide();
|
headerStore.disableAutoHide();
|
||||||
// Nettoyer le timer local
|
headerStore.disableReaderToolbarAutoHide();
|
||||||
clearTimeout(localButtonsTimer);
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggleReadingMode,
|
||||||
|
toggleReadingDirection,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
handleZoomChange,
|
||||||
|
handleDoublePageModeChange,
|
||||||
|
handleDoublePageAutoDetectChange,
|
||||||
|
handleDetectionThresholdChange,
|
||||||
|
handleResetPreferences,
|
||||||
|
resetButtonsTimer,
|
||||||
|
showButtonsWithTimer,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
.chapter-reader {
|
.chapter-reader {
|
||||||
@apply w-full h-full flex flex-col items-center justify-center bg-gray-900 text-white;
|
@apply w-full h-full flex flex-col bg-gray-900 text-white;
|
||||||
@apply p-0 sm:p-2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
@@ -265,8 +171,7 @@ import SingleModeReader from './SingleModeReader.vue';
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reader-content {
|
.reader-content {
|
||||||
@apply w-full h-full flex flex-col;
|
@apply w-full flex-1 flex flex-col min-h-0;
|
||||||
@apply p-0 sm:p-2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rtl {
|
.rtl {
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="infinite-reader" ref="containerRef">
|
<div class="infinite-reader" ref="containerRef">
|
||||||
<!-- Navigation en haut -->
|
<div v-for="(page, index) in pages" :key="index" class="page-wrapper" :data-page-index="index">
|
||||||
<div class="navigation-wrapper top">
|
<ReaderPage
|
||||||
<ChapterNavigation position="top" />
|
v-if="isPageInWindow(index) && page?.url"
|
||||||
</div>
|
:page-data="page"
|
||||||
|
:page-number="index + 1"
|
||||||
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
|
:zoom="zoom"
|
||||||
<div v-if="!page?.url" class="loading">
|
:double-page-mode="doublePageMode"
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
loading="eager"
|
||||||
</div>
|
/>
|
||||||
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
|
<div v-else class="page-placeholder" :style="getPlaceholderStyle(page)" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navigation en bas -->
|
|
||||||
<div class="navigation-wrapper bottom">
|
|
||||||
<ChapterNavigation position="bottom" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bouton flottant pour revenir en haut -->
|
<!-- Bouton flottant pour revenir en haut -->
|
||||||
@@ -29,13 +24,14 @@
|
|||||||
<button
|
<button
|
||||||
v-show="showFloatingButtons"
|
v-show="showFloatingButtons"
|
||||||
@click="scrollToTop"
|
@click="scrollToTop"
|
||||||
class="fixed bottom-6 right-6 z-[9999] bg-blue-600 hover:bg-blue-700 text-white w-12 h-12 rounded-full shadow-lg hover:shadow-xl flex items-center justify-center transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
class="fixed bottom-6 right-6 z-[9999] bg-gray-800 hover:bg-gray-700 text-white hover:text-green-500 flex flex-col items-center justify-center w-12 h-12 rounded shadow-lg transition-colors duration-200"
|
||||||
title="Revenir en haut"
|
title="Revenir en haut"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs hidden sm:inline">Haut</span>
|
||||||
</button>
|
</button>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,9 +40,27 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||||
import ChapterNavigation from './ChapterNavigation.vue';
|
|
||||||
import ReaderPage from './ReaderPage.vue';
|
import ReaderPage from './ReaderPage.vue';
|
||||||
|
|
||||||
|
const WINDOW_SIZE = 3;
|
||||||
|
const currentVisibleIndex = ref(0); // initialisé via prop initialPage dans onMounted
|
||||||
|
|
||||||
|
const isPageInWindow = (index) => Math.abs(index - currentVisibleIndex.value) <= WINDOW_SIZE;
|
||||||
|
|
||||||
|
const getPlaceholderStyle = (page) => {
|
||||||
|
if (page?.dimensions?.width && page?.dimensions?.height) {
|
||||||
|
const maxW = windowWidth.value < 1200
|
||||||
|
? windowWidth.value * 0.95
|
||||||
|
: 1200;
|
||||||
|
return {
|
||||||
|
aspectRatio: `${page.dimensions.width} / ${page.dimensions.height}`,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: `${Math.min(page.dimensions.width, maxW)}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { height: '800px', width: '100%' };
|
||||||
|
};
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
pages: {
|
pages: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -59,6 +73,10 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
doublePageMode: {
|
doublePageMode: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
initialPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,12 +96,15 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
let scrollDirection = 'down';
|
let scrollDirection = 'down';
|
||||||
|
|
||||||
const observeIntersection = entries => {
|
const observeIntersection = entries => {
|
||||||
entries.forEach(entry => {
|
const intersectingIndices = entries
|
||||||
if (entry.isIntersecting) {
|
.filter(e => e.isIntersecting)
|
||||||
const pageIndex = parseInt(entry.target.getAttribute('data-page-index'));
|
.map(e => parseInt(e.target.getAttribute('data-page-index')));
|
||||||
emit('pageVisible', pageIndex);
|
|
||||||
}
|
if (intersectingIndices.length > 0) {
|
||||||
});
|
const minIdx = Math.min(...intersectingIndices);
|
||||||
|
currentVisibleIndex.value = minIdx;
|
||||||
|
emit('pageVisible', minIdx);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupIntersectionObserver = () => {
|
const setupIntersectionObserver = () => {
|
||||||
@@ -99,8 +120,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
|
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
|
||||||
if (pageElements) {
|
if (pageElements) {
|
||||||
pageElements.forEach((element, index) => {
|
pageElements.forEach(element => {
|
||||||
element.setAttribute('data-page-index', index);
|
|
||||||
observer.value.observe(element);
|
observer.value.observe(element);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -169,10 +189,8 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
scrollDirection = 'up';
|
scrollDirection = 'up';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion du header auto-hide (seulement si largeur < 1200px)
|
// Gestion du header auto-hide (header : seulement si largeur < 1200px, toolbar : toujours)
|
||||||
if (windowWidth.value < 1200) {
|
headerStore.updateScrollDirection(scrollTop);
|
||||||
headerStore.updateScrollDirection(scrollTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gestion de la visibilité des boutons flottants (même condition pour tous)
|
// Gestion de la visibilité des boutons flottants (même condition pour tous)
|
||||||
// Afficher si on scroll et qu'on est à plus de 300px
|
// Afficher si on scroll et qu'on est à plus de 300px
|
||||||
@@ -259,6 +277,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
currentVisibleIndex.value = props.initialPage;
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
|
|
||||||
// Activer l'auto-hide du header si la largeur < 1200px
|
// Activer l'auto-hide du header si la largeur < 1200px
|
||||||
@@ -304,53 +323,37 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
.infinite-reader {
|
.infinite-reader {
|
||||||
@apply flex-1 flex flex-col items-center overflow-y-auto relative;
|
@apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
|
||||||
/* Réduction du padding sur mobile */
|
/* Réduction du padding sur mobile */
|
||||||
@apply py-2 sm:py-8;
|
@apply py-2 sm:py-8;
|
||||||
height: calc(100vh - 8rem);
|
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
@apply w-full flex justify-center min-h-[200px];
|
@apply w-full flex justify-center;
|
||||||
/* Réduction des marges sur mobile */
|
|
||||||
@apply mb-2 sm:mb-4 px-1 sm:px-4;
|
@apply mb-2 sm:mb-4 px-1 sm:px-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
.page-placeholder {
|
||||||
|
@apply flex justify-center;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
@apply flex items-center justify-center min-h-[400px];
|
@apply text-red-500 text-xl bg-red-500/10 rounded-lg flex items-center justify-center min-h-[400px];
|
||||||
/* Largeur adaptative selon la taille d'écran */
|
width: 95vw;
|
||||||
width: 95vw; /* Mobile : 95% de la largeur */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@screen sm {
|
@screen sm {
|
||||||
.loading,
|
|
||||||
.error {
|
.error {
|
||||||
width: 80vw; /* Tablette : 80% de la largeur */
|
width: 80vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@screen lg {
|
@screen lg {
|
||||||
.loading,
|
|
||||||
.error {
|
.error {
|
||||||
width: 70vw; /* Desktop : 70% de la largeur */
|
width: 70vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation-wrapper {
|
|
||||||
@apply w-full max-w-4xl mx-auto px-4 mb-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation-wrapper.top {
|
|
||||||
@apply mt-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation-wrapper.bottom {
|
|
||||||
@apply mt-8 mb-4;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container" :style="{ transform: `scale(${zoom})` }">
|
<div
|
||||||
|
class="page-container"
|
||||||
|
:style="containerStyle"
|
||||||
|
>
|
||||||
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div>
|
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div>
|
||||||
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
|
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
|
||||||
|
|
||||||
@@ -12,6 +15,7 @@
|
|||||||
:alt="`Page ${pageNumber} (Double page)`"
|
:alt="`Page ${pageNumber} (Double page)`"
|
||||||
class="page-image rotated"
|
class="page-image rotated"
|
||||||
:style="doublePageRotatedStyle"
|
:style="doublePageRotatedStyle"
|
||||||
|
:loading="loading"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
ref="imageRef" />
|
ref="imageRef" />
|
||||||
<div class="rotation-hint">
|
<div class="rotation-hint">
|
||||||
@@ -30,6 +34,7 @@
|
|||||||
:alt="`Page ${pageNumber} (Double page)`"
|
:alt="`Page ${pageNumber} (Double page)`"
|
||||||
class="page-image scrollable"
|
class="page-image scrollable"
|
||||||
:style="doublePageScrollStyle"
|
:style="doublePageScrollStyle"
|
||||||
|
:loading="loading"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
ref="imageRef" />
|
ref="imageRef" />
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +54,7 @@
|
|||||||
:alt="`Page ${pageNumber}`"
|
:alt="`Page ${pageNumber}`"
|
||||||
class="page-image"
|
class="page-image"
|
||||||
:style="imageStyle"
|
:style="imageStyle"
|
||||||
|
:loading="loading"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
ref="imageRef" />
|
ref="imageRef" />
|
||||||
</div>
|
</div>
|
||||||
@@ -75,10 +81,24 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
||||||
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: String,
|
||||||
|
default: 'lazy',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = useReaderStore();
|
const store = useReaderStore();
|
||||||
|
|
||||||
|
// En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles)
|
||||||
|
// En mode infinite : zoom via transform: scale (pas d'impact layout souhaité)
|
||||||
|
const containerStyle = computed(() => {
|
||||||
|
if (store.readingMode === 'single') {
|
||||||
|
return { zoom: props.zoom };
|
||||||
|
}
|
||||||
|
return { transform: `scale(${props.zoom})` };
|
||||||
|
});
|
||||||
|
|
||||||
const imageRef = ref(null);
|
const imageRef = ref(null);
|
||||||
const scrollContainerRef = ref(null);
|
const scrollContainerRef = ref(null);
|
||||||
const naturalWidth = ref(0);
|
const naturalWidth = ref(0);
|
||||||
@@ -187,13 +207,27 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imageStyle = computed(() => {
|
const imageStyle = computed(() => {
|
||||||
if (!maxWidth.value) return {};
|
// Mode simple : laisser CSS contraindre les deux dimensions proportionnellement
|
||||||
|
if (store.readingMode === 'single') {
|
||||||
|
return {
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
width: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// Mode scroll : fixer la largeur, hauteur libre
|
||||||
width: `${maxWidth.value}px`,
|
const style = {
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
maxWidth: '100%'
|
maxWidth: '100%',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (maxWidth.value) {
|
||||||
|
style.width = `${maxWidth.value}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Styles spéciaux pour les doubles pages
|
// Styles spéciaux pour les doubles pages
|
||||||
@@ -279,17 +313,15 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
.page-container {
|
.page-container {
|
||||||
@apply flex-1 flex items-center justify-center overflow-hidden;
|
@apply flex items-center justify-center;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
/* Réduction des marges sur mobile */
|
|
||||||
@apply p-0 sm:p-2;
|
@apply p-0 sm:p-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-image {
|
.page-image {
|
||||||
@apply object-contain;
|
@apply object-contain;
|
||||||
/* La largeur est gérée par le JavaScript, on garde juste les contraintes max */
|
/* La largeur et max-height sont gérées par imageStyle selon le mode */
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Styles pour les doubles pages sur mobile */
|
/* Styles pour les doubles pages sur mobile */
|
||||||
|
|||||||
@@ -1,29 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="reader-settings">
|
<div class="reader-settings">
|
||||||
<!-- Bouton pour ouvrir/fermer les paramètres -->
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
|
||||||
leave-active-class="transition-all duration-300 ease-in"
|
|
||||||
enter-from-class="opacity-0 translate-y-5 scale-75"
|
|
||||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
||||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
||||||
leave-to-class="opacity-0 translate-y-5 scale-75"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-show="visible"
|
|
||||||
@click="toggleSettings"
|
|
||||||
class="settings-toggle"
|
|
||||||
:class="{ 'active': effectiveIsOpen }"
|
|
||||||
:data-external-control="forceOpen !== null"
|
|
||||||
title="Paramètres du lecteur"
|
|
||||||
>
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Panel des paramètres -->
|
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
leave-active-class="transition-all duration-300 ease-in"
|
leave-active-class="transition-all duration-300 ease-in"
|
||||||
@@ -32,63 +8,9 @@
|
|||||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||||
leave-to-class="opacity-0 translate-y-4 scale-95"
|
leave-to-class="opacity-0 translate-y-4 scale-95"
|
||||||
>
|
>
|
||||||
<div v-show="effectiveIsOpen" class="settings-panel" :data-external-control="forceOpen !== null" ref="panelRef">
|
<div v-show="open" class="settings-panel" ref="panelRef">
|
||||||
<!-- Paramètres de base -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3 class="section-title">Mode de lecture</h3>
|
|
||||||
<div class="setting-group">
|
|
||||||
<button
|
|
||||||
@click="onToggleReadingMode"
|
|
||||||
class="setting-button"
|
|
||||||
:class="{ 'active': readingMode === 'infinite' }"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
|
|
||||||
</svg>
|
|
||||||
{{ readingMode === 'single' ? 'Mode Infini' : 'Mode Simple' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<!-- Paramètres des doubles pages (mobile uniquement) -->
|
||||||
@click="onToggleReadingDirection"
|
|
||||||
class="setting-button"
|
|
||||||
:class="{ 'active': readingDirection === 'rtl' }"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18" />
|
|
||||||
</svg>
|
|
||||||
{{ readingDirection === 'ltr' ? 'RTL' : 'LTR' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contrôles du zoom -->
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3 class="section-title">Zoom</h3>
|
|
||||||
<div class="zoom-controls">
|
|
||||||
<button @click="onZoomOut" class="zoom-button" :disabled="zoom <= 0.5">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<span class="zoom-display">{{ Math.round(zoom * 100) }}%</span>
|
|
||||||
<button @click="onZoomIn" class="zoom-button" :disabled="zoom >= 2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
:value="zoom"
|
|
||||||
@input="onZoomChange($event.target.value)"
|
|
||||||
min="0.5"
|
|
||||||
max="2"
|
|
||||||
step="0.1"
|
|
||||||
class="zoom-slider"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Paramètres des doubles pages -->
|
|
||||||
<div class="settings-section" v-if="isMobile">
|
<div class="settings-section" v-if="isMobile">
|
||||||
<h3 class="section-title">
|
<h3 class="section-title">
|
||||||
<svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -97,7 +19,6 @@
|
|||||||
Doubles pages (Mobile)
|
Doubles pages (Mobile)
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Activation/désactivation -->
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<label class="setting-label">
|
<label class="setting-label">
|
||||||
<input
|
<input
|
||||||
@@ -113,7 +34,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mode d'affichage (si la détection automatique est activée) -->
|
|
||||||
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
||||||
<label class="setting-label">Mode d'affichage</label>
|
<label class="setting-label">Mode d'affichage</label>
|
||||||
<select
|
<select
|
||||||
@@ -125,22 +45,13 @@
|
|||||||
<option value="scroll">Défilement horizontal</option>
|
<option value="scroll">Défilement horizontal</option>
|
||||||
<option value="normal">Affichage normal</option>
|
<option value="normal">Affichage normal</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Descriptions des modes -->
|
|
||||||
<p class="setting-description">
|
<p class="setting-description">
|
||||||
<span v-if="doublePageMode === 'rotate'">
|
<span v-if="doublePageMode === 'rotate'">Suggère de tourner l'appareil pour une meilleure lecture</span>
|
||||||
Suggère de tourner l'appareil pour une meilleure lecture
|
<span v-else-if="doublePageMode === 'scroll'">Permet le défilement horizontal pour naviguer dans la page (commence à droite)</span>
|
||||||
</span>
|
<span v-else>Affichage standard sans optimisation spéciale</span>
|
||||||
<span v-else-if="doublePageMode === 'scroll'">
|
|
||||||
Permet le défilement horizontal pour naviguer dans la page (commence à droite)
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
Affichage standard sans optimisation spéciale
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Seuil de détection -->
|
|
||||||
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
||||||
<label class="setting-label">
|
<label class="setting-label">
|
||||||
Sensibilité de détection: {{ doublePageSettings.detectionThreshold.toFixed(1) }}
|
Sensibilité de détection: {{ doublePageSettings.detectionThreshold.toFixed(1) }}
|
||||||
@@ -160,14 +71,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Réinitialiser -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="setting-actions">
|
<div class="setting-actions">
|
||||||
<button @click="onResetPreferences" class="action-button reset">
|
<button @click="onResetPreferences" class="action-button reset">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</svg>
|
</svg>
|
||||||
Réinitialiser
|
Réinitialiser les préférences
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,21 +88,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
readingMode: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
readingDirection: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
type: Number,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
doublePageMode: {
|
doublePageMode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'rotate'
|
default: 'rotate'
|
||||||
@@ -204,138 +103,38 @@
|
|||||||
detectionThreshold: 1.4
|
detectionThreshold: 1.4
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
// Visibilité contrôlée par le parent
|
open: {
|
||||||
visible: {
|
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: false
|
||||||
},
|
|
||||||
// Contrôle externe de l'ouverture (pour le bouton intégré)
|
|
||||||
forceOpen: {
|
|
||||||
type: Boolean,
|
|
||||||
default: null // null = pas de contrôle externe, true/false = contrôle externe
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'toggleReadingMode',
|
'toggleSettings',
|
||||||
'toggleReadingDirection',
|
|
||||||
'zoomIn',
|
|
||||||
'zoomOut',
|
|
||||||
'zoomChange',
|
|
||||||
'doublePageModeChange',
|
'doublePageModeChange',
|
||||||
'doublePageAutoDetectChange',
|
'doublePageAutoDetectChange',
|
||||||
'detectionThresholdChange',
|
'detectionThresholdChange',
|
||||||
'resetPreferences',
|
'resetPreferences',
|
||||||
'buttonClick' // Signaler l'interaction au parent
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isOpen = ref(false);
|
|
||||||
const isMobile = computed(() => window.innerWidth < 768);
|
const isMobile = computed(() => window.innerWidth < 768);
|
||||||
const panelRef = ref(null);
|
const panelRef = ref(null);
|
||||||
|
|
||||||
// Computed pour gérer l'état d'ouverture (interne ou externe)
|
|
||||||
const effectiveIsOpen = computed(() => {
|
|
||||||
// Si forceOpen est défini (true/false), on l'utilise
|
|
||||||
if (props.forceOpen !== null) {
|
|
||||||
return props.forceOpen;
|
|
||||||
}
|
|
||||||
// Sinon, on utilise l'état interne
|
|
||||||
return isOpen.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleSettings = () => {
|
|
||||||
// Si on est en contrôle externe, ne pas permettre le toggle via le bouton flottant
|
|
||||||
if (props.forceOpen !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isOpen.value = !isOpen.value;
|
|
||||||
// Signaler l'interaction au parent
|
|
||||||
emit('buttonClick');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fonction pour fermer le panel (utilisée par les clics externes et internes)
|
|
||||||
const closePanel = () => {
|
|
||||||
if (props.forceOpen !== null) {
|
|
||||||
// Mode externe : émettre l'événement pour que le parent gère la fermeture
|
|
||||||
emit('buttonClick');
|
|
||||||
} else {
|
|
||||||
// Mode interne : fermer directement
|
|
||||||
isOpen.value = false;
|
|
||||||
emit('buttonClick');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gestion des clics en dehors du panel
|
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (effectiveIsOpen.value && panelRef.value && !panelRef.value.contains(event.target)) {
|
if (props.open && panelRef.value && !panelRef.value.contains(event.target)) {
|
||||||
// Vérifier que le clic n'est pas sur le bouton de toggle
|
emit('toggleSettings');
|
||||||
const settingsButton = document.querySelector('.settings-toggle, .settings-button');
|
|
||||||
if (settingsButton && settingsButton.contains(event.target)) {
|
|
||||||
return; // Laisser le bouton gérer le toggle
|
|
||||||
}
|
|
||||||
|
|
||||||
closePanel();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watcher pour empêcher la fermeture du bouton quand le panel est ouvert
|
onMounted(() => document.addEventListener('click', handleClickOutside, true));
|
||||||
watch(
|
onUnmounted(() => document.removeEventListener('click', handleClickOutside, true));
|
||||||
() => effectiveIsOpen.value,
|
|
||||||
(newIsOpen) => {
|
|
||||||
if (newIsOpen || !newIsOpen) {
|
|
||||||
// Signaler l'interaction à chaque changement
|
|
||||||
emit('buttonClick');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cycle de vie des event listeners
|
const onDoublePageModeChange = (mode) => emit('doublePageModeChange', mode);
|
||||||
onMounted(() => {
|
const onDoublePageAutoDetectChange = (enabled) => emit('doublePageAutoDetectChange', enabled);
|
||||||
document.addEventListener('click', handleClickOutside, true);
|
const onDetectionThresholdChange = (threshold) => emit('detectionThresholdChange', parseFloat(threshold));
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener('click', handleClickOutside, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Méthodes des événements (toutes signalent l'interaction)
|
|
||||||
const onToggleReadingMode = () => {
|
|
||||||
emit('toggleReadingMode');
|
|
||||||
emit('buttonClick');
|
|
||||||
};
|
|
||||||
const onToggleReadingDirection = () => {
|
|
||||||
emit('toggleReadingDirection');
|
|
||||||
emit('buttonClick');
|
|
||||||
};
|
|
||||||
const onZoomIn = () => {
|
|
||||||
emit('zoomIn');
|
|
||||||
emit('buttonClick');
|
|
||||||
};
|
|
||||||
const onZoomOut = () => {
|
|
||||||
emit('zoomOut');
|
|
||||||
emit('buttonClick');
|
|
||||||
};
|
|
||||||
const onZoomChange = (value) => {
|
|
||||||
emit('zoomChange', parseFloat(value));
|
|
||||||
emit('buttonClick');
|
|
||||||
};
|
|
||||||
const onDoublePageModeChange = (mode) => {
|
|
||||||
emit('doublePageModeChange', mode);
|
|
||||||
emit('buttonClick');
|
|
||||||
};
|
|
||||||
const onDoublePageAutoDetectChange = (enabled) => {
|
|
||||||
emit('doublePageAutoDetectChange', enabled);
|
|
||||||
emit('buttonClick');
|
|
||||||
};
|
|
||||||
const onDetectionThresholdChange = (threshold) => {
|
|
||||||
emit('detectionThresholdChange', parseFloat(threshold));
|
|
||||||
emit('buttonClick');
|
|
||||||
};
|
|
||||||
const onResetPreferences = () => {
|
const onResetPreferences = () => {
|
||||||
emit('resetPreferences');
|
emit('resetPreferences');
|
||||||
emit('buttonClick');
|
emit('toggleSettings');
|
||||||
isOpen.value = false;
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -344,25 +143,10 @@
|
|||||||
@apply relative;
|
@apply relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-toggle {
|
|
||||||
@apply fixed top-20 right-4 z-50 w-12 h-12 bg-gray-800 hover:bg-gray-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200;
|
|
||||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Masquer le bouton flottant si on est en contrôle externe */
|
|
||||||
.settings-toggle[data-external-control="true"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-toggle.active {
|
|
||||||
@apply bg-blue-600 hover:bg-blue-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
@apply fixed top-36 right-4 z-40 w-80 max-w-[calc(100vw-2rem)] bg-gray-800 rounded-lg shadow-xl border border-gray-700 max-h-[80vh] overflow-y-auto;
|
@apply fixed top-20 right-4 z-40 w-80 max-w-[calc(100vw-2rem)] bg-gray-800 rounded-lg shadow-xl border border-gray-700 max-h-[80vh] overflow-y-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive pour settings-panel */
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
@@ -371,14 +155,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Position adaptative pour le contrôle externe (bouton intégré) */
|
|
||||||
.settings-panel[data-external-control="true"] {
|
|
||||||
@apply top-32 left-1/2 right-auto;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
/* S'assurer qu'il ne couvre pas les contrôles */
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
@apply p-4 border-b border-gray-700 last:border-b-0;
|
@apply p-4 border-b border-gray-700 last:border-b-0;
|
||||||
}
|
}
|
||||||
@@ -387,44 +163,6 @@
|
|||||||
@apply text-white font-semibold text-lg mb-3 flex items-center;
|
@apply text-white font-semibold text-lg mb-3 flex items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-group {
|
|
||||||
@apply flex flex-col gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-button {
|
|
||||||
@apply flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors duration-200 text-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-button.active {
|
|
||||||
@apply bg-blue-600 hover:bg-blue-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Contrôles du zoom */
|
|
||||||
.zoom-controls {
|
|
||||||
@apply flex items-center gap-3 mb-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-button {
|
|
||||||
@apply w-8 h-8 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-white rounded flex items-center justify-center transition-colors;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-display {
|
|
||||||
@apply text-white font-mono text-sm min-w-[3rem] text-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-slider {
|
|
||||||
@apply w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-slider::-webkit-slider-thumb {
|
|
||||||
@apply appearance-none w-4 h-4 bg-blue-600 rounded-full cursor-pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-slider::-moz-range-thumb {
|
|
||||||
@apply w-4 h-4 bg-blue-600 rounded-full cursor-pointer border-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Paramètres des doubles pages */
|
|
||||||
.setting-item {
|
.setting-item {
|
||||||
@apply mb-4 last:mb-0;
|
@apply mb-4 last:mb-0;
|
||||||
}
|
}
|
||||||
@@ -457,7 +195,6 @@
|
|||||||
@apply text-gray-400 text-xs leading-relaxed;
|
@apply text-gray-400 text-xs leading-relaxed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions */
|
|
||||||
.setting-actions {
|
.setting-actions {
|
||||||
@apply flex gap-2;
|
@apply flex gap-2;
|
||||||
}
|
}
|
||||||
@@ -470,23 +207,9 @@
|
|||||||
@apply bg-red-600 hover:bg-red-700 text-white;
|
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
@apply right-2 w-72;
|
@apply right-2 w-72;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-toggle {
|
|
||||||
@apply right-2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pour les très petits écrans */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.settings-toggle {
|
|
||||||
right: 0.25rem;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<Toolbar :config="toolbarConfig">
|
||||||
|
<template #center>
|
||||||
|
<!-- Mode simple : navigation entre pages -->
|
||||||
|
<div v-if="store.readingMode === 'single'" class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
@click="store.previousPage()"
|
||||||
|
:disabled="store.isFirstPage"
|
||||||
|
class="nav-btn"
|
||||||
|
title="Page précédente"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span class="text-white text-sm w-16 text-center">
|
||||||
|
{{ store.currentPage + 1 }} / {{ store.totalPages }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="store.nextPage()"
|
||||||
|
:disabled="store.isLastPage"
|
||||||
|
class="nav-btn"
|
||||||
|
title="Page suivante"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode scroll : navigation entre chapitres (ordre inversé en RTL) -->
|
||||||
|
<div v-else class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
@click="leftChapterAction"
|
||||||
|
:disabled="!canGoLeftChapter || store.isLoading"
|
||||||
|
class="chapter-nav-btn"
|
||||||
|
:title="store.readingDirection === 'rtl' ? 'Chapitre suivant' : 'Chapitre précédent'"
|
||||||
|
>
|
||||||
|
<ChevronDoubleLeftIcon class="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span class="text-xs">{{ store.readingDirection === 'rtl' ? 'Suivant' : 'Précédent' }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="rightChapterAction"
|
||||||
|
:disabled="!canGoRightChapter || store.isLoading"
|
||||||
|
class="chapter-nav-btn"
|
||||||
|
:title="store.readingDirection === 'rtl' ? 'Chapitre précédent' : 'Chapitre suivant'"
|
||||||
|
>
|
||||||
|
<span class="text-xs">{{ store.readingDirection === 'rtl' ? 'Précédent' : 'Suivant' }}</span>
|
||||||
|
<ChevronDoubleRightIcon class="h-4 w-4 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Toolbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
ChevronDoubleLeftIcon,
|
||||||
|
ChevronDoubleRightIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
DocumentIcon,
|
||||||
|
EyeIcon,
|
||||||
|
EyeSlashIcon,
|
||||||
|
ListBulletIcon,
|
||||||
|
MinusIcon,
|
||||||
|
PlusIcon
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||||
|
import { useReaderStore } from '../../application/store/readerStore';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
chapterReaderRef: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useReaderStore();
|
||||||
|
const headerStore = useHeaderStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Vue auto-unwrap les refs dans le template : chapterReaderRef est déjà l'instance
|
||||||
|
const reader = computed(() => props.chapterReaderRef);
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
const mangaId = store.currentChapter?.mangaId;
|
||||||
|
if (mangaId) {
|
||||||
|
router.push({ name: 'manga-details', params: { id: mangaId } });
|
||||||
|
} else {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleReadingMode = () => reader.value?.toggleReadingMode();
|
||||||
|
const toggleReadingDirection = () => reader.value?.toggleReadingDirection();
|
||||||
|
const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||||
|
const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||||
|
|
||||||
|
// En RTL, le bouton gauche (◄◄) avance dans l'histoire (chapitre suivant)
|
||||||
|
const isRtl = computed(() => store.readingDirection === 'rtl');
|
||||||
|
const leftChapterAction = () => isRtl.value ? store.goToNextChapter() : store.goToPreviousChapter();
|
||||||
|
const rightChapterAction = () => isRtl.value ? store.goToPreviousChapter() : store.goToNextChapter();
|
||||||
|
const canGoLeftChapter = computed(() => isRtl.value ? store.hasNextChapter : store.hasPreviousChapter);
|
||||||
|
const canGoRightChapter = computed(() => isRtl.value ? store.hasPreviousChapter : store.hasNextChapter);
|
||||||
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowLeftIcon,
|
||||||
|
label: 'Retour',
|
||||||
|
onClick: goBack,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'label',
|
||||||
|
text: store.currentChapter?.title || '',
|
||||||
|
class: 'text-sm font-medium',
|
||||||
|
},
|
||||||
|
...(store.currentChapter?.number != null ? [{
|
||||||
|
type: 'label',
|
||||||
|
text: `Ch.${store.currentChapter.number}`,
|
||||||
|
}] : []),
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: store.readingMode === 'single' ? ListBulletIcon : DocumentIcon,
|
||||||
|
label: store.readingMode === 'single' ? 'Scroll' : 'Simple',
|
||||||
|
active: store.readingMode === 'infinite',
|
||||||
|
onClick: toggleReadingMode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
label: store.readingDirection.toUpperCase(),
|
||||||
|
active: store.readingDirection === 'rtl',
|
||||||
|
onClick: toggleReadingDirection,
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: MinusIcon,
|
||||||
|
disabled: store.zoom <= 0.5,
|
||||||
|
onClick: zoomOut,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'label',
|
||||||
|
text: `${Math.round(store.zoom * 100)}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: PlusIcon,
|
||||||
|
disabled: store.zoom >= 2,
|
||||||
|
onClick: zoomIn,
|
||||||
|
},
|
||||||
|
...(store.readingMode === 'infinite' ? [
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: headerStore.isReaderToolbarAutoHideEnabled ? EyeSlashIcon : EyeIcon,
|
||||||
|
active: headerStore.isReaderToolbarAutoHideEnabled,
|
||||||
|
title: headerStore.isReaderToolbarAutoHideEnabled ? 'Toolbar auto-masquée' : 'Toolbar toujours visible',
|
||||||
|
onClick: () => headerStore.toggleReaderToolbarAutoHide(),
|
||||||
|
},
|
||||||
|
] : []),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.nav-btn {
|
||||||
|
@apply flex items-center justify-center w-7 h-7 rounded bg-gray-700 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-nav-btn {
|
||||||
|
@apply flex items-center justify-between gap-1 h-7 w-28 px-2 rounded bg-gray-700 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
<!-- Zone de navigation gauche (invisible) -->
|
<!-- Zone de navigation gauche (invisible) -->
|
||||||
<div
|
<div
|
||||||
class="navigation-zone left-zone"
|
class="navigation-zone left-zone"
|
||||||
@click.stop="goToPrevious"
|
@click.stop="onLeftZoneClick"
|
||||||
@mouseenter="showLeftHint"
|
@mouseenter="showLeftHint"
|
||||||
@mouseleave="hideLeftHint"
|
@mouseleave="hideLeftHint"
|
||||||
title="Page précédente"
|
:title="isRtl ? 'Page suivante' : 'Page précédente'"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Page centrale -->
|
<!-- Page centrale -->
|
||||||
@@ -24,21 +24,21 @@
|
|||||||
<!-- Zone de navigation droite (invisible) -->
|
<!-- Zone de navigation droite (invisible) -->
|
||||||
<div
|
<div
|
||||||
class="navigation-zone right-zone"
|
class="navigation-zone right-zone"
|
||||||
@click.stop="goToNext"
|
@click.stop="onRightZoneClick"
|
||||||
@mouseenter="showRightHint"
|
@mouseenter="showRightHint"
|
||||||
@mouseleave="hideRightHint"
|
@mouseleave="hideRightHint"
|
||||||
title="Page suivante"
|
:title="isRtl ? 'Page précédente' : 'Page suivante'"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Indicateurs visuels de navigation -->
|
<!-- Indicateurs visuels de navigation -->
|
||||||
<div class="navigation-hints">
|
<div class="navigation-hints">
|
||||||
<div class="hint left-hint" v-if="canGoToPrevious && (showNavigationHints || showLeftHintHover)">
|
<div class="hint left-hint" v-if="canGoLeft && (showNavigationHints || showLeftHintHover)">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint right-hint" v-if="canGoToNext && (showNavigationHints || showRightHintHover)">
|
<div class="hint right-hint" v-if="canGoRight && (showNavigationHints || showRightHintHover)">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -81,14 +81,18 @@ const showLeftHintHover = ref(false);
|
|||||||
const showRightHintHover = ref(false);
|
const showRightHintHover = ref(false);
|
||||||
let hintTimeout = null;
|
let hintTimeout = null;
|
||||||
|
|
||||||
// Computed pour vérifier les possibilités de navigation
|
const isRtl = computed(() => store.readingDirection === 'rtl');
|
||||||
const canGoToPrevious = computed(() => {
|
|
||||||
return !store.isFirstPage || store.hasPreviousChapter;
|
|
||||||
});
|
|
||||||
|
|
||||||
const canGoToNext = computed(() => {
|
// Computed pour vérifier les possibilités de navigation
|
||||||
return !store.isLastPage || store.hasNextChapter;
|
const canGoToPrevious = computed(() => !store.isFirstPage || store.hasPreviousChapter);
|
||||||
});
|
const canGoToNext = computed(() => !store.isLastPage || store.hasNextChapter);
|
||||||
|
|
||||||
|
// En RTL, le côté gauche avance dans l'histoire (page suivante) et le droit recule
|
||||||
|
const canGoLeft = computed(() => isRtl.value ? canGoToNext.value : canGoToPrevious.value);
|
||||||
|
const canGoRight = computed(() => isRtl.value ? canGoToPrevious.value : canGoToNext.value);
|
||||||
|
|
||||||
|
const onLeftZoneClick = () => isRtl.value ? goToNext() : goToPrevious();
|
||||||
|
const onRightZoneClick = () => isRtl.value ? goToPrevious() : goToNext();
|
||||||
|
|
||||||
// Navigation vers la page/chapitre précédent
|
// Navigation vers la page/chapitre précédent
|
||||||
const goToPrevious = async () => {
|
const goToPrevious = async () => {
|
||||||
@@ -151,22 +155,20 @@ const hideRightHint = () => {
|
|||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
.single-mode-reader {
|
.single-mode-reader {
|
||||||
@apply relative w-full h-full flex items-center justify-center;
|
@apply relative w-full flex-1 flex flex-col min-h-0 overflow-hidden;
|
||||||
/* Suppression des marges sur mobile */
|
@apply py-2;
|
||||||
@apply p-0 sm:p-2;
|
|
||||||
/* Ajouter des marges en haut et en bas pour l'espace des contrôles et paramètres */
|
|
||||||
@apply py-8 sm:py-12;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-navigation-wrapper {
|
.page-navigation-wrapper {
|
||||||
@apply relative w-full h-full flex items-center justify-center cursor-pointer;
|
/* overflow-auto : scrollbars quand l'image zoomée déborde */
|
||||||
|
@apply relative w-full flex-1 min-h-0 overflow-auto cursor-pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-content {
|
.page-content {
|
||||||
@apply flex-1 h-full flex items-center justify-center;
|
/* min-h-full : centre l'image quand elle est plus petite que le conteneur */
|
||||||
pointer-events: none; /* Empêche les clics sur l'image elle-même */
|
min-height: 100%;
|
||||||
/* Optimisation pour mobile */
|
@apply flex items-center justify-center;
|
||||||
@apply p-0;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigation-zone {
|
.navigation-zone {
|
||||||
|
|||||||
@@ -1,56 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chapter-page">
|
<div class="chapter-page">
|
||||||
<div class="chapter-header">
|
<div
|
||||||
<!-- Bouton de retour -->
|
class="toolbar-wrapper"
|
||||||
<div class="flex items-center gap-4 mb-4">
|
:class="{ 'toolbar-hidden': !headerStore.shouldShowReaderToolbar }"
|
||||||
<button
|
>
|
||||||
@click="goBackToManga"
|
<div class="toolbar-slide">
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors duration-200"
|
<ReaderToolbar :chapter-reader-ref="chapterReaderRef" />
|
||||||
:disabled="!currentChapter?.mangaId"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon class="h-5 w-5" />
|
|
||||||
<span class="text-sm font-medium">Retour au manga</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Titre du chapitre amélioré -->
|
|
||||||
<div class="chapter-title-section">
|
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-white leading-tight">
|
|
||||||
{{ currentChapter?.title || 'Chargement...' }}
|
|
||||||
</h1>
|
|
||||||
<div class="chapter-meta mt-3">
|
|
||||||
<span class="inline-flex items-center px-3 py-1 bg-blue-600 text-white text-sm font-semibold rounded-full">
|
|
||||||
Chapitre {{ currentChapter?.number }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="reader-container">
|
<div class="reader-container">
|
||||||
<ChapterReader :chapter-id="chapterId" />
|
<ChapterReader ref="chapterReaderRef" :chapter-id="chapterId" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ArrowLeftIcon } from '@heroicons/vue/24/outline';
|
import { computed, ref } from 'vue';
|
||||||
import { computed } from 'vue';
|
import { useRoute } from 'vue-router';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||||
import { useReaderStore } from '../../application/store/readerStore';
|
|
||||||
import ChapterReader from '../components/ChapterReader.vue';
|
import ChapterReader from '../components/ChapterReader.vue';
|
||||||
|
import ReaderToolbar from '../components/ReaderToolbar.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const headerStore = useHeaderStore();
|
||||||
const store = useReaderStore();
|
|
||||||
|
|
||||||
const chapterId = computed(() => route.params.chapterId);
|
const chapterId = computed(() => route.params.chapterId);
|
||||||
const currentChapter = computed(() => store.currentChapter);
|
const chapterReaderRef = ref(null);
|
||||||
|
|
||||||
const goBackToManga = () => {
|
|
||||||
if (currentChapter.value?.mangaId) {
|
|
||||||
router.push({ name: 'manga-details', params: { id: currentChapter.value.mangaId } });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
@@ -58,19 +33,26 @@ import ChapterReader from '../components/ChapterReader.vue';
|
|||||||
@apply w-full h-full flex flex-col;
|
@apply w-full h-full flex flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-header {
|
.toolbar-wrapper {
|
||||||
@apply p-6 bg-gradient-to-b from-gray-800 to-gray-900 border-b border-gray-700 shadow-lg;
|
@apply overflow-hidden;
|
||||||
|
max-height: 5rem;
|
||||||
|
transition: max-height 300ms ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-title-section {
|
.toolbar-wrapper.toolbar-hidden {
|
||||||
@apply space-y-2;
|
max-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-meta {
|
.toolbar-slide {
|
||||||
@apply flex flex-wrap items-center gap-3;
|
transform: translateY(0);
|
||||||
|
transition: transform 300ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-hidden .toolbar-slide {
|
||||||
|
transform: translateY(-100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-container {
|
.reader-container {
|
||||||
@apply flex-1 overflow-hidden;
|
@apply flex-1 overflow-hidden min-h-0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -64,6 +64,11 @@
|
|||||||
@click="store.setDefaultView('list')">
|
@click="store.setDefaultView('list')">
|
||||||
{{ t('preferences.defaultView.list') }}
|
{{ t('preferences.defaultView.list') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:class="viewButtonClass('table')"
|
||||||
|
@click="store.setDefaultView('table')">
|
||||||
|
{{ t('preferences.defaultView.table') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mangas par page -->
|
<!-- Mangas par page -->
|
||||||
|
|||||||
@@ -82,12 +82,6 @@ const routes = [
|
|||||||
name: 'convert',
|
name: 'convert',
|
||||||
component: ConversionPage
|
component: ConversionPage
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/calendar',
|
|
||||||
name: 'calendar',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Calendrier' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/activity',
|
path: '/activity',
|
||||||
name: 'activity',
|
name: 'activity',
|
||||||
|
|||||||
@@ -15,20 +15,41 @@
|
|||||||
<Bars3Icon class="h-6 w-6" />
|
<Bars3Icon class="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center flex-1">
|
<div class="flex items-center flex-1">
|
||||||
<router-link to="/" class="text-white text-2xl font-bold ml-4">
|
<router-link to="/" class="ml-4">
|
||||||
Mangarr
|
<img src="/img/mangarr_logo.png" alt="Mangarr" class="h-10" />
|
||||||
</router-link>
|
</router-link>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
@click="toggleDarkMode"
|
||||||
|
class="mr-4 text-white p-2 hover:text-green-200 transition-colors"
|
||||||
|
:title="isDark ? 'Passer en mode clair' : 'Passer en mode sombre'"
|
||||||
|
>
|
||||||
|
<SunIcon v-if="isDark" class="h-6 w-6" />
|
||||||
|
<MoonIcon v-else class="h-6 w-6" />
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Bars3Icon } from '@heroicons/vue/24/outline';
|
import { computed } from 'vue';
|
||||||
|
import { Bars3Icon, SunIcon, MoonIcon } from '@heroicons/vue/24/outline';
|
||||||
import { useHeaderStore } from '../../stores/headerStore';
|
import { useHeaderStore } from '../../stores/headerStore';
|
||||||
|
import { useUserPreferencesStore } from '../../../domain/setting/application/store/userPreferencesStore';
|
||||||
import SearchBar from './SearchBar.vue';
|
import SearchBar from './SearchBar.vue';
|
||||||
|
|
||||||
const headerStore = useHeaderStore();
|
const headerStore = useHeaderStore();
|
||||||
|
const preferencesStore = useUserPreferencesStore();
|
||||||
|
|
||||||
|
const isDark = computed(() => {
|
||||||
|
if (preferencesStore.theme === 'dark') return true;
|
||||||
|
if (preferencesStore.theme === 'light') return false;
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleDarkMode() {
|
||||||
|
preferencesStore.setTheme(isDark.value ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
showMenuButton: {
|
showMenuButton: {
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
@add-manga-click="$emit('add-manga-click', $event)" />
|
@add-manga-click="$emit('add-manga-click', $event)" />
|
||||||
|
|
||||||
<main :class="[
|
<main :class="[
|
||||||
'flex-1 mt-16 flex flex-col overflow-hidden',
|
'flex-1 flex flex-col overflow-hidden',
|
||||||
|
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
|
||||||
isReaderMode ? '' : 'md:ml-60'
|
isReaderMode ? '' : 'md:ml-60'
|
||||||
]">
|
]" style="transition: margin-top 300ms ease-in-out;">
|
||||||
<RouterView></RouterView>
|
<RouterView></RouterView>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,10 +24,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useHeaderStore } from '../../stores/headerStore';
|
||||||
import Header from './Header.vue';
|
import Header from './Header.vue';
|
||||||
import Sidebar from './Sidebar.vue';
|
import Sidebar from './Sidebar.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const headerStore = useHeaderStore();
|
||||||
const isSidebarOpen = ref(false);
|
const isSidebarOpen = ref(false);
|
||||||
|
|
||||||
// Détecte si on est en mode Reader
|
// Détecte si on est en mode Reader
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
ArrowsRightLeftIcon,
|
ArrowsRightLeftIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
CalendarIcon,
|
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
ComputerDesktopIcon,
|
ComputerDesktopIcon,
|
||||||
@@ -69,12 +68,6 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
|||||||
to: '/convert',
|
to: '/convert',
|
||||||
id: 'convert'
|
id: 'convert'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: CalendarIcon,
|
|
||||||
text: 'Calendrier',
|
|
||||||
to: '/calendar',
|
|
||||||
id: 'calendar'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: ClockIcon,
|
icon: ClockIcon,
|
||||||
text: 'Activité',
|
text: 'Activité',
|
||||||
|
|||||||
@@ -3,24 +3,25 @@
|
|||||||
class="border-l-4"
|
class="border-l-4"
|
||||||
:class="{
|
:class="{
|
||||||
'border-green-600': isActive,
|
'border-green-600': isActive,
|
||||||
'hover:bg-gray-700 border-transparent': !isActive
|
'border-transparent': !isActive
|
||||||
}">
|
}">
|
||||||
<div class="flex w-full" @click="toggleExpanded">
|
<div class="flex w-full">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="to"
|
:to="to"
|
||||||
class="flex-grow px-4 py-2 flex items-center"
|
class="flex-grow px-4 py-2 flex items-center"
|
||||||
:class="{
|
:class="isActive
|
||||||
'text-green-600 bg-gray-800': isActive
|
? 'text-green-600 bg-gray-800'
|
||||||
}">
|
: 'hover:bg-gray-700 hover:text-white'">
|
||||||
<div class="flex items-center flex-grow">
|
<component :is="icon" class="w-5 h-5 mr-3" />
|
||||||
<component :is="icon" class="w-5 h-5 mr-3" />
|
<span class="px-2">{{ text }}</span>
|
||||||
<span class="px-2">{{ text }}</span>
|
|
||||||
</div>
|
|
||||||
<component
|
|
||||||
v-if="subItems.length > 0"
|
|
||||||
:is="expanded ? ChevronUpIcon : ChevronDownIcon"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
v-if="subItems.length > 0"
|
||||||
|
class="px-3 hover:bg-gray-700"
|
||||||
|
:class="isActive ? 'text-green-600 bg-gray-800' : 'hover:text-white'"
|
||||||
|
@click="toggleExpanded">
|
||||||
|
<component :is="expanded ? ChevronUpIcon : ChevronDownIcon" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-if="subItems.length > 0" class="ml-8 mt-2 space-y-4" v-show="expanded">
|
<ul v-if="subItems.length > 0" class="ml-8 mt-2 space-y-4" v-show="expanded">
|
||||||
@@ -71,14 +72,14 @@
|
|||||||
|
|
||||||
const isActive = computed(() => {
|
const isActive = computed(() => {
|
||||||
if (!props.to) {
|
if (!props.to) {
|
||||||
return props.subItems?.some(subItem => route.path === subItem.to) || false;
|
return props.subItems?.some(subItem => route.path.startsWith(subItem.to)) || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.to === '/') {
|
if (props.to === '/') {
|
||||||
return route.path === props.to || props.subItems.map(item => item.to).includes(route.path);
|
return route.path === props.to || props.subItems.some(item => route.path.startsWith(item.to));
|
||||||
}
|
}
|
||||||
|
|
||||||
return route.path.startsWith(props.to);
|
return route.path.startsWith(props.to) || props.subItems.some(item => route.path.startsWith(item.to));
|
||||||
});
|
});
|
||||||
|
|
||||||
const isRouteMatching = path => {
|
const isRouteMatching = path => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<li>
|
<li>
|
||||||
<RouterLink v-if="to" :to="to" class="block hover:text-green-600" role="menuitem">
|
<RouterLink v-if="to" :to="to" class="block px-2 py-1 rounded hover:bg-gray-700 hover:text-white" role="menuitem">
|
||||||
{{ text }}
|
{{ text }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<button v-else @click="$emit('click')" class="w-full text-left hover:text-green-600" role="menuitem">
|
<button v-else @click="$emit('click')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-700 hover:text-white" role="menuitem">
|
||||||
{{ text }}
|
{{ text }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<!-- Left section -->
|
<!-- Left section -->
|
||||||
<ToolbarSection :items="config.leftSection" />
|
<ToolbarSection :items="config.leftSection" />
|
||||||
|
|
||||||
|
<!-- Center section (optional slot) -->
|
||||||
|
<slot name="center" />
|
||||||
|
|
||||||
<!-- Right section -->
|
<!-- Right section -->
|
||||||
<ToolbarSection :items="config.rightSection" />
|
<ToolbarSection :items="config.rightSection" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
@click="$emit('click')"
|
@click="$emit('click')"
|
||||||
|
:disabled="disabled"
|
||||||
:class="[
|
:class="[
|
||||||
'flex flex-col items-center justify-center min-h-12 sm:min-h-14 w-min px-2 sm:ml-4 ml-1 rounded group text-white',
|
'flex flex-col items-center justify-center min-h-12 sm:min-h-14 w-min px-2 sm:ml-4 ml-1 rounded group text-white',
|
||||||
active
|
active
|
||||||
? 'text-green-500' // Style actif
|
? 'text-green-500' // Style actif
|
||||||
: 'hover:text-green-500' // Effet de survol
|
: 'hover:text-green-500', // Effet de survol
|
||||||
|
disabled ? 'opacity-40 cursor-not-allowed' : ''
|
||||||
]"
|
]"
|
||||||
:aria-label="label || 'Toolbar button'">
|
:aria-label="label || 'Toolbar button'">
|
||||||
<component v-if="icon" :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" />
|
<component v-if="icon" :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
@@ -30,6 +32,10 @@
|
|||||||
active: {
|
active: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
:icon="item.icon"
|
:icon="item.icon"
|
||||||
:label="item.label"
|
:label="item.label"
|
||||||
:active="item.active"
|
:active="item.active"
|
||||||
|
:disabled="item.disabled"
|
||||||
@click="item.onClick" />
|
@click="item.onClick" />
|
||||||
<ToolbarDropdown
|
<ToolbarDropdown
|
||||||
v-else-if="item.type === 'dropdown'"
|
v-else-if="item.type === 'dropdown'"
|
||||||
@@ -14,7 +15,9 @@
|
|||||||
:active="item.active"
|
:active="item.active"
|
||||||
:items="item.items" />
|
:items="item.items" />
|
||||||
<Divider v-else-if="item.type === 'divider'" />
|
<Divider v-else-if="item.type === 'divider'" />
|
||||||
<!-- Ajoutez d'autres types d'éléments ici si nécessaire -->
|
<span
|
||||||
|
v-else-if="item.type === 'label'"
|
||||||
|
:class="['text-white px-1 select-none', item.class || 'text-xs']">{{ item.text }}</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
item.type &&
|
item.type &&
|
||||||
(item.type === 'button' ||
|
(item.type === 'button' ||
|
||||||
item.type === 'divider' ||
|
item.type === 'divider' ||
|
||||||
|
item.type === 'label' ||
|
||||||
(item.type === 'dropdown' && Array.isArray(item.items)))
|
(item.type === 'dropdown' && Array.isArray(item.items)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"defaultView": {
|
"defaultView": {
|
||||||
"label": "Default view",
|
"label": "Default view",
|
||||||
"grid": "Grid",
|
"grid": "Grid",
|
||||||
"list": "List"
|
"list": "List",
|
||||||
|
"table": "Table"
|
||||||
},
|
},
|
||||||
"itemsPerPage": {
|
"itemsPerPage": {
|
||||||
"label": "Mangas per page"
|
"label": "Mangas per page"
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"defaultView": {
|
"defaultView": {
|
||||||
"label": "Vue par défaut",
|
"label": "Vue par défaut",
|
||||||
"grid": "Grille",
|
"grid": "Grille",
|
||||||
"list": "Liste"
|
"list": "Liste",
|
||||||
|
"table": "Tableau"
|
||||||
},
|
},
|
||||||
"itemsPerPage": {
|
"itemsPerPage": {
|
||||||
"label": "Mangas par page"
|
"label": "Mangas par page"
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ export const useHeaderStore = defineStore('header', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
isHeaderVisible: true,
|
isHeaderVisible: true,
|
||||||
isAutoHideEnabled: false,
|
isAutoHideEnabled: false,
|
||||||
|
isReaderToolbarVisible: true,
|
||||||
|
isReaderToolbarAutoHideEnabled: false,
|
||||||
lastScrollY: 0,
|
lastScrollY: 0,
|
||||||
scrollDirection: 'up'
|
scrollDirection: 'up'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
shouldShowHeader: (state) => {
|
shouldShowHeader: (state) => {
|
||||||
// Si l'auto-hide n'est pas activé, toujours afficher le header
|
if (!state.isAutoHideEnabled) return true;
|
||||||
if (!state.isAutoHideEnabled) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si l'auto-hide est activé, suivre la visibilité
|
|
||||||
return state.isHeaderVisible;
|
return state.isHeaderVisible;
|
||||||
|
},
|
||||||
|
shouldShowReaderToolbar: (state) => {
|
||||||
|
if (!state.isReaderToolbarAutoHideEnabled) return true;
|
||||||
|
return state.isReaderToolbarVisible;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -27,35 +28,47 @@ export const useHeaderStore = defineStore('header', {
|
|||||||
|
|
||||||
disableAutoHide() {
|
disableAutoHide() {
|
||||||
this.isAutoHideEnabled = false;
|
this.isAutoHideEnabled = false;
|
||||||
this.isHeaderVisible = true; // Toujours visible quand désactivé
|
this.isHeaderVisible = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateScrollDirection(scrollY) {
|
enableReaderToolbarAutoHide() {
|
||||||
// Éviter les calculs inutiles si pas d'auto-hide
|
this.isReaderToolbarAutoHideEnabled = true;
|
||||||
if (!this.isAutoHideEnabled) {
|
this.isReaderToolbarVisible = true;
|
||||||
this.lastScrollY = scrollY;
|
},
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Détecter la direction du scroll avec un seuil pour éviter les micro-mouvements
|
disableReaderToolbarAutoHide() {
|
||||||
|
this.isReaderToolbarAutoHideEnabled = false;
|
||||||
|
this.isReaderToolbarVisible = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleReaderToolbarAutoHide() {
|
||||||
|
if (this.isReaderToolbarAutoHideEnabled) {
|
||||||
|
this.disableReaderToolbarAutoHide();
|
||||||
|
this.disableAutoHide();
|
||||||
|
} else {
|
||||||
|
this.enableReaderToolbarAutoHide();
|
||||||
|
this.enableAutoHide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateScrollDirection(scrollY) {
|
||||||
const scrollDifference = Math.abs(scrollY - this.lastScrollY);
|
const scrollDifference = Math.abs(scrollY - this.lastScrollY);
|
||||||
|
|
||||||
if (scrollDifference < 5) {
|
if (scrollDifference < 5) {
|
||||||
// Mouvement trop petit, on ignore
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollY > this.lastScrollY && scrollY > 100) {
|
if (scrollY > this.lastScrollY && scrollY > 100) {
|
||||||
// Scroll vers le bas et suffisamment de scroll
|
|
||||||
if (this.scrollDirection !== 'down') {
|
if (this.scrollDirection !== 'down') {
|
||||||
this.scrollDirection = 'down';
|
this.scrollDirection = 'down';
|
||||||
this.isHeaderVisible = false;
|
if (this.isAutoHideEnabled) this.isHeaderVisible = false;
|
||||||
|
if (this.isReaderToolbarAutoHideEnabled) this.isReaderToolbarVisible = false;
|
||||||
}
|
}
|
||||||
} else if (scrollY < this.lastScrollY) {
|
} else if (scrollY < this.lastScrollY) {
|
||||||
// Scroll vers le haut
|
|
||||||
if (this.scrollDirection !== 'up') {
|
if (this.scrollDirection !== 'up') {
|
||||||
this.scrollDirection = 'up';
|
this.scrollDirection = 'up';
|
||||||
this.isHeaderVisible = true;
|
if (this.isAutoHideEnabled) this.isHeaderVisible = true;
|
||||||
|
if (this.isReaderToolbarAutoHideEnabled) this.isReaderToolbarVisible = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,21 @@ readonly class GetMangaListHandler
|
|||||||
|
|
||||||
$total = $this->mangaRepository->count();
|
$total = $this->mangaRepository->count();
|
||||||
|
|
||||||
|
$chapterCounts = [];
|
||||||
|
foreach ($mangas as $manga) {
|
||||||
|
$id = $manga->getId()->getValue();
|
||||||
|
$chapterCounts[$id] = [
|
||||||
|
'total' => $this->mangaRepository->countChapters($id),
|
||||||
|
'scraped' => $this->mangaRepository->countAvailableChapters($id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return new MangaListResponse(
|
return new MangaListResponse(
|
||||||
mangas: $mangas,
|
mangas: $mangas,
|
||||||
total: $total,
|
total: $total,
|
||||||
page: $query->page,
|
page: $query->page,
|
||||||
limit: $query->limit
|
limit: $query->limit,
|
||||||
|
chapterCounts: $chapterCounts
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ readonly class MangaListResponse
|
|||||||
public array $mangas,
|
public array $mangas,
|
||||||
public int $total,
|
public int $total,
|
||||||
public int $page,
|
public int $page,
|
||||||
public int $limit
|
public int $limit,
|
||||||
|
public array $chapterCounts = []
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface MangaRepositoryInterface
|
|||||||
|
|
||||||
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
|
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
|
||||||
public function countChapters(string $mangaId): int;
|
public function countChapters(string $mangaId): int;
|
||||||
|
public function countAvailableChapters(string $mangaId): int;
|
||||||
public function findChapterById(string $id): ?Chapter;
|
public function findChapterById(string $id): ?Chapter;
|
||||||
public function findVisibleChapterById(string $id): ?Chapter;
|
public function findVisibleChapterById(string $id): ?Chapter;
|
||||||
public function findChapterByMangaIdAndNumber(string $mangaId, float $chapterNumber): ?Chapter;
|
public function findChapterByMangaIdAndNumber(string $mangaId, float $chapterNumber): ?Chapter;
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ readonly class MangaListItem
|
|||||||
public string $status,
|
public string $status,
|
||||||
public ?float $rating,
|
public ?float $rating,
|
||||||
public DateTimeImmutable $createdAt,
|
public DateTimeImmutable $createdAt,
|
||||||
|
public bool $monitored = false,
|
||||||
|
public int $chaptersTotal = 0,
|
||||||
|
public int $chaptersScraped = 0,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ readonly class GetMangaListStateProvider implements ProviderInterface
|
|||||||
|
|
||||||
return new MangaCollection(
|
return new MangaCollection(
|
||||||
items: array_map(
|
items: array_map(
|
||||||
fn (Manga $manga) => $this->createMangaListItem($manga),
|
fn (Manga $manga) => $this->createMangaListItem(
|
||||||
|
$manga,
|
||||||
|
$response->chapterCounts[$manga->getId()->getValue()] ?? []
|
||||||
|
),
|
||||||
$response->mangas
|
$response->mangas
|
||||||
),
|
),
|
||||||
total: $response->total,
|
total: $response->total,
|
||||||
@@ -40,7 +43,7 @@ readonly class GetMangaListStateProvider implements ProviderInterface
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createMangaListItem(Manga $manga): MangaListItem
|
private function createMangaListItem(Manga $manga, array $counts = []): MangaListItem
|
||||||
{
|
{
|
||||||
return new MangaListItem(
|
return new MangaListItem(
|
||||||
id: $manga->getId()->getValue(),
|
id: $manga->getId()->getValue(),
|
||||||
@@ -54,7 +57,10 @@ readonly class GetMangaListStateProvider implements ProviderInterface
|
|||||||
genres: $manga->getGenres(),
|
genres: $manga->getGenres(),
|
||||||
status: $manga->getStatus(),
|
status: $manga->getStatus(),
|
||||||
rating: $manga->getRating(),
|
rating: $manga->getRating(),
|
||||||
createdAt: $manga->getCreatedAt()
|
createdAt: $manga->getCreatedAt(),
|
||||||
|
monitored: $manga->getMonitoringStatus()->isEnabled(),
|
||||||
|
chaptersTotal: $counts['total'] ?? 0,
|
||||||
|
chaptersScraped: $counts['scraped'] ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,18 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
|||||||
->getSingleScalarResult();
|
->getSingleScalarResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function countAvailableChapters(string $mangaId): int
|
||||||
|
{
|
||||||
|
return $this->entityManager->createQueryBuilder()
|
||||||
|
->select('COUNT(c.id)')
|
||||||
|
->from(EntityChapter::class, 'c')
|
||||||
|
->where('c.manga = :mangaId')
|
||||||
|
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
|
||||||
|
->setParameter('mangaId', $mangaId)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult();
|
||||||
|
}
|
||||||
|
|
||||||
public function findByExternalId(ExternalId $externalId): ?DomainManga
|
public function findByExternalId(ExternalId $externalId): ?DomainManga
|
||||||
{
|
{
|
||||||
$entity = $this->entityManager->getRepository(EntityManga::class)->findOneBy([
|
$entity = $this->entityManager->getRepository(EntityManga::class)->findOneBy([
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ final readonly class GetChapterContextHandler
|
|||||||
$context = $this->chapterRepository->getChapterContext($chapterId);
|
$context = $this->chapterRepository->getChapterContext($chapterId);
|
||||||
|
|
||||||
return new ChapterContextResponse(
|
return new ChapterContextResponse(
|
||||||
$query->getChapterId(),
|
id: $query->getChapterId(),
|
||||||
$context->getChapterTitle(),
|
mangaId: $context->getMangaId(),
|
||||||
$context->getNumber(),
|
title: $context->getChapterTitle(),
|
||||||
$context->getTotalPages(),
|
number: $context->getNumber(),
|
||||||
$context->getPreviousChapterId()?->getValue(),
|
totalPages: $context->getTotalPages(),
|
||||||
$context->getNextChapterId()?->getValue(),
|
previousChapterId: $context->getPreviousChapterId()?->getValue(),
|
||||||
|
nextChapterId: $context->getNextChapterId()?->getValue(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ final readonly class ChapterContextResponse
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private string $id,
|
private string $id,
|
||||||
|
private string $mangaId,
|
||||||
private string $title,
|
private string $title,
|
||||||
private float $number,
|
private float $number,
|
||||||
private int $totalPages,
|
private int $totalPages,
|
||||||
@@ -21,6 +22,11 @@ final readonly class ChapterContextResponse
|
|||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMangaId(): string
|
||||||
|
{
|
||||||
|
return $this->mangaId;
|
||||||
|
}
|
||||||
|
|
||||||
public function getTitle(): string
|
public function getTitle(): string
|
||||||
{
|
{
|
||||||
return $this->title;
|
return $this->title;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ readonly class ChapterContext
|
|||||||
private ChapterId $id,
|
private ChapterId $id,
|
||||||
private ?ChapterId $previousChapterId,
|
private ?ChapterId $previousChapterId,
|
||||||
private ?ChapterId $nextChapterId,
|
private ?ChapterId $nextChapterId,
|
||||||
|
private string $mangaId,
|
||||||
private string $mangaTitle,
|
private string $mangaTitle,
|
||||||
private float $number,
|
private float $number,
|
||||||
private ?string $chapterTitle,
|
private ?string $chapterTitle,
|
||||||
@@ -39,6 +40,11 @@ readonly class ChapterContext
|
|||||||
return $this->nextChapterId;
|
return $this->nextChapterId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMangaId(): string
|
||||||
|
{
|
||||||
|
return $this->mangaId;
|
||||||
|
}
|
||||||
|
|
||||||
public function getMangaTitle(): string
|
public function getMangaTitle(): string
|
||||||
{
|
{
|
||||||
return $this->mangaTitle;
|
return $this->mangaTitle;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ final readonly class ChapterContextProvider implements ProviderInterface
|
|||||||
|
|
||||||
return new ChapterContextResponse(
|
return new ChapterContextResponse(
|
||||||
id: $response->getId(),
|
id: $response->getId(),
|
||||||
|
mangaId: $response->getMangaId(),
|
||||||
title: $response->getTitle(),
|
title: $response->getTitle(),
|
||||||
number: $response->getNumber(),
|
number: $response->getNumber(),
|
||||||
totalPages: $response->getTotalPages(),
|
totalPages: $response->getTotalPages(),
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
|||||||
id: $chapterId,
|
id: $chapterId,
|
||||||
previousChapterId: $this->getPreviousChapterId($chapterId),
|
previousChapterId: $this->getPreviousChapterId($chapterId),
|
||||||
nextChapterId: $this->getNextChapterId($chapterId),
|
nextChapterId: $this->getNextChapterId($chapterId),
|
||||||
|
mangaId: (string) $chapter->getManga()->getId(),
|
||||||
mangaTitle: $chapter->getManga()->getTitle(),
|
mangaTitle: $chapter->getManga()->getTitle(),
|
||||||
number: $chapter->getNumber(),
|
number: $chapter->getNumber(),
|
||||||
chapterTitle: $chapter->getTitle(),
|
chapterTitle: $chapter->getTitle(),
|
||||||
|
|||||||
@@ -135,6 +135,14 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
|||||||
return count($this->chapters[$mangaId] ?? []);
|
return count($this->chapters[$mangaId] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function countAvailableChapters(string $mangaId): int
|
||||||
|
{
|
||||||
|
return count(array_filter(
|
||||||
|
$this->chapters[$mangaId] ?? [],
|
||||||
|
fn (Chapter $c) => $c->isAvailable()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
public function findChapterById(string $id): ?Chapter
|
public function findChapterById(string $id): ?Chapter
|
||||||
{
|
{
|
||||||
return $this->chaptersById[$id] ?? null;
|
return $this->chaptersById[$id] ?? null;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ final class GetChapterContextTest extends AbstractApiTestCase
|
|||||||
|
|
||||||
$this->assertJsonContains([
|
$this->assertJsonContains([
|
||||||
'id' => (string)$chapter1->getId(),
|
'id' => (string)$chapter1->getId(),
|
||||||
|
'mangaId' => (string)$manga->getId(),
|
||||||
'title' => 'Chapter 1',
|
'title' => 'Chapter 1',
|
||||||
'number' => 1,
|
'number' => 1,
|
||||||
'totalPages' => 0,
|
'totalPages' => 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user