32 Commits

Author SHA1 Message Date
2cefea3f72 Merge pull request 'style(import): aligner l'UI d'import sur le design system settings' (#19) from style/import-ui-settings-layout into main
All checks were successful
Deploy / deploy (push) Successful in 3m1s
Reviewed-on: #19
2026-03-15 20:14:09 +01:00
3e85167875 Merge branch 'main' into style/import-ui-settings-layout 2026-03-15 20:13:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
f72ae3cab9 style(import): aligner l'UI d'import sur le design system settings
- Layout max-width supprimé → pleine largeur disponible
- Sections avec border-t et titres uppercase comme les settings
- FileImportCard : card → row (divide-y, py-3, pas de shadow/border)
- ImportResults : card → sections border-t inline dans la page
- Inputs : padding explicite, border explicite, sans rounded
- Suppression de tous les rounded-* sur la page (boutons, badges, images, zone upload)
2026-03-15 20:13:31 +01:00
2c7f97c8b7 Merge pull request 'style(import): simplifier et harmoniser l'interface d'import de bibliothèque' (#18) from style/import-ui-simplification into main
All checks were successful
Deploy / deploy (push) Successful in 2m50s
Reviewed-on: #18
2026-03-15 19:42:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
1477106459 style(import): simplifier et harmoniser l'interface d'import de bibliothèque
- NewImportPage : layout flex/h-full + bg-gray-50 cohérent avec ConversionPage,
  Toolbar sombre pour les actions (sélection auto, importer, effacer),
  suppression du grand header h1 et du confirm() natif,
  ImportResults seul affiché en fin de session
- FileImportCard : en-tête compact avec actions inline (import + ×),
  suppression du bloc "selected manga preview" redondant,
  SVG inline remplacés par Heroicons, grille de correspondances élargie
- MangaMatchCard : suppression de la barre de score (doublon) et des
  slugs alternatifs, carte compacte avec coche de sélection Heroicons
2026-03-15 19:42:35 +01:00
2243716800 Merge pull request 'feat(manga): regrouper les chapitres d'un volume importé dans la liste API' (#17) from feat/volume-chapter-grouping into main
All checks were successful
Deploy / deploy (push) Successful in 2m59s
Reviewed-on: #17
2026-03-15 19:21:44 +01:00
d8a47072da Merge branch 'main' into feat/volume-chapter-grouping 2026-03-15 19:21:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
fb8f64ee59 feat(manga): regrouper les chapitres d'un volume importé dans la liste API
Les chapitres partageant le même pagesDirectory non-null et le même volume
non-null (import CBZ en bloc) sont fusionnés en un seul item isVolumeGroup=true
côté Application, avec volumeChaptersRange et volumeChapterCount. Le frontend
affiche "Vol. X — Chapitres Y-Z" à la place de N lignes identiques.
2026-03-15 19:21:02 +01:00
23c1028ec6 Merge pull request 'perf(reader): virtual rendering avec IntersectionObserver en mode scroll' (#16) from perf/reader-virtual-rendering into main
All checks were successful
Deploy / deploy (push) Successful in 2m49s
Reviewed-on: #16
2026-03-15 18:51:26 +01:00
ext.jeremy.guillot@maxicoffee.domains
aba8e36231 perf(reader): virtual rendering avec IntersectionObserver en mode scroll
Remplace le rendu de tous les composants ReaderPage par un système de
virtual rendering : seules les pages dans la zone ±1000px du viewport
sont montées, les autres sont remplacées par un placeholder dimensionné.

- InfiniteReader : ajout visibilityObserver + mountedPageIndices (Set
  réactif), helper getPlaceholderHeight(), suppression de 5 console.log
- ReaderPage : prop windowWidth injectable depuis le parent, listener
  resize conditionnel, suppression de 3 console.log de debug
2026-03-15 18:51:06 +01:00
c268b2c312 Merge pull request 'fix(import): extraire les images CBZ vers le stockage individuel' (#15) from fix/import-cbz-image-storage into main
All checks were successful
Deploy / deploy (push) Successful in 3m3s
Reviewed-on: #15
2026-03-15 18:27:28 +01:00
c060e7b95e Merge branch 'main' into fix/import-cbz-image-storage 2026-03-15 18:27:07 +01:00
ext.jeremy.guillot@maxicoffee.domains
2e3abb76c3 fix(import): extraire les images CBZ vers le stockage individuel
Corrige l'import de chapitres/volumes CBZ qui stockait le chemin du fichier
CBZ comme pagesDirectory. Le reader ne trouvait aucune image car
LegacyChapterRepository attend un dossier d'images individuelles.

- Déplace ImageStorageInterface dans Shared (storeChapterImages + extractFromCbz + countCbzImages)
- Crée ImageStorageManager dans Shared/Infrastructure (extraction ZIP + copie)
- Supprime LocalImageStorage et l'ancienne interface dans Scraping
- Refactore ImportChapterHandler et ImportVolumeHandler pour utiliser ImageStorageInterface
- Corrige LegacyChapterRepository : construit l'URL depuis basename(pagesDirectory)
  au lieu de chapterId (fix pour les volumes partagés)
2026-03-15 18:26:28 +01:00
b40892b924 Merge pull request 'perf(reader): windowing + eager loading sur l'InfiniteReader' (#14) from perf/reader-lazy-loading into main
All checks were successful
Deploy / deploy (push) Successful in 2m51s
Reviewed-on: #14
2026-03-15 17:51:07 +01:00
ext.jeremy.guillot@maxicoffee.domains
74f033f5d1 perf(reader): windowing + eager loading sur l'InfiniteReader
- Windowing côté rendu : seules les pages dans une fenêtre de ±3 autour
  de la page visible sont montées en tant que ReaderPage ; les autres
  sont remplacées par des placeholders dimensionnés via aspect-ratio CSS
  pour maintenir la hauteur de scroll sans saut
- IntersectionObserver utilise le minimum des indices intersectants pour
  éviter que les entrées simultanées au chargement ne décalent la fenêtre
- Prop initialPage passé depuis ChapterReader pour ancrer la fenêtre sur
  la page courante dès le montage
- loading="eager" sur les ReaderPage montés (le windowing est le mécanisme
  de lazy-loading, pas l'attribut HTML natif)
- Prop loading bindé sur les 3 balises <img> de ReaderPage
2026-03-15 17:46:00 +01:00
be8a3c6de8 Merge pull request 'style(reader): améliorer la toolbar et l'UI du mode scroll' (#13) from style/reader-toolbar-improvements into main
All checks were successful
Deploy / deploy (push) Successful in 2m48s
Reviewed-on: #13
2026-03-15 16:50:49 +01:00
ext.jeremy.guillot@maxicoffee.domains
9c47c717d0 style(reader): améliorer la toolbar et l'UI du mode scroll
- Corriger la troncature de la toolbar (max-height 4rem → 5rem)
- Animer la toolbar en translateY pour un effet "bloc uni" avec le header
- Corriger le bug d'auto-hide du header après switch simple → scroll
- Augmenter la taille du titre de chapitre dans la toolbar (text-sm font-medium)
- Harmoniser le bouton scroll-to-top avec le style des ToolbarButtons
- Ajouter support de prop `class` sur les labels de ToolbarSection
2026-03-15 16:50:02 +01:00
ext.jeremy.guillot@maxicoffee.domains
cc702cff19 style(header): ajouter bouton toggle dark mode dans le header
All checks were successful
Deploy / deploy (push) Successful in 2m46s
feat(conversion): simplifier ConversionPage et brancher les toasts
style(manga): réécriture de la liste de résultats dans AddManga
chore(task): ajouter tâche conversion CBR→CBZ dans TASK.md
2026-03-14 02:17:24 +01:00
ext.jeremy.guillot@maxicoffee.domains
b609fe0a45 style(header): remplacer le texte Mangarr par le logo de l'application
All checks were successful
Deploy / deploy (push) Successful in 2m42s
2026-03-14 01:43:12 +01:00
ext.jeremy.guillot@maxicoffee.domains
10d10d2c2f style(manga-overview): réécriture complète de MangaOverview.vue
All checks were successful
Deploy / deploy (push) Successful in 2m50s
Remplace les grandes cartes verbeux par des lignes compactes avec cover,
titre (text-2xl), badge statut, résumé tronqué et 3 boutons d'action
verticaux (éditer, sources, rafraîchir) — cohérent avec MangaTable.

Archivage de la tâche [UI] Améliorer la vue Overview dans TASK.md.
2026-03-14 01:37:20 +01:00
74f903d78d Merge pull request 'style/restyling-manga-grid' (#12) from style/restyling-manga-grid into main
All checks were successful
Deploy / deploy (push) Successful in 2m46s
Reviewed-on: #12
2026-03-14 01:04:38 +01:00
ext.jeremy.guillot@maxicoffee.domains
b997b87f51 style(manga-grid): afficher l'année de parution sous le titre, gap-3 entre les cards 2026-03-14 01:01:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
7fb73d3a69 chore: archiver tâche Restyling vue grille dans DONE.md 2026-03-14 00:58:31 +01:00
ext.jeremy.guillot@maxicoffee.domains
9a4fb26b06 style(manga-grid): cards sans arrondis, overlay actions au survol, grille plus dense
- Supprime rounded-lg et hover:scale-105 sur MangaCard
- Ajoute overlay gradient + 3 boutons (éditer, sources, rafraîchir) visibles au survol en bas à gauche de la cover
- MangaCard émet les événements edit/sources/refresh vers MangaGrid
- MangaGrid gère les modales et composables (edit, preferredSources, refresh)
- Grille plus dense : cols-3/4/5/7/8 selon breakpoint, gap-2
2026-03-14 00:58:05 +01:00
2cedd14f97 Merge pull request 'chore: rattrapage' (#11) from style/sidebar-cleanup-and-ui-polish into main
All checks were successful
Deploy / deploy (push) Successful in 2m55s
Reviewed-on: #11
2026-03-14 00:46:49 +01:00
bc0339646f Merge branch 'main' into style/sidebar-cleanup-and-ui-polish 2026-03-14 00:46:32 +01:00
ext.jeremy.guillot@maxicoffee.domains
7fba3c6fcb chore: rattrapage 2026-03-14 00:45:29 +01:00
3791a58e3c Merge pull request 'style/sidebar-cleanup-and-ui-polish' (#9) from style/sidebar-cleanup-and-ui-polish into main
Some checks failed
Deploy / deploy (push) Failing after 1m54s
Reviewed-on: #9
2026-03-14 00:37:29 +01:00
798befd642 Merge branch 'main' into style/sidebar-cleanup-and-ui-polish 2026-03-14 00:37:15 +01:00
ext.jeremy.guillot@maxicoffee.domains
8e1c4637ba chore: archiver tâches Sidebar et Calendrier dans DONE.md 2026-03-14 00:36:19 +01:00
ext.jeremy.guillot@maxicoffee.domains
d219ed1b3b style(sidebar): supprimer Calendrier, corriger isActive, séparer toggle/nav, harmoniser hover
- Retrait de l'entrée "Calendrier" du menu et de sa route Vue Router
- isActive inclut désormais les sous-items (fix: groupe Mangas actif sur /import)
- Chevron déplacé dans un <button> séparé du RouterLink (plus de double toggle/nav)
- Hover harmonisé : hover:bg-gray-700 + hover:text-white sur parent et sous-items
2026-03-14 00:33:38 +01:00
9a1d1954ad Merge pull request 'style/simplifier-table-homepage' (#8) from style/simplifier-table-homepage into main
Some checks failed
Deploy / deploy (push) Failing after 2m10s
Reviewed-on: #8
2026-03-14 00:24:07 +01:00
69 changed files with 1970 additions and 1647 deletions

40
DONE.md Normal file
View 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
View 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
---

View File

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

View File

@@ -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="[
'w-5 h-5',
conversionStore.isProcessing && 'animate-spin'
]"
/>
<span>
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }} {{ 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 class="space-y-3">
<div <div
v-for="(conversion, index) in conversionStore.conversionHistory" v-for="(conversion, index) in conversionStore.conversionHistory"
:key="index" :key="index"
class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-600 last:border-b-0" class="flex items-center justify-between py-3"
> >
<div class="flex-1"> <div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"> <p class="text-sm text-gray-900 dark:text-gray-100">{{ conversion.originalName }}</p>
{{ conversion.originalName }} <p class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(conversion.timestamp) }}</p>
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ formatDate(conversion.timestamp) }}
</p>
</div> </div>
<div class="text-right"> <div class="text-right text-sm">
<p class="text-sm text-gray-600 dark:text-gray-300"> <p class="text-gray-600 dark:text-gray-300">
{{ formatFileSize(conversion.originalSize) }} {{ formatFileSize(conversion.convertedSize) }} {{ formatFileSize(conversion.originalSize) }} {{ formatFileSize(conversion.convertedSize) }}
</p> </p>
<p class="text-xs text-green-600"> <p class="text-xs text-green-600">{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}</p>
{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}
</p>
</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></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 handleFileClear = () => {
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(); conversionStore.resetConversion();
}; };
const handleConvert = async () => { const handleConvert = async () => {
if (!conversionStore.currentFile) return; if (!conversionStore.currentFile) return;
const success = await conversionStore.convertCurrentFile(); const success = await conversionStore.convertCurrentFile();
if (success) { if (success) {
console.log('Conversion réussie'); showSuccess('Conversion réussie !');
} else { } else {
console.error('Échec de la conversion'); showError(conversionStore.conversionError ?? 'Échec de la conversion');
} }
}; };
const handleDownload = () => { const handleDownload = () => conversionStore.downloadConvertedFile();
conversionStore.downloadConvertedFile(); const handleReset = () => conversionStore.resetConversion();
};
const handleReset = () => { const formatFileSize = (bytes) => {
conversionStore.resetConversion();
};
const handleClearHistory = () => {
conversionStore.clearHistory();
};
// Utility functions
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 octets'; if (bytes === 0) return '0 octets';
const k = 1024; const k = 1024;
const sizes = ['octets', 'Ko', 'Mo', 'Go']; const sizes = ['octets', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}; };
const formatDate = (isoString) => { const formatDate = (isoString) =>
const date = new Date(isoString); new Intl.DateTimeFormat('fr-FR', {
return new Intl.DateTimeFormat('fr-FR', {
day: 'numeric', day: 'numeric',
month: 'short', month: 'short',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}).format(date); }).format(new Date(isoString));
};
const calculateSaving = (originalSize, convertedSize) => { const calculateSaving = (originalSize, convertedSize) => {
if (!originalSize || !convertedSize) return ''; if (!originalSize || !convertedSize) return '';
const saving = ((originalSize - convertedSize) / originalSize) * 100; const saving = ((originalSize - convertedSize) / originalSize) * 100;
if (saving > 0) { if (saving > 0) return `-${saving.toFixed(1)}%`;
return `-${saving.toFixed(1)}%`; if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`;
} else if (saving < 0) {
return `+${Math.abs(saving).toFixed(1)}%`;
}
return '0%'; 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> onMounted(() => conversionStore.resetConversion());
/* Styles spécifiques si nécessaires */ </script>
</style>

View File

@@ -1,64 +1,75 @@
<template> <template>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6"> <div class="py-3">
<div class="flex items-start space-x-4">
<!-- File Icon and Info --> <!-- Row principal : icône, nom, statut, actions -->
<div class="flex-shrink-0"> <div class="flex items-center gap-3">
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center"> <div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <DocumentIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div> </div>
<!-- File Details -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center justify-between"> <p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</p>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 truncate"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ file.filename }} {{ file.getFormattedSize() }} · {{ file.getFileExtension().toUpperCase() }}
</h3> <span v-if="file.isAnalyzed() && file.getExtractedChapterNumber()" class="ml-2 text-green-600 dark:text-green-400">
Ch. {{ file.getExtractedChapterNumber() }}
</span>
<span v-if="file.isAnalyzed() && file.getExtractedVolumeNumber()" class="ml-2 text-green-600 dark:text-green-400">
Vol. {{ file.getExtractedVolumeNumber() }}
</span>
</p>
</div>
<!-- Status Badge --> <div class="flex items-center gap-2 shrink-0">
<div class="flex-shrink-0 ml-4">
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" /> <StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
<button
v-if="file.isReadyForImport()"
@click="$emit('import-file')"
:disabled="isImporting"
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white text-xs font-medium transition-colors"
>
<ArrowUpTrayIcon class="w-3.5 h-3.5" />
Importer
</button>
<button
v-if="file.hasError()"
@click="$emit('retry-file')"
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium transition-colors"
>
Réessayer
</button>
<button
@click="$emit('remove-file')"
class="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
title="Supprimer"
>
<XMarkIcon class="w-4 h-4" />
</button>
</div> </div>
</div> </div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> <!-- Message d'erreur -->
{{ file.getFormattedSize() }} {{ file.getFileExtension().toUpperCase() }} <div v-if="file.hasError()" class="mt-2 flex items-start gap-2 text-xs text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-3 py-2">
<ExclamationCircleIcon class="w-4 h-4 shrink-0 mt-0.5" />
{{ file.errorMessage }}
</div>
<!-- Aucun manga trouvé -->
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-2 flex items-start gap-2 text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-3 py-2">
<ExclamationTriangleIcon class="w-4 h-4 shrink-0 mt-0.5" />
Aucun manga correspondant trouvé. Vérifiez le nom du fichier.
</div>
<!-- Sélection du manga -->
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-3 space-y-3">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{{ file.getMatches().length }} correspondance(s)
</p> </p>
<!-- Extracted Info --> <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
Chapitre {{ file.getExtractedChapterNumber() }}
</span>
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
Volume {{ file.getExtractedVolumeNumber() }}
</span>
</div>
<!-- Error Display -->
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div class="flex">
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">Erreur</h3>
<div class="mt-2 text-sm text-red-700 dark:text-red-400">{{ file.errorMessage }}</div>
</div>
</div>
</div>
<!-- Manga Selection -->
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
</label>
<!-- Matches Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<MangaMatchCard <MangaMatchCard
v-for="match in sortedMatches" v-for="match in sortedMatches"
:key="match.id" :key="match.id"
@@ -69,130 +80,47 @@
</div> </div>
</div> </div>
<!-- Selected Manga Preview --> <!-- Numéros de chapitre / volume -->
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md"> <div v-if="file.selectedManga" class="mt-3 grid grid-cols-2 gap-3">
<img
v-if="file.selectedManga.thumbnailUrl"
:src="file.selectedManga.thumbnailUrl"
:alt="file.selectedManga.title"
class="w-12 h-16 object-cover rounded"
/>
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-gray-100">{{ file.selectedManga.title }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ file.selectedManga.slug }}</p>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
</div>
</div>
<!-- Chapter/Volume Number Inputs -->
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
<!-- Chapter Number -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Chapitre</label>
Numéro de chapitre
</label>
<input <input
type="number" type="number"
step="0.5" step="0.5"
:value="file.selectedChapterNumber ?? ''" :value="file.selectedChapterNumber ?? ''"
@input="handleChapterNumberInput" @input="handleChapterNumberInput"
:disabled="file.selectedVolumeNumber !== null" :disabled="file.selectedVolumeNumber !== null"
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600" class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:text-gray-400"
placeholder="Ex: 1, 1.5, 2..." placeholder="Ex: 1, 1.5..."
/> />
</div> </div>
<!-- Volume Number -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Volume</label>
Numéro de volume
</label>
<input <input
type="number" type="number"
step="0.5" step="0.5"
:value="file.selectedVolumeNumber ?? ''" :value="file.selectedVolumeNumber ?? ''"
@input="handleVolumeNumberInput" @input="handleVolumeNumberInput"
:disabled="file.selectedChapterNumber !== null" :disabled="file.selectedChapterNumber !== null"
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600" class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:text-gray-400"
placeholder="Ex: 1, 1.5, 2..." placeholder="Ex: 1, 1.5..."
/> />
</div> </div>
</div> </div>
</div>
<!-- No Matches Message -->
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
<div class="flex">
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Aucun manga trouvé</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="mt-6 flex justify-between items-center border-t dark:border-gray-700 pt-4">
<div class="flex space-x-3">
<!-- Import Button -->
<button
v-if="file.isReadyForImport()"
@click="$emit('import-file')"
:disabled="isImporting"
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md text-sm font-medium flex items-center"
>
<svg v-if="isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ isImporting ? 'Import en cours...' : 'Importer' }}
</button>
<!-- Retry Button -->
<button
v-if="file.hasError()"
@click="$emit('retry-file')"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
>
Réessayer
</button>
</div>
<!-- Remove Button -->
<button
@click="$emit('remove-file')"
class="text-red-600 hover:text-red-700 text-sm font-medium"
>
Supprimer
</button>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ArrowUpTrayIcon, DocumentIcon, ExclamationCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { computed } from 'vue'; import { computed } from 'vue';
import MangaMatchCard from './MangaMatchCard.vue'; import MangaMatchCard from './MangaMatchCard.vue';
import StatusBadge from './StatusBadge.vue'; import StatusBadge from './StatusBadge.vue';
const props = defineProps({ const props = defineProps({
file: { file: { type: Object, required: true },
type: Object, isAnalyzing: { type: Boolean, default: false },
required: true isImporting: { type: Boolean, default: false },
},
isAnalyzing: {
type: Boolean,
default: false
},
isImporting: {
type: Boolean,
default: false
}
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -201,28 +129,22 @@ const emit = defineEmits([
'volume-number-selected', 'volume-number-selected',
'import-file', 'import-file',
'retry-file', 'retry-file',
'remove-file' 'remove-file',
]); ]);
// Computed property to get sorted matches const sortedMatches = computed(() =>
const sortedMatches = computed(() => { [...props.file.getMatches()].sort((a, b) => b.matchScore - a.matchScore)
const matches = props.file.getMatches(); );
return matches.sort((a, b) => b.matchScore - a.matchScore);
});
const handleMangaSelection = (selectedManga) => { const handleMangaSelection = (manga) => emit('manga-selected', manga);
emit('manga-selected', selectedManga);
};
const handleChapterNumberInput = (event) => { const handleChapterNumberInput = (event) => {
const value = event.target.value; const value = event.target.value;
const chapterNumber = value ? parseFloat(value) : null; emit('chapter-number-selected', value ? parseFloat(value) : null);
emit('chapter-number-selected', chapterNumber);
}; };
const handleVolumeNumberInput = (event) => { const handleVolumeNumberInput = (event) => {
const value = event.target.value; const value = event.target.value;
const volumeNumber = value ? parseFloat(value) : null; emit('volume-number-selected', value ? parseFloat(value) : null);
emit('volume-number-selected', volumeNumber);
}; };
</script> </script>

View File

@@ -1,96 +1,94 @@
<template> <template>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6"> <div>
<div class="text-center mb-6"> <!-- En-tête -->
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/40 mb-4"> <section class="border-t border-gray-200 dark:border-gray-700 pt-6">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="flex items-center justify-between">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <div class="flex items-center gap-3">
</svg> <div class="flex items-center justify-center h-9 w-9 bg-green-100 dark:bg-green-900/40">
<CheckCircleIcon class="h-5 w-5 text-green-600" />
</div> </div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Import terminé</h3> <div>
<p class="text-sm text-gray-500 dark:text-gray-400"> <h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">Import terminé</h3>
Voici le résumé de votre session d'import <p class="text-xs text-gray-500 dark:text-gray-400">Voici le résumé de votre session d'import</p>
</p>
</div> </div>
</div>
<div class="flex items-center gap-6 text-center">
<div>
<div class="text-xl font-bold text-green-600">{{ importedCount }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Importés</div>
</div>
<div>
<div class="text-xl font-bold text-red-600">{{ errorCount }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Erreurs</div>
</div>
<div>
<div class="text-xl font-bold text-gray-600 dark:text-gray-300">{{ totalCount }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Total</div>
</div>
</div>
</div>
</section>
<!-- Statistics --> <!-- Fichiers importés -->
<div class="grid grid-cols-3 gap-4 mb-6"> <section v-if="importedFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="text-center"> <h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div> Importés ({{ importedFiles.length }})
<div class="text-sm text-gray-500 dark:text-gray-400">Importés</div> </h2>
</div> <div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div class="text-center"> <div
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
<div class="text-sm text-gray-500">Erreurs</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-gray-600">{{ totalCount }}</div>
<div class="text-sm text-gray-500">Total</div>
</div>
</div>
<!-- Success Files List -->
<div v-if="importedFiles.length > 0" class="mb-6">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
Fichiers importés avec succès ({{ importedFiles.length }})
</h4>
<ul class="space-y-2">
<li
v-for="file in importedFiles" v-for="file in importedFiles"
:key="file.id" :key="file.id"
class="flex items-center text-sm" class="flex items-center gap-2 py-2.5 text-sm"
> >
<svg class="flex-shrink-0 h-4 w-4 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20"> <CheckCircleIcon class="flex-shrink-0 h-4 w-4 text-green-400" />
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /> <span class="text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</span>
</svg> <span v-if="file.selectedManga" class="text-gray-400 dark:text-gray-500 shrink-0">→ {{ file.selectedManga.title }}</span>
<span class="text-gray-900 dark:text-gray-100">{{ file.filename }}</span>
<span v-if="file.selectedManga" class="ml-2 text-gray-500 dark:text-gray-400">
→ {{ file.selectedManga.title }}
</span>
</li>
</ul>
</div> </div>
</div>
</section>
<!-- Error Files List --> <!-- Fichiers en erreur -->
<div v-if="errorFiles.length > 0" class="mb-6"> <section v-if="errorFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3"> <h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
Fichiers en erreur ({{ errorFiles.length }}) Erreurs ({{ errorFiles.length }})
</h4> </h2>
<ul class="space-y-2"> <div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<li <div
v-for="file in errorFiles" v-for="file in errorFiles"
:key="file.id" :key="file.id"
class="flex items-start text-sm" class="flex items-start gap-2 py-2.5 text-sm"
> >
<svg class="flex-shrink-0 h-4 w-4 text-red-400 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> <XCircleIcon class="flex-shrink-0 h-4 w-4 text-red-400 mt-0.5" />
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<div> <div>
<div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div> <div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div>
<div class="text-red-600 dark:text-red-400 text-xs mt-1">{{ file.errorMessage }}</div> <div class="text-red-600 dark:text-red-400 text-xs mt-0.5">{{ file.errorMessage }}</div>
</div> </div>
</li>
</ul>
</div> </div>
</div>
</section>
<!-- Actions --> <!-- Actions -->
<div class="flex justify-center space-x-4 pt-6 border-t dark:border-gray-700"> <section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="flex gap-3">
<button <button
@click="startNewImport" @click="startNewImport"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 text-sm font-medium"
> >
Nouvel import Nouvel import
</button> </button>
<button <button
@click="goToLibrary" @click="goToLibrary"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 text-sm font-medium"
> >
Aller à la bibliothèque Aller à la bibliothèque
</button> </button>
</div> </div>
</section>
</div> </div>
</template> </template>
<script setup> <script setup>
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/solid';
import { computed } from 'vue'; import { computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useNewImportStore } from '../../application/store/newImportStore'; import { useNewImportStore } from '../../application/store/newImportStore';

View File

@@ -1,116 +1,47 @@
<template> <template>
<div <div
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md" class="border p-2.5 cursor-pointer transition-all duration-150"
:class="{ :class="isSelected
'border-blue-500 bg-blue-50 dark:bg-blue-900/20': isSelected, ? 'border-green-500 bg-green-50 dark:bg-green-900/20'
'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-500': !isSelected : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800'"
}"
@click="$emit('select-match', match)" @click="$emit('select-match', match)"
> >
<!-- Match Header with Score --> <div class="flex gap-2.5">
<div class="flex items-center justify-between mb-3"> <!-- Couverture -->
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full"
:class="{
'bg-blue-500': isSelected,
'bg-gray-300': !isSelected
}"
></div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Score: {{ match.matchScore }}</span>
</div>
<div v-if="isSelected" class="text-blue-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
</div>
<!-- Manga Thumbnail -->
<div class="flex space-x-3">
<div class="flex-shrink-0">
<img <img
v-if="match.thumbnailUrl" v-if="match.thumbnailUrl"
:src="match.thumbnailUrl" :src="match.thumbnailUrl"
:alt="match.title" :alt="match.title"
class="w-16 h-20 object-cover rounded border" class="w-12 h-16 object-cover shrink-0"
/> />
<div <div
v-else v-else
class="w-16 h-20 bg-gray-200 dark:bg-gray-700 rounded border dark:border-gray-600 flex items-center justify-center" class="w-12 h-16 bg-gray-100 dark:bg-gray-700 shrink-0 flex items-center justify-center"
> >
<svg class="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <PhotoIcon class="w-6 h-6 text-gray-400" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</div> </div>
<!-- Manga Info --> <!-- Infos -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0 flex flex-col justify-between py-0.5">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" :title="match.title"> <p class="text-xs font-medium text-gray-900 dark:text-gray-100 line-clamp-3 leading-snug" :title="match.title">
{{ match.title }} {{ match.title }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate" :title="match.slug">
{{ match.slug }}
</p> </p>
<div class="flex items-center justify-between mt-1">
<!-- Alternative Slugs --> <span class="text-xs text-gray-400 dark:text-gray-500">{{ match.matchScore }}%</span>
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2"> <CheckCircleIcon v-if="isSelected" class="w-4 h-4 text-green-500 shrink-0" />
<p class="text-xs text-gray-400 dark:text-gray-500">Autres titres:</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
:key="altSlug"
class="text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded"
>
{{ altSlug }}
</span>
<span
v-if="match.alternativeSlugs.length > 2"
class="text-xs text-gray-400 dark:text-gray-500"
>
+{{ match.alternativeSlugs.length - 2 }} autres
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Score Bar -->
<div class="mt-3">
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span>Correspondance</span>
<span>{{ match.matchScore }}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="{
'bg-blue-500': isSelected,
'bg-gray-400': !isSelected
}"
:style="{ width: match.matchScore + '%' }"
></div>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { CheckCircleIcon, PhotoIcon } from '@heroicons/vue/24/outline';
const props = defineProps({ const props = defineProps({
match: { match: { type: Object, required: true },
type: Object, isSelected: { type: Boolean, default: false },
required: true
},
isSelected: {
type: Boolean,
default: false
}
}); });
const emit = defineEmits(['select-match']); const emit = defineEmits(['select-match']);
</script> </script>

View File

@@ -46,10 +46,10 @@ const badgeText = computed(() => {
}); });
const badgeClasses = computed(() => { const badgeClasses = computed(() => {
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium'; const baseClasses = 'inline-flex items-center px-2.5 py-0.5 text-xs font-medium';
if (props.isImporting || props.isAnalyzing) { if (props.isImporting || props.isAnalyzing) {
return `${baseClasses} bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300`; return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
} }
switch (props.status) { switch (props.status) {
@@ -58,7 +58,7 @@ const badgeClasses = computed(() => {
case 'analyzed': case 'analyzed':
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`; return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
case 'importing': case 'importing':
return `${baseClasses} bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300`; return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
case 'imported': case 'imported':
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`; return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
case 'error': case 'error':

View File

@@ -1,77 +1,41 @@
<template> <template>
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8"> <div class="flex flex-col h-full">
<!-- Header --> <Toolbar :config="toolbarConfig" />
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">Import de Bibliothèque</h1>
<p class="text-gray-600 dark:text-gray-400">
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
</p>
</div>
<!-- Progress Bar (if files are being processed) --> <div class="overflow-y-auto flex-1">
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8"> <div class="px-6 py-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Progression</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ store.progressPercentage }}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: store.progressPercentage + '%' }"
></div>
</div>
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-2">
<span>{{ store.importedCount }} importés</span>
<span>{{ store.errorCount }} erreurs</span>
<span>{{ store.totalFiles }} total</span>
</div>
</div>
</div>
<!-- File Upload Zone --> <!-- Zone de dépôt -->
<div v-if="!store.hasFiles || store.allFilesProcessed" class="mb-8"> <section v-if="!store.hasFiles" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichiers</h2>
<FileUpload <FileUpload
label="Importer des fichiers CBZ/CBR" label="Importer des fichiers CBZ/CBR"
accept=".cbz,.cbr" accept=".cbz,.cbr"
:multiple="true" :multiple="true"
description="Formats CBZ ou CBR uniquement" description="Formats CBZ ou CBR uniquement"
@files-selected="handleFilesSelected" @files-selected="store.addFiles($event)"
/>
</section>
<!-- Fichiers en cours -->
<template v-if="store.hasFiles && !store.allFilesProcessed">
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
{{ store.totalFiles }} fichier(s)
</h2>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ store.importedCount }}/{{ store.totalFiles }}
<span v-if="store.errorCount > 0" class="text-red-500 ml-1">· {{ store.errorCount }} erreur(s)</span>
</span>
</div>
<div class="bg-gray-200 dark:bg-gray-700 h-1.5 mb-4">
<div
class="bg-green-600 h-1.5 transition-all duration-300"
:style="{ width: store.progressPercentage + '%' }"
/> />
</div> </div>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<!-- Files List -->
<div v-if="store.hasFiles" class="space-y-6">
<!-- Action Buttons -->
<div class="flex flex-wrap gap-4 mb-6">
<button
v-if="store.hasReadyFiles"
@click="importAllFiles"
:disabled="store.isLoading"
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md font-medium"
>
<LoadingSpinner v-if="store.isLoading" class="w-4 h-4 mr-2" />
Importer tous les fichiers prêts ({{ store.readyCount }})
</button>
<button
v-if="store.analyzedFiles.length > 0"
@click="autoSelectMatches"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium"
>
Sélection automatique
</button>
<button
@click="clearAllFiles"
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium"
>
Effacer tout
</button>
</div>
<!-- Files Grid -->
<div class="grid gap-6">
<FileImportCard <FileImportCard
v-for="file in store.files" v-for="file in store.files"
:key="file.id" :key="file.id"
@@ -79,37 +43,61 @@
:is-analyzing="store.analyzingFiles.has(file.id)" :is-analyzing="store.analyzingFiles.has(file.id)"
:is-importing="store.importingFiles.has(file.id)" :is-importing="store.importingFiles.has(file.id)"
@manga-selected="(manga) => store.setFileManga(file.id, manga)" @manga-selected="(manga) => store.setFileManga(file.id, manga)"
@chapter-number-selected="(chapterNumber) => store.setFileChapterNumber(file.id, chapterNumber)" @chapter-number-selected="(n) => store.setFileChapterNumber(file.id, n)"
@volume-number-selected="(volumeNumber) => store.setFileVolumeNumber(file.id, volumeNumber)" @volume-number-selected="(n) => store.setFileVolumeNumber(file.id, n)"
@import-file="() => importSingleFile(file.id)" @import-file="() => importSingleFile(file.id)"
@retry-file="() => retryFile(file.id)" @retry-file="() => retryFile(file.id)"
@remove-file="() => store.removeFile(file.id)" @remove-file="() => store.removeFile(file.id)"
/> />
</div> </div>
</div> </section>
</template>
<!-- Résultats -->
<ImportResults v-if="store.allFilesProcessed" />
<!-- Results Summary (when all files are processed) -->
<div v-if="store.allFilesProcessed" class="mt-8">
<ImportResults />
</div> </div>
</div></div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { onUnmounted } from 'vue'; import { ArrowUpTrayIcon, SparklesIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { computed, onUnmounted } from 'vue';
import FileUpload from '../../../../shared/components/ui/FileUpload.vue'; import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
import LoadingSpinner from '../../../../shared/components/ui/LoadingSpinner.vue'; import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useNewImportStore } from '../../application/store/newImportStore'; import { useNewImportStore } from '../../application/store/newImportStore';
import FileImportCard from '../components/FileImportCard.vue'; import FileImportCard from '../components/FileImportCard.vue';
import ImportResults from '../components/ImportResults.vue'; import ImportResults from '../components/ImportResults.vue';
const store = useNewImportStore(); const store = useNewImportStore();
// === EVENT HANDLERS === const toolbarConfig = computed(() => ({
leftSection: [
const handleFilesSelected = (files) => { { type: 'label', text: 'Import de bibliothèque', class: 'text-sm font-medium' },
store.addFiles(files); ],
}; rightSection: [
...(store.analyzedFiles.length > 0 ? [{
type: 'button',
icon: SparklesIcon,
label: 'Sélection auto',
onClick: () => store.autoSelectBestMatches(),
}] : []),
...(store.hasReadyFiles ? [{
type: 'button',
icon: ArrowUpTrayIcon,
label: `Importer (${store.readyCount})`,
onClick: importAllFiles,
disabled: store.isLoading,
}] : []),
{
type: 'button',
icon: TrashIcon,
label: 'Effacer',
onClick: () => store.clearFiles(),
},
],
}));
const importAllFiles = async () => { const importAllFiles = async () => {
try { try {
@@ -135,19 +123,6 @@ const retryFile = async (fileId) => {
} }
}; };
const autoSelectMatches = () => {
store.autoSelectBestMatches();
};
const clearAllFiles = () => {
if (confirm('Êtes-vous sûr de vouloir effacer tous les fichiers ?')) {
store.clearFiles();
}
};
// === LIFECYCLE ===
// Reset state when component unmounts
onUnmounted(() => { onUnmounted(() => {
store.resetGlobalState(); store.resetGlobalState();
}); });

View File

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

View File

@@ -1,37 +1,60 @@
<template> <template>
<div class="group relative bg-white dark:bg-gray-800 overflow-hidden shadow-sm">
<!-- Cover avec overlay -->
<div class="relative pb-[140%]">
<RouterLink <RouterLink
:to="{ name: 'manga-details', params: { id: manga.id } }" :to="{ name: 'manga-details', params: { id: manga.id } }"
class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block"> class="absolute inset-0">
<div class="relative pb-[150%]">
<img <img
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'" :src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
:alt="manga.title" :alt="manga.title"
class="absolute inset-0 w-full h-full object-cover bg-gray-100" /> class="w-full h-full object-cover bg-gray-100" />
</div>
<div class="p-2">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">{{ manga.title }}</h3>
<div class="flex items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
</div>
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400"> Added: {{ formatDate(manga.createdAt) }} </div>
</div>
</RouterLink> </RouterLink>
<!-- 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>
<!-- 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';
import { RouterLink } from 'vue-router';
defineProps({
manga: { manga: {
type: Object, type: Object,
required: true required: true
} }
}); });
const formatDate = dateString => { defineEmits(['edit', 'sources', 'refresh']);
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
</script> </script>

View File

@@ -1,7 +1,8 @@
<template> <template>
<tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20"> <tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20">
<td class="px-4 py-2 text-gray-900 dark:text-gray-100" :class="{ 'text-green-500 dark:text-green-400': chapter.isAvailable }"> <td class="px-4 py-2 text-gray-900 dark:text-gray-100" :class="{ 'text-green-500 dark:text-green-400': chapter.isAvailable }">
{{ String(chapter.number).padStart(2, '0') }} <template v-if="chapter.isVolumeGroup">Vol. {{ chapter.volume }}</template>
<template v-else>{{ String(chapter.number).padStart(2, '0') }}</template>
</td> </td>
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100"> <td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
<router-link <router-link
@@ -13,9 +14,17 @@
chapterId: chapter.id chapterId: chapter.id
} }
}"> }">
{{ chapter.title || 'Sans titre' }} <template v-if="chapter.isVolumeGroup">
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
</template>
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
</router-link> </router-link>
<span v-else class="text-gray-500 dark:text-gray-400">{{ chapter.title || 'Sans titre' }}</span> <span v-else class="text-gray-500 dark:text-gray-400">
<template v-if="chapter.isVolumeGroup">
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
</template>
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
</span>
</td> </td>
<td class="px-4 py-2 flex justify-end gap-2"> <td class="px-4 py-2 flex justify-end gap-2">
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass"> <button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,26 @@
<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"
<div class="navigation-wrapper top"> class="page-wrapper" :data-page-index="index">
<ChapterNavigation position="top" />
</div>
<div v-for="(page, index) in pages" :key="index" class="page-wrapper"> <!-- Pas d'URL : spinner de chargement -->
<div v-if="!page?.url" class="loading"> <div v-if="!page?.url" class="loading">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div> </div>
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
</div>
<!-- Navigation en bas --> <!-- Hors de la zone de rendu : placeholder dimensionné -->
<div class="navigation-wrapper bottom"> <div v-else-if="!mountedPageIndices.has(index)"
<ChapterNavigation position="bottom" /> class="page-placeholder"
:style="{ height: getPlaceholderHeight(page) + 'px' }" />
<!-- Dans la zone : composant complet -->
<ReaderPage v-else
:page-data="page"
:page-number="index + 1"
:zoom="zoom"
:double-page-mode="doublePageMode"
:window-width="windowWidth"
loading="lazy" />
</div> </div>
<!-- Bouton flottant pour revenir en haut --> <!-- Bouton flottant pour revenir en haut -->
@@ -29,22 +35,22 @@
<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>
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { nextTick, onMounted, onUnmounted, reactive, 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 props = defineProps({ const props = defineProps({
@@ -67,6 +73,8 @@ import ReaderPage from './ReaderPage.vue';
const headerStore = useHeaderStore(); const headerStore = useHeaderStore();
const containerRef = ref(null); const containerRef = ref(null);
const observer = ref(null); const observer = ref(null);
const visibilityObserver = ref(null);
const mountedPageIndices = reactive(new Set());
const windowWidth = ref(window.innerWidth); const windowWidth = ref(window.innerWidth);
// État unique pour tous les boutons flottants avec timer de 3 secondes // État unique pour tous les boutons flottants avec timer de 3 secondes
@@ -86,25 +94,47 @@ import ReaderPage from './ReaderPage.vue';
}); });
}; };
const setupIntersectionObserver = () => { // Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
if (observer.value) { const getPlaceholderHeight = (page) => {
observer.value.disconnect(); const dims = page?.dimensions;
} if (!dims?.width || !dims?.height) return 800;
const displayWidth = windowWidth.value < 1200
? Math.min(dims.width, windowWidth.value * 0.95)
: Math.min(dims.width, 1200);
return Math.round((dims.height / dims.width) * displayWidth);
};
const setupObservers = () => {
observer.value?.disconnect();
visibilityObserver.value?.disconnect();
observer.value = new IntersectionObserver(observeIntersection, { observer.value = new IntersectionObserver(observeIntersection, {
root: null, root: null,
threshold: 0.5 threshold: 0.5
}); });
nextTick(() => { visibilityObserver.value = new IntersectionObserver(
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper'); (entries) => {
if (pageElements) { entries.forEach(entry => {
pageElements.forEach((element, index) => { const idx = parseInt(entry.target.getAttribute('data-page-index'));
element.setAttribute('data-page-index', index); if (entry.isIntersecting) {
observer.value.observe(element); mountedPageIndices.add(idx);
}); } else {
mountedPageIndices.delete(idx);
} }
}); });
},
{ root: null, rootMargin: '1000px 0px', threshold: 0 }
);
nextTick(() => {
const els = containerRef.value?.querySelectorAll('.page-wrapper');
els?.forEach((el, i) => {
el.setAttribute('data-page-index', i);
observer.value.observe(el);
visibilityObserver.value.observe(el);
});
});
}; };
// Fonction unique pour gérer la visibilité de tous les boutons flottants // Fonction unique pour gérer la visibilité de tous les boutons flottants
@@ -169,10 +199,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
@@ -189,21 +217,16 @@ import ReaderPage from './ReaderPage.vue';
// Fonction pour revenir en haut de la page // Fonction pour revenir en haut de la page
const scrollToTop = () => { const scrollToTop = () => {
console.log('scrollToTop appelée'); // Debug
// Réinitialiser le timer lors du clic // Réinitialiser le timer lors du clic
resetButtonsTimer(); resetButtonsTimer();
// Stratégie 1: Scroll sur le conteneur direct // Stratégie 1: Scroll sur le conteneur direct
if (containerRef.value) { if (containerRef.value) {
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
if (containerRef.value.scrollTop > 0) { if (containerRef.value.scrollTop > 0) {
containerRef.value.scrollTo({ containerRef.value.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
}); });
console.log('Scroll sur containerRef effectué'); // Debug
return; return;
} }
} }
@@ -213,7 +236,6 @@ import ReaderPage from './ReaderPage.vue';
while (currentElement) { while (currentElement) {
const styles = window.getComputedStyle(currentElement); const styles = window.getComputedStyle(currentElement);
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) { if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
currentElement.scrollTo({ currentElement.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
@@ -224,7 +246,6 @@ import ReaderPage from './ReaderPage.vue';
} }
// Stratégie 3: Scroll sur la fenêtre entière // Stratégie 3: Scroll sur la fenêtre entière
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
window.scrollTo({ window.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
@@ -240,7 +261,8 @@ import ReaderPage from './ReaderPage.vue';
watch( watch(
() => props.pages, () => props.pages,
() => { () => {
setupIntersectionObserver(); mountedPageIndices.clear();
setupObservers();
}, },
{ immediate: true } { immediate: true }
); );
@@ -259,7 +281,7 @@ import ReaderPage from './ReaderPage.vue';
}; };
onMounted(() => { onMounted(() => {
setupIntersectionObserver(); setupObservers();
// Activer l'auto-hide du header si la largeur < 1200px // Activer l'auto-hide du header si la largeur < 1200px
if (windowWidth.value < 1200) { if (windowWidth.value < 1200) {
@@ -279,9 +301,8 @@ import ReaderPage from './ReaderPage.vue';
}); });
onUnmounted(() => { onUnmounted(() => {
if (observer.value) { observer.value?.disconnect();
observer.value.disconnect(); visibilityObserver.value?.disconnect();
}
// Désactiver l'auto-hide du header en quittant // Désactiver l'auto-hide du header en quittant
headerStore.disableAutoHide(); headerStore.disableAutoHide();
@@ -304,19 +325,23 @@ 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;
} }
.page-placeholder {
@apply w-full;
max-width: 1200px;
min-height: 400px;
}
.loading, .loading,
.error { .error {
@apply flex items-center justify-center min-h-[400px]; @apply flex items-center justify-center min-h-[400px];
@@ -342,15 +367,4 @@ import ReaderPage from './ReaderPage.vue';
@apply text-red-500 text-xl bg-red-500/10 rounded-lg; @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>

View File

@@ -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>
@@ -75,16 +78,33 @@ 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)
},
windowWidth: {
type: Number,
default: null
} }
}); });
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);
const naturalHeight = ref(0); const naturalHeight = ref(0);
const windowWidth = ref(window.innerWidth); const localWindowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 768); const effectiveWindowWidth = computed(() =>
props.windowWidth !== null ? props.windowWidth : localWindowWidth.value
);
const isMobile = computed(() => effectiveWindowWidth.value < 768);
const imageLoaded = ref(false); const imageLoaded = ref(false);
const imageSource = computed(() => { const imageSource = computed(() => {
@@ -103,17 +123,13 @@ import { useReaderStore } from '../../application/store/readerStore';
// Utiliser d'abord les dimensions de l'API si disponibles // Utiliser d'abord les dimensions de l'API si disponibles
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) { if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height; const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
const isDouble = ratio > threshold; return ratio > threshold;
console.log(`API Dimensions - Page ${props.pageNumber}: ${props.pageData.dimensions.width}x${props.pageData.dimensions.height}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
return isDouble;
} }
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée) // Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) { if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
const ratio = naturalWidth.value / naturalHeight.value; const ratio = naturalWidth.value / naturalHeight.value;
const isDouble = ratio > threshold; return ratio > threshold;
console.log(`Natural Dimensions - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
return isDouble;
} }
return false; return false;
@@ -124,7 +140,6 @@ import { useReaderStore } from '../../application/store/readerStore';
naturalWidth.value = imageRef.value.naturalWidth; naturalWidth.value = imageRef.value.naturalWidth;
naturalHeight.value = imageRef.value.naturalHeight; naturalHeight.value = imageRef.value.naturalHeight;
imageLoaded.value = true; imageLoaded.value = true;
console.log(`Image loaded - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}`);
// Positionner le scroll à droite si c'est le mode scroll // Positionner le scroll à droite si c'est le mode scroll
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) { if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
@@ -175,7 +190,7 @@ import { useReaderStore } from '../../application/store/readerStore';
if (!width || !height) return null; if (!width || !height) return null;
const availableWidth = windowWidth.value; const availableWidth = effectiveWindowWidth.value;
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur // Si la largeur disponible est < 1200px : utiliser 95% de la largeur
if (availableWidth < 1200) { if (availableWidth < 1200) {
@@ -187,13 +202,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 { return {
width: `${maxWidth.value}px`, maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto', height: 'auto',
maxWidth: '100%'
}; };
}
// Mode scroll : fixer la largeur, hauteur libre
const style = {
height: 'auto',
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
@@ -210,7 +239,7 @@ import { useReaderStore } from '../../application/store/readerStore';
if (!width || !height) return {}; if (!width || !height) return {};
// En mode rotation : maximiser l'utilisation de l'espace // En mode rotation : maximiser l'utilisation de l'espace
const availableWidth = windowWidth.value; const availableWidth = effectiveWindowWidth.value;
const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles
// Après rotation, la largeur originale devient la hauteur affichée // Après rotation, la largeur originale devient la hauteur affichée
@@ -260,36 +289,32 @@ import { useReaderStore } from '../../application/store/readerStore';
}; };
}); });
// Gestion du redimensionnement de la fenêtre let ownResizeHandler = null;
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => { onMounted(() => {
if (imageRef.value && imageRef.value.complete) { if (props.windowWidth === null) {
handleImageLoad(); ownResizeHandler = () => { localWindowWidth.value = window.innerWidth; };
window.addEventListener('resize', ownResizeHandler, { passive: true });
} }
window.addEventListener('resize', handleResize); if (imageRef.value?.complete) handleImageLoad();
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize); if (ownResizeHandler) window.removeEventListener('resize', ownResizeHandler);
}); });
</script> </script>
<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 */

View File

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

View File

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

View File

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

View File

@@ -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"
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"
:disabled="!currentChapter?.mangaId"
> >
<ArrowLeftIcon class="h-5 w-5" /> <div class="toolbar-slide">
<span class="text-sm font-medium">Retour au manga</span> <ReaderToolbar :chapter-reader-ref="chapterReaderRef" />
</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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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é',

View File

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

View File

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

View File

@@ -1,35 +1,23 @@
<template> <template>
<div class="file-upload"> <div class="file-upload">
<label :for="inputId" class="block text-sm font-medium text-gray-700 mb-2"> <label :for="inputId" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ label }} {{ label }}
</label> </label>
<div <div
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md" class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed "
:class="{ 'border-green-500 bg-green-50': isDragOver, 'hover:border-gray-400': !isDragOver }" :class="{ 'border-green-500 bg-green-50 dark:bg-green-900/20': isDragOver, 'hover:border-gray-400': !isDragOver }"
@drop.prevent="handleDrop" @drop.prevent="handleDrop"
@dragover.prevent="isDragOver = true" @dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false" @dragleave.prevent="isDragOver = false"
> >
<div class="space-y-1 text-center"> <div class="space-y-1 text-center">
<svg <ArrowUpTrayIcon class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
class="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div class="flex text-sm text-gray-600"> <div class="flex text-sm text-gray-600">
<label <label
:for="inputId" :for="inputId"
class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500" class="relative cursor-pointer font-medium text-green-600 hover:text-green-500"
> >
<span>Sélectionner des fichiers</span> <span>Sélectionner des fichiers</span>
<input <input
@@ -50,8 +38,8 @@
</p> </p>
<div v-if="selectedFiles.length > 0" class="mt-4"> <div v-if="selectedFiles.length > 0" class="mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Fichiers sélectionnés :</h4> <h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Fichiers sélectionnés :</h4>
<ul class="text-xs text-gray-600 space-y-1"> <ul class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center"> <li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
<span class="truncate">{{ file.name }}</span> <span class="truncate">{{ file.name }}</span>
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span> <span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
@@ -64,6 +52,7 @@
</template> </template>
<script setup> <script setup>
import { ArrowUpTrayIcon } from '@heroicons/vue/24/outline';
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
const props = defineProps({ const props = defineProps({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
},
enableReaderToolbarAutoHide() {
this.isReaderToolbarAutoHideEnabled = true;
this.isReaderToolbarVisible = true;
},
disableReaderToolbarAutoHide() {
this.isReaderToolbarAutoHideEnabled = false;
this.isReaderToolbarVisible = true;
},
toggleReaderToolbarAutoHide() {
if (this.isReaderToolbarAutoHideEnabled) {
this.disableReaderToolbarAutoHide();
this.disableAutoHide();
} else {
this.enableReaderToolbarAutoHide();
this.enableAutoHide();
}
}, },
updateScrollDirection(scrollY) { updateScrollDirection(scrollY) {
// Éviter les calculs inutiles si pas d'auto-hide
if (!this.isAutoHideEnabled) {
this.lastScrollY = scrollY;
return;
}
// Détecter la direction du scroll avec un seuil pour éviter les micro-mouvements
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;
} }
} }

View File

@@ -126,10 +126,10 @@ services:
tags: tags:
- { name: messenger.message_handler, bus: command.bus } - { name: messenger.message_handler, bus: command.bus }
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface: App\Domain\Shared\Domain\Contract\ImageStorageInterface:
alias: App\Domain\Scraping\Infrastructure\Service\LocalImageStorage alias: App\Domain\Shared\Infrastructure\Service\ImageStorageManager
App\Domain\Scraping\Infrastructure\Service\LocalImageStorage: App\Domain\Shared\Infrastructure\Service\ImageStorageManager:
arguments: arguments:
$storagePath: '%kernel.project_dir%/public/images' $storagePath: '%kernel.project_dir%/public/images'

View File

@@ -12,7 +12,7 @@ services:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository' class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
public: true public: true
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface: App\Domain\Shared\Domain\Contract\ImageStorageInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage' class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage'
public: true public: true

View File

@@ -6,13 +6,13 @@ use App\Domain\Manga\Application\Command\ImportChapter;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException; use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException; use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
readonly class ImportChapterHandler readonly class ImportChapterHandler
{ {
public function __construct( public function __construct(
private MangaRepositoryInterface $mangaRepository, private MangaRepositoryInterface $mangaRepository,
private MangaPathManagerInterface $pathManager private ImageStorageInterface $imageStorage
) { ) {
} }
@@ -39,11 +39,15 @@ readonly class ImportChapterHandler
throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}"); throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}");
} }
// 4. Save the CBZ file to storage using the path manager // 4. Extract CBZ into individual images storage
$cbzPath = $this->saveCbzFile($command, $manga, $existingChapter); $pagesDirectory = $this->imageStorage->extractFromCbz(
$existingChapter->getId(),
$command->fileBinary
);
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
// 5. Update existing chapter with new path through the aggregate // 5. Update existing chapter with new path through the aggregate
$manga->updateChapterPages($existingChapter, $cbzPath, $existingChapter->getPageCount()); $manga->updateChapterPages($existingChapter, $pagesDirectory, $pageCount);
$this->mangaRepository->save($manga); $this->mangaRepository->save($manga);
} }
@@ -53,21 +57,4 @@ readonly class ImportChapterHandler
return strpos($fileBinary, $zipMagicNumber) === 0; return strpos($fileBinary, $zipMagicNumber) === 0;
} }
private function saveCbzFile(ImportChapter $command, \App\Domain\Manga\Domain\Model\Manga $manga, \App\Domain\Manga\Domain\Model\Chapter $chapter): string
{
$volumeNumber = $chapter->getVolume() ?? 0;
$cbzPath = $this->pathManager->buildChapterCbzPath(
$manga->getTitle()->getValue(),
(string)$manga->getPublicationYear(),
$volumeNumber,
(string)$command->chapterNumber
);
if (!file_put_contents($cbzPath, $command->fileBinary)) {
throw new \RuntimeException('Failed to save CBZ file');
}
return $cbzPath;
}
} }

View File

@@ -5,13 +5,13 @@ namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportVolume; use App\Domain\Manga\Application\Command\ImportVolume;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException; use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
readonly class ImportVolumeHandler readonly class ImportVolumeHandler
{ {
public function __construct( public function __construct(
private MangaRepositoryInterface $mangaRepository, private MangaRepositoryInterface $mangaRepository,
private MangaPathManagerInterface $pathManager private ImageStorageInterface $imageStorage
) { ) {
} }
@@ -40,12 +40,14 @@ readonly class ImportVolumeHandler
); );
} }
// 4. Save the CBZ file to storage using the path manager // 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
$cbzPath = $this->saveCbzFile($command, $manga); $volumeDirectoryId = sprintf('volume_%s_%d', $command->mangaId, $command->volumeNumber);
$pagesDirectory = $this->imageStorage->extractFromCbz($volumeDirectoryId, $command->fileBinary);
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
// 5. Update all chapters with the volume path through the aggregate // 5. Update all chapters with the volume path through the aggregate
foreach ($chapters as $chapter) { foreach ($chapters as $chapter) {
$manga->updateChapterPages($chapter, $cbzPath, $chapter->getPageCount()); $manga->updateChapterPages($chapter, $pagesDirectory, $pageCount);
} }
$this->mangaRepository->save($manga); $this->mangaRepository->save($manga);
} }
@@ -56,19 +58,4 @@ readonly class ImportVolumeHandler
return strpos($fileBinary, $zipMagicNumber) === 0; return strpos($fileBinary, $zipMagicNumber) === 0;
} }
private function saveCbzFile(ImportVolume $command, \App\Domain\Manga\Domain\Model\Manga $manga): string
{
$cbzPath = $this->pathManager->buildVolumeCbzPath(
$manga->getTitle()->getValue(),
(string)$manga->getPublicationYear(),
$command->volumeNumber
);
if (!file_put_contents($cbzPath, $command->fileBinary)) {
throw new \RuntimeException('Failed to save CBZ file');
}
return $cbzPath;
}
} }

View File

@@ -23,18 +23,60 @@ readonly class GetMangaChaptersHandler
throw new MangaNotFoundException(); throw new MangaNotFoundException();
} }
$chapters = $this->mangaRepository->findChapters( $allChapters = $this->mangaRepository->findAllChapters(
mangaId: $query->mangaId, mangaId: $query->mangaId,
page: $query->page, sortOrder: 'asc'
limit: $query->limit,
sortOrder: $query->sortOrder
); );
$total = $this->mangaRepository->countChapters($query->mangaId); $grouped = $this->groupChapters($allChapters);
if ($query->sortOrder === 'desc') {
usort($grouped, fn (ChapterResponse $a, ChapterResponse $b) => $b->number <=> $a->number);
}
$total = count($grouped);
$offset = ($query->page - 1) * $query->limit;
$paginatedChapters = array_slice($grouped, $offset, $query->limit);
return new ChapterListResponse( return new ChapterListResponse(
chapters: array_map( chapters: $paginatedChapters,
fn (Chapter $chapter) => new ChapterResponse( total: $total,
page: $query->page,
limit: $query->limit
);
}
/** @param Chapter[] $chapters */
private function groupChapters(array $chapters): array
{
$result = [];
$currentGroup = [];
$currentPagesDir = null;
$currentVolume = null;
foreach ($chapters as $chapter) {
$pagesDir = $chapter->getPagesDirectory();
$volume = $chapter->getVolume();
if ($pagesDir !== null && $volume !== null) {
if ($pagesDir === $currentPagesDir && $volume === $currentVolume) {
$currentGroup[] = $chapter;
} else {
if (!empty($currentGroup)) {
$result[] = $this->buildVolumeGroupResponse($currentGroup);
}
$currentGroup = [$chapter];
$currentPagesDir = $pagesDir;
$currentVolume = $volume;
}
} else {
if (!empty($currentGroup)) {
$result[] = $this->buildVolumeGroupResponse($currentGroup);
$currentGroup = [];
$currentPagesDir = null;
$currentVolume = null;
}
$result[] = new ChapterResponse(
id: $chapter->getId(), id: $chapter->getId(),
number: $chapter->getNumber(), number: $chapter->getNumber(),
title: $chapter->getTitle(), title: $chapter->getTitle(),
@@ -42,12 +84,39 @@ readonly class GetMangaChaptersHandler
isVisible: $chapter->isVisible(), isVisible: $chapter->isVisible(),
pagesDirectory: $chapter->getPagesDirectory(), pagesDirectory: $chapter->getPagesDirectory(),
createdAt: $chapter->getCreatedAt()->format(\DateTimeInterface::RFC3339) createdAt: $chapter->getCreatedAt()->format(\DateTimeInterface::RFC3339)
), );
$chapters }
), }
total: $total,
page: $query->page, if (!empty($currentGroup)) {
limit: $query->limit $result[] = $this->buildVolumeGroupResponse($currentGroup);
}
return $result;
}
/** @param Chapter[] $group */
private function buildVolumeGroupResponse(array $group): ChapterResponse
{
$first = $group[0];
$numbers = array_map(fn (Chapter $c) => $c->getNumber(), $group);
$min = min($numbers);
$max = max($numbers);
$fmt = fn (float $n) => $n == (int) $n ? (string) (int) $n : (string) $n;
$range = count($group) > 1 ? $fmt($min) . '-' . $fmt($max) : $fmt($min);
return new ChapterResponse(
id: $first->getId(),
number: $first->getNumber(),
title: $first->getTitle(),
volume: $first->getVolume(),
isVisible: $first->isVisible(),
pagesDirectory: $first->getPagesDirectory(),
createdAt: $first->getCreatedAt()->format(\DateTimeInterface::RFC3339),
isVolumeGroup: true,
volumeChaptersRange: $range,
volumeChapterCount: count($group)
); );
} }
} }

View File

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

View File

@@ -11,7 +11,10 @@ readonly class ChapterResponse
public ?int $volume, public ?int $volume,
public bool $isVisible, public bool $isVisible,
public ?string $pagesDirectory, public ?string $pagesDirectory,
public string $createdAt public string $createdAt,
public bool $isVolumeGroup = false,
public ?string $volumeChaptersRange = null,
public int $volumeChapterCount = 0,
) { ) {
} }
} }

View File

@@ -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 = []
) { ) {
} }

View File

@@ -30,7 +30,13 @@ interface MangaRepositoryInterface
// --- Chapters (read) --- // --- Chapters (read) ---
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;
/**
* @return Chapter[]
*/
public function findAllChapters(string $mangaId, 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;

View File

@@ -14,7 +14,10 @@ readonly class ChapterListItem
public ?int $volume, public ?int $volume,
public bool $isVisible, public bool $isVisible,
public bool $isAvailable, public bool $isAvailable,
public string $createdAt public string $createdAt,
public bool $isVolumeGroup = false,
public ?string $volumeChaptersRange = null,
public int $volumeChapterCount = 0,
) { ) {
} }
} }

View File

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

View File

@@ -54,7 +54,10 @@ readonly class GetMangaChaptersStateProvider implements ProviderInterface
volume: $chapter->volume, volume: $chapter->volume,
isVisible: $chapter->isVisible, isVisible: $chapter->isVisible,
isAvailable: $chapter->pagesDirectory !== null, isAvailable: $chapter->pagesDirectory !== null,
createdAt: $chapter->createdAt createdAt: $chapter->createdAt,
isVolumeGroup: $chapter->isVolumeGroup,
volumeChaptersRange: $chapter->volumeChaptersRange,
volumeChapterCount: $chapter->volumeChapterCount,
); );
} }
} }

View File

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

View File

@@ -185,6 +185,21 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
); );
} }
public function findAllChapters(string $mangaId, string $sortOrder = 'desc'): array
{
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('c')
->from(EntityChapter::class, 'c')
->where('c.manga = :mangaId')
->orderBy('c.number', $sortOrder)
->setParameter('mangaId', $mangaId);
return array_map(
fn (EntityChapter $entity) => $this->toChapterDomain($entity),
$queryBuilder->getQuery()->getResult()
);
}
public function countChapters(string $mangaId): int public function countChapters(string $mangaId): int
{ {
return $this->entityManager->createQueryBuilder() return $this->entityManager->createQueryBuilder()
@@ -196,6 +211,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([

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

@@ -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(),
@@ -153,7 +154,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
$pages[] = new Page( $pages[] = new Page(
basename($files[$i]), basename($files[$i]),
new PageNumber($i + 1), new PageNumber($i + 1),
sprintf('/images/pages/%s/%s', $chapterId->getValue(), basename($files[$i])), sprintf('/images/pages/%s/%s', basename($pagesDirectory), basename($files[$i])),
$imageSize[0], $imageSize[0],
$imageSize[1] $imageSize[1]
); );

View File

@@ -6,7 +6,7 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface; use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface; use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
use App\Domain\Shared\Domain\Event\ChapterScraped; use App\Domain\Shared\Domain\Event\ChapterScraped;

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Domain\Scraping\Domain\Contract\Service;
interface ImageStorageInterface
{
/**
* Copies images to permanent storage. Returns the pagesDirectory path.
*
* @param string $chapterId The chapter UUID used as directory name
* @param string[] $localImagePaths Paths to the locally downloaded image files
*
* @return string Absolute path to the directory where images were stored
*/
public function storeChapterImages(string $chapterId, array $localImagePaths): string;
}

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Domain\Scraping\Infrastructure\Service;
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
readonly class LocalImageStorage implements ImageStorageInterface
{
public function __construct(private string $storagePath)
{
}
public function storeChapterImages(string $chapterId, array $localImagePaths): string
{
$targetDir = $this->storagePath . '/pages/' . $chapterId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
sort($localImagePaths);
foreach ($localImagePaths as $index => $localPath) {
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
copy($localPath, $targetFile);
}
return $targetDir;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domain\Shared\Domain\Contract;
interface ImageStorageInterface
{
/**
* Store images from local file paths into the individual images storage.
* Used by the scraping flow.
*
* @param string[] $localImagePaths
* @return string The directory path where images are stored (pagesDirectory)
*/
public function storeChapterImages(string $targetId, array $localImagePaths): string;
/**
* Extract images from a CBZ binary into the individual images storage.
* Used by the import flow.
*
* @return string The directory path where images are stored (pagesDirectory)
*/
public function extractFromCbz(string $targetId, string $cbzBinary): string;
/**
* Count images in a CBZ binary.
*/
public function countCbzImages(string $cbzBinary): int;
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Domain\Shared\Infrastructure\Service;
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
use ZipArchive;
class ImageStorageManager implements ImageStorageInterface
{
public function __construct(private string $storagePath)
{
}
public function storeChapterImages(string $targetId, array $localImagePaths): string
{
$targetDir = $this->storagePath . '/pages/' . $targetId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
sort($localImagePaths);
foreach ($localImagePaths as $index => $localPath) {
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
copy($localPath, $targetFile);
}
return $targetDir;
}
public function extractFromCbz(string $targetId, string $cbzBinary): string
{
$targetDir = $this->storagePath . '/pages/' . $targetId;
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
file_put_contents($tmpFile, $cbzBinary);
$zip = new ZipArchive();
if ($zip->open($tmpFile) !== true) {
unlink($tmpFile);
throw new \RuntimeException('Failed to open CBZ file as ZIP archive');
}
$imageEntries = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $name)) {
$imageEntries[] = ['index' => $i, 'name' => $name];
}
}
usort($imageEntries, fn ($a, $b) => strcmp($a['name'], $b['name']));
foreach ($imageEntries as $seq => $entry) {
$extension = strtolower(pathinfo($entry['name'], PATHINFO_EXTENSION)) ?: 'jpg';
$targetFile = sprintf('%s/%03d.%s', $targetDir, $seq + 1, $extension);
$content = $zip->getFromIndex($entry['index']);
file_put_contents($targetFile, $content);
}
$zip->close();
unlink($tmpFile);
return $targetDir;
}
public function countCbzImages(string $cbzBinary): int
{
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
file_put_contents($tmpFile, $cbzBinary);
$zip = new ZipArchive();
if ($zip->open($tmpFile) !== true) {
unlink($tmpFile);
throw new \RuntimeException('Failed to open CBZ file as ZIP archive');
}
$count = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $name)) {
$count++;
}
}
$zip->close();
unlink($tmpFile);
return $count;
}
}

View File

@@ -112,6 +112,23 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
}; };
} }
public function findAllChapters(string $mangaId, string $sortOrder = 'desc'): array
{
if (!isset($this->chapters[$mangaId])) {
return [];
}
$chapters = $this->chapters[$mangaId];
usort($chapters, function (Chapter $a, Chapter $b) use ($sortOrder) {
return $sortOrder === 'desc'
? $b->getNumber() <=> $a->getNumber()
: $a->getNumber() <=> $b->getNumber();
});
return $chapters;
}
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
{ {
if (!isset($this->chapters[$mangaId])) { if (!isset($this->chapters[$mangaId])) {
@@ -135,6 +152,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;
@@ -190,6 +215,15 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
)); ));
} }
public function addChapter(string $mangaId, Chapter $chapter): void
{
if (!isset($this->chapters[$mangaId])) {
$this->chapters[$mangaId] = [];
}
$this->chapters[$mangaId][] = $chapter;
$this->chaptersById[$chapter->getId()] = $chapter;
}
public function addChaptersToManga(string $mangaId, int $count): void public function addChaptersToManga(string $mangaId, int $count): void
{ {
$this->chapters[$mangaId] = []; $this->chapters[$mangaId] = [];

View File

@@ -13,22 +13,22 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug; use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle; use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager; use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class ImportChapterHandlerTest extends TestCase class ImportChapterHandlerTest extends TestCase
{ {
private InMemoryMangaRepository $mangaRepository; private InMemoryMangaRepository $mangaRepository;
private InMemoryPathManager $pathManager; private InMemoryImageStorage $imageStorage;
private ImportChapterHandler $handler; private ImportChapterHandler $handler;
protected function setUp(): void protected function setUp(): void
{ {
$this->mangaRepository = new InMemoryMangaRepository(); $this->mangaRepository = new InMemoryMangaRepository();
$this->pathManager = new InMemoryPathManager(); $this->imageStorage = new InMemoryImageStorage();
$this->handler = new ImportChapterHandler( $this->handler = new ImportChapterHandler(
$this->mangaRepository, $this->mangaRepository,
$this->pathManager $this->imageStorage
); );
} }

View File

@@ -12,22 +12,22 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug; use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle; use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository; use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager; use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class ImportVolumeHandlerTest extends TestCase class ImportVolumeHandlerTest extends TestCase
{ {
private InMemoryMangaRepository $mangaRepository; private InMemoryMangaRepository $mangaRepository;
private InMemoryPathManager $pathManager; private InMemoryImageStorage $imageStorage;
private ImportVolumeHandler $handler; private ImportVolumeHandler $handler;
protected function setUp(): void protected function setUp(): void
{ {
$this->mangaRepository = new InMemoryMangaRepository(); $this->mangaRepository = new InMemoryMangaRepository();
$this->pathManager = new InMemoryPathManager(); $this->imageStorage = new InMemoryImageStorage();
$this->handler = new ImportVolumeHandler( $this->handler = new ImportVolumeHandler(
$this->mangaRepository, $this->mangaRepository,
$this->pathManager $this->imageStorage
); );
} }

View File

@@ -5,7 +5,9 @@ namespace App\Tests\Domain\Manga\Application\QueryHandler;
use App\Domain\Manga\Application\Query\GetMangaChapters; use App\Domain\Manga\Application\Query\GetMangaChapters;
use App\Domain\Manga\Application\QueryHandler\GetMangaChaptersHandler; use App\Domain\Manga\Application\QueryHandler\GetMangaChaptersHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException; use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\Manga; use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId; use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug; use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle; use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
@@ -67,6 +69,139 @@ class GetMangaChaptersHandlerTest extends TestCase
$this->assertTrue($response->hasPreviousPage()); $this->assertTrue($response->hasPreviousPage());
} }
public function testGroupsVolumeChaptersWithSharedPagesDirectory(): void
{
// Arrange
$this->givenMangaExists('1');
$sharedDir = '/manga/vol1/';
foreach ([1, 2, 3] as $num) {
$this->repository->addChapter('1', new Chapter(
id: new ChapterId((string) $num),
mangaId: new MangaId('1'),
number: (float) $num,
title: null,
volume: 1,
isVisible: true,
pagesDirectory: $sharedDir,
));
}
// Act
$response = $this->handler->handle(new GetMangaChapters('1'));
// Assert
$this->assertCount(1, $response->chapters);
$this->assertEquals(1, $response->total);
$item = $response->chapters[0];
$this->assertTrue($item->isVolumeGroup);
$this->assertEquals('1-3', $item->volumeChaptersRange);
$this->assertEquals(3, $item->volumeChapterCount);
$this->assertEquals(1, $item->volume);
$this->assertEquals(1.0, $item->number);
}
public function testGroupsSingleVolumeChapter(): void
{
// Arrange
$this->givenMangaExists('1');
$this->repository->addChapter('1', new Chapter(
id: new ChapterId('10'),
mangaId: new MangaId('1'),
number: 5.0,
title: null,
volume: 2,
isVisible: true,
pagesDirectory: '/manga/vol2/',
));
// Act
$response = $this->handler->handle(new GetMangaChapters('1'));
// Assert
$this->assertCount(1, $response->chapters);
$item = $response->chapters[0];
$this->assertTrue($item->isVolumeGroup);
$this->assertEquals('5', $item->volumeChaptersRange);
$this->assertEquals(1, $item->volumeChapterCount);
}
public function testDoesNotGroupChaptersWithDistinctPagesDirectory(): void
{
// Arrange — 3 chapitres scrapés avec pagesDirectory distinctes, pas de volume
$this->givenMangaExists('1');
foreach ([1, 2, 3] as $num) {
$this->repository->addChapter('1', new Chapter(
id: new ChapterId((string) $num),
mangaId: new MangaId('1'),
number: (float) $num,
title: null,
volume: null,
isVisible: true,
pagesDirectory: '/manga/ch' . $num . '/',
));
}
// Act
$response = $this->handler->handle(new GetMangaChapters('1'));
// Assert — 3 items distincts, aucun groupe
$this->assertCount(3, $response->chapters);
$this->assertEquals(3, $response->total);
foreach ($response->chapters as $item) {
$this->assertFalse($item->isVolumeGroup);
}
}
public function testMixedNormalAndVolumeChapters(): void
{
// Arrange — 2 chapitres scrapés + 3 chapitres de volume importé
$this->givenMangaExists('1');
// Chapitres scrapés (pagesDirectory individuel, pas de volume)
foreach ([1, 2] as $num) {
$this->repository->addChapter('1', new Chapter(
id: new ChapterId((string) $num),
mangaId: new MangaId('1'),
number: (float) $num,
title: null,
volume: null,
isVisible: true,
pagesDirectory: '/manga/ch' . $num . '/',
));
}
// Volume importé — 3 chapitres avec même pagesDirectory
$sharedDir = '/manga/vol1/';
foreach ([3, 4, 5] as $num) {
$this->repository->addChapter('1', new Chapter(
id: new ChapterId((string) ($num + 10)),
mangaId: new MangaId('1'),
number: (float) $num,
title: null,
volume: 1,
isVisible: true,
pagesDirectory: $sharedDir,
));
}
// Act
$response = $this->handler->handle(new GetMangaChapters('1', sortOrder: 'asc'));
// Assert — 2 chapitres normaux + 1 groupe = 3 items
$this->assertCount(3, $response->chapters);
$this->assertEquals(3, $response->total);
// Les 2 premiers sont des chapitres normaux
$this->assertFalse($response->chapters[0]->isVolumeGroup);
$this->assertFalse($response->chapters[1]->isVolumeGroup);
// Le 3e est un groupe de volume
$volumeItem = $response->chapters[2];
$this->assertTrue($volumeItem->isVolumeGroup);
$this->assertEquals('3-5', $volumeItem->volumeChaptersRange);
$this->assertEquals(3, $volumeItem->volumeChapterCount);
}
protected function tearDown(): void protected function tearDown(): void
{ {
$this->repository->clear(); $this->repository->clear();

View File

@@ -2,18 +2,31 @@
namespace App\Tests\Domain\Scraping\Adapter; namespace App\Tests\Domain\Scraping\Adapter;
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface; use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
class InMemoryImageStorage implements ImageStorageInterface class InMemoryImageStorage implements ImageStorageInterface
{ {
/** @var array<string, string> chapterId => pagesDirectory */ /** @var array<string, string> targetId => pagesDirectory */
public array $stored = []; public array $stored = [];
public function storeChapterImages(string $chapterId, array $localImagePaths): string public function storeChapterImages(string $targetId, array $localImagePaths): string
{ {
$dir = '/fake/pages/' . $chapterId; $dir = '/fake/pages/' . $targetId;
$this->stored[$chapterId] = $dir; $this->stored[$targetId] = $dir;
return $dir; return $dir;
} }
public function extractFromCbz(string $targetId, string $cbzBinary): string
{
$dir = '/fake/pages/' . $targetId;
$this->stored[$targetId] = $dir;
return $dir;
}
public function countCbzImages(string $cbzBinary): int
{
return 0;
}
} }

View File

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