95 Commits

Author SHA1 Message Date
ext.jeremy.guillot@maxicoffee.domains
420c3922c2 fix(manga): corriger le conflit de shortName sur MangaDiscoverResource 2026-03-15 21:55:06 +01:00
ext.jeremy.guillot@maxicoffee.domains
814fe46ce5 feat(manga): implémenter la page Découvrir avec recommandations MangaDex
- Endpoint GET /api/manga-discover via DiscoverMangaStateProvider + DiscoverMangaHandler
- Algorithme : top 5 manga de la collection → appel /manga/{id}/recommendation
  par source → agrégation avec système de votes (multi-sources = plus pertinent)
- Filtrage : tags exclus (Oneshot, Doujinshi, Self-Published), contentRating,
  et suppression des manga déjà en bibliothèque
- Page Vue DiscoverPage.vue : chargement auto au montage, bouton Actualiser,
  modal détail, ajout à la bibliothèque
- Adapteurs InMemory de test mis à jour (discover + getMangaRecommendations)
2026-03-15 21:43:57 +01:00
ext.jeremy.guillot@maxicoffee.domains
65453c87e5 style(manga): refondre la page d'ajout de manga sur le design system
- Layout canonique : flex flex-col h-full + Toolbar + overflow-y-auto flex-1
- Titre de page dans la Toolbar, bouton Rechercher toujours visible (disabled si vide)
- Auto-search debounced 500ms au-delà de 3 caractères
- Suppression de tous les rounded-* pour cohérence globale
- Modale enrichie : auteur, année, statut, note, genres, description complète
2026-03-15 20:55:46 +01:00
ext.jeremy.guillot@maxicoffee.domains
78897eda4a chore(claude): versionner les skills partagés dans le repo
All checks were successful
Deploy / deploy (push) Successful in 2m47s
Ajoute les exceptions .gitignore pour tracker .claude/skills/ tout en
continuant d'ignorer settings.local.json et projects/ (fichiers perso).
Inclut les skills task-workflow et ui-style.
2026-03-15 20:42:48 +01:00
02ad36fb34 Merge pull request 'style(conversion): aligner l'UI de conversion sur le design system import' (#20) from style/conversion-ui-align-import into main
All checks were successful
Deploy / deploy (push) Successful in 2m51s
Reviewed-on: #20
2026-03-15 20:24:42 +01:00
929a7d0d61 Merge branch 'main' into style/conversion-ui-align-import 2026-03-15 20:24:31 +01:00
ext.jeremy.guillot@maxicoffee.domains
9f83f9c137 style(conversion): aligner l'UI de conversion sur le design system import
Ajout du Toolbar avec titre et bouton d'action, restructuration en sections
avec bordures et titres typographiques, harmonisation des espacements et
classes Tailwind avec NewImportPage.vue.
2026-03-15 20:24:05 +01:00
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
ext.jeremy.guillot@maxicoffee.domains
cc27fc4564 style(homepage): supprimer px-4 pour tableau pleine largeur sans marges 2026-03-14 00:22:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
e1909b9804 style(homepage): remplacer container par w-full pour pleine largeur en vue table 2026-03-14 00:21:20 +01:00
ext.jeremy.guillot@maxicoffee.domains
07d3b56d1b style(manga-table): supprimer le padding du wrapper pour pleine largeur 2026-03-14 00:19:40 +01:00
ext.jeremy.guillot@maxicoffee.domains
ac19cc53ca style(manga-table): supprimer wrapper card + hover vert + icônes Bookmark 2026-03-14 00:18:23 +01:00
ext.jeremy.guillot@maxicoffee.domains
15cb59e420 style: scrollbar isolée dans la zone de contenu + suppression des flèches
All checks were successful
Deploy / deploy (push) Successful in 2m38s
- Layout: h-screen overflow-hidden, <main> flex-col avec mt-16
- Pages avec toolbar: toolbar hors du conteneur scrollable (flex-col + overflow-y-auto flex-1)
- Pages sans toolbar: wrapper overflow-y-auto h-full
- app.scss: scrollbar-width/color limité à Firefox via @supports (-moz-appearance: none) pour éviter le conflit avec les pseudo-éléments webkit sur Chrome 121+
- Suppression des flèches de scrollbar via ::-webkit-scrollbar-button
- html/body overflow:hidden pour éviter la double scrollbar
2026-03-13 19:32:45 +01:00
ext.jeremy.guillot@maxicoffee.domains
d4e456961a fix: volume gap filling for chapter transitions between different volumes
All checks were successful
Deploy / deploy (push) Successful in 3m3s
`fillVolumeGaps` incorrectly left chapters null when surrounded by two
different non-null volumes (e.g. Vol10 → null → Vol11). Simplify the
condition to always prefer the previous volume, covering all cases.

Also fix `InMemoryMangaRepository::findExistingChaptersByNumbers` to
return an array keyed by chapter number, matching the Doctrine contract.

Add 5 unit tests for MangadxChapterSynchronizationService covering
volume transitions, start-of-series gaps, explicit volumes, FR/EN
priority, and deduplication of existing chapters.
2026-03-13 18:43:51 +01:00
ext.jeremy.guillot@maxicoffee.domains
465a05c13b fix: disable referrer on MangaDex cover images to prevent hotlink blocking
All checks were successful
Deploy / deploy (push) Successful in 2m59s
2026-03-13 18:15:16 +01:00
ext.jeremy.guillot@maxicoffee.domains
2ffe559832 fix: MangaDex title fallback + image CDN URL
All checks were successful
Deploy / deploy (push) Successful in 2m31s
- Title: cascade en → fr → ja-ro → ko-ro → zh-ro → first available to avoid silently dropping mangas without English title (e.g. One Piece stored as ja-ro)
- Image: use uploads.mangadex.org CDN with .512.jpg thumbnail suffix instead of mangadex.org/covers which fails in prod
2026-03-13 18:08:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
5eb650df6f style: simplify settings page — replace cards with border-top sections
All checks were successful
Deploy / deploy (push) Successful in 2m46s
2026-03-13 17:47:47 +01:00
b60a68cbd7 Merge pull request 'feat: dark mode complet + préférences utilisateur' (#7) from feature/dark-mode-user-preferences into main
All checks were successful
Deploy / deploy (push) Successful in 2m45s
Reviewed-on: #7
2026-03-12 20:45:18 +01:00
ext.jeremy.guillot@maxicoffee.domains
ec1ef8fe68 feat: dark mode complet + préférences utilisateur
- Ajout du store userPreferencesStore (thème, vue, tri, pagination, lecteur)
- Page UserPreferencesPage pour configurer toutes les préférences
- Câblage des prefs dans HomePage (viewMode, sortBy, itemsPerPage), readerStore (fallback prefs), ChapterReader (autoHide, autoFullscreen, sync), useNotifications (toastDuration)
- Thème sombre (dark: Tailwind) sur tous les composants Vue : Layout, Pagination, NotificationToast, MangaCard, MangaVolume, MangaDetails, AddManga, HomePage, ActivityPage, JobItem, MangaDeleteModal, MangaEditModal, MangaPreferredSourcesModal, ManageChaptersModal, MangaChapterList, MangaChapter, ConversionPage, FileUploadArea, ConversionProgress, NewImportPage, FileImportCard, MangaMatchCard, StatusBadge, ImportResults
- i18n partiellement initialisé

Jeremy Guillot
2026-03-12 20:38:29 +01:00
48d819ba72 Merge pull request 'feature/notification-system' (#6) from feature/notification-system into main
All checks were successful
Deploy / deploy (push) Successful in 2m34s
Reviewed-on: #6
2026-03-12 18:56:30 +01:00
156d2eea37 Merge branch 'main' into feature/notification-system 2026-03-12 18:56:20 +01:00
ext.jeremy.guillot@maxicoffee.domains
e5c319db79 fix: amélioration du système de notifications
- Correction de l'affichage du texte dans le toast (suppression de w-0/truncate)
- Déplacement des toasts en bas à gauche avec animation slide depuis la gauche
- Inversion de l'ordre des éléments : bouton fermeture > texte > icône > bande couleur
- Fix timing : ChapterScrapingStarted synchrone pour notif "démarrage" avant le scraping
- Ajout make notify-test pour tester les 4 types de notifications

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:55:15 +01:00
ext.jeremy.guillot@maxicoffee.domains
41ca08f20e feat: notification system via Mercure for scraping events
- NotificationInterface: add sendInfo() and sendWarning() levels
- SymfonyNotification: implement new levels (publishes to 'notifications' topic)
- ChapterScrapingStarted: carry mangaTitle + chapterNumber, now dispatched
- ScrapeChapterHandler: dispatch ChapterScrapingStarted before scraping loop
- ScrapingEventSubscriber: wire NotificationInterface for started/scraped/failed events
- useMercureNotifications: new global Vue composable subscribing to 'notifications' topic
- App.vue: mount useMercureNotifications() at app root
- SendTestNotificationCommand: `app:notify:test --type --message` for dev/prod testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 00:57:21 +01:00
13653b4ced Merge pull request 'feat: activity page' (#5) from feature/activity-ddd-port into main
All checks were successful
Deploy / deploy (push) Successful in 2m48s
Reviewed-on: #5
2026-03-11 22:25:01 +01:00
e9b56b80e6 Merge branch 'main' into feature/activity-ddd-port 2026-03-11 22:24:46 +01:00
ext.jeremy.guillot@maxicoffee.domains
95f224d69a feat: enrich activity job display with manga/chapter context
- Add mangaTitle to ScrapingJob context at creation time
- Fix job.js constructor to map failureReason, attempts, maxAttempts, context from API
- JobItem: show readable type label, manga name, chapter number, source and attempts counter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 22:23:09 +01:00
ext.jeremy.guillot@maxicoffee.domains
ff8b945014 fix: test deploy
All checks were successful
Deploy / deploy (push) Successful in 2m48s
2026-03-11 22:00:43 +01:00
ext.jeremy.guillot@maxicoffee.domains
2a8b6bc397 feat: deploy optimisation
All checks were successful
Deploy / deploy (push) Successful in 3m23s
2026-03-11 21:56:23 +01:00
ext.jeremy.guillot@maxicoffee.domains
eb25d2c34e fix: run cache:clear after docker restart, not before
All checks were successful
Deploy / deploy (push) Successful in 3m47s
Docker resolves bind mounts at container start time, not dynamically when
the Deployer symlink changes. Running cache:clear before restart means
docker exec sees the old release code, causing errors on changed config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 21:38:40 +01:00
ext.jeremy.guillot@maxicoffee.domains
c981ce27c5 test
Some checks failed
Deploy / deploy (push) Failing after 2m58s
2026-03-11 21:32:39 +01:00
ext.jeremy.guillot@maxicoffee.domains
6f3efab0fc chore: trigger deploy
Some checks failed
Deploy / deploy (push) Failing after 3m2s
2026-03-11 21:25:30 +01:00
ext.jeremy.guillot@maxicoffee.domains
ed86c9074d fix: remove unsupported priority key from YAML route definition
Some checks failed
Deploy / deploy (push) Failing after 3m17s
Symfony's YAML route loader does not support the priority key (only PHP config does).
Relying on vue_app being defined first in routes.yaml to ensure it is matched
before legacy controller routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 21:13:37 +01:00
ext.jeremy.guillot@maxicoffee.domains
1becbe9254 fix: ensure vue_app catch-all is matched before legacy controllers
Some checks failed
Deploy / deploy (push) Has been cancelled
Move vue_app before controllers in routes.yaml AND keep priority:1.
Using both guarantees Symfony matches the Vue SPA catch-all first
regardless of how the router compiles equal-priority routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 21:11:56 +01:00
ext.jeremy.guillot@maxicoffee.domains
aea4e57b9e fix: Vue SPA catch-all takes priority over legacy Twig routes
All checks were successful
Deploy / deploy (push) Successful in 4m45s
Without priority:1, Symfony matched legacy controllers (e.g. app_activity at /activity)
before the vue_app catch-all on hard reload. Now vue_app matches first for all paths
except /api/* and /legacy* which still route to Symfony controllers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 20:59:11 +01:00
ext.jeremy.guillot@maxicoffee.domains
19395b4869 feat: activity page 2026-03-11 20:54:55 +01:00
ext.jeremy.guillot@maxicoffee.domains
f418b36167 fix: clear Symfony cache before container restart on deploy
All checks were successful
Deploy / deploy (push) Successful in 3m21s
The var/ directory is a persistent Docker volume. Without explicit cache:clear,
docker restart keeps serving old cached routes (e.g. / → MangaController).
New code is already visible via bind mount before restart, so docker exec works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 20:17:43 +01:00
ext.jeremy.guillot@maxicoffee.domains
c085c3453a feat: Vue SPA as default interface at root URL
All checks were successful
Deploy / deploy (push) Successful in 3m32s
- Route `/` now serves the Vue SPA directly (catch-all `/{req}`)
- Legacy Twig interface moved to `/legacy`
- Vue Router base changed from `/vue/` to `/`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 20:06:02 +01:00
ext.jeremy.guillot@maxicoffee.domains
d299e0b9ae fix: deploy
All checks were successful
Deploy / deploy (push) Successful in 3m34s
2026-03-11 19:27:10 +01:00
ext.jeremy.guillot@maxicoffee.domains
e78a6230b5 fix: deploy
All checks were successful
Deploy / deploy (push) Successful in 3m37s
2026-03-11 19:22:56 +01:00
ext.jeremy.guillot@maxicoffee.domains
9d61e4231a fix: deploy
All checks were successful
Deploy / deploy (push) Successful in 3m15s
2026-03-11 19:16:57 +01:00
ext.jeremy.guillot@maxicoffee.domains
027f795bc0 fix: test deploy
All checks were successful
Deploy / deploy (push) Successful in 3m34s
2026-03-11 19:06:41 +01:00
ext.jeremy.guillot@maxicoffee.domains
19f1633c7a fix: deploy
All checks were successful
Deploy / deploy (push) Successful in 3m58s
2026-03-10 23:28:57 +01:00
ext.jeremy.guillot@maxicoffee.domains
751fb1e74b fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 1m52s
2026-03-10 23:25:03 +01:00
ext.jeremy.guillot@maxicoffee.domains
c60301d1ca fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 1m10s
2026-03-10 23:16:32 +01:00
ext.jeremy.guillot@maxicoffee.domains
944994b7d7 fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 56s
2026-03-10 23:14:16 +01:00
ext.jeremy.guillot@maxicoffee.domains
08e005a0d3 fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 2m19s
2026-03-10 23:10:15 +01:00
ext.jeremy.guillot@maxicoffee.domains
566b62450e fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 3m11s
2026-03-10 23:05:11 +01:00
ext.jeremy.guillot@maxicoffee.domains
16f87d5f06 fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 3m10s
2026-03-10 22:59:39 +01:00
ext.jeremy.guillot@maxicoffee.domains
78971a7e2b fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 38s
2026-03-10 22:56:15 +01:00
ext.jeremy.guillot@maxicoffee.domains
b1feb6a83f fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 42s
2026-03-10 22:50:45 +01:00
ext.jeremy.guillot@maxicoffee.domains
8b41626894 fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 11s
2026-03-10 22:47:23 +01:00
ext.jeremy.guillot@maxicoffee.domains
4e7a277d49 fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 19s
2026-03-10 22:43:40 +01:00
ext.jeremy.guillot@maxicoffee.domains
01428cbdeb fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 33s
2026-03-10 22:41:38 +01:00
ext.jeremy.guillot@maxicoffee.domains
5f5271e1b5 fix: deploy
Some checks failed
Deploy / deploy (push) Failing after 11s
2026-03-10 22:38:09 +01:00
ext.jeremy.guillot@maxicoffee.domains
939f6da0c4 fix: test deploy
Some checks failed
Deploy / deploy (push) Failing after 16s
2026-03-10 22:33:26 +01:00
ext.jeremy.guillot@maxicoffee.domains
0756460fbc fix: git token
Some checks failed
Deploy / deploy (push) Failing after 12s
2026-03-10 22:08:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
3941cb4b8f feat: deployer
Some checks failed
Deploy / deploy (push) Failing after 21s
2026-03-10 21:48:18 +01:00
ext.jeremy.guillot@maxicoffee.domains
3507349167 fix: symfony css selector in prod
All checks were successful
Build and Deploy / deploy (push) Successful in 1m25s
2026-03-09 23:10:32 +01:00
487f400418 Merge pull request 'refactor(reader): serve pages as static files instead of base64' (#4) from feat/reader-static-images into main
All checks were successful
Build and Deploy / deploy (push) Successful in 1m23s
Reviewed-on: #4
2026-03-09 22:07:34 +01:00
ext.jeremy.guillot@maxicoffee.domains
322c396165 refactor(reader): serve pages as static files instead of base64
Replace the per-page API call (base64 payload) with static image URLs
served directly by Caddy from public/images/pages/{chapterId}/.

- LocalImageStorage now stores to public/images/ (was MANGA_DATA_PATH)
- LegacyChapterRepository returns /images/pages/{id}/{file} URLs,
  uses getimagesize() instead of loading file content into memory
- Delete GetChapterPage query/handler/response, ChapterPageResource,
  ChapterPageProvider, PageContent model
- Remove getPageContent() from ChapterRepositoryInterface
- Frontend: loadChapter() fetches chapter + all pages in parallel,
  ReaderPage uses URL instead of base64 data URI, InfiniteReader drops
  lazy-load observer side effect, readerStore drops loadedPages/preload
- GetChapterPagesTest: extract fixture images from CBZ at runtime,
  ignore tests/Fixtures/pages/ in .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 22:05:45 +01:00
6875ad4222 Merge pull request 'refactor(scraping): DDD refactoring — stockage images individuelles' (#3) from feat/scraping-ddd-image-storage into main
All checks were successful
Build and Deploy / deploy (push) Successful in 1m42s
Reviewed-on: #3
2026-03-09 20:53:15 +01:00
ext.jeremy.guillot@maxicoffee.domains
c311cfe80c refactor(scraping): DDD refactoring — stockage images individuelles
Le domaine Scraping ne génère plus d'archives CBZ ni ne modifie les
entités du domaine Manga directement. Il scrape, stocke les images
individuellement, et émet un événement partagé.

- Suppression : CbzGeneratorInterface, CbzGenerator, CbzGenerationRequest,
  CbzPath, CbzGenerationException
- Suppression : save() de ChapterRepositoryInterface (Scraping)
- Suppression : cbzPath du modèle Chapter (Scraping)
- Ajout : ImageStorageInterface + LocalImageStorage
  (stockage dans {MANGA_DATA_PATH}/pages/{chapterId}/)
- ScrapeChapterHandler utilise ImageStorage au lieu du générateur CBZ

- ChapterScraped déplacé dans Domain/Shared/Domain/Event/
  avec jobId, chapterId, pagesDirectory, pageCount
- Routing Messenger ajouté

- Ajout : ChapterScrapedEventListener + ChapterScrapedMessageHandler
  pour mettre à jour Chapter.pagesDirectory via le Repository Manga

- LegacyChapterRepository en dual-mode :
  pagesDirectory en priorité, fallback cbzPath (backward compat)
- Requêtes prev/next : filtrent pagesDirectory IS NOT NULL OR cbzPath IS NOT NULL
- ChapterContext expose pagesDirectory

- phparkitect.php : App\Domain\Shared\Domain\Event autorisé dans
  les couches Application (correction violations pré-existantes
  ChapterImported/VolumeImported + nouvelle ChapterScraped)

- 218/218 tests passent (+3 nouveaux)
- InMemoryImageStorage créé pour les tests unitaires

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 20:52:16 +01:00
ext.jeremy.guillot@maxicoffee.domains
d444f86315 Merge branch 'main' of ssh://git.homelab.nestor-server.fr:2222/colgora/Mangarr
All checks were successful
Build and Deploy / deploy (push) Successful in 1m46s
# Conflicts:
#	src/Domain/Manga/Application/CommandHandler/DeleteChapterHandler.php
#	src/Domain/Manga/Application/CommandHandler/EditMultipleChaptersHandler.php
#	src/Domain/Manga/Application/EventListener/ChapterImportedEventListener.php
#	src/Domain/Manga/Application/EventListener/VolumeImportedEventListener.php
#	src/Domain/Manga/Application/Response/ChapterResponse.php
#	src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteCbzProvider.php
#	src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteChapterProvider.php
#	src/Domain/Manga/Infrastructure/Persistence/Repository/LegacyChapterRepository.php
2026-03-09 20:47:43 +01:00
ext.jeremy.guillot@maxicoffee.domains
7506a7a3c1 style: apply php-cs-fixer formatting (PSR-12)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 20:46:59 +01:00
378 changed files with 8387 additions and 3559 deletions

View File

@@ -0,0 +1,142 @@
---
name: task-workflow
description: Workflow complet pour traiter une tâche du TASK.md — branche git, développement, tests, commit conventionnel, push, puis archivage dans DONE.md. Utiliser quand l'utilisateur veut implémenter une tâche listée dans TASK.md.
allowed-tools: Read, Bash, Edit, Write, Glob, Grep
---
# Workflow de traitement d'une tâche (TASK.md → DONE.md)
Quand l'utilisateur demande de traiter une tâche du `TASK.md`, suivre **dans l'ordre** les étapes ci-dessous.
---
## ⚠️ Étape 0 — Repartir d'une branche saine depuis `origin/main`
**IMPORTANT : toujours commencer par cette étape, sans exception.**
```bash
git fetch origin
git checkout main
git pull origin main
```
Ensuite seulement créer la branche de travail (voir étape 2).
> Règle : ne jamais partir d'une branche de feature existante. Toujours tirer depuis `main` à jour.
---
## Étape 1 — Lire et choisir la tâche
1. Lire `TASK.md` pour identifier la tâche à traiter (si non précisée, demander laquelle).
2. Extraire : le titre, les fichiers impactés, et la liste des sous-tâches.
---
## Étape 2 — Créer une branche git
Nommer la branche d'après le type et le titre de la tâche :
```
<type>/<slug-de-la-tache>
```
Exemples de types : `feat`, `fix`, `style`, `refactor`, `test`, `chore`
```bash
git checkout -b style/simplifier-table-homepage
```
Règle : **ne jamais committer directement sur `main`**.
---
## Étape 3 — Implémenter la tâche
- Lire tous les fichiers mentionnés dans la tâche avant de les modifier.
- Cocher mentalement chaque sous-tâche `[ ]` au fur et à mesure.
- Respecter les skills existants selon les fichiers touchés :
- Composant Vue → skill `vue-frontend`
- Domaine PHP → skills `ddd-core`, `hexagonal-arch`, `cqrs`, `api-platform`
- Tests → skill `testing-strategy`
---
## Étape 4 — Vérifier que tous les tests passent
```bash
make test
```
- Si des tests échouent, **corriger avant de continuer**.
- Ne pas passer à l'étape suivante tant que la suite n'est pas verte.
- Pour un test spécifique : `make test f="NomDeLaClasse"`
---
## Étape 5 — Commit conventionnel
Format Conventional Commits :
```
<type>(<scope>): <description courte en français>
[corps optionnel : explication du pourquoi]
```
**Types autorisés :** `feat`, `fix`, `style`, `refactor`, `test`, `chore`, `docs`
**Scope :** nom du domaine ou du composant impacté (ex: `manga-table`, `sidebar`, `homepage`)
Exemples :
```
style(manga-table): simplifier le wrapper card + hover vert sur le titre
fix(sidebar): séparer toggle et navigation sur MenuGroup
```
```bash
git add <fichiers modifiés>
git commit -m "style(manga-table): simplifier le wrapper card + hover vert sur le titre"
```
---
## Étape 6 — Push de la branche
**Demander confirmation à l'utilisateur avant de pusher.**
```bash
git push -u origin <nom-de-la-branche>
```
---
## Étape 7 — Archiver la tâche dans DONE.md
1. Retirer le bloc de la tâche de `TASK.md` (section complète, du titre `##` jusqu'au `---` suivant).
2. Ajouter la tâche dans `DONE.md` (créer le fichier s'il n'existe pas) avec la date et le sha du commit :
Format dans `DONE.md` :
```markdown
## [TYPE] Titre de la tâche — YYYY-MM-DD
> Branche : `<nom-de-la-branche>` | Commit : `<sha court>`
- [x] Sous-tâche 1
- [x] Sous-tâche 2
```
---
## Résumé du flux
```
fetch + checkout main + pull (branche saine)
→ branche git depuis main
→ TASK.md (choisir la tâche)
→ implémentation
→ make test (vert obligatoire)
→ conventional commit
→ push (après confirmation)
→ DONE.md
```

View File

@@ -0,0 +1,223 @@
---
name: ui-style
description: Design system et harmonisation UI de Mangarr — layout de page canonique (Toolbar + flex + sections border-t), palette Tailwind, patterns composants (boutons, badges, upload, progression). Utiliser quand on crée ou modifie une page Vue ou un composant UI.
allowed-tools: Read, Grep, Glob
---
# Design system Mangarr — Guide UI
Les pages de référence canoniques sont :
- `assets/vue/app/domain/manga/infrastructure/presentation/pages/NewImportPage.vue`
- `assets/vue/app/domain/conversion/infrastructure/presentation/pages/ConversionPage.vue`
En cas de doute, les lire pour vérifier le pattern en vigueur.
---
## 1. Layout de page canonique
```vue
<template>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<section 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">
Titre section
</h2>
<!-- contenu -->
</section>
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<!-- section suivante -->
</section>
</div>
</div>
</div>
</template>
```
**Règles absolues :**
- `flex flex-col h-full` toujours à la racine du template
- `<Toolbar>` toujours en premier enfant direct de la racine
- `overflow-y-auto flex-1` pour le contenu scrollable
- `px-6 py-8` comme wrapper interne — **jamais** `container mx-auto`
- Chaque bloc logique = une `<section>` avec `border-t border-gray-200 dark:border-gray-700`
- **Jamais** de `<h1>` volant dans le contenu — le titre de page va dans `toolbarConfig.leftSection`
---
## 2. Configuration Toolbar
```javascript
import { computed } from 'vue';
import { SomeIcon } from '@heroicons/vue/24/outline';
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Titre de la page', class: 'text-sm font-medium' },
],
rightSection: [
{
type: 'button',
icon: SomeIcon,
label: 'Action principale',
onClick: handler,
disabled: condition,
},
// Bouton conditionnel :
...(showAction ? [{
type: 'button',
icon: OtherIcon,
label: 'Action contextuelle',
onClick: otherHandler,
}] : []),
],
}));
```
- Icônes : Heroicons v24/outline (`@heroicons/vue/24/outline`)
- Boutons toolbar visibles uniquement si pertinents — utiliser le spread conditionnel
- `rightSection` peut être vide `[]`
---
## 3. Headers de section
```vue
<!-- Header simple -->
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
Titre
</h2>
<!-- Header avec info contextuelle à droite -->
<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">
Titre
</h2>
<span class="text-xs text-gray-500 dark:text-gray-400">info contextuelle</span>
</div>
```
---
## 4. Palette de couleurs
| Usage | Classes Tailwind |
|-------|-----------------|
| Primaire (action principale) | `bg-green-600 hover:bg-green-700` |
| Secondaire | `bg-blue-600 hover:bg-blue-700` |
| Danger | `bg-red-600 hover:bg-red-700` |
| Désactivé | `disabled:bg-gray-400 disabled:cursor-not-allowed` |
| Texte principal | `text-gray-900 dark:text-gray-100` |
| Texte secondaire | `text-gray-600 dark:text-gray-300` |
| Texte subtil | `text-gray-500 dark:text-gray-400` |
| Étiquette section | `text-gray-400 dark:text-gray-500` |
| Fond carte / panel | `bg-white dark:bg-gray-800` |
| Bordure | `border-gray-200 dark:border-gray-700` |
| Séparateur de liste | `divide-y divide-gray-100 dark:divide-gray-700/50` |
---
## 5. Boutons
```vue
<!-- Bouton action principale (submit, lancer, confirmer) -->
<button
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 rounded-md font-medium transition-colors"
:disabled="condition"
>
Label
</button>
<!-- Bouton ghost / discret -->
<button class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
Label
</button>
```
---
## 6. Barre de progression
```vue
<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: progress + '%' }"
/>
</div>
```
> **Important :** toujours `bg-green-600`, jamais `bg-blue-600` pour les barres de progression.
---
## 7. Liste avec séparateurs
```vue
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div
v-for="item in items"
:key="item.id"
class="flex items-center justify-between py-3"
>
<!-- contenu de l'item -->
</div>
</div>
```
---
## 8. Zone de drop / upload de fichier
```vue
<div
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
:class="isDragging
? 'border-green-500 bg-green-50 dark:bg-green-900/10'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="handleDrop"
>
<SomeIcon class="mx-auto h-8 w-8 text-gray-400 mb-3" />
<p class="text-sm text-gray-600 dark:text-gray-300">
Message principal
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Précision format/taille
</p>
</div>
```
---
## 9. Pages non conformes à corriger
Les pages suivantes dévient encore du pattern canonique :
| Page | Chemin relatif | Déviations principales |
|------|---------------|----------------------|
| `HomePage.vue` | `domain/manga/.../pages/` | Pas de `px-6 py-8`, pas de sections `border-t` |
| `AddManga.vue` | `domain/manga/.../pages/` | Pas de Toolbar, pas de `flex flex-col h-full` |
| `ActivityPage.vue` | `domain/activity/.../pages/` | Pas de `flex flex-col`, pas de Toolbar intégré |
| `UserPreferencesPage.vue` | `domain/setting/.../pages/` | `h1` volant, pas de Toolbar |
| `ScrapperConfigurations.vue` | `domain/setting/.../pages/` | `h1` volant, `container mx-auto` |
| `ScrapperEdit.vue` | `domain/setting/.../pages/` | `container mx-auto` au lieu de `px-6 py-8` |
| `MangaDetails.vue` | `domain/manga/.../pages/` | Layout spécial (cover + chapitres), à traiter séparément |
| `ChapterPage.vue` | `domain/reader/.../pages/` | Layout lecteur spécialisé — **exception justifiée**, ne pas modifier |
---
## 10. Checklist avant de livrer une page
- [ ] Racine : `flex flex-col h-full`
- [ ] Premier enfant : `<Toolbar :config="toolbarConfig" />`
- [ ] Contenu scrollable : `overflow-y-auto flex-1`
- [ ] Wrapper interne : `px-6 py-8` (jamais `container mx-auto`)
- [ ] Blocs logiques : `<section class="border-t border-gray-200 dark:border-gray-700 pt-6">`
- [ ] Titre de page dans `toolbarConfig.leftSection`, pas de `<h1>` dans le contenu
- [ ] Headers de section : classes `text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider`
- [ ] Barres de progression : `bg-green-600` (pas `bg-blue-600`)
- [ ] Dark mode : chaque couleur a sa variante `dark:`

View File

@@ -0,0 +1,251 @@
---
name: vue-frontend
description: Architecture Vue.js du projet Mangarr — structure DDD front (domain/application/infrastructure/presentation), patterns Pinia store, TanStack Query composables, API repositories, conventions de nommage. Utiliser quand on crée ou modifie un composant Vue, une page, un store Pinia, un composable, ou un repository API dans assets/vue/app/.
allowed-tools: Read, Grep, Glob
---
# Architecture Vue.js — Mangarr Frontend
## Structure des dossiers
```
assets/vue/app/
index.js # Point d'entrée : Vue + Pinia + Router + VueQuery
App.vue # Root : <router-view> + <NotificationToast>
router/index.js # Routes imbriquées sous Layout, base /vue/
domain/
{DomainName}/
domain/
entities/ # Classes entités JS
constants/ # Constantes du domaine
application/
store/ # Stores Pinia
infrastructure/
api/ # Clients HTTP (ApiXxxRepository)
presentation/
pages/ # Composants pleine page
components/ # Composants réutilisables
composables/ # Logique Vue (useXxx)
shared/
components/
layout/ # Layout, Header, Sidebar
ui/ # Composants UI génériques
composables/ # useNotifications, etc.
stores/ # headerStore, menuStore
plugin/ # vueQuery.js config
```
**Domaines existants :** `manga`, `reader`, `import`, `conversion`, `activity`, `setting`
## Conventions de nommage
| Couche | Pattern | Exemple |
|--------|---------|---------|
| Entité | `PascalCase` | `Manga`, `ImportFile`, `Job` |
| Store Pinia | `use{Domain}Store()` | `useMangaStore()` |
| Composable | `use{Feature}()` | `useMangaDetails()`, `useNotifications()` |
| Repository API | `Api{Domain}Repository` | `ApiMangaRepository` |
| Page | `{Domain}{Action}.vue` | `MangaDetails.vue`, `NewImportPage.vue` |
| Composant | `{Domain}{Feature}.vue` | `MangaCard.vue`, `StatusBadge.vue` |
| Modal | `{Feature}Modal.vue` | `MangaDeleteModal.vue` |
## Pattern Store Pinia
```javascript
// application/store/xyzStore.js
export const useXyzStore = defineStore('xyz', {
state: () => ({
data: null,
isLoading: false,
error: null,
}),
getters: {
isReady: (state) => state.data && !state.isLoading,
},
actions: {
async load() {
this.isLoading = true
try {
const repo = new ApiXyzRepository()
this.data = await repo.getAll()
} catch (err) {
this.error = err.message
throw err
} finally {
this.isLoading = false
}
},
},
})
```
## Pattern Composable avec TanStack Query
Préférer TanStack Query pour les lectures (queries), le store Pinia pour les mutations et l'état global.
```javascript
// presentation/composables/useXyzDetails.js
export function useXyzDetails(xyzId) {
const repo = new ApiXyzRepository()
return useQuery({
queryKey: ['xyz', xyzId],
queryFn: () => repo.getById(xyzId.value),
enabled: computed(() => !!xyzId.value),
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: true,
})
}
// Mutation
export function useXyzEdit() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data) => new ApiXyzRepository().edit(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['xyz'] }),
})
}
```
## Pattern Repository API
```javascript
// infrastructure/api/apiXyzRepository.js
export class ApiXyzRepository {
async getAll() {
const response = await fetch('/api/xyz')
if (!response.ok) throw new Error(await this.#extractError(response))
const data = await response.json()
return data.items.map(Xyz.fromApiData)
}
async getById(id) {
const response = await fetch(`/api/xyz/${id}`)
if (!response.ok) throw new Error(await this.#extractError(response))
return Xyz.fromApiData(await response.json())
}
async create(payload) {
const response = await fetch('/api/xyz', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) throw new Error(await this.#extractError(response))
return Xyz.fromApiData(await response.json())
}
async #extractError(response) {
try {
const data = await response.json()
return data.error || data.detail || `HTTP ${response.status}`
} catch {
return `HTTP ${response.status}`
}
}
}
```
## Pattern Entité
```javascript
// domain/entities/xyz.js
export class Xyz {
constructor({ id, name, status }) {
this.id = id
this.name = name
this.status = status
}
static fromApiData(data) {
return new Xyz(data)
}
isActive() { return this.status === 'active' }
isCompleted() { return this.status === 'completed' }
}
```
## Pattern Page
```vue
<template>
<div>
<Toolbar :config="toolbarConfig" />
<LoadingSpinner v-if="isLoading" />
<div v-else-if="error">{{ error }}</div>
<ChildComponent v-else :data="data" @action="handleAction" />
<FeatureModal :is-open="isModalOpen" @close="closeModal" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useFeatureComposable } from '../composables/useFeature'
const route = useRoute()
const { data, isLoading, error } = useFeatureComposable(
computed(() => route.params.id)
)
const isModalOpen = ref(false)
const closeModal = () => (isModalOpen.value = false)
</script>
```
## Système de notifications (global)
```javascript
import { useNotifications } from '@/shared/composables/useNotifications'
const { showSuccess, showError, showWarning, showInfo } = useNotifications()
showSuccess('Manga ajouté avec succès')
showError('Erreur lors du chargement')
```
## Configuration VueQuery (shared/plugin/vueQuery.js)
- `staleTime`: 5 minutes
- `gcTime`: 10 minutes
- `retry`: 1
- `refetchOnWindowFocus`: true
## Upload de fichiers (FormData)
Ne pas définir `Content-Type` manuellement — le navigateur le gère automatiquement avec le boundary correct.
```javascript
const formData = new FormData()
formData.append('file', file)
formData.append('mangaId', mangaId)
const response = await fetch('/api/xyz/import', {
method: 'POST',
body: formData, // pas de Content-Type header
})
```
## Commandes utiles
```bash
make npm-run # Build dev one-shot — vérifie qu'il n'y a pas d'erreur de compilation
make npm-watch # Watch + rebuild automatique pendant le développement
make npm-add p=pkg # Ajouter une dépendance npm
```
Après toute modification de composants Vue, stores ou repositories, lancer `make npm-run` pour valider le build.
## Règles à respecter
- **Domain** : entités JS pures, aucune dépendance Vue/fetch
- **Application** : stores Pinia uniquement, pas d'appels fetch directs (passer par Infrastructure)
- **Infrastructure** : repositories API, aucune logique Vue
- **Presentation** : composants + composables, import uniquement depuis Application et Infrastructure
- **Shared** : composants/composables transversaux, pas de dépendances vers les domaines
- Préférer `useQuery`/`useMutation` (TanStack) pour les données serveur, Pinia pour l'état UI global
- Un composable = une responsabilité, nommé `use{FeatureVerb}` (ex: `useMangaDelete`, `useMangaEdit`)

View File

@@ -1,4 +1,4 @@
name: Build and Deploy
name: Deploy
on:
push:
@@ -9,63 +9,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Install tools
- name: Setup SSH
run: |
apt-get update && apt-get install -y docker.io curl jq
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
- name: Build production image
run: |
docker build --target frankenphp_prod -t mangarr:latest .
- name: Redeploy via Portainer API
- name: Deploy via Deployer
env:
PORTAINER_USER: ${{ secrets.PORTAINER_USER }}
PORTAINER_PASSWORD: ${{ secrets.PORTAINER_PASSWORD }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
JWT=$(curl -s -X POST http://portainer:9000/api/auth \
-H "Content-Type: application/json" \
-d "{\"Username\":\"$PORTAINER_USER\",\"Password\":\"$PORTAINER_PASSWORD\"}" | jq -r '.jwt')
# Créer le container sans le démarrer (évite le problème DinD avec les volumes)
CONTAINER=$(docker create \
-e DEPLOY_HOST \
-e GITEA_TOKEN \
-w /app \
deployphp/deployer:v7 \
-f /app/deploy.php deploy production -vvv)
if [ -z "$JWT" ] || [ "$JWT" = "null" ]; then
echo "Erreur: authentification Portainer echouee"
exit 1
fi
# Copier les sources et les clés SSH dans le container
docker cp "$PWD/." "$CONTAINER:/app/"
docker cp "$HOME/.ssh/." "$CONTAINER:/root/.ssh/"
STACK_INFO=$(curl -s http://portainer:9000/api/stacks \
-H "Authorization: Bearer $JWT")
STACK_ID=$(echo "$STACK_INFO" | jq '.[] | select(.Name=="mangarr") | .Id')
ENDPOINT_ID=$(echo "$STACK_INFO" | jq '.[] | select(.Name=="mangarr") | .EndpointId')
if [ -z "$STACK_ID" ] || [ "$STACK_ID" = "null" ]; then
echo "Erreur: stack mangarr non trouvee"
exit 1
fi
echo "Stack ID: $STACK_ID, Endpoint ID: $ENDPOINT_ID"
STACK_FILE=$(curl -s "http://portainer:9000/api/stacks/$STACK_ID/file" \
-H "Authorization: Bearer $JWT" | jq -r '.StackFileContent')
STACK_ENV=$(curl -s "http://portainer:9000/api/stacks/$STACK_ID" \
-H "Authorization: Bearer $JWT" | jq '.Env')
HTTP_CODE=$(curl -s -o /tmp/deploy_result.json -w "%{http_code}" -X PUT \
"http://portainer:9000/api/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d "{\"stackFileContent\":$(echo "$STACK_FILE" | jq -Rs .),\"env\":$STACK_ENV,\"prune\":true,\"pullImage\":false}")
echo "Portainer redeploy: HTTP $HTTP_CODE"
if [ "$HTTP_CODE" -ge 300 ]; then
cat /tmp/deploy_result.json
exit 1
fi
- name: Run migrations
run: |
echo "Attente du demarrage de Mangarr..."
sleep 15
docker exec mangarr php bin/console doctrine:migrations:migrate --no-interaction || echo "Rien a migrer"
docker exec mangarr php bin/console cache:clear --env=prod || true
echo "Deploy termine avec succes"
# Démarrer et attendre la fin
docker start -a "$CONTAINER"
EXIT_CODE=$?
docker rm "$CONTAINER" || true
exit $EXIT_CODE

8
.gitignore vendored
View File

@@ -38,3 +38,11 @@ yarn-error.log
/public/images/
src/Controller/TestController.php
.phpunit.cache/test-results
/tests/Fixtures/pages/
# Claude Code — versionner les skills partagés, ignorer les fichiers perso
!.claude/
!.claude/skills/
!.claude/skills/**
.claude/settings.local.json
.claude/projects/

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)

View File

@@ -68,6 +68,19 @@ ENTRYPOINT ["docker-entrypoint"]
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
# Runtime FrankenPHP image (sans code baked-in)
# Le code vient du bind mount /srv/mangarr/current:/app (géré par Deployer)
# Builder une seule fois : docker build --target frankenphp_runtime -t mangarr:runtime .
FROM frankenphp_base AS frankenphp_runtime
ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="import worker.Caddyfile"
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --link frankenphp/conf.d/app.prod.ini $PHP_INI_DIR/conf.d/
COPY --link frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile
# Dev FrankenPHP image
FROM frankenphp_base AS frankenphp_dev

View File

@@ -145,6 +145,13 @@ twig-extension: ## Create a new twig extension
stimulus: ## Create a new stimulus controller
@$(SYMFONY) make:stimulus-controller
notify-test: ## Envoie les 4 types de notifications de test avec 2s d'intervalle
@for type in info success error warning; do \
$(SYMFONY) app:notify:test --type=$$type --message="Test $$type depuis Mangarr"; \
echo "[$$type] envoyé"; \
sleep 2; \
done
consume-commands: ## Consume commands messages
@$(SYMFONY) messenger:consume commands -vv

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

@@ -3,6 +3,11 @@
@import "tailwindcss/components";
@import "tailwindcss/utilities";
html, body {
overflow: hidden;
height: 100%;
}
body {
background-color: white;
}
@@ -82,6 +87,33 @@ body {
@apply bg-gray-700;
}
/* Firefox uniquement — évite le conflit avec les pseudo-éléments webkit sur Chrome 121+ */
@supports (-moz-appearance: none) {
* {
scrollbar-width: thin;
scrollbar-color: #16a34a transparent;
}
.dark * {
scrollbar-color: #16a34a #1f2937;
}
}
/* Dark mode — webkit track */
.dark ::-webkit-scrollbar-track {
@apply bg-gray-800;
}
/* Supprime les flèches de la scrollbar */
::-webkit-scrollbar-button:start:decrement,
::-webkit-scrollbar-button:end:increment,
::-webkit-scrollbar-button:start:increment,
::-webkit-scrollbar-button:end:decrement {
display: none;
width: 0;
height: 0;
}
///* Custom styles for the scrollbar buttons */
//::-webkit-scrollbar-button {
// @apply bg-gray-700;

View File

@@ -5,6 +5,9 @@
<script setup>
import NotificationToast from './shared/components/ui/NotificationToast.vue';
import { useMercureNotifications } from './shared/composables/useMercureNotifications';
useMercureNotifications();
</script>
<style>

View File

@@ -7,8 +7,12 @@ export class Job {
payload = {},
result = null,
error = null,
failureReason = null,
createdAt = new Date().toISOString(),
updatedAt = new Date().toISOString()
updatedAt = new Date().toISOString(),
attempts = 0,
maxAttempts = 1,
context = {}
}) {
this.id = id;
this.type = type;
@@ -16,9 +20,12 @@ export class Job {
this.progress = progress;
this.payload = payload;
this.result = result;
this.error = error;
this.error = failureReason ?? error;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.attempts = attempts;
this.maxAttempts = maxAttempts;
this.context = context;
}
static create(data) {

View File

@@ -23,8 +23,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
url += `&status=${status.join(',')}`;
}
console.log('Fetching jobs from URL:', url);
const response = await fetch(url);
if (!response.ok) {
@@ -32,7 +30,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
}
const data = await response.json();
console.log('API Response:', data);
// Gérer différents formats de réponse API
let jobs, total, currentPage, limit_returned, hasNext, hasPrev;
@@ -63,15 +60,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
hasPrev = !!data.hasPreviousPage;
}
console.log('Processed data:', {
jobs: jobs.length,
total,
currentPage,
limit_returned,
hasNext,
hasPrev
});
return new JobCollection(
jobs,
total,
@@ -81,7 +69,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
hasPrev
);
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
@@ -102,7 +89,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
const data = await response.json();
return Job.create(data);
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
@@ -124,7 +110,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
@@ -158,7 +143,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
const data = await response.json();
return data.deleted || 0;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}

View File

@@ -1,39 +1,56 @@
<template>
<tr
class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out"
class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition duration-150 ease-in-out"
:class="{
'bg-yellow-50': job.status === 'pending',
'bg-blue-50': job.status === 'in_progress',
'bg-green-50': job.status === 'completed',
'bg-red-50': job.status === 'failed'
'bg-yellow-50 dark:bg-yellow-900/20': job.status === 'pending',
'bg-blue-50 dark:bg-blue-900/20': job.status === 'in_progress',
'bg-green-50 dark:bg-green-900/20': job.status === 'completed',
'bg-red-50 dark:bg-red-900/20': job.status === 'failed'
}">
<td class="py-4 px-4 text-center">
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" />
</td>
<td class="py-4 px-4 font-medium">{{ job.type }}</td>
<td class="py-4 px-4 font-medium">
<div>{{ jobTypeLabel }}</div>
<div v-if="job.context?.mangaTitle" class="text-xs text-gray-500 mt-0.5">
{{ job.context.mangaTitle }}
</div>
</td>
<td class="py-4 px-4">
<span
class="px-2 py-1 text-xs rounded-full"
:class="{
'bg-yellow-100 text-yellow-800': job.status === 'pending',
'bg-blue-100 text-blue-800': job.status === 'in_progress',
'bg-green-100 text-green-800': job.status === 'completed',
'bg-red-100 text-red-800': job.status === 'failed'
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': job.status === 'pending',
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': job.status === 'in_progress',
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': job.status === 'completed',
'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300': job.status === 'failed'
}">
{{ job.status }}
</span>
</td>
<td class="py-4 px-4">
<div v-if="job.error" class="text-sm text-red-600">
<div v-if="job.error" class="text-sm text-red-600 dark:text-red-400">
{{ job.error }}
</div>
<div v-else class="text-sm text-gray-600">
<div v-else-if="job.context?.mangaTitle || job.context?.chapterNumber !== undefined || job.context?.sourceId"
class="text-sm text-gray-700 dark:text-gray-300 space-y-0.5">
<div v-if="job.context.mangaTitle" class="font-medium">
{{ job.context.mangaTitle }}
</div>
<div v-if="job.context.chapterNumber !== undefined" class="text-gray-500 dark:text-gray-400">
Chapitre {{ job.context.chapterNumber }}
</div>
<div v-if="job.context.sourceId" class="text-xs text-gray-400 dark:text-gray-500">
Source : {{ job.context.sourceId }}
</div>
</div>
<div v-else class="text-sm text-gray-600 dark:text-gray-400">
{{ formatDate(job.createdAt) }}
</div>
</td>
<td class="py-4 px-4">
<div v-if="job.status === 'in_progress'" class="mt-2">
<div class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
<div class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
<div
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
:style="{ width: `${job.progress}%` }"></div>
@@ -42,7 +59,7 @@
</div>
</div>
</div>
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
<div
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
style="width: 100%"></div>
@@ -50,7 +67,7 @@
100%
</div>
</div>
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
<div
class="absolute top-0 left-0 h-full bg-red-400 transition-all duration-300 ease-out"
style="width: 100%"></div>
@@ -58,14 +75,19 @@
Erreur
</div>
</div>
<div v-else class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
<div v-else class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
<div
class="absolute top-0 left-0 h-full bg-yellow-400 transition-all duration-300 ease-out"
style="width: 0%"></div>
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600">
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600 dark:text-gray-300">
En attente
</div>
</div>
<div v-if="job.maxAttempts > 1 || job.attempts > 0"
class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">
{{ job.attempts }} / {{ job.maxAttempts }} tentative{{ job.maxAttempts > 1 ? 's' : '' }}
</div>
</td>
<td class="py-4 px-4">
<button
@@ -79,24 +101,33 @@
</template>
<script setup>
import { TrashIcon } from '@heroicons/vue/24/outline';
import { defineEmits, defineProps } from 'vue';
import { TrashIcon } from '@heroicons/vue/24/outline';
import { computed, defineEmits, defineProps } from 'vue';
const props = defineProps({
job: {
type: Object,
required: true
}
});
const emit = defineEmits(['delete']);
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString();
const props = defineProps({
job: {
type: Object,
required: true
}
});
function onDelete() {
emit('delete', props.job.id);
}
const emit = defineEmits(['delete']);
const JOB_TYPE_LABELS = {
scraping_job: 'Scraping',
conversion_job: 'Conversion',
};
const jobTypeLabel = computed(() =>
JOB_TYPE_LABELS[props.job.type] ?? props.job.type
);
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString();
}
function onDelete() {
emit('delete', props.job.id);
}
</script>

View File

@@ -1,31 +1,21 @@
<template>
<div>
<div class="overflow-y-auto h-full">
<Toolbar :config="toolbarConfig" class="mb-6" />
<div v-if="activityStore.loading" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
</div>
<div v-else-if="activityStore.error" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<div v-else-if="activityStore.error" class="bg-red-100 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-400 p-4 mb-6">
<p>{{ activityStore.error }}</p>
</div>
<div v-else class="container mx-auto p-2">
<!-- Debug pagination - À supprimer plus tard -->
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" v-if="true">
<strong>Debug Pagination:</strong>
Total: {{ activityStore.total }},
Limit: {{ activityStore.limit }},
Pages: {{ activityStore.totalPages }},
Page courante: {{ activityStore.currentPage }},
Condition: {{ activityStore.total > activityStore.limit }}
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div class="overflow-x-auto">
<table class="min-w-full bg-white">
<table class="min-w-full bg-white dark:bg-gray-800">
<thead>
<tr class="bg-gray-200 text-gray-800">
<tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
<th class="w-1/12 py-3 px-4 text-left">
<input
type="checkbox"
@@ -39,14 +29,14 @@
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
</tr>
</thead>
<tbody class="text-gray-700">
<tbody class="text-gray-700 dark:text-gray-300">
<template v-if="activityStore.jobs.length === 0">
<tr>
<td colspan="6" class="py-8 px-4 text-center text-gray-500">
<div class="flex flex-col items-center">
<ClockIcon class="h-12 w-12 text-gray-300 mb-4" />
<p class="text-lg font-medium">Aucune activité trouvée</p>
<p class="text-sm">Aucune activité ne correspond aux filtres actuels.</p>
<ClockIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mb-4" />
<p class="text-lg font-medium dark:text-gray-300">Aucune activité trouvée</p>
<p class="text-sm dark:text-gray-400">Aucune activité ne correspond aux filtres actuels.</p>
</div>
</td>
</tr>

View File

@@ -20,7 +20,6 @@ export const useConversionStore = defineStore('conversion', {
// État de l'interface
isDragOver: false,
showSuccessMessage: false,
}),
getters: {
@@ -86,7 +85,6 @@ export const useConversionStore = defineStore('conversion', {
this.clearError();
this.conversionSuccess = false;
this.convertedFile = null;
this.showSuccessMessage = false;
// Stockage du fichier
this.currentFile = file;
@@ -125,7 +123,6 @@ export const useConversionStore = defineStore('conversion', {
// Stockage du fichier converti
this.convertedFile = convertedFileBlob;
this.conversionSuccess = true;
this.showSuccessMessage = true;
// Ajout à l'historique
this.addToHistory({
@@ -171,7 +168,6 @@ export const useConversionStore = defineStore('conversion', {
this.currentFile = null;
this.convertedFile = null;
this.conversionSuccess = false;
this.showSuccessMessage = false;
this.conversionProgress = 0;
this.clearError();
},
@@ -183,7 +179,6 @@ export const useConversionStore = defineStore('conversion', {
setError(message) {
this.conversionError = message;
this.conversionSuccess = false;
this.showSuccessMessage = false;
},
/**
@@ -193,13 +188,6 @@ export const useConversionStore = defineStore('conversion', {
this.conversionError = null;
},
/**
* Cache le message de succès
*/
hideSuccessMessage() {
this.showSuccessMessage = false;
},
/**
* Gère l'état du drag and drop
* @param {boolean} isDragOver - Indique si un fichier est survolé

View File

@@ -24,10 +24,10 @@
<!-- Message de statut -->
<div class="flex-1">
<p class="text-sm font-medium text-gray-900">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ statusMessage }}
</p>
<p v-if="fileName" class="text-xs text-gray-500">
<p v-if="fileName" class="text-xs text-gray-500 dark:text-gray-400">
{{ fileName }}
</p>
</div>
@@ -35,11 +35,11 @@
<!-- Barre de progression -->
<div v-if="showProgress" class="space-y-2">
<div class="flex justify-between text-xs text-gray-600">
<div class="flex justify-between text-xs text-gray-600 dark:text-gray-400">
<span>Progression</span>
<span>{{ Math.round(progress) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-blue-500 h-2 rounded-full transition-all duration-300 ease-out"
:style="{ width: `${progress}%` }"
@@ -48,7 +48,7 @@
</div>
<!-- Détails de la conversion -->
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 space-y-1">
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<div v-if="originalSize" class="flex justify-between">
<span>Taille originale:</span>
<span>{{ formatFileSize(originalSize) }}</span>
@@ -77,7 +77,7 @@
<button
v-if="canReset"
@click="$emit('reset')"
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
<ArrowPathIcon class="w-4 h-4" />
<span>Convertir un autre fichier</span>
@@ -85,14 +85,14 @@
</div>
<!-- Message d'erreur détaillé -->
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 border border-red-200 rounded-md">
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div class="flex">
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 flex-shrink-0" />
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">
Erreur de conversion
</h3>
<p class="mt-1 text-sm text-red-700">
<p class="mt-1 text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>

View File

@@ -10,8 +10,8 @@
:class="[
'border-2 border-dashed rounded-lg p-8 text-center transition-all duration-200',
isDragOver
? 'border-green-400 bg-green-50'
: 'border-gray-300 hover:border-gray-400'
? 'border-green-400 bg-green-50 dark:bg-green-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
]"
>
<!-- Zone d'upload -->
@@ -28,13 +28,13 @@
<!-- Message principal -->
<div class="space-y-2">
<h3 class="text-lg font-medium text-gray-900">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ isDragOver ? 'Déposez votre fichier ici' : 'Sélectionnez un fichier CBR ou CBZ' }}
</h3>
<p class="text-sm text-gray-500">
<p class="text-sm text-gray-500 dark:text-gray-400">
Glissez-déposez votre fichier ou cliquez pour le sélectionner
</p>
<p class="text-xs text-gray-400">
<p class="text-xs text-gray-400 dark:text-gray-500">
Fichiers supportés: .cbr, .cbz (max. 150MB)
</p>
</div>
@@ -63,20 +63,20 @@
</div>
<!-- Informations du fichier sélectionné -->
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 rounded-lg">
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center space-x-3">
<DocumentIcon class="w-8 h-8 text-gray-600" />
<DocumentIcon class="w-8 h-8 text-gray-600 dark:text-gray-400" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ selectedFile.name }}
</p>
<p class="text-sm text-gray-500">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ formatFileSize(selectedFile.size) }}
</p>
</div>
<button
@click="clearFile"
class="p-1 text-gray-400 hover:text-gray-600 transition-colors"
class="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title="Supprimer le fichier"
>
<XMarkIcon class="w-5 h-5" />

View File

@@ -1,285 +1,149 @@
<template>
<div class="container mx-auto px-4 py-8 max-w-4xl">
<!-- En-tête -->
<div class="mb-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">
Convertir CBR en CBZ
</h1>
</div>
<p class="text-lg text-gray-600">
Convertissez vos fichiers CBR (Comic Book RAR) en CBZ (Comic Book ZIP) pour une meilleure compatibilité.
</p>
</div>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<!-- Zone principale -->
<div class="bg-white 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>
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<!-- Contenu de la carte -->
<div class="p-6 space-y-6">
<!-- Zone d'upload -->
<FileUploadArea
:selected-file="conversionStore.currentFile"
:disabled="conversionStore.isProcessing"
@file-selected="handleFileSelected"
@file-cleared="handleFileClear"
/>
<!-- Zone d'upload -->
<section 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">Fichier</h2>
<FileUploadArea
:selected-file="conversionStore.currentFile"
:disabled="conversionStore.isProcessing"
@file-selected="handleFileSelected"
@file-cleared="handleFileClear"
/>
</section>
<!-- Bouton de conversion -->
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="flex justify-center">
<button
@click="handleConvert"
:disabled="conversionStore.isProcessing"
:class="[
'flex items-center space-x-2 px-6 py-3 text-white font-medium rounded-lg transition-all duration-200',
conversionStore.isProcessing
? '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'
]"
>
<ArrowPathIcon
:class="[
'w-5 h-5',
conversionStore.isProcessing && 'animate-spin'
]"
/>
<span>
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
</span>
</button>
</div>
<!-- Progression -->
<section v-if="showProgress" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<ConversionProgress
:is-converting="conversionStore.isProcessing"
:progress="conversionStore.conversionProgress"
:is-success="conversionStore.hasSucceeded"
:has-error="conversionStore.hasError"
:error-message="conversionStore.conversionError"
:file-name="conversionStore.currentFileName"
:original-size="conversionStore.currentFile?.size || 0"
:converted-size="conversionStore.convertedFile?.size || 0"
@download="handleDownload"
@reset="handleReset"
/>
</section>
<!-- Progression et résultat -->
<ConversionProgress
v-if="showProgress"
:is-converting="conversionStore.isProcessing"
:progress="conversionStore.conversionProgress"
:is-success="conversionStore.hasSucceeded"
:has-error="conversionStore.hasError"
:error-message="conversionStore.conversionError"
:file-name="conversionStore.currentFileName"
:original-size="conversionStore.currentFile?.size || 0"
:converted-size="conversionStore.convertedFile?.size || 0"
@download="handleDownload"
@reset="handleReset"
/>
<!-- Historique -->
<section v-if="conversionStore.conversionCount > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-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">Historique</h2>
<button
@click="conversionStore.clearHistory()"
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
>
Effacer
</button>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div
v-for="(conversion, index) in conversionStore.conversionHistory"
:key="index"
class="flex items-center justify-between py-3"
>
<div>
<p class="text-sm text-gray-900 dark:text-gray-100">{{ conversion.originalName }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(conversion.timestamp) }}</p>
</div>
<div class="text-right text-sm">
<p class="text-gray-600 dark:text-gray-300">
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
</p>
<p class="text-xs text-green-600">{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}</p>
</div>
</div>
</div>
</section>
<!-- Message d'information -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex">
<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">
À propos de la conversion
</h3>
<div class="mt-2 text-sm text-blue-700 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">
Historique des conversions
</h3>
<button
@click="handleClearHistory"
class="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Effacer l'historique
</button>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="space-y-3">
<div
v-for="(conversion, index) in conversionStore.conversionHistory"
:key="index"
class="flex items-center justify-between py-2 border-b border-gray-200 last:border-b-0"
>
<div class="flex-1">
<p class="text-sm font-medium text-gray-900">
{{ conversion.originalName }}
</p>
<p class="text-xs text-gray-500">
{{ formatDate(conversion.timestamp) }}
</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600">
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
</p>
<p class="text-xs text-green-600">
{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 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>
</template>
<script>
import {
ArchiveBoxIcon,
ArrowPathIcon,
CheckCircleIcon,
InformationCircleIcon,
XMarkIcon,
} from '@heroicons/vue/24/outline';
<script setup>
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
import { computed, onMounted } from 'vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useConversionStore } from '../../application/store/conversionStore';
import { useNotifications } from '../../../../shared/composables/useNotifications';
import ConversionProgress from '../components/ConversionProgress.vue';
import FileUploadArea from '../components/FileUploadArea.vue';
export default {
name: 'ConversionPage',
const conversionStore = useConversionStore();
const { showSuccess, showError } = useNotifications();
components: {
FileUploadArea,
ConversionProgress,
ArrowPathIcon,
ArchiveBoxIcon,
InformationCircleIcon,
CheckCircleIcon,
XMarkIcon,
},
const showProgress = computed(() =>
conversionStore.hasSelectedFile &&
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
);
setup() {
const conversionStore = useConversionStore();
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Conversion CBR CBZ', class: 'text-sm font-medium' },
],
rightSection: [
...(conversionStore.hasSelectedFile && !conversionStore.hasSucceeded ? [{
type: 'button',
icon: ArrowPathIcon,
label: conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ',
onClick: handleConvert,
disabled: conversionStore.isProcessing,
}] : []),
],
}));
// Computed properties
const showProgress = computed(() => {
return conversionStore.hasSelectedFile &&
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError);
});
const handleFileSelected = (file) => {
conversionStore.selectFile(file);
};
// Event handlers
const handleFileSelected = (file) => {
const success = conversionStore.selectFile(file);
if (!success) {
// L'erreur est déjà gérée par le store
console.warn('Fichier non valide:', file);
}
};
const handleFileClear = () => {
conversionStore.resetConversion();
};
const handleFileClear = () => {
conversionStore.resetConversion();
};
const handleConvert = async () => {
if (!conversionStore.currentFile) return;
const success = await conversionStore.convertCurrentFile();
if (success) {
showSuccess('Conversion réussie !');
} else {
showError(conversionStore.conversionError ?? 'Échec de la conversion');
}
};
const handleConvert = async () => {
if (!conversionStore.currentFile) return;
const handleDownload = () => conversionStore.downloadConvertedFile();
const handleReset = () => conversionStore.resetConversion();
const success = await conversionStore.convertCurrentFile();
if (success) {
console.log('Conversion réussie');
} else {
console.error('Échec de la conversion');
}
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 octets';
const k = 1024;
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const handleDownload = () => {
conversionStore.downloadConvertedFile();
};
const handleReset = () => {
conversionStore.resetConversion();
};
const handleClearHistory = () => {
conversionStore.clearHistory();
};
// Utility functions
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 octets';
const k = 1024;
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const formatDate = (isoString) => {
const date = new Date(isoString);
return new Intl.DateTimeFormat('fr-FR', {
const formatDate = (isoString) =>
new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
}).format(new Date(isoString));
const calculateSaving = (originalSize, convertedSize) => {
if (!originalSize || !convertedSize) return '';
const saving = ((originalSize - convertedSize) / originalSize) * 100;
if (saving > 0) {
return `-${saving.toFixed(1)}%`;
} else if (saving < 0) {
return `+${Math.abs(saving).toFixed(1)}%`;
}
return '0%';
};
// Lifecycle
onMounted(() => {
// Réinitialiser l'état au montage de la page
conversionStore.resetConversion();
});
return {
conversionStore,
showProgress,
handleFileSelected,
handleFileClear,
handleConvert,
handleDownload,
handleReset,
handleClearHistory,
formatFileSize,
formatDate,
calculateSaving,
};
},
const calculateSaving = (originalSize, convertedSize) => {
if (!originalSize || !convertedSize) return '';
const saving = ((originalSize - convertedSize) / originalSize) * 100;
if (saving > 0) return `-${saving.toFixed(1)}%`;
if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`;
return '0%';
};
</script>
<style scoped>
/* Styles spécifiques si nécessaires */
</style>
onMounted(() => conversionStore.resetConversion());
</script>

View File

@@ -1,228 +1,150 @@
<template>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-start space-x-4">
<!-- File Icon and Info -->
<div class="flex-shrink-0">
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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 class="py-3">
<!-- File Details -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900 truncate">
{{ file.filename }}
</h3>
<!-- Status Badge -->
<div class="flex-shrink-0 ml-4">
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
</div>
</div>
<p class="text-sm text-gray-500 mt-1">
{{ file.getFormattedSize() }} {{ file.getFileExtension().toUpperCase() }}
</p>
<!-- Extracted Info -->
<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 text-blue-700">
Chapitre {{ file.getExtractedChapterNumber() }}
</span>
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 text-purple-700">
Volume {{ file.getExtractedVolumeNumber() }}
</span>
</div>
<!-- Error Display -->
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 border border-red-200 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">Erreur</h3>
<div class="mt-2 text-sm text-red-700">{{ file.errorMessage }}</div>
<!-- Row principal : icône, nom, statut, actions -->
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 flex items-center justify-center shrink-0">
<DocumentIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ file.getFormattedSize() }} · {{ file.getFileExtension().toUpperCase() }}
<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>
<div class="flex items-center gap-2 shrink-0">
<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>
<!-- 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 mb-3">
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
</label>
<!-- Message d'erreur -->
<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>
<!-- Matches Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<MangaMatchCard
v-for="match in sortedMatches"
:key="match.id"
:match="match"
:is-selected="file.selectedManga?.id === match.id"
@select-match="handleMangaSelection"
/>
<!-- 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>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
<MangaMatchCard
v-for="match in sortedMatches"
:key="match.id"
:match="match"
:is-selected="file.selectedManga?.id === match.id"
@select-match="handleMangaSelection"
/>
</div>
</div>
</div>
<!-- Selected Manga Preview -->
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
<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">{{ file.selectedManga.title }}</p>
<p class="text-sm text-gray-500">{{ file.selectedManga.slug }}</p>
<p class="text-xs text-blue-600 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 -->
<!-- Numéros de chapitre / volume -->
<div v-if="file.selectedManga" class="mt-3 grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Numéro de chapitre
</label>
<input
type="number"
step="0.5"
:value="file.selectedChapterNumber ?? ''"
@input="handleChapterNumberInput"
:disabled="file.selectedVolumeNumber !== null"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
placeholder="Ex: 1, 1.5, 2..."
/>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Chapitre</label>
<input
type="number"
step="0.5"
:value="file.selectedChapterNumber ?? ''"
@input="handleChapterNumberInput"
:disabled="file.selectedVolumeNumber !== null"
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..."
/>
</div>
<!-- Volume Number -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Numéro de volume
</label>
<input
type="number"
step="0.5"
:value="file.selectedVolumeNumber ?? ''"
@input="handleVolumeNumberInput"
:disabled="file.selectedChapterNumber !== null"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
placeholder="Ex: 1, 1.5, 2..."
/>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Volume</label>
<input
type="number"
step="0.5"
:value="file.selectedVolumeNumber ?? ''"
@input="handleVolumeNumberInput"
:disabled="file.selectedChapterNumber !== null"
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..."
/>
</div>
</div>
</div>
<!-- No Matches Message -->
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 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">Aucun manga trouvé</h3>
<div class="mt-2 text-sm text-yellow-700">
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">
<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>
</template>
<script setup>
import { ArrowUpTrayIcon, DocumentIcon, ExclamationCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { computed } from 'vue';
import MangaMatchCard from './MangaMatchCard.vue';
import StatusBadge from './StatusBadge.vue';
const props = defineProps({
file: {
type: Object,
required: true
},
isAnalyzing: {
type: Boolean,
default: false
},
isImporting: {
type: Boolean,
default: false
}
file: { type: Object, required: true },
isAnalyzing: { type: Boolean, default: false },
isImporting: { type: Boolean, default: false },
});
const emit = defineEmits([
'manga-selected',
'chapter-number-selected',
'volume-number-selected',
'import-file',
'retry-file',
'remove-file'
'manga-selected',
'chapter-number-selected',
'volume-number-selected',
'import-file',
'retry-file',
'remove-file',
]);
// Computed property to get sorted matches
const sortedMatches = computed(() => {
const matches = props.file.getMatches();
return matches.sort((a, b) => b.matchScore - a.matchScore);
});
const sortedMatches = computed(() =>
[...props.file.getMatches()].sort((a, b) => b.matchScore - a.matchScore)
);
const handleMangaSelection = (selectedManga) => {
emit('manga-selected', selectedManga);
};
const handleMangaSelection = (manga) => emit('manga-selected', manga);
const handleChapterNumberInput = (event) => {
const value = event.target.value;
const chapterNumber = value ? parseFloat(value) : null;
emit('chapter-number-selected', chapterNumber);
const value = event.target.value;
emit('chapter-number-selected', value ? parseFloat(value) : null);
};
const handleVolumeNumberInput = (event) => {
const value = event.target.value;
const volumeNumber = value ? parseFloat(value) : null;
emit('volume-number-selected', volumeNumber);
const value = event.target.value;
emit('volume-number-selected', value ? parseFloat(value) : null);
};
</script>

View File

@@ -1,96 +1,94 @@
<template>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="text-center mb-6">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
<div>
<!-- En-tête -->
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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-sm font-medium text-gray-900 dark:text-gray-100">Import terminé</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Voici le résumé de votre session d'import</p>
</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>
<h3 class="text-lg font-medium text-gray-900 mb-2">Import terminé</h3>
<p class="text-sm text-gray-500">
Voici le résumé de votre session d'import
</p>
</div>
</section>
<!-- Statistics -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
<div class="text-sm text-gray-500">Importés</div>
</div>
<div class="text-center">
<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 mb-3">
Fichiers importés avec succès ({{ importedFiles.length }})
</h4>
<ul class="space-y-2">
<li
<!-- Fichiers importés -->
<section v-if="importedFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
Importés ({{ importedFiles.length }})
</h2>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div
v-for="file in importedFiles"
: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">
<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" />
</svg>
<span class="text-gray-900">{{ file.filename }}</span>
<span v-if="file.selectedManga" class="ml-2 text-gray-500">
→ {{ file.selectedManga.title }}
</span>
</li>
</ul>
</div>
<CheckCircleIcon class="flex-shrink-0 h-4 w-4 text-green-400" />
<span class="text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</span>
<span v-if="file.selectedManga" class="text-gray-400 dark:text-gray-500 shrink-0">→ {{ file.selectedManga.title }}</span>
</div>
</div>
</section>
<!-- Error Files List -->
<div v-if="errorFiles.length > 0" class="mb-6">
<h4 class="text-sm font-medium text-gray-900 mb-3">
Fichiers en erreur ({{ errorFiles.length }})
</h4>
<ul class="space-y-2">
<li
<!-- Fichiers en erreur -->
<section v-if="errorFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
Erreurs ({{ errorFiles.length }})
</h2>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div
v-for="file in errorFiles"
: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">
<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>
<XCircleIcon class="flex-shrink-0 h-4 w-4 text-red-400 mt-0.5" />
<div>
<div class="text-gray-900">{{ file.filename }}</div>
<div class="text-red-600 text-xs mt-1">{{ file.errorMessage }}</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-0.5">{{ file.errorMessage }}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Actions -->
<div class="flex justify-center space-x-4 pt-6 border-t">
<button
@click="startNewImport"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
>
Nouvel import
</button>
<button
@click="goToLibrary"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
>
Aller à la bibliothèque
</button>
</div>
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="flex gap-3">
<button
@click="startNewImport"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 text-sm font-medium"
>
Nouvel import
</button>
<button
@click="goToLibrary"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 text-sm font-medium"
>
Aller à la bibliothèque
</button>
</div>
</section>
</div>
</template>
<script setup>
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/solid';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useNewImportStore } from '../../application/store/newImportStore';

View File

@@ -1,116 +1,47 @@
<template>
<div
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md"
:class="{
'border-blue-500 bg-blue-50': isSelected,
'border-gray-200 hover:border-gray-300': !isSelected
}"
@click="$emit('select-match', match)"
>
<!-- Match Header with Score -->
<div class="flex items-center justify-between mb-3">
<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">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
v-if="match.thumbnailUrl"
:src="match.thumbnailUrl"
:alt="match.title"
class="w-16 h-20 object-cover rounded border"
/>
<div
v-else
class="w-16 h-20 bg-gray-200 rounded border flex items-center justify-center"
>
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<!-- Manga Info -->
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate" :title="match.title">
{{ match.title }}
</h4>
<p class="text-xs text-gray-500 mt-1 truncate" :title="match.slug">
{{ match.slug }}
</p>
<!-- Alternative Slugs -->
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
<p class="text-xs text-gray-400">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 text-gray-600 px-2 py-1 rounded"
<div
class="border p-2.5 cursor-pointer transition-all duration-150"
:class="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-600 bg-white dark:bg-gray-800'"
@click="$emit('select-match', match)"
>
<div class="flex gap-2.5">
<!-- Couverture -->
<img
v-if="match.thumbnailUrl"
:src="match.thumbnailUrl"
:alt="match.title"
class="w-12 h-16 object-cover shrink-0"
/>
<div
v-else
class="w-12 h-16 bg-gray-100 dark:bg-gray-700 shrink-0 flex items-center justify-center"
>
{{ altSlug }}
</span>
<span
v-if="match.alternativeSlugs.length > 2"
class="text-xs text-gray-400"
>
+{{ match.alternativeSlugs.length - 2 }} autres
</span>
</div>
</div>
</div>
</div>
<PhotoIcon class="w-6 h-6 text-gray-400" />
</div>
<!-- Score Bar -->
<div class="mt-3">
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Correspondance</span>
<span>{{ match.matchScore }}%</span>
</div>
<div class="w-full bg-gray-200 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>
<!-- Infos -->
<div class="flex-1 min-w-0 flex flex-col justify-between py-0.5">
<p class="text-xs font-medium text-gray-900 dark:text-gray-100 line-clamp-3 leading-snug" :title="match.title">
{{ match.title }}
</p>
<div class="flex items-center justify-between mt-1">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ match.matchScore }}%</span>
<CheckCircleIcon v-if="isSelected" class="w-4 h-4 text-green-500 shrink-0" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { CheckCircleIcon, PhotoIcon } from '@heroicons/vue/24/outline';
const props = defineProps({
match: {
type: Object,
required: true
},
isSelected: {
type: Boolean,
default: false
}
match: { type: Object, required: true },
isSelected: { type: Boolean, default: false },
});
const emit = defineEmits(['select-match']);
</script>

View File

@@ -46,25 +46,25 @@ const badgeText = 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) {
return `${baseClasses} bg-blue-100 text-blue-800`;
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
}
switch (props.status) {
case 'pending':
return `${baseClasses} bg-gray-100 text-gray-800`;
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
case 'analyzed':
return `${baseClasses} bg-yellow-100 text-yellow-800`;
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
case 'importing':
return `${baseClasses} bg-blue-100 text-blue-800`;
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
case 'imported':
return `${baseClasses} bg-green-100 text-green-800`;
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
case 'error':
return `${baseClasses} bg-red-100 text-red-800`;
return `${baseClasses} bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
}
});
</script>

View File

@@ -1,115 +1,103 @@
<template>
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Import de Bibliothèque</h1>
<p class="text-gray-600">
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
</p>
</div>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<!-- Zone de dépôt -->
<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
label="Importer des fichiers CBZ/CBR"
accept=".cbz,.cbr"
:multiple="true"
description="Formats CBZ ou CBR uniquement"
@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 class="divide-y divide-gray-100 dark:divide-gray-700/50">
<FileImportCard
v-for="file in store.files"
:key="file.id"
:file="file"
:is-analyzing="store.analyzingFiles.has(file.id)"
:is-importing="store.importingFiles.has(file.id)"
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
@chapter-number-selected="(n) => store.setFileChapterNumber(file.id, n)"
@volume-number-selected="(n) => store.setFileVolumeNumber(file.id, n)"
@import-file="() => importSingleFile(file.id)"
@retry-file="() => retryFile(file.id)"
@remove-file="() => store.removeFile(file.id)"
/>
</div>
</section>
</template>
<!-- Résultats -->
<ImportResults v-if="store.allFilesProcessed" />
<!-- Progress Bar (if files are being processed) -->
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8">
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">Progression</span>
<span class="text-sm text-gray-500">{{ store.progressPercentage }}%</span>
</div>
<div class="w-full bg-gray-200 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 mt-2">
<span>{{ store.importedCount }} importés</span>
<span>{{ store.errorCount }} erreurs</span>
<span>{{ store.totalFiles }} total</span>
</div>
</div>
</div>
<!-- File Upload Zone -->
<div v-if="!store.hasFiles || store.allFilesProcessed" class="mb-8">
<FileUpload
label="Importer des fichiers CBZ/CBR"
accept=".cbz,.cbr"
:multiple="true"
description="Formats CBZ ou CBR uniquement"
@files-selected="handleFilesSelected"
/>
</div>
<!-- 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
v-for="file in store.files"
:key="file.id"
:file="file"
:is-analyzing="store.analyzingFiles.has(file.id)"
:is-importing="store.importingFiles.has(file.id)"
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
@chapter-number-selected="(chapterNumber) => store.setFileChapterNumber(file.id, chapterNumber)"
@volume-number-selected="(volumeNumber) => store.setFileVolumeNumber(file.id, volumeNumber)"
@import-file="() => importSingleFile(file.id)"
@retry-file="() => retryFile(file.id)"
@remove-file="() => store.removeFile(file.id)"
/>
</div>
</div>
<!-- Results Summary (when all files are processed) -->
<div v-if="store.allFilesProcessed" class="mt-8">
<ImportResults />
</div>
</div>
</template>
<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 LoadingSpinner from '../../../../shared/components/ui/LoadingSpinner.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useNewImportStore } from '../../application/store/newImportStore';
import FileImportCard from '../components/FileImportCard.vue';
import ImportResults from '../components/ImportResults.vue';
const store = useNewImportStore();
// === EVENT HANDLERS ===
const handleFilesSelected = (files) => {
store.addFiles(files);
};
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Import de bibliothèque', class: 'text-sm font-medium' },
],
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 () => {
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(() => {
store.resetGlobalState();
});

View File

@@ -40,7 +40,12 @@ export const useMangaStore = defineStore('manga', {
// --- Add Manga State ---
addingManga: false,
addMangaError: null
addMangaError: null,
// --- Discover State ---
discoverResults: [],
loadingDiscover: false,
discoverError: null
}),
getters: {
@@ -170,6 +175,25 @@ export const useMangaStore = defineStore('manga', {
this.loadingSearch = false;
},
// --- Discover Actions ---
async loadDiscoverRecommendations() {
if (this.loadingDiscover) return;
this.loadingDiscover = true;
this.discoverError = null;
this.discoverResults = [];
try {
const data = await mangaRepository.discoverManga();
this.discoverResults = data.items || [];
} catch (error) {
this.discoverError = error.message;
throw error;
} finally {
this.loadingDiscover = false;
}
},
// --- Add Manga Actions ---
async createFromMangaDex(externalId) {
if (this.addingManga) return;

View File

@@ -11,7 +11,10 @@ export class Manga {
status = null,
rating = null,
genres = [],
createdAt = new Date().toISOString()
createdAt = new Date().toISOString(),
monitored = false,
chaptersTotal = 0,
chaptersScraped = 0,
}) {
this.id = id;
this.slug = slug;
@@ -25,6 +28,9 @@ export class Manga {
this.rating = rating;
this.genres = genres;
this.createdAt = createdAt;
this.monitored = monitored;
this.chaptersTotal = chaptersTotal;
this.chaptersScraped = chaptersScraped;
}
static create(data) {

View File

@@ -104,6 +104,17 @@ export class ApiMangaRepository {
}
}
async discoverManga() {
try {
const response = await fetch('/api/manga-discover');
if (!response.ok) throw new Error('Failed to fetch discover recommendations');
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async createFromMangaDex(externalId) {
try {
const response = await fetch('/api/mangas/create-from-mangadex', {

View File

@@ -5,32 +5,32 @@
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="handleClose"></div>
<!-- Modal avec style Material Design -->
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100">
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100 dark:border-gray-700">
<!-- Header Material Design -->
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<FolderIcon class="h-5 w-5 text-green-600" />
</div>
<div>
<h3 class="text-xl font-medium text-gray-900 leading-6">
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100 leading-6">
Gérer les chapitres
</h3>
<p class="text-sm text-gray-600 mt-1">{{ manga?.title }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ manga?.title }}</p>
</div>
</div>
<button
@click="handleClose"
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center transition-colors duration-200"
>
<XMarkIcon class="h-5 w-5 text-gray-600" />
<XMarkIcon class="h-5 w-5 text-gray-600 dark:text-gray-300" />
</button>
</div>
</div>
<!-- Content avec style Material Design -->
<div class="bg-white px-6 py-6 sm:px-8 sm:py-8">
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-8">
<div v-if="isLoading" class="flex justify-center items-center h-32">
<div class="relative">
<div class="w-8 h-8 border-4 border-green-200 rounded-full"></div>
@@ -38,7 +38,7 @@
</div>
</div>
<div v-else-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
<div v-else-if="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
<div class="w-5 h-5 bg-red-100 rounded-full flex items-center justify-center">
<XMarkIcon class="h-3 w-3 text-red-600" />
</div>
@@ -47,7 +47,7 @@
<div v-else class="space-y-6">
<!-- Actions avec style Material Design -->
<div class="flex items-center justify-between bg-gray-50 rounded-xl p-4">
<div class="flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4">
<div class="flex items-center space-x-3">
<button
@click="showCreateVolumeModal = true"
@@ -58,7 +58,7 @@
</button>
<button
@click="showUnassignedChapters = !showUnassignedChapters"
class="text-gray-600 hover:text-gray-800 text-sm font-medium hover:bg-gray-100 px-3 py-2 rounded-lg transition-colors duration-200"
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700 px-3 py-2 rounded-lg transition-colors duration-200"
>
{{ showUnassignedChapters ? 'Masquer' : 'Afficher' }} les chapitres non assignés
</button>
@@ -88,17 +88,17 @@
</button>
</div>
</div>
<div class="text-sm text-gray-500 bg-white px-3 py-1.5 rounded-lg border border-gray-200">
<div class="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-700 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-600">
{{ totalChapters }} chapitres, {{ volumes.length }} volumes
</div>
</div>
<!-- Arborescence avec style Material Design -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden shadow-sm">
<!-- Chapitres non assignés -->
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700/50 dark:to-gray-700/30 border-b border-gray-200 dark:border-gray-600">
<div class="px-6 py-4">
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center space-x-2">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center space-x-2">
<DocumentIcon class="h-4 w-4 text-gray-500" />
<span>Chapitres non assignés ({{ unassignedChapters.length }})</span>
</h4>
@@ -119,11 +119,11 @@
/>
</div>
<DocumentIcon class="h-5 w-5 text-gray-400" />
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
<div class="flex-1">
<div v-if="!chapter.isEditing" class="flex items-center">
<span
class="text-sm text-gray-900 cursor-pointer hover:text-green-600 transition-colors duration-200"
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
@click="startEditingTitle(chapter)"
>
{{ chapter.title || 'Sans titre' }}
@@ -173,22 +173,22 @@
</div>
<!-- Volumes avec style Material Design -->
<div class="divide-y divide-gray-100">
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<div
v-for="volume in volumes"
:key="volume.number"
class="bg-white"
class="bg-white dark:bg-gray-800"
>
<!-- En-tête du volume Material Design -->
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 border-b border-green-100">
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-b border-green-100 dark:border-green-900/30">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<FolderIcon class="h-4 w-4 text-green-600" />
</div>
<div>
<span class="text-sm font-semibold text-green-900">Volume {{ volume.number }}</span>
<span class="text-xs text-green-600 ml-2">({{ volume.chapters.length }} chapitres)</span>
<span class="text-sm font-semibold text-green-900 dark:text-green-300">Volume {{ volume.number }}</span>
<span class="text-xs text-green-600 dark:text-green-400 ml-2">({{ volume.chapters.length }} chapitres)</span>
</div>
</div>
<div class="flex items-center space-x-2">
@@ -211,10 +211,10 @@
<!-- Chapitres du volume -->
<div v-if="volume.isExpanded" class="px-6 py-4">
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500">
<DocumentIcon class="h-12 w-12 text-gray-300 mx-auto mb-3" />
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<DocumentIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
<p class="text-sm">Aucun chapitre assigné à ce volume.</p>
<p class="text-xs text-gray-400 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
</div>
<div v-else class="space-y-2">
<div
@@ -233,11 +233,11 @@
/>
</div>
<DocumentIcon class="h-5 w-5 text-gray-400" />
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
<div class="flex-1">
<div v-if="!chapter.isEditing" class="flex items-center">
<span
class="text-sm text-gray-900 cursor-pointer hover:text-green-600 transition-colors duration-200"
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
@click="startEditingTitle(chapter)"
>
{{ chapter.title || 'Sans titre' }}
@@ -291,12 +291,12 @@
</div>
<!-- Footer Material Design -->
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
<button
@click="handleClose"
:disabled="isSaving"
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Annuler
</button>
@@ -320,24 +320,24 @@
<div v-if="showCreateVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showCreateVolumeModal = false"></div>
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<PlusIcon class="h-5 w-5 text-green-600" />
</div>
<h3 class="text-lg font-medium text-gray-900">Créer un nouveau volume</h3>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Créer un nouveau volume</h3>
</div>
</div>
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Numéro du volume</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Numéro du volume</label>
<input
v-model="newVolumeNumber"
type="number"
min="1"
class="block w-full border border-gray-300 rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
class="block w-full border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-3 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
placeholder="Ex: 1"
/>
</div>
@@ -351,7 +351,7 @@
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
<button
@click="showCreateVolumeModal = false"
@@ -376,8 +376,8 @@
<div v-if="showAssignModal" class="fixed inset-0 z-60 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showAssignModal = false"></div>
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<DocumentIcon class="h-5 w-5 text-green-600" />
@@ -385,7 +385,7 @@
<h3 class="text-lg font-medium text-gray-900">Assigner le chapitre {{ selectedChapter?.number }}</h3>
</div>
</div>
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Volume</label>
@@ -401,7 +401,7 @@
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
<button
@click="showAssignModal = false"
@@ -426,8 +426,8 @@
<div v-if="showMoveToVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showMoveToVolumeModal = false"></div>
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<ArrowPathIcon class="h-5 w-5 text-green-600" />
@@ -435,7 +435,7 @@
<h3 class="text-lg font-medium text-gray-900">Déplacer {{ selectedChapters.length }} chapitre(s)</h3>
</div>
</div>
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
<div class="space-y-4">
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
<p class="text-sm text-green-800 font-medium">
@@ -457,7 +457,7 @@
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
<button
@click="showMoveToVolumeModal = false"
@@ -491,7 +491,7 @@
<h3 class="text-lg font-medium text-gray-900">Séparer le volume 00</h3>
</div>
</div>
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
<div class="space-y-4">
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
<p class="text-sm text-green-800 font-medium">
@@ -517,7 +517,7 @@
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
<button
@click="showSplitVolumeZeroModal = false"

View File

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

View File

@@ -1,20 +1,30 @@
<template>
<tr class="border-t hover:bg-green-100">
<td class="px-4 py-2" :class="{ 'text-green-500': chapter.isAvailable }">
{{ String(chapter.number).padStart(2, '0') }}
<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 }">
<template v-if="chapter.isVolumeGroup">Vol. {{ chapter.volume }}</template>
<template v-else>{{ String(chapter.number).padStart(2, '0') }}</template>
</td>
<td class="px-4 py-2 w-full text-left">
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
<router-link
v-if="chapter.isAvailable"
class="hover:text-green-500 dark:hover:text-green-400"
:to="{
name: 'reader',
params: {
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>
<span v-else>{{ 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 class="px-4 py-2 flex justify-end gap-2">
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">

View File

@@ -1,8 +1,8 @@
<template>
<div class="p-2 border-t">
<div class="p-2 border-t dark:border-gray-700">
<table class="min-w-full table-auto">
<thead>
<tr>
<tr class="text-gray-700 dark:text-gray-300">
<th class="px-4 py-2 text-left">#</th>
<th class="px-4 py-2 text-left">Titre</th>
<th class="px-4 py-2 text-right">Actions</th>

View File

@@ -10,7 +10,7 @@
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
</TransitionChild>
<div class="fixed inset-0 z-10 overflow-y-auto">
@@ -24,15 +24,15 @@
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div class="mb-6">
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
Supprimer le manga
</DialogTitle>
</div>
<!-- Error state -->
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
{{ error.message || 'Une erreur est survenue lors de la suppression.' }}
</div>
@@ -40,19 +40,19 @@
<div class="mb-6">
<div class="flex items-center mb-4">
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
<span class="text-sm font-medium text-gray-900">Action irréversible</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Action irréversible</span>
</div>
<p class="text-sm text-gray-600 mb-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Êtes-vous sûr de vouloir supprimer le manga <strong>"{{ manga?.title }}"</strong> ?
</p>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-md p-4">
<div class="flex">
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">
Attention
</h3>
<div class="mt-2 text-sm text-yellow-700">
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
<p>Cette action supprimera définitivement :</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>Le manga et toutes ses métadonnées</li>
@@ -69,7 +69,7 @@
<div class="mt-6 flex justify-end space-x-3">
<button
type="button"
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="closeModal"
:disabled="isLoading"
>

View File

@@ -10,7 +10,7 @@
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
</TransitionChild>
<div class="fixed inset-0 z-10 overflow-y-auto">
@@ -24,15 +24,15 @@
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
<div class="mb-6">
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
Edit Manga
</DialogTitle>
</div>
<!-- Error state -->
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
{{ error.message || 'Une erreur est survenue lors de la sauvegarde.' }}
</div>
@@ -41,49 +41,49 @@
<!-- Titre et Slug -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Titre</label>
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre</label>
<input
id="title"
v-model="formData.title"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Titre du manga"
/>
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700 mb-2">Slug</label>
<label for="slug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slug</label>
<input
id="slug"
:value="manga?.slug || ''"
type="text"
disabled
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm text-gray-500"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-600 shadow-sm sm:text-sm text-gray-500 dark:text-gray-400"
/>
</div>
</div>
<!-- Année de publication -->
<div>
<label for="publicationYear" class="block text-sm font-medium text-gray-700 mb-2">Année de publication</label>
<label for="publicationYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Année de publication</label>
<input
id="publicationYear"
v-model.number="formData.publicationYear"
type="number"
min="1900"
:max="new Date().getFullYear()"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="2023"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">Description</label>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<textarea
id="description"
v-model="formData.description"
rows="4"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Description du manga"
/>
</div>
@@ -91,22 +91,22 @@
<!-- Auteur et Statut -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="author" class="block text-sm font-medium text-gray-700 mb-2">Auteur</label>
<label for="author" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Auteur</label>
<input
id="author"
v-model="formData.author"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Auteur du manga"
/>
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-2">Statut</label>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Statut</label>
<input
id="status"
v-model="formData.status"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="ongoing"
/>
</div>
@@ -114,7 +114,7 @@
<!-- Note -->
<div>
<label for="rating" class="block text-sm font-medium text-gray-700 mb-2">Note</label>
<label for="rating" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Note</label>
<input
id="rating"
v-model.number="formData.rating"
@@ -122,20 +122,20 @@
min="0"
max="10"
step="0.001"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="9.541"
/>
</div>
<!-- Slugs alternatifs -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Slugs alternatifs</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slugs alternatifs</label>
<div class="space-y-2">
<div v-if="formData.alternativeSlugs.length > 0" class="flex flex-wrap gap-2">
<span
v-for="(slug, index) in formData.alternativeSlugs"
:key="index"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300"
>
{{ slug }}
<button
@@ -158,7 +158,7 @@
<input
v-model="newAlternativeSlug"
type="text"
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Nouveau slug alternatif"
@keyup.enter="addAlternativeSlug"
/>
@@ -175,19 +175,19 @@
<!-- Genres -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Genres</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Genres</label>
<div class="space-y-3">
<div v-if="formData.genres.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-2">
<span
v-for="(genre, index) in formData.genres"
:key="index"
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 text-gray-800"
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
>
{{ genre }}
<button
type="button"
@click="removeGenre(index)"
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 hover:text-gray-600"
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
>
<XMarkIcon class="w-3 h-3" />
</button>
@@ -204,7 +204,7 @@
<input
v-model="newGenre"
type="text"
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Nouveau genre"
@keyup.enter="addGenre"
/>
@@ -224,7 +224,7 @@
<div class="mt-8 flex justify-end space-x-3">
<button
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600"
@click="closeModal"
:disabled="isSaving"
>

View File

@@ -1,16 +1,96 @@
<template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6">
<MangaCard v-for="manga in mangas" :key="manga.id" :manga="manga" />
<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"
@edit="openEdit"
@sources="openSources"
@refresh="doRefresh" />
</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>
<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({
mangas: {
type: Array,
required: true
}
});
defineProps({
mangas: {
type: Array,
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>

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" />
<!-- 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

@@ -10,7 +10,7 @@
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
</TransitionChild>
<div class="fixed inset-0 z-10 overflow-y-auto">
@@ -24,17 +24,17 @@
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
<Cog6ToothIcon class="h-6 w-6 text-blue-600" aria-hidden="true" />
</div>
<div class="mt-3 text-center sm:mt-5">
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900">
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">
Sources préférées
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-gray-500">
<p class="text-sm text-gray-500 dark:text-gray-400">
Configurez l'ordre de priorité des sources pour ce manga. Glissez-déposez les sources pour les réorganiser.
</p>
</div>
@@ -47,13 +47,13 @@
</div>
<!-- Error state -->
<div v-else-if="error" class="mt-5 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<div v-else-if="error" class="mt-5 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
{{ error.message || 'Une erreur est survenue lors du chargement des sources.' }}
</div>
<!-- Sources list -->
<div v-else class="mt-5">
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500">
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
Aucune source disponible
</div>
<div v-else class="space-y-3">
@@ -63,10 +63,10 @@
:class="[
'group relative flex items-center p-4 rounded-lg border-2 transition-all duration-200 cursor-grab active:cursor-grabbing select-none',
{
'bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-300 shadow-md': index === 0,
'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300': index === 1,
'bg-gradient-to-r from-yellow-50 to-amber-50 border-yellow-300': index === 2,
'bg-gray-50 border-gray-200': index > 2,
'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-300 dark:border-blue-700 shadow-md': index === 0,
'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-green-300 dark:border-green-700': index === 1,
'bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border-yellow-300 dark:border-yellow-700': index === 2,
'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600': index > 2,
'scale-105 shadow-lg border-blue-400': draggedIndex === index,
'opacity-50': dragOverIndex === index && draggedIndex !== index,
'scale-95 active:scale-95': isPressed === index
@@ -102,10 +102,10 @@
<div :class="[
'flex items-center space-x-1 px-3 py-1 rounded-full text-xs font-semibold',
{
'bg-blue-100 text-blue-800': index === 0,
'bg-green-100 text-green-800': index === 1,
'bg-yellow-100 text-yellow-800': index === 2,
'bg-gray-100 text-gray-600': index > 2
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': index === 0,
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': index === 1,
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': index === 2,
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300': index > 2
}
]">
<span v-if="index === 0">🥇 Priorité haute</span>
@@ -117,14 +117,14 @@
<!-- Informations de la source -->
<div class="flex-1 min-w-0">
<div class="font-semibold text-gray-900 truncate">{{ source.name }}</div>
<div class="text-sm text-gray-600 truncate">
<div class="font-semibold text-gray-900 dark:text-gray-100 truncate">{{ source.name }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400 truncate">
<a :href="source.baseUrl" target="_blank" class="hover:text-blue-600 hover:underline">{{ source.baseUrl }}</a>
</div>
</div>
<!-- Indicateur de drag -->
<div class="ml-4 text-gray-400 group-hover:text-gray-600 transition-colors duration-200">
<div class="ml-4 text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors duration-200">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9h8M8 15h8" />
</svg>
@@ -148,7 +148,7 @@
</button>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:col-start-1 sm:mt-0"
class="mt-3 inline-flex w-full justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:col-start-1 sm:mt-0"
@click="closeModal"
:disabled="isSaving"
>

View File

@@ -0,0 +1,208 @@
<template>
<div>
<div class="border-t border-gray-200 dark:border-gray-700">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="w-10 px-4 py-3"></th>
<th class="py-3 pr-4 text-left font-medium">Titre</th>
<th class="py-3 pr-4 text-left font-medium w-44">Source préférée</th>
<th class="py-3 pr-4 text-left font-medium w-44">Chapitres</th>
<th class="py-3 px-4 text-right font-medium w-28">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<tr
v-for="manga in mangas"
:key="manga.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
<!-- Monitoring -->
<td class="px-4 py-3 text-center">
<button
:title="manga.monitored ? 'Monitoring actif — cliquer pour désactiver' : 'Monitoring inactif — cliquer pour activer'"
:class="manga.monitored
? 'text-green-500 hover:text-green-600'
: 'text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500'"
class="transition-colors"
@click="doToggleMonitoring(manga)">
<component
:is="manga.monitored ? BookmarkIcon : BookmarkSlashIcon"
class="w-4 h-4" />
</button>
</td>
<!-- Titre -->
<td class="py-3 pr-4">
<RouterLink
:to="{ name: 'manga-details', params: { id: manga.id } }"
class="font-medium text-gray-900 dark:text-gray-100 hover:text-green-500 dark:hover:text-green-400 transition-colors">
{{ manga.title }}
</RouterLink>
</td>
<!-- Source préférée -->
<td class="py-3 pr-4">
<MangaPreferredSourceCell :manga-id="manga.id" />
</td>
<!-- Chapitres barre de progression -->
<td class="py-3 pr-4">
<div v-if="manga.chaptersTotal > 0">
<div class="flex items-center justify-between mb-1">
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
{{ manga.chaptersScraped }} / {{ manga.chaptersTotal }}
</span>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ progressPercent(manga) }}%
</span>
</div>
<div class="w-full bg-gray-100 dark:bg-gray-600 rounded-full h-1.5">
<div
class="h-1.5 rounded-full transition-all"
:class="progressPercent(manga) >= 100
? 'bg-green-500'
: 'bg-blue-500'"
:style="{ width: progressPercent(manga) + '%' }" />
</div>
</div>
<span v-else class="text-gray-400 dark:text-gray-600 text-xs"></span>
</td>
<!-- Actions -->
<td class="py-3 px-4">
<div class="flex items-center justify-end gap-0.5">
<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="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="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="doRefresh(manga)">
<ArrowPathIcon
class="w-4 h-4"
:class="{ 'animate-spin': refreshingId === manga.id }" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</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, BookmarkIcon, BookmarkSlashIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
import { computed, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { useMangaEdit } from '../composables/useMangaEdit';
import { useMangaMonitoring } from '../composables/useMangaMonitoring';
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
import { useMangaRefresh } from '../composables/useMangaRefresh';
import MangaEditModal from './MangaEditModal.vue';
import MangaPreferredSourceCell from './MangaPreferredSourceCell.vue';
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
const props = defineProps({
mangas: {
type: Array,
required: true
}
});
function progressPercent(manga) {
if (!manga.chaptersTotal) return 0;
return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100);
}
// ── Monitoring ────────────────────────────────────────────
const { toggleMonitoring } = useMangaMonitoring();
async function doToggleMonitoring(manga) {
await toggleMonitoring(manga.id, !manga.monitored);
manga.monitored = !manga.monitored;
}
// ── 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

@@ -1,13 +1,13 @@
<template>
<div class="bg-white rounded-sm shadow mb-2">
<div class="bg-white dark:bg-gray-800 rounded-sm shadow mb-2">
<!-- En-tête du volume -->
<div class="relative bg-white p-3 sm:p-4 rounded-t-sm">
<div class="relative bg-white dark:bg-gray-800 p-3 sm:p-4 rounded-t-sm">
<!-- Layout mobile/desktop -->
<div class="flex items-center justify-between">
<!-- Partie gauche -->
<div class="flex items-center space-x-1 sm:space-x-4 flex-1 min-w-0">
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 flex-shrink-0" />
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 dark:text-gray-400 flex-shrink-0" />
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0 dark:text-gray-100">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
<div class="flex items-center">
<span
:class="[
@@ -65,7 +65,7 @@
<MangaChapterList v-show="isOpen" :chapters="volume.chapters" :manga-slug="mangaSlug" :manga-id="mangaId" />
<!-- Chevron de fermeture -->
<div v-show="isOpen" class="flex justify-center p-2 bg-white rounded-b-sm">
<div v-show="isOpen" class="flex justify-center p-2 bg-white dark:bg-gray-800 rounded-b-sm">
<button @click="toggleVolume" class="w-8 h-8 flex items-center justify-center">
<ChevronUpIcon
class="h-5 w-5 sm:h-6 sm:w-6 bg-gray-400 rounded-full p-1 text-white hover:bg-green-500 cursor-pointer"

View File

@@ -1,80 +1,142 @@
<template>
<div class="container mx-auto px-4 py-8">
<!-- Barre de recherche -->
<div class="mb-8">
<div class="flex gap-4">
<input
type="text"
v-model="searchQuery"
@keyup.enter="performSearch"
placeholder="Rechercher un manga..."
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<button
@click="performSearch"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Rechercher
</button>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<!-- Recherche -->
<section 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">Recherche</h2>
<input
type="text"
v-model="searchQuery"
@keyup.enter="performSearch"
placeholder="Rechercher un manga..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
</section>
<!-- État de chargement -->
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
<span class="text-sm">Recherche en cours...</span>
</div>
</section>
<!-- Message d'erreur -->
<section v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</section>
<!-- Résultats -->
<section v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Résultats</h2>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ searchResults.length }} manga(s)</span>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div
v-for="manga in searchResults"
:key="manga.externalId"
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
@click="openMangaModal(manga)">
<img
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
alt=""
class="h-36 w-24 object-cover flex-shrink-0"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0">
<p class="text-sm 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>
</section>
<!-- Aucun résultat -->
<section v-else-if="hasSearched && !loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Aucun résultat trouvé</p>
</section>
</div>
</div>
<!-- État de chargement -->
<div v-if="loading" class="text-center py-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-4 text-gray-600">Recherche en cours...</p>
</div>
<!-- Message d'erreur -->
<div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
{{ error }}
</div>
<!-- Résultats de recherche -->
<div class="max-w-full overflow-hidden">
<MangaList v-if="searchResults.length > 0" :mangas="searchResults" @manga-click="openMangaModal" />
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600">Aucun résultat trouvé</p>
</div>
<!-- Modal de confirmation -->
<!-- Modal de détail -->
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" />
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
<div class="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel class="w-full max-w-lg bg-white rounded-xl shadow-xl p-6">
<DialogTitle class="text-lg mb-4"> Ajouter à la bibliothèque </DialogTitle>
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
<div v-if="selectedManga">
<div class="flex gap-4">
<img
:src="selectedManga.imageUrl || '/placeholder-cover.png'"
:alt="selectedManga.title"
class="h-48 w-32 object-cover" />
<div class="flex-1 min-w-0">
<h4 class="text-lg">{{ selectedManga.title }}</h4>
<p class="mt-2">
{{ truncatedDescription }}
</p>
<!-- En-tête avec couverture -->
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
<img
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
:alt="selectedManga.title"
class="h-64 w-44 object-cover flex-shrink-0"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
<div>
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
{{ selectedManga.title }}
</DialogTitle>
<div class="mt-3 space-y-1.5">
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
</p>
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Publication</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
</p>
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Statut</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
</p>
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Note</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
</p>
</div>
</div>
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
<span
v-for="genre in selectedManga.genres"
:key="genre"
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{{ genre }}
</span>
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<!-- Description -->
<div class="px-6 py-4 overflow-y-auto flex-1">
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{{ selectedManga.description }}
</p>
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
</div>
<!-- Actions -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
@click="closeModal"
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50">
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
Annuler
</button>
<button
type="button"
@click="addManga"
:disabled="adding"
class="px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center">
<span v-if="adding" class="mr-2">
<ArrowPathIcon class="h-5 w-5 animate-spin" />
</span>
{{ adding ? 'Ajout en cours...' : 'Ajouter' }}
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
</button>
</div>
</DialogPanel>
</div>
</Dialog>
@@ -82,76 +144,91 @@
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon } from '@heroicons/vue/24/solid';
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
import MangaList from '../components/MangaList.vue';
const router = useRouter();
const route = useRoute();
const mangaStore = useMangaStore();
const router = useRouter();
const route = useRoute();
const mangaStore = useMangaStore();
const searchQuery = ref('');
const isModalOpen = ref(false);
const selectedManga = ref(null);
const searchQuery = ref('');
const hasSearched = ref(false);
const isModalOpen = ref(false);
const selectedManga = ref(null);
// Récupération des états du store
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
const truncatedDescription = computed(() => {
if (!selectedManga.value?.description) return '';
return selectedManga.value.description.length > 500
? selectedManga.value.description.slice(0, 500) + '...'
: selectedManga.value.description;
});
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Ajouter un manga', class: 'text-sm font-medium' },
],
rightSection: [
{
type: 'button',
icon: MagnifyingGlassIcon,
label: 'Rechercher',
onClick: performSearch,
disabled: !searchQuery.value.trim() || loading.value,
},
],
}));
// Effectuer la recherche au chargement si un paramètre q est présent
onMounted(() => {
const queryParam = route.query.q;
if (queryParam) {
searchQuery.value = queryParam;
performSearch();
}
});
let debounceTimer = null;
watch(searchQuery, newVal => {
clearTimeout(debounceTimer);
if (newVal.trim().length > 3) {
debounceTimer = setTimeout(performSearch, 500);
}
});
// Nettoyer la recherche et les résultats lors du démontage du composant
onBeforeUnmount(() => {
searchQuery.value = '';
mangaStore.clearSearchResults();
});
onMounted(() => {
const queryParam = route.query.q;
if (queryParam) {
searchQuery.value = queryParam;
performSearch();
}
});
const performSearch = async () => {
if (!searchQuery.value.trim()) return;
try {
await mangaStore.searchMangaDex(searchQuery.value);
} catch (e) {
console.error('Erreur de recherche:', e);
}
};
onBeforeUnmount(() => {
clearTimeout(debounceTimer);
searchQuery.value = '';
mangaStore.clearSearchResults();
});
const openMangaModal = manga => {
selectedManga.value = manga;
isModalOpen.value = true;
};
const performSearch = async () => {
if (!searchQuery.value.trim()) return;
try {
await mangaStore.searchMangaDex(searchQuery.value);
hasSearched.value = true;
} catch (e) {
console.error('Erreur de recherche:', e);
}
};
const closeModal = () => {
isModalOpen.value = false;
selectedManga.value = null;
};
const openMangaModal = manga => {
selectedManga.value = manga;
isModalOpen.value = true;
};
const addManga = async () => {
if (!selectedManga.value) return;
const closeModal = () => {
isModalOpen.value = false;
selectedManga.value = null;
};
try {
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
router.push('/manga');
} catch (e) {
console.error("Erreur d'ajout:", e);
} finally {
closeModal();
}
};
const addManga = async () => {
if (!selectedManga.value) return;
try {
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
router.push('/manga');
} catch (e) {
console.error("Erreur d'ajout:", e);
} finally {
closeModal();
}
};
</script>

View File

@@ -0,0 +1,192 @@
<template>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<!-- État de chargement -->
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
<span class="text-sm">Chargement des recommandations...</span>
</div>
</section>
<!-- Message d'erreur -->
<section v-else-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</section>
<!-- Résultats -->
<section v-else-if="discoverResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Recommandations</h2>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ discoverResults.length }} manga(s)</span>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div
v-for="manga in discoverResults"
:key="manga.externalId"
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
@click="openMangaModal(manga)">
<img
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
alt=""
class="h-36 w-24 object-cover flex-shrink-0"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0">
<p class="text-sm 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>
</section>
<!-- Collection locale vide -->
<section v-else-if="!loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Ajoutez des manga pour obtenir des recommandations.</p>
</section>
</div>
</div>
<!-- Modal de détail -->
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
<div class="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
<!-- En-tête avec couverture -->
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
<img
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
:alt="selectedManga.title"
class="h-64 w-44 object-cover flex-shrink-0"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
<div>
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
{{ selectedManga.title }}
</DialogTitle>
<div class="mt-3 space-y-1.5">
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
</p>
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Publication</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
</p>
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Statut</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
</p>
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Note</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
</p>
</div>
</div>
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
<span
v-for="genre in selectedManga.genres"
:key="genre"
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{{ genre }}
</span>
</div>
</div>
</div>
<!-- Description -->
<div class="px-6 py-4 overflow-y-auto flex-1">
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{{ selectedManga.description }}
</p>
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
</div>
<!-- Actions -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
@click="closeModal"
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
Annuler
</button>
<button
type="button"
@click="addManga"
:disabled="adding"
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</div>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon, ArrowPathRoundedSquareIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
const router = useRouter();
const mangaStore = useMangaStore();
const isModalOpen = ref(false);
const selectedManga = ref(null);
const { discoverResults, loadingDiscover: loading, discoverError: error, addingManga: adding } = storeToRefs(mangaStore);
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Découvrir', class: 'text-sm font-medium' },
],
rightSection: [
{
type: 'button',
icon: ArrowPathRoundedSquareIcon,
label: 'Actualiser',
onClick: () => mangaStore.loadDiscoverRecommendations(),
disabled: loading.value,
},
],
}));
onMounted(() => {
mangaStore.loadDiscoverRecommendations();
});
const openMangaModal = manga => {
selectedManga.value = manga;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
selectedManga.value = null;
};
const addManga = async () => {
if (!selectedManga.value) return;
try {
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
router.push('/manga');
} catch (e) {
console.error("Erreur d'ajout:", e);
} finally {
closeModal();
}
};
</script>

View File

@@ -1,18 +1,30 @@
<template>
<div>
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
<div class="container mx-auto px-4">
<MangaGrid v-if="viewMode === 'grid'" :mangas="collection?.items || []" />
<MangaList
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="w-full">
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
<MangaOverview
v-else-if="viewMode === 'list'"
:mangas="collection?.items || []"
:mangas="pagedItems"
@manga-click="handleMangaClick" />
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" />
<Pagination
v-if="totalPages > 1"
:current-page="currentPage"
:total-pages="totalPages"
:total="sortedCollection.length"
:limit="prefs.itemsPerPage"
:has-next-page="currentPage < totalPages"
:has-previous-page="currentPage > 1"
@page-change="currentPage = $event" />
<div
v-if="isBackgroundLoading"
class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
Mise à jour en cours...
</div>
</div>
</div>
</div>
</template>
@@ -26,15 +38,19 @@
MagnifyingGlassIcon
} from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
import Pagination from '../../../../shared/components/ui/Pagination.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
import MangaGrid from '../components/MangaGrid.vue';
import MangaList from '../components/MangaList.vue';
import MangaOverview from '../components/MangaOverview.vue';
import MangaTable from '../components/MangaTable.vue';
const router = useRouter();
const mangaStore = useMangaStore();
const prefs = useUserPreferencesStore();
const {
collection,
@@ -43,7 +59,8 @@ import MangaList from '../components/MangaList.vue';
isBackgroundLoadingCollection: isBackgroundLoading
} = storeToRefs(mangaStore);
const viewMode = ref('grid');
const viewMode = ref(prefs.defaultView);
const currentPage = ref(1);
onMounted(() => {
mangaStore.loadCollection();
@@ -53,6 +70,27 @@ import MangaList from '../components/MangaList.vue';
router.push({ name: 'manga-details', params: { id: manga.id } });
};
const sortedCollection = computed(() => {
const items = [...(collection.value?.items || [])];
if (prefs.sortBy === 'title') {
items.sort((a, b) => a.title.localeCompare(b.title));
} else if (prefs.sortBy === 'addedAt') {
items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return items;
});
const pagedItems = computed(() => {
const start = (currentPage.value - 1) * prefs.itemsPerPage;
return sortedCollection.value.slice(start, start + prefs.itemsPerPage);
});
const totalPages = computed(() => Math.ceil(sortedCollection.value.length / prefs.itemsPerPage));
watch(() => prefs.itemsPerPage, () => {
currentPage.value = 1;
});
const toolbarConfig = {
leftSection: [
{
@@ -71,8 +109,9 @@ import MangaList from '../components/MangaList.vue';
type: 'dropdown',
label: 'View',
items: [
{ label: 'List', onClick: () => (viewMode.value = 'list') },
{ label: 'Grid', onClick: () => (viewMode.value = 'grid') }
{ label: 'Overview', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
{ label: 'Grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
{ label: 'Table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
]
},
{
@@ -80,10 +119,9 @@ import MangaList from '../components/MangaList.vue';
type: 'dropdown',
label: 'Sort',
items: [
{ label: 'Title', onClick: () => {} },
{ label: 'Author', onClick: () => {} },
{ label: 'Status', onClick: () => {} },
{ label: 'Year', onClick: () => {} }
{ label: 'Title', onClick: () => prefs.setSortBy('title') },
{ label: "Date d'ajout", onClick: () => prefs.setSortBy('addedAt') },
{ label: 'Progression', onClick: () => prefs.setSortBy('progress') }
]
},
{

View File

@@ -1,9 +1,13 @@
<template>
<div class="min-h-screen bg-gray-50">
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
<!-- Notifications Toast -->
<NotificationToast />
<div v-if="errorDetails" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mx-4 mt-4">
<Toolbar v-if="currentManga" :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div v-if="errorDetails" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded mx-4 mt-4">
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
</div>
@@ -11,9 +15,7 @@
<!-- Composant invisible qui écoute les mises à jour Mercure -->
<MercureListener :manga-id="String(mangaId)" />
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 z-20">
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 dark:text-gray-400 z-20">
<ArrowPathIcon class="h-5 w-5 animate-spin" />
</div>
@@ -24,7 +26,7 @@
<div v-if="isLoadingVolumes" class="flex justify-center items-center h-32">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<div v-else-if="errorVolumes" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<div v-else-if="errorVolumes" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
{{ errorVolumes.message || 'Une erreur est survenue lors du chargement des volumes.' }}
</div>
<MangaVolumeList
@@ -84,9 +86,11 @@
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
<div v-else class="text-center text-gray-500 py-10 px-4">
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-10 px-4">
Aucun manga sélectionné ou trouvé.
</div>
</div>
</div>
</template>

View File

@@ -1,4 +1,5 @@
import { defineStore } from 'pinia';
import { useUserPreferencesStore } from '../../../setting/application/store/userPreferencesStore';
import { Chapter } from '../../domain/entities/Chapter';
import { ApiChapterRepository } from '../../infrastructure/repository/ApiChapterRepository';
@@ -13,7 +14,6 @@ export const useReaderStore = defineStore('reader', {
error: null,
pages: [],
totalPages: 0,
loadedPages: new Set(), // Garder une trace des pages déjà chargées
// Paramètres pour les doubles pages
doublePageSettings: {
@@ -32,7 +32,6 @@ export const useReaderStore = defineStore('reader', {
// Getters pour les doubles pages
effectiveDoublePageMode: (state) => {
// Si la détection automatique est désactivée, retourner 'normal'
if (!state.doublePageSettings.autoDetect) {
return 'normal';
}
@@ -55,28 +54,20 @@ export const useReaderStore = defineStore('reader', {
try {
const repository = new ApiChapterRepository();
// Charger les informations du chapitre
const chapterData = await repository.getChapter(chapterId);
const [chapterData, pagesData] = await Promise.all([
repository.getChapter(chapterId),
repository.getChapterPages(chapterId, 1, 9999),
]);
this.currentChapter = Chapter.create(chapterData);
// Charger la liste des pages
const pagesData = await repository.getChapterPages(chapterId);
// Initialiser le tableau avec des placeholders
this.pages = new Array(pagesData.totalItems).fill(null);
this.pages = pagesData.pages.map(p => ({
id: p.id,
pageNumber: p.pageNumber,
url: p.url,
dimensions: p.dimensions,
}));
this.totalPages = pagesData.totalItems;
this.loadedPages.clear();
// Charger la première page
if (this.totalPages > 0) {
this.currentPage = 0;
await this.loadPageData(0);
// En mode infini, précharger les premières pages
if (this.readingMode === 'infinite') {
await this.preloadNextPages(0);
}
}
this.currentPage = 0;
} catch (error) {
this.error = error.message;
} finally {
@@ -84,100 +75,28 @@ export const useReaderStore = defineStore('reader', {
}
},
async loadPageData(pageIndex) {
if (!this.currentChapter || pageIndex < 0 || pageIndex >= this.totalPages) {
return;
}
// Si la page est déjà chargée, ne rien faire
if (this.loadedPages.has(pageIndex)) {
return;
}
const pageNumber = pageIndex + 1; // Convertir en 1-based pour l'API
// Marquer la page comme en cours de chargement
const newPages = [...this.pages];
newPages[pageIndex] = { loading: true };
this.pages = newPages;
try {
const repository = new ApiChapterRepository();
const pageData = await repository.getChapterPage(this.currentChapter.id, pageNumber);
// Vérifier que les données sont valides
if (!pageData || !pageData.base64Content) {
throw new Error("Données de page invalides reçues de l'API");
}
// Mettre à jour la page
const updatedPages = [...this.pages];
updatedPages[pageIndex] = {
id: pageData.id,
pageNumber: pageData.pageNumber,
base64Content: pageData.base64Content,
mimeType: pageData.mimeType,
dimensions: pageData.dimensions
};
this.pages = updatedPages;
this.loadedPages.add(pageIndex);
} catch (error) {
console.error(`Erreur lors du chargement de la page ${pageNumber}:`, error);
// Marquer la page comme en erreur
const errorPages = [...this.pages];
errorPages[pageIndex] = { error: error.message };
this.pages = errorPages;
}
},
async preloadNextPages(startIndex, count = 3) {
const promises = [];
for (let i = 1; i <= count; i++) {
const pageIndex = startIndex + i;
if (pageIndex < this.totalPages) {
promises.push(this.loadPageData(pageIndex));
}
}
await Promise.all(promises);
},
async handlePageVisible(pageIndex) {
handlePageVisible(pageIndex) {
if (pageIndex !== this.currentPage) {
this.currentPage = pageIndex;
// Précharger les pages suivantes
if (this.readingMode === 'infinite') {
await this.preloadNextPages(pageIndex);
}
}
},
async nextPage() {
nextPage() {
if (!this.isLastPage) {
this.currentPage++;
await this.loadPageData(this.currentPage);
}
},
async previousPage() {
previousPage() {
if (!this.isFirstPage) {
this.currentPage--;
await this.loadPageData(this.currentPage);
}
},
async setReadingMode(mode) {
if (mode === this.readingMode) return;
this.readingMode = mode;
this.savePreferences();
// S'assurer que la page courante est chargée
await this.loadPageData(this.currentPage);
// Si on passe en mode infini, précharger les pages suivantes
if (mode === 'infinite') {
await this.preloadNextPages(this.currentPage);
}
},
setReadingDirection(direction) {
@@ -190,7 +109,6 @@ export const useReaderStore = defineStore('reader', {
this.savePreferences();
},
// Nouvelles actions pour les doubles pages
setDoublePageMode(mode) {
if (['rotate', 'scroll', 'normal'].includes(mode)) {
this.doublePageSettings.mobileMode = mode;
@@ -225,16 +143,10 @@ export const useReaderStore = defineStore('reader', {
async goToPreviousChapter() {
if (this.currentChapter?.navigation?.previousChapter) {
await this.loadChapter(this.currentChapter.navigation.previousChapter);
// Aller à la dernière page du chapitre précédent
this.currentPage = Math.max(0, this.totalPages - 1);
// S'assurer que la page est chargée
if (this.totalPages > 0) {
await this.loadPageData(this.currentPage);
}
}
},
// Gestion de la persistance des préférences
savePreferences() {
try {
const preferences = {
@@ -252,10 +164,19 @@ export const useReaderStore = defineStore('reader', {
loadPreferences() {
try {
const stored = localStorage.getItem('mangarr-reader-preferences');
if (!stored) {
const userPrefs = useUserPreferencesStore();
this.readingDirection = userPrefs.readingDirection;
const modeMap = { scroll: 'infinite', single: 'single', double: 'single' };
this.readingMode = modeMap[userPrefs.readingMode] ?? 'single';
if (userPrefs.readingMode === 'double') {
this.doublePageSettings.autoDetect = true;
}
return;
}
if (stored) {
const preferences = JSON.parse(stored);
// Appliquer les préférences sauvegardées
if (preferences.readingMode) this.readingMode = preferences.readingMode;
if (preferences.readingDirection) this.readingDirection = preferences.readingDirection;
if (typeof preferences.zoom === 'number') this.zoom = preferences.zoom;
@@ -277,7 +198,6 @@ export const useReaderStore = defineStore('reader', {
}
},
// Réinitialiser les préférences
resetPreferences() {
this.readingMode = 'single';
this.readingDirection = 'ltr';

View File

@@ -9,7 +9,7 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
return response.json();
}
async getChapterPages(chapterId, page = 1, itemsPerPage = 20) {
async getChapterPages(chapterId, page = 1, itemsPerPage = 9999) {
const response = await fetch(
`/api/reader/chapter/${chapterId}/pages?page=${page}&itemsPerPage=${itemsPerPage}`
);
@@ -18,12 +18,4 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
}
return response.json();
}
async getChapterPage(chapterId, pageNumber) {
const response = await fetch(`/api/reader/chapter/${chapterId}/page/${pageNumber}`);
if (!response.ok) {
throw new Error('Failed to fetch chapter page');
}
return response.json();
}
}

View File

@@ -9,19 +9,6 @@
</div>
<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'">
<SingleModeReader
:page-data="store.currentPageData"
@@ -35,29 +22,10 @@
:pages="store.pages"
:zoom="store.zoom"
:double-page-mode="store.effectiveDoublePageMode"
:initial-page="store.currentPage"
@page-visible="store.handlePageVisible"
@buttons-visibility-change="handleButtonsVisibilityChange"
ref="infiniteReaderRef" />
</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>
</template>
@@ -65,10 +33,9 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useHeaderStore } from '../../../../shared/stores/headerStore';
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
import { useReaderStore } from '../../application/store/readerStore';
import InfiniteReader from './InfiniteReader.vue';
import ReaderControls from './ReaderControls.vue';
import ReaderSettings from './ReaderSettings.vue';
import SingleModeReader from './SingleModeReader.vue';
const props = defineProps({
@@ -84,128 +51,64 @@ import SingleModeReader from './SingleModeReader.vue';
const store = useReaderStore();
const headerStore = useHeaderStore();
const prefs = useUserPreferencesStore();
// Référence vers InfiniteReader pour accéder à ses méthodes
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
const toggleReadingMode = () => {
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
store.setReadingMode(newMode);
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
// Gérer la visibilité selon le mode
if (newMode === 'single') {
headerStore.disableAutoHide();
// En mode simple : toujours visible
showFloatingButtons.value = true;
clearTimeout(localButtonsTimer); // Annuler tout timer local
headerStore.disableReaderToolbarAutoHide();
} else {
// En mode infini : utiliser la logique d'InfiniteReader
headerStore.enableReaderToolbarAutoHide();
headerStore.enableAutoHide();
showButtonsWithTimer();
}
};
const toggleReadingDirection = () => {
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
resetButtonsTimer();
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
store.setReadingDirection(newDir);
prefs.setReadingDirection(newDir);
};
const zoomIn = () => {
store.setZoom(Math.min(store.zoom + 0.1, 2));
resetButtonsTimer();
};
const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
const zoomOut = () => {
store.setZoom(Math.max(store.zoom - 0.1, 0.5));
resetButtonsTimer();
};
const handleZoomChange = (zoom) => store.setZoom(zoom);
const handleZoomChange = (zoom) => {
store.setZoom(zoom);
resetButtonsTimer();
};
const handleDoublePageModeChange = (mode) => store.setDoublePageMode(mode);
const handleDoublePageAutoDetectChange = (enabled) => store.setDoublePageAutoDetect(enabled);
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 = () => {
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
// Mode infini : utiliser la logique d'InfiniteReader
infiniteReaderRef.value.showButtonsWithTimer();
} else {
// Mode simple : toujours visible, pas de timer
showFloatingButtons.value = true;
}
};
// Fonction centralisée pour réinitialiser le timer
const resetButtonsTimer = () => {
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
// Mode infini : utiliser la logique d'InfiniteReader
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 => {
if (store.readingMode === 'single') {
if (event.key === 'ArrowRight') {
store.nextPage();
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
} else if (event.key === 'ArrowLeft') {
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(
() => props.chapterId,
newId => {
@@ -217,28 +120,46 @@ import SingleModeReader from './SingleModeReader.vue';
);
onMounted(() => {
// Charger les préférences sauvegardées
store.loadPreferences();
window.addEventListener('keydown', handleKeyPress);
// Afficher les boutons au démarrage
showButtonsWithTimer();
if (prefs.autoHideHeaderReader) {
headerStore.enableAutoHide();
}
if (store.readingMode === 'infinite') {
headerStore.enableReaderToolbarAutoHide();
}
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(() => {});
}
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyPress);
// S'assurer que l'auto-hide est désactivé en quittant le lecteur
headerStore.disableAutoHide();
// Nettoyer le timer local
clearTimeout(localButtonsTimer);
headerStore.disableReaderToolbarAutoHide();
});
defineExpose({
toggleReadingMode,
toggleReadingDirection,
zoomIn,
zoomOut,
handleZoomChange,
handleDoublePageModeChange,
handleDoublePageAutoDetectChange,
handleDetectionThresholdChange,
handleResetPreferences,
resetButtonsTimer,
showButtonsWithTimer,
});
</script>
<style lang="postcss" scoped>
.chapter-reader {
@apply w-full h-full flex flex-col items-center justify-center bg-gray-900 text-white;
@apply p-0 sm:p-2;
@apply w-full h-full flex flex-col bg-gray-900 text-white;
}
.loading {
@@ -250,8 +171,7 @@ import SingleModeReader from './SingleModeReader.vue';
}
.reader-content {
@apply w-full h-full flex flex-col;
@apply p-0 sm:p-2;
@apply w-full flex-1 flex flex-col min-h-0;
}
.rtl {

View File

@@ -1,23 +1,26 @@
<template>
<div class="infinite-reader" ref="containerRef">
<!-- Navigation en haut -->
<div class="navigation-wrapper top">
<ChapterNavigation position="top" />
</div>
<div v-for="(page, index) in pages" :key="index"
class="page-wrapper" :data-page-index="index">
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
<div v-if="page?.loading" class="loading">
<!-- Pas d'URL : spinner de chargement -->
<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>
<div v-else-if="page?.error" class="error">
{{ page.error }}
</div>
<ReaderPage v-else-if="page?.base64Content" :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" />
</div>
<!-- Navigation en bas -->
<div class="navigation-wrapper bottom">
<ChapterNavigation position="bottom" />
<!-- Hors de la zone de rendu : placeholder dimensionné -->
<div v-else-if="!mountedPageIndices.has(index)"
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>
<!-- Bouton flottant pour revenir en haut -->
@@ -32,22 +35,22 @@
<button
v-show="showFloatingButtons"
@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"
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" />
</svg>
<span class="text-xs hidden sm:inline">Haut</span>
</button>
</Transition>
</div>
</template>
<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 ChapterNavigation from './ChapterNavigation.vue';
import ReaderPage from './ReaderPage.vue';
const props = defineProps({
@@ -70,6 +73,8 @@ import ReaderPage from './ReaderPage.vue';
const headerStore = useHeaderStore();
const containerRef = ref(null);
const observer = ref(null);
const visibilityObserver = ref(null);
const mountedPageIndices = reactive(new Set());
const windowWidth = ref(window.innerWidth);
// État unique pour tous les boutons flottants avec timer de 3 secondes
@@ -89,24 +94,46 @@ import ReaderPage from './ReaderPage.vue';
});
};
const setupIntersectionObserver = () => {
if (observer.value) {
observer.value.disconnect();
}
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
const getPlaceholderHeight = (page) => {
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, {
root: null,
threshold: 0.5
});
nextTick(() => {
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
if (pageElements) {
pageElements.forEach((element, index) => {
element.setAttribute('data-page-index', index);
observer.value.observe(element);
visibilityObserver.value = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const idx = parseInt(entry.target.getAttribute('data-page-index'));
if (entry.isIntersecting) {
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);
});
});
};
@@ -172,10 +199,8 @@ import ReaderPage from './ReaderPage.vue';
scrollDirection = 'up';
}
// Gestion du header auto-hide (seulement si largeur < 1200px)
if (windowWidth.value < 1200) {
headerStore.updateScrollDirection(scrollTop);
}
// Gestion du header auto-hide (header : seulement si largeur < 1200px, toolbar : toujours)
headerStore.updateScrollDirection(scrollTop);
// Gestion de la visibilité des boutons flottants (même condition pour tous)
// Afficher si on scroll et qu'on est à plus de 300px
@@ -192,21 +217,16 @@ import ReaderPage from './ReaderPage.vue';
// Fonction pour revenir en haut de la page
const scrollToTop = () => {
console.log('scrollToTop appelée'); // Debug
// Réinitialiser le timer lors du clic
resetButtonsTimer();
// Stratégie 1: Scroll sur le conteneur direct
if (containerRef.value) {
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
if (containerRef.value.scrollTop > 0) {
containerRef.value.scrollTo({
top: 0,
behavior: 'smooth'
});
console.log('Scroll sur containerRef effectué'); // Debug
return;
}
}
@@ -216,7 +236,6 @@ import ReaderPage from './ReaderPage.vue';
while (currentElement) {
const styles = window.getComputedStyle(currentElement);
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
currentElement.scrollTo({
top: 0,
behavior: 'smooth'
@@ -227,7 +246,6 @@ import ReaderPage from './ReaderPage.vue';
}
// Stratégie 3: Scroll sur la fenêtre entière
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
window.scrollTo({
top: 0,
behavior: 'smooth'
@@ -243,7 +261,8 @@ import ReaderPage from './ReaderPage.vue';
watch(
() => props.pages,
() => {
setupIntersectionObserver();
mountedPageIndices.clear();
setupObservers();
},
{ immediate: true }
);
@@ -262,7 +281,7 @@ import ReaderPage from './ReaderPage.vue';
};
onMounted(() => {
setupIntersectionObserver();
setupObservers();
// Activer l'auto-hide du header si la largeur < 1200px
if (windowWidth.value < 1200) {
@@ -282,9 +301,8 @@ import ReaderPage from './ReaderPage.vue';
});
onUnmounted(() => {
if (observer.value) {
observer.value.disconnect();
}
observer.value?.disconnect();
visibilityObserver.value?.disconnect();
// Désactiver l'auto-hide du header en quittant
headerStore.disableAutoHide();
@@ -307,19 +325,23 @@ import ReaderPage from './ReaderPage.vue';
<style lang="postcss" scoped>
.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 */
@apply py-2 sm:py-8;
height: calc(100vh - 8rem);
scroll-behavior: smooth;
}
.page-wrapper {
@apply w-full flex justify-center min-h-[200px];
/* Réduction des marges sur mobile */
@apply w-full flex justify-center;
@apply mb-2 sm:mb-4 px-1 sm:px-4;
}
.page-placeholder {
@apply w-full;
max-width: 1200px;
min-height: 400px;
}
.loading,
.error {
@apply flex items-center justify-center min-h-[400px];
@@ -345,15 +367,4 @@ import ReaderPage from './ReaderPage.vue';
@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>

View File

@@ -1,7 +1,10 @@
<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-else-if="!pageData.base64Content" class="error">Contenu de l'image manquant</div>
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
<!-- Affichage spécial pour les doubles pages sur mobile -->
<div v-else-if="isDoublePage && isMobile && doublePageMode !== 'normal'" class="double-page-mobile">
@@ -75,23 +78,37 @@ import { useReaderStore } from '../../application/store/readerStore';
type: String,
default: 'rotate', // 'rotate', 'scroll', 'normal'
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
},
windowWidth: {
type: Number,
default: null
}
});
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 scrollContainerRef = ref(null);
const naturalWidth = ref(0);
const naturalHeight = ref(0);
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 768);
const localWindowWidth = ref(window.innerWidth);
const effectiveWindowWidth = computed(() =>
props.windowWidth !== null ? props.windowWidth : localWindowWidth.value
);
const isMobile = computed(() => effectiveWindowWidth.value < 768);
const imageLoaded = ref(false);
const imageSource = computed(() => {
if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
return '';
}
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`;
return props.pageData?.url ?? '';
});
// Détection des doubles pages basée sur le ratio largeur/hauteur et les dimensions API
@@ -106,17 +123,13 @@ import { useReaderStore } from '../../application/store/readerStore';
// Utiliser d'abord les dimensions de l'API si disponibles
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
const isDouble = 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;
return ratio > threshold;
}
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
const ratio = naturalWidth.value / naturalHeight.value;
const isDouble = ratio > threshold;
console.log(`Natural Dimensions - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
return isDouble;
return ratio > threshold;
}
return false;
@@ -127,7 +140,6 @@ import { useReaderStore } from '../../application/store/readerStore';
naturalWidth.value = imageRef.value.naturalWidth;
naturalHeight.value = imageRef.value.naturalHeight;
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
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
@@ -178,7 +190,7 @@ import { useReaderStore } from '../../application/store/readerStore';
if (!width || !height) return null;
const availableWidth = windowWidth.value;
const availableWidth = effectiveWindowWidth.value;
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur
if (availableWidth < 1200) {
@@ -190,13 +202,27 @@ import { useReaderStore } from '../../application/store/readerStore';
});
const imageStyle = computed(() => {
if (!maxWidth.value) return {};
// Mode simple : laisser CSS contraindre les deux dimensions proportionnellement
if (store.readingMode === 'single') {
return {
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
};
}
return {
width: `${maxWidth.value}px`,
// Mode scroll : fixer la largeur, hauteur libre
const style = {
height: 'auto',
maxWidth: '100%'
maxWidth: '100%',
};
if (maxWidth.value) {
style.width = `${maxWidth.value}px`;
}
return style;
});
// Styles spéciaux pour les doubles pages
@@ -213,7 +239,7 @@ import { useReaderStore } from '../../application/store/readerStore';
if (!width || !height) return {};
// 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
// Après rotation, la largeur originale devient la hauteur affichée
@@ -263,36 +289,32 @@ import { useReaderStore } from '../../application/store/readerStore';
};
});
// Gestion du redimensionnement de la fenêtre
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
let ownResizeHandler = null;
onMounted(() => {
if (imageRef.value && imageRef.value.complete) {
handleImageLoad();
if (props.windowWidth === null) {
ownResizeHandler = () => { localWindowWidth.value = window.innerWidth; };
window.addEventListener('resize', ownResizeHandler, { passive: true });
}
window.addEventListener('resize', handleResize);
if (imageRef.value?.complete) handleImageLoad();
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (ownResizeHandler) window.removeEventListener('resize', ownResizeHandler);
});
</script>
<style lang="postcss" scoped>
.page-container {
@apply flex-1 flex items-center justify-center overflow-hidden;
@apply flex items-center justify-center;
transform-origin: center;
/* Réduction des marges sur mobile */
@apply p-0 sm:p-2;
}
.page-image {
@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-height: 100%;
}
/* Styles pour les doubles pages sur mobile */

View File

@@ -1,29 +1,5 @@
<template>
<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
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-in"
@@ -32,63 +8,9 @@
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-4 scale-95"
>
<div v-show="effectiveIsOpen" class="settings-panel" :data-external-control="forceOpen !== null" 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>
<div v-show="open" class="settings-panel" ref="panelRef">
<button
@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 -->
<!-- Paramètres des doubles pages (mobile uniquement) -->
<div class="settings-section" v-if="isMobile">
<h3 class="section-title">
<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)
</h3>
<!-- Activation/désactivation -->
<div class="setting-item">
<label class="setting-label">
<input
@@ -113,7 +34,6 @@
</p>
</div>
<!-- Mode d'affichage (si la détection automatique est activée) -->
<div v-if="doublePageSettings.autoDetect" class="setting-item">
<label class="setting-label">Mode d'affichage</label>
<select
@@ -125,22 +45,13 @@
<option value="scroll">Défilement horizontal</option>
<option value="normal">Affichage normal</option>
</select>
<!-- Descriptions des modes -->
<p class="setting-description">
<span v-if="doublePageMode === 'rotate'">
Suggère de tourner l'appareil pour une meilleure lecture
</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>
<span v-if="doublePageMode === 'rotate'">Suggère de tourner l'appareil pour une meilleure lecture</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>
</div>
<!-- Seuil de détection -->
<div v-if="doublePageSettings.autoDetect" class="setting-item">
<label class="setting-label">
Sensibilité de détection: {{ doublePageSettings.detectionThreshold.toFixed(1) }}
@@ -160,14 +71,14 @@
</div>
</div>
<!-- Actions -->
<!-- Réinitialiser -->
<div class="settings-section">
<div class="setting-actions">
<button @click="onResetPreferences" class="action-button reset">
<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" />
</svg>
Réinitialiser
Réinitialiser les préférences
</button>
</div>
</div>
@@ -177,21 +88,9 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
const props = defineProps({
readingMode: {
type: String,
required: true
},
readingDirection: {
type: String,
required: true
},
zoom: {
type: Number,
required: true
},
doublePageMode: {
type: String,
default: 'rotate'
@@ -204,138 +103,38 @@
detectionThreshold: 1.4
})
},
// Visibilité contrôlée par le parent
visible: {
open: {
type: Boolean,
default: true
},
// 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
default: false
}
});
const emit = defineEmits([
'toggleReadingMode',
'toggleReadingDirection',
'zoomIn',
'zoomOut',
'zoomChange',
'toggleSettings',
'doublePageModeChange',
'doublePageAutoDetectChange',
'detectionThresholdChange',
'resetPreferences',
'buttonClick' // Signaler l'interaction au parent
]);
const isOpen = ref(false);
const isMobile = computed(() => window.innerWidth < 768);
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) => {
if (effectiveIsOpen.value && panelRef.value && !panelRef.value.contains(event.target)) {
// Vérifier que le clic n'est pas sur le bouton de toggle
const settingsButton = document.querySelector('.settings-toggle, .settings-button');
if (settingsButton && settingsButton.contains(event.target)) {
return; // Laisser le bouton gérer le toggle
}
closePanel();
if (props.open && panelRef.value && !panelRef.value.contains(event.target)) {
emit('toggleSettings');
}
};
// Watcher pour empêcher la fermeture du bouton quand le panel est ouvert
watch(
() => effectiveIsOpen.value,
(newIsOpen) => {
if (newIsOpen || !newIsOpen) {
// Signaler l'interaction à chaque changement
emit('buttonClick');
}
}
);
onMounted(() => document.addEventListener('click', handleClickOutside, true));
onUnmounted(() => document.removeEventListener('click', handleClickOutside, true));
// Cycle de vie des event listeners
onMounted(() => {
document.addEventListener('click', handleClickOutside, true);
});
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 onDoublePageModeChange = (mode) => emit('doublePageModeChange', mode);
const onDoublePageAutoDetectChange = (enabled) => emit('doublePageAutoDetectChange', enabled);
const onDetectionThresholdChange = (threshold) => emit('detectionThresholdChange', parseFloat(threshold));
const onResetPreferences = () => {
emit('resetPreferences');
emit('buttonClick');
isOpen.value = false;
emit('toggleSettings');
};
</script>
@@ -344,25 +143,10 @@
@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 {
@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) {
.settings-panel {
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 {
@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;
}
.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 {
@apply mb-4 last:mb-0;
}
@@ -457,7 +195,6 @@
@apply text-gray-400 text-xs leading-relaxed;
}
/* Actions */
.setting-actions {
@apply flex gap-2;
}
@@ -470,23 +207,9 @@
@apply bg-red-600 hover:bg-red-700 text-white;
}
/* Responsive */
@media (max-width: 768px) {
.settings-panel {
@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>

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) -->
<div
class="navigation-zone left-zone"
@click.stop="goToPrevious"
@click.stop="onLeftZoneClick"
@mouseenter="showLeftHint"
@mouseleave="hideLeftHint"
title="Page précédente"
:title="isRtl ? 'Page suivante' : 'Page précédente'"
></div>
<!-- Page centrale -->
@@ -24,21 +24,21 @@
<!-- Zone de navigation droite (invisible) -->
<div
class="navigation-zone right-zone"
@click.stop="goToNext"
@click.stop="onRightZoneClick"
@mouseenter="showRightHint"
@mouseleave="hideRightHint"
title="Page suivante"
:title="isRtl ? 'Page précédente' : 'Page suivante'"
></div>
</div>
<!-- Indicateurs visuels de navigation -->
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
@@ -81,14 +81,18 @@ const showLeftHintHover = ref(false);
const showRightHintHover = ref(false);
let hintTimeout = null;
// Computed pour vérifier les possibilités de navigation
const canGoToPrevious = computed(() => {
return !store.isFirstPage || store.hasPreviousChapter;
});
const isRtl = computed(() => store.readingDirection === 'rtl');
const canGoToNext = computed(() => {
return !store.isLastPage || store.hasNextChapter;
});
// Computed pour vérifier les possibilités de navigation
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
const goToPrevious = async () => {
@@ -151,22 +155,20 @@ const hideRightHint = () => {
<style lang="postcss" scoped>
.single-mode-reader {
@apply relative w-full h-full flex items-center justify-center;
/* Suppression des marges sur mobile */
@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;
@apply relative w-full flex-1 flex flex-col min-h-0 overflow-hidden;
@apply py-2;
}
.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 {
@apply flex-1 h-full flex items-center justify-center;
pointer-events: none; /* Empêche les clics sur l'image elle-même */
/* Optimisation pour mobile */
@apply p-0;
/* min-h-full : centre l'image quand elle est plus petite que le conteneur */
min-height: 100%;
@apply flex items-center justify-center;
pointer-events: none;
}
.navigation-zone {

View File

@@ -1,56 +1,31 @@
<template>
<div class="chapter-page">
<div class="chapter-header">
<!-- Bouton de retour -->
<div class="flex items-center gap-4 mb-4">
<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" />
<span class="text-sm font-medium">Retour au manga</span>
</button>
</div>
<!-- Titre du chapitre amélioré -->
<div class="chapter-title-section">
<h1 class="text-3xl md:text-4xl font-bold text-white leading-tight">
{{ currentChapter?.title || 'Chargement...' }}
</h1>
<div class="chapter-meta mt-3">
<span class="inline-flex items-center px-3 py-1 bg-blue-600 text-white text-sm font-semibold rounded-full">
Chapitre {{ currentChapter?.number }}
</span>
</div>
<div
class="toolbar-wrapper"
:class="{ 'toolbar-hidden': !headerStore.shouldShowReaderToolbar }"
>
<div class="toolbar-slide">
<ReaderToolbar :chapter-reader-ref="chapterReaderRef" />
</div>
</div>
<div class="reader-container">
<ChapterReader :chapter-id="chapterId" />
<ChapterReader ref="chapterReaderRef" :chapter-id="chapterId" />
</div>
</div>
</template>
<script setup>
import { ArrowLeftIcon } from '@heroicons/vue/24/outline';
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useReaderStore } from '../../application/store/readerStore';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useHeaderStore } from '../../../../shared/stores/headerStore';
import ChapterReader from '../components/ChapterReader.vue';
import ReaderToolbar from '../components/ReaderToolbar.vue';
const route = useRoute();
const router = useRouter();
const store = useReaderStore();
const headerStore = useHeaderStore();
const chapterId = computed(() => route.params.chapterId);
const currentChapter = computed(() => store.currentChapter);
const goBackToManga = () => {
if (currentChapter.value?.mangaId) {
router.push({ name: 'manga-details', params: { id: currentChapter.value.mangaId } });
}
};
const chapterReaderRef = ref(null);
</script>
<style lang="postcss" scoped>
@@ -58,19 +33,26 @@ import ChapterReader from '../components/ChapterReader.vue';
@apply w-full h-full flex flex-col;
}
.chapter-header {
@apply p-6 bg-gradient-to-b from-gray-800 to-gray-900 border-b border-gray-700 shadow-lg;
.toolbar-wrapper {
@apply overflow-hidden;
max-height: 5rem;
transition: max-height 300ms ease-in-out;
}
.chapter-title-section {
@apply space-y-2;
.toolbar-wrapper.toolbar-hidden {
max-height: 0;
}
.chapter-meta {
@apply flex flex-wrap items-center gap-3;
.toolbar-slide {
transform: translateY(0);
transition: transform 300ms ease-in-out;
}
.toolbar-hidden .toolbar-slide {
transform: translateY(-100%);
}
.reader-container {
@apply flex-1 overflow-hidden;
@apply flex-1 overflow-hidden min-h-0;
}
</style>

View File

@@ -0,0 +1,142 @@
import { defineStore } from 'pinia';
const STORAGE_KEY = 'mangarr_preferences';
const defaultState = {
theme: 'system',
language: 'fr',
defaultView: 'grid',
itemsPerPage: 20,
sortBy: 'title',
readingDirection: 'ltr',
readingMode: 'scroll',
autoFullscreen: false,
autoHideHeaderReader: true,
toastDuration: 5000,
};
function loadFromStorage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return { ...defaultState, ...JSON.parse(stored) };
}
} catch {
// ignore parse errors
}
return { ...defaultState };
}
let mediaQueryUnsubscribe = null;
export const useUserPreferencesStore = defineStore('userPreferences', {
state: () => loadFromStorage(),
actions: {
applyTheme() {
// Nettoyer le listener précédent
if (mediaQueryUnsubscribe) {
mediaQueryUnsubscribe();
mediaQueryUnsubscribe = null;
}
const html = document.documentElement;
if (this.theme === 'dark') {
html.classList.add('dark');
} else if (this.theme === 'light') {
html.classList.remove('dark');
} else {
// mode 'system'
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => {
if (e.matches) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
};
handler(mq);
mq.addEventListener('change', handler);
mediaQueryUnsubscribe = () => mq.removeEventListener('change', handler);
}
},
setTheme(theme) {
this.theme = theme;
this.persist();
this.applyTheme();
},
setLanguage(language) {
this.language = language;
this.persist();
},
setDefaultView(view) {
this.defaultView = view;
this.persist();
},
setItemsPerPage(count) {
this.itemsPerPage = count;
this.persist();
},
setSortBy(sort) {
this.sortBy = sort;
this.persist();
},
setReadingDirection(direction) {
this.readingDirection = direction;
this.persist();
},
setReadingMode(mode) {
this.readingMode = mode;
this.persist();
},
setAutoFullscreen(value) {
this.autoFullscreen = value;
this.persist();
},
setAutoHideHeaderReader(value) {
this.autoHideHeaderReader = value;
this.persist();
},
setToastDuration(duration) {
this.toastDuration = duration;
this.persist();
},
resetToDefaults() {
Object.assign(this, defaultState);
this.persist();
this.applyTheme();
},
persist() {
try {
const data = {
theme: this.theme,
language: this.language,
defaultView: this.defaultView,
itemsPerPage: this.itemsPerPage,
sortBy: this.sortBy,
readingDirection: this.readingDirection,
readingMode: this.readingMode,
autoFullscreen: this.autoFullscreen,
autoHideHeaderReader: this.autoHideHeaderReader,
toastDuration: this.toastDuration,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// ignore storage errors
}
},
},
});

View File

@@ -1,7 +1,8 @@
<template>
<div>
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="mb-8">
@@ -71,6 +72,7 @@
Configuration exportée !
</div>
</div>
</div>
<!-- Import Modal -->
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">

View File

@@ -1,7 +1,8 @@
<template>
<div>
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="container mx-auto px-4 py-6">
<!-- Back Navigation -->
<div class="mb-6">
@@ -180,6 +181,7 @@
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,247 @@
<template>
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8 max-w-3xl">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('preferences.title') }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('preferences.subtitle') }}</p>
</div>
<button
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
@click="handleReset">
{{ t('preferences.reset') }}
</button>
</div>
<!-- Apparence -->
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
{{ t('preferences.sections.appearance') }}
</h2>
<div class="space-y-1">
<!-- Thème -->
<div class="flex items-center justify-between py-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.theme.label') }}</label>
<select
:value="store.theme"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="store.setTheme($event.target.value)">
<option value="light">{{ t('preferences.theme.light') }}</option>
<option value="dark">{{ t('preferences.theme.dark') }}</option>
<option value="system">{{ t('preferences.theme.system') }}</option>
</select>
</div>
<!-- Langue -->
<div class="flex items-center justify-between py-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.language.label') }}</label>
<select
:value="store.language"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="handleLanguageChange($event.target.value)">
<option value="fr">{{ t('preferences.language.fr') }}</option>
<option value="en">{{ t('preferences.language.en') }}</option>
</select>
</div>
</div>
</section>
<!-- Affichage collection -->
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
{{ t('preferences.sections.collection') }}
</h2>
<div class="space-y-1">
<!-- Vue par défaut -->
<div class="flex items-center justify-between py-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.defaultView.label') }}</label>
<div class="flex gap-2">
<button
:class="viewButtonClass('grid')"
@click="store.setDefaultView('grid')">
{{ t('preferences.defaultView.grid') }}
</button>
<button
:class="viewButtonClass('list')"
@click="store.setDefaultView('list')">
{{ t('preferences.defaultView.list') }}
</button>
<button
:class="viewButtonClass('table')"
@click="store.setDefaultView('table')">
{{ t('preferences.defaultView.table') }}
</button>
</div>
</div>
<!-- Mangas par page -->
<div class="flex items-center justify-between py-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.itemsPerPage.label') }}</label>
<div class="flex gap-2">
<button
v-for="n in [12, 20, 40]"
:key="n"
:class="countButtonClass(n)"
@click="store.setItemsPerPage(n)">
{{ n }}
</button>
</div>
</div>
<!-- Tri par défaut -->
<div class="flex items-center justify-between py-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.sortBy.label') }}</label>
<select
:value="store.sortBy"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="store.setSortBy($event.target.value)">
<option value="title">{{ t('preferences.sortBy.title') }}</option>
<option value="addedAt">{{ t('preferences.sortBy.addedAt') }}</option>
<option value="progress">{{ t('preferences.sortBy.progress') }}</option>
</select>
</div>
</div>
</section>
<!-- Lecture -->
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
{{ t('preferences.sections.reading') }}
</h2>
<div class="space-y-1">
<!-- Direction de lecture -->
<div class="flex items-center justify-between py-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingDirection.label') }}</label>
<select
:value="store.readingDirection"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="store.setReadingDirection($event.target.value)">
<option value="ltr">{{ t('preferences.readingDirection.ltr') }}</option>
<option value="rtl">{{ t('preferences.readingDirection.rtl') }}</option>
</select>
</div>
<!-- Mode d'affichage -->
<div class="flex items-center justify-between py-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingMode.label') }}</label>
<select
:value="store.readingMode"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="store.setReadingMode($event.target.value)">
<option value="scroll">{{ t('preferences.readingMode.scroll') }}</option>
<option value="single">{{ t('preferences.readingMode.single') }}</option>
<option value="double">{{ t('preferences.readingMode.double') }}</option>
</select>
</div>
<!-- Auto plein écran -->
<div class="flex items-center justify-between py-3">
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoFullscreen.label') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoFullscreen.description') }}</p>
</div>
<button
:class="toggleClass(store.autoFullscreen)"
role="switch"
:aria-checked="store.autoFullscreen"
@click="store.setAutoFullscreen(!store.autoFullscreen)">
<span :class="toggleKnobClass(store.autoFullscreen)" />
</button>
</div>
<!-- Auto-hide header -->
<div class="flex items-center justify-between py-3">
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoHideHeaderReader.label') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoHideHeaderReader.description') }}</p>
</div>
<button
:class="toggleClass(store.autoHideHeaderReader)"
role="switch"
:aria-checked="store.autoHideHeaderReader"
@click="store.setAutoHideHeaderReader(!store.autoHideHeaderReader)">
<span :class="toggleKnobClass(store.autoHideHeaderReader)" />
</button>
</div>
</div>
</section>
<!-- Notifications -->
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
{{ t('preferences.sections.notifications') }}
</h2>
<div class="space-y-1">
<!-- Durée des toasts -->
<div class="flex items-center justify-between py-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.toastDuration.label') }}</label>
<div class="flex gap-2">
<button
v-for="[val, label] in toastOptions"
:key="val"
:class="countButtonClass(val, store.toastDuration)"
@click="store.setToastDuration(val)">
{{ t(label) }}
</button>
</div>
</div>
</div>
</section>
</div></div>
</template>
<script setup>
import { useI18n } from 'vue-i18n';
import { useUserPreferencesStore } from '../../application/store/userPreferencesStore';
import { i18n } from '../../../../shared/i18n';
const { t, locale } = useI18n();
const store = useUserPreferencesStore();
const toastOptions = [
[3000, 'preferences.toastDuration.3s'],
[5000, 'preferences.toastDuration.5s'],
[10000, 'preferences.toastDuration.10s'],
];
function handleLanguageChange(lang) {
store.setLanguage(lang);
i18n.global.locale.value = lang;
locale.value = lang;
}
function handleReset() {
if (confirm(t('preferences.resetConfirm'))) {
store.resetToDefaults();
i18n.global.locale.value = store.language;
locale.value = store.language;
}
}
function viewButtonClass(view) {
const active = store.defaultView === view;
return [
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
active
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
];
}
function countButtonClass(val, current = store.itemsPerPage) {
const active = current === val;
return [
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
active
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
];
}
function toggleClass(active) {
return [
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
active ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600',
];
}
function toggleKnobClass(active) {
return [
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
active ? 'translate-x-6' : 'translate-x-1',
];
}
</script>

View File

@@ -4,6 +4,9 @@ import App from './App.vue';
import { router } from './router';
import '../../styles/app.scss';
import { installVueQuery } from './shared/plugin/vueQuery';
import { i18n } from './shared/i18n';
import { useUserPreferencesStore } from './domain/setting/application/store/userPreferencesStore';
// Création du store
const pinia = createPinia();
@@ -14,5 +17,12 @@ const app = createApp(App);
app.use(router);
app.use(pinia);
app.use(installVueQuery);
app.use(i18n);
// Appliquer le thème et la langue sauvegardés
const prefs = useUserPreferencesStore();
prefs.applyTheme();
i18n.global.locale.value = prefs.language;
// Montage de l'application
app.mount('#vue-app');

View File

@@ -3,11 +3,13 @@ import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue
import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue';
import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue';
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
import DiscoverPage from '../domain/manga/presentation/pages/DiscoverPage.vue';
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
import Layout from '../shared/components/layout/Layout.vue';
// Placeholder component for new routes
@@ -73,20 +75,13 @@ const routes = [
{
path: '/manga/discover',
name: 'discover',
component: PlaceholderComponent,
props: { title: 'Découvrir' }
component: DiscoverPage
},
{
path: '/convert',
name: 'convert',
component: ConversionPage
},
{
path: '/calendar',
name: 'calendar',
component: PlaceholderComponent,
props: { title: 'Calendrier' }
},
{
path: '/activity',
name: 'activity',
@@ -129,8 +124,7 @@ const routes = [
{
path: '/settings/ui',
name: 'settings-ui',
component: PlaceholderComponent,
props: { title: "Paramètres de l'interface" }
component: UserPreferencesPage
},
// Système
{
@@ -168,6 +162,6 @@ const routes = [
];
export const router = createRouter({
history: createWebHistory('/vue/'),
history: createWebHistory('/'),
routes
});

View File

@@ -15,20 +15,41 @@
<Bars3Icon class="h-6 w-6" />
</button>
<div class="flex items-center flex-1">
<router-link to="/" class="text-white text-2xl font-bold ml-4">
Mangarr
<router-link to="/" class="ml-4">
<img src="/img/mangarr_logo.png" alt="Mangarr" class="h-10" />
</router-link>
<SearchBar />
</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>
</template>
<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 { useUserPreferencesStore } from '../../../domain/setting/application/store/userPreferencesStore';
import SearchBar from './SearchBar.vue';
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({
showMenuButton: {

View File

@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-gray-50 flex">
<div class="h-screen overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
<Header
:show-menu-button="isReaderMode"
@menu-click="toggleSidebar"
@@ -12,9 +12,10 @@
@add-manga-click="$emit('add-manga-click', $event)" />
<main :class="[
'flex-1 pt-16',
'flex-1 flex flex-col overflow-hidden',
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
isReaderMode ? '' : 'md:ml-60'
]">
]" style="transition: margin-top 300ms ease-in-out;">
<RouterView></RouterView>
</main>
</div>
@@ -23,10 +24,12 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useHeaderStore } from '../../stores/headerStore';
import Header from './Header.vue';
import Sidebar from './Sidebar.vue';
const route = useRoute();
const headerStore = useHeaderStore();
const isSidebarOpen = ref(false);
// Détecte si on est en mode Reader

View File

@@ -27,7 +27,6 @@
ArrowDownTrayIcon,
ArrowsRightLeftIcon,
BookOpenIcon,
CalendarIcon,
ClockIcon,
Cog6ToothIcon,
ComputerDesktopIcon,
@@ -69,12 +68,6 @@ import MenuGroup from './sidebar/MenuGroup.vue';
to: '/convert',
id: 'convert'
},
{
icon: CalendarIcon,
text: 'Calendrier',
to: '/calendar',
id: 'calendar'
},
{
icon: ClockIcon,
text: 'Activité',

View File

@@ -3,24 +3,25 @@
class="border-l-4"
:class="{
'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
:to="to"
class="flex-grow px-4 py-2 flex items-center"
:class="{
'text-green-600 bg-gray-800': isActive
}">
<div class="flex items-center flex-grow">
<component :is="icon" class="w-5 h-5 mr-3" />
<span class="px-2">{{ text }}</span>
</div>
<component
v-if="subItems.length > 0"
:is="expanded ? ChevronUpIcon : ChevronDownIcon"
class="w-4 h-4" />
:class="isActive
? 'text-green-600 bg-gray-800'
: 'hover:bg-gray-700 hover:text-white'">
<component :is="icon" class="w-5 h-5 mr-3" />
<span class="px-2">{{ text }}</span>
</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>
<ul v-if="subItems.length > 0" class="ml-8 mt-2 space-y-4" v-show="expanded">
@@ -71,14 +72,14 @@
const isActive = computed(() => {
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 === '/') {
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 => {

View File

@@ -1,9 +1,9 @@
<template>
<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 }}
</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 }}
</button>
</li>

View File

@@ -1,35 +1,23 @@
<template>
<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>
<div
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
:class="{ 'border-green-500 bg-green-50': isDragOver, 'hover:border-gray-400': !isDragOver }"
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 dark:bg-green-900/20': isDragOver, 'hover:border-gray-400': !isDragOver }"
@drop.prevent="handleDrop"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
>
<div class="space-y-1 text-center">
<svg
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>
<ArrowUpTrayIcon class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="flex text-sm text-gray-600">
<label
: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>
<input
@@ -50,8 +38,8 @@
</p>
<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>
<ul class="text-xs text-gray-600 space-y-1">
<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 dark:text-gray-400 space-y-1">
<li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
<span class="truncate">{{ file.name }}</span>
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
@@ -64,6 +52,7 @@
</template>
<script setup>
import { ArrowUpTrayIcon } from '@heroicons/vue/24/outline';
import { ref, computed, watch } from 'vue';
const props = defineProps({

View File

@@ -1,40 +1,40 @@
<template>
<div class="fixed top-4 right-4 z-50 space-y-2">
<div class="fixed bottom-4 left-4 z-50 flex flex-col-reverse gap-2">
<TransitionGroup
name="notification"
tag="div"
class="space-y-2"
class="flex flex-col-reverse gap-2"
>
<div
v-for="notification in notifications"
:key="notification.id"
:class="[
'max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden',
'max-w-md w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden',
getNotificationClass(notification.type)
]"
>
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<component :is="getIcon(notification.type)" :class="[
'h-6 w-6',
getIconClass(notification.type)
]" />
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-medium text-gray-900">
{{ notification.message }}
</p>
</div>
<div class="ml-4 flex-shrink-0 flex">
<div class="flex-shrink-0 mr-3">
<button
@click="removeNotification(notification.id)"
class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
class="bg-white dark:bg-gray-800 rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span class="sr-only">Close</span>
<XMarkIcon class="h-5 w-5" />
</button>
</div>
<div class="flex-1 pt-0.5 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 break-words">
{{ notification.message }}
</p>
</div>
<div class="flex-shrink-0 ml-3">
<component :is="getIcon(notification.type)" :class="[
'h-6 w-6',
getIconClass(notification.type)
]" />
</div>
</div>
</div>
</div>
@@ -66,10 +66,10 @@ const getIcon = (type) => {
const getNotificationClass = (type) => {
const classes = {
success: 'border-l-4 border-green-400',
error: 'border-l-4 border-red-400',
warning: 'border-l-4 border-yellow-400',
info: 'border-l-4 border-blue-400'
success: 'border-r-4 border-green-400',
error: 'border-r-4 border-red-400',
warning: 'border-r-4 border-yellow-400',
info: 'border-r-4 border-blue-400'
};
return classes[type] || classes.info;
};
@@ -93,11 +93,11 @@ const getIconClass = (type) => {
.notification-enter-from {
opacity: 0;
transform: translateX(100%);
transform: translateX(-100%);
}
.notification-leave-to {
opacity: 0;
transform: translateX(100%);
transform: translateX(-100%);
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<!-- Informations de pagination -->
<div class="flex items-center text-sm text-gray-700">
<div class="flex items-center text-sm text-gray-700 dark:text-gray-300">
<span>
Affichage de
<span class="font-medium">{{ startItem }}</span>
@@ -22,8 +22,8 @@
:class="[
'relative inline-flex items-center px-2 py-2 text-sm font-medium rounded-md',
hasPreviousPage
? 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
: 'text-gray-300 bg-gray-100 border border-gray-200 cursor-not-allowed'
? 'text-gray-500 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
: 'text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 cursor-not-allowed'
]">
<span class="sr-only">Précédent</span>
<ChevronLeftIcon class="h-5 w-5" />
@@ -38,14 +38,14 @@
:class="[
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
currentPage === 1
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
]">
1
</button>
<!-- Points de suspension gauche -->
<span v-if="showLeftDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700">
<span v-if="showLeftDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300">
...
</span>
@@ -57,14 +57,14 @@
:class="[
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
currentPage === page
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
]">
{{ page }}
</button>
<!-- Points de suspension droite -->
<span v-if="showRightDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700">
<span v-if="showRightDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300">
...
</span>
@@ -75,8 +75,8 @@
:class="[
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
currentPage === totalPages
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
]">
{{ totalPages }}
</button>
@@ -84,7 +84,7 @@
<!-- Pagination mobile -->
<div class="md:hidden flex items-center space-x-2">
<span class="text-sm text-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ currentPage }} / {{ totalPages }}
</span>
</div>
@@ -96,8 +96,8 @@
:class="[
'relative inline-flex items-center px-2 py-2 text-sm font-medium rounded-md',
hasNextPage
? 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
: 'text-gray-300 bg-gray-100 border border-gray-200 cursor-not-allowed'
? 'text-gray-500 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
: 'text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 cursor-not-allowed'
]">
<span class="sr-only">Suivant</span>
<ChevronRightIcon class="h-5 w-5" />

View File

@@ -4,6 +4,9 @@
<!-- Left section -->
<ToolbarSection :items="config.leftSection" />
<!-- Center section (optional slot) -->
<slot name="center" />
<!-- Right section -->
<ToolbarSection :items="config.rightSection" />
</div>
@@ -18,7 +21,6 @@
type: Object,
required: true,
validator: value => {
// Vérifie que leftSection et rightSection sont des tableaux
return Array.isArray(value.leftSection) && Array.isArray(value.rightSection);
}
}

View File

@@ -1,11 +1,13 @@
<template>
<button
@click="$emit('click')"
:disabled="disabled"
: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',
active
? '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'">
<component v-if="icon" :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" />
@@ -30,6 +32,10 @@
active: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
});

View File

@@ -6,6 +6,7 @@
:icon="item.icon"
:label="item.label"
:active="item.active"
:disabled="item.disabled"
@click="item.onClick" />
<ToolbarDropdown
v-else-if="item.type === 'dropdown'"
@@ -14,7 +15,9 @@
:active="item.active"
:items="item.items" />
<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>
</div>
</template>
@@ -36,6 +39,7 @@
item.type &&
(item.type === 'button' ||
item.type === 'divider' ||
item.type === 'label' ||
(item.type === 'dropdown' && Array.isArray(item.items)))
);
}

View File

@@ -0,0 +1,45 @@
import { onMounted, onBeforeUnmount } from 'vue';
import { useNotifications } from './useNotifications';
export function useMercureNotifications() {
const { showSuccess, showError, showInfo, showWarning } = useNotifications();
let eventSource = null;
const handleNotification = data => {
const message = data.message ?? 'Notification';
switch (data.status) {
case 'success': showSuccess(message); break;
case 'error': showError(message); break;
case 'warning': showWarning(message); break;
default: showInfo(message);
}
};
const setup = () => {
const url = new URL('/.well-known/mercure', window.location.origin);
url.searchParams.append('topic', 'notifications');
eventSource = new EventSource(url, { withCredentials: true });
eventSource.onmessage = event => {
try {
const data = JSON.parse(event.data);
handleNotification(data);
} catch (e) {
console.error('useMercureNotifications: erreur de parsing', e);
}
};
eventSource.onerror = () => {
eventSource?.close();
setTimeout(setup, 5000);
};
};
onMounted(setup);
onBeforeUnmount(() => {
eventSource?.close();
eventSource = null;
});
}

View File

@@ -1,4 +1,5 @@
import { ref } from 'vue';
import { useUserPreferencesStore } from '../../domain/setting/application/store/userPreferencesStore';
const notifications = ref([]);
let nextId = 1;
@@ -36,20 +37,24 @@ export function useNotifications() {
notifications.value = [];
};
const showSuccess = (message, duration = 4000) => {
return addNotification(message, 'success', duration);
const showSuccess = (message, duration) => {
const prefs = useUserPreferencesStore();
return addNotification(message, 'success', duration ?? prefs.toastDuration);
};
const showError = (message, duration = 6000) => {
return addNotification(message, 'error', duration);
const showError = (message, duration) => {
const prefs = useUserPreferencesStore();
return addNotification(message, 'error', duration ?? prefs.toastDuration);
};
const showWarning = (message, duration = 5000) => {
return addNotification(message, 'warning', duration);
const showWarning = (message, duration) => {
const prefs = useUserPreferencesStore();
return addNotification(message, 'warning', duration ?? prefs.toastDuration);
};
const showInfo = (message, duration = 4000) => {
return addNotification(message, 'info', duration);
const showInfo = (message, duration) => {
const prefs = useUserPreferencesStore();
return addNotification(message, 'info', duration ?? prefs.toastDuration);
};
return {

View File

@@ -0,0 +1,10 @@
import { createI18n } from 'vue-i18n';
import fr from './locales/fr.json';
import en from './locales/en.json';
export const i18n = createI18n({
legacy: false,
locale: 'fr',
fallbackLocale: 'fr',
messages: { fr, en },
});

View File

@@ -0,0 +1,68 @@
{
"nav": {
"preferences": "Preferences"
},
"preferences": {
"title": "Preferences",
"subtitle": "Customize the interface to your liking",
"reset": "Reset",
"resetConfirm": "Reset to default values?",
"sections": {
"appearance": "Appearance",
"collection": "Collection display",
"reading": "Reading",
"notifications": "Notifications"
},
"theme": {
"label": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System (automatic)"
},
"language": {
"label": "Language",
"fr": "Français",
"en": "English"
},
"defaultView": {
"label": "Default view",
"grid": "Grid",
"list": "List",
"table": "Table"
},
"itemsPerPage": {
"label": "Mangas per page"
},
"sortBy": {
"label": "Default sort",
"title": "Title",
"addedAt": "Date added",
"progress": "Progress"
},
"readingDirection": {
"label": "Reading direction",
"ltr": "Left → Right (western)",
"rtl": "Right → Left (manga)"
},
"readingMode": {
"label": "Display mode",
"scroll": "Vertical scroll",
"single": "Single page",
"double": "Double page"
},
"autoFullscreen": {
"label": "Auto fullscreen",
"description": "Enter fullscreen when starting the reader"
},
"autoHideHeaderReader": {
"label": "Auto-hide header",
"description": "Hide the navigation bar in reading mode"
},
"toastDuration": {
"label": "Notification duration",
"3s": "3 seconds",
"5s": "5 seconds",
"10s": "10 seconds"
}
}
}

View File

@@ -0,0 +1,68 @@
{
"nav": {
"preferences": "Préférences"
},
"preferences": {
"title": "Préférences",
"subtitle": "Personnalisez l'interface selon vos goûts",
"reset": "Réinitialiser",
"resetConfirm": "Remettre les valeurs par défaut ?",
"sections": {
"appearance": "Apparence",
"collection": "Affichage de la collection",
"reading": "Lecture",
"notifications": "Notifications"
},
"theme": {
"label": "Thème",
"light": "Clair",
"dark": "Sombre",
"system": "Système (automatique)"
},
"language": {
"label": "Langue",
"fr": "Français",
"en": "English"
},
"defaultView": {
"label": "Vue par défaut",
"grid": "Grille",
"list": "Liste",
"table": "Tableau"
},
"itemsPerPage": {
"label": "Mangas par page"
},
"sortBy": {
"label": "Tri par défaut",
"title": "Titre",
"addedAt": "Date d'ajout",
"progress": "Progression"
},
"readingDirection": {
"label": "Direction de lecture",
"ltr": "Gauche → Droite (occidental)",
"rtl": "Droite → Gauche (manga)"
},
"readingMode": {
"label": "Mode d'affichage",
"scroll": "Défilement vertical",
"single": "Page unique",
"double": "Double page"
},
"autoFullscreen": {
"label": "Plein écran automatique",
"description": "Passer en plein écran au démarrage du lecteur"
},
"autoHideHeaderReader": {
"label": "Masquer automatiquement l'en-tête",
"description": "Masquer la barre de navigation en mode lecture"
},
"toastDuration": {
"label": "Durée des notifications",
"3s": "3 secondes",
"5s": "5 secondes",
"10s": "10 secondes"
}
}
}

View File

@@ -4,19 +4,20 @@ export const useHeaderStore = defineStore('header', {
state: () => ({
isHeaderVisible: true,
isAutoHideEnabled: false,
isReaderToolbarVisible: true,
isReaderToolbarAutoHideEnabled: false,
lastScrollY: 0,
scrollDirection: 'up'
}),
getters: {
shouldShowHeader: (state) => {
// Si l'auto-hide n'est pas activé, toujours afficher le header
if (!state.isAutoHideEnabled) {
return true;
}
// Si l'auto-hide est activé, suivre la visibilité
if (!state.isAutoHideEnabled) return true;
return state.isHeaderVisible;
},
shouldShowReaderToolbar: (state) => {
if (!state.isReaderToolbarAutoHideEnabled) return true;
return state.isReaderToolbarVisible;
}
},
@@ -27,35 +28,47 @@ export const useHeaderStore = defineStore('header', {
disableAutoHide() {
this.isAutoHideEnabled = false;
this.isHeaderVisible = true; // Toujours visible quand désactivé
this.isHeaderVisible = true;
},
updateScrollDirection(scrollY) {
// Éviter les calculs inutiles si pas d'auto-hide
if (!this.isAutoHideEnabled) {
this.lastScrollY = scrollY;
return;
}
enableReaderToolbarAutoHide() {
this.isReaderToolbarAutoHideEnabled = true;
this.isReaderToolbarVisible = true;
},
// Détecter la direction du scroll avec un seuil pour éviter les micro-mouvements
disableReaderToolbarAutoHide() {
this.isReaderToolbarAutoHideEnabled = false;
this.isReaderToolbarVisible = true;
},
toggleReaderToolbarAutoHide() {
if (this.isReaderToolbarAutoHideEnabled) {
this.disableReaderToolbarAutoHide();
this.disableAutoHide();
} else {
this.enableReaderToolbarAutoHide();
this.enableAutoHide();
}
},
updateScrollDirection(scrollY) {
const scrollDifference = Math.abs(scrollY - this.lastScrollY);
if (scrollDifference < 5) {
// Mouvement trop petit, on ignore
return;
}
if (scrollY > this.lastScrollY && scrollY > 100) {
// Scroll vers le bas et suffisamment de scroll
if (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) {
// Scroll vers le haut
if (this.scrollDirection !== 'up') {
this.scrollDirection = 'up';
this.isHeaderVisible = true;
if (this.isAutoHideEnabled) this.isHeaderVisible = true;
if (this.isReaderToolbarAutoHideEnabled) this.isReaderToolbarVisible = true;
}
}

View File

@@ -26,6 +26,7 @@
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.0.*",
"symfony/console": "7.0.*",
"symfony/css-selector": "7.0.*",
"symfony/doctrine-messenger": "7.0.*",
"symfony/dotenv": "7.0.*",
"symfony/expression-language": "7.0.*",
@@ -117,7 +118,6 @@
"phpmd/phpmd": "^2.15",
"phpunit/phpunit": "^10.5",
"symfony/browser-kit": "7.0.*",
"symfony/css-selector": "7.0.*",
"symfony/maker-bundle": "^1.52",
"symfony/phpunit-bridge": "^7.0",
"symfony/stopwatch": "7.0.*",

198
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1ec83e325be6f57ff404050f1ad58e2d",
"content-hash": "0fc13b604085c0e8bcdf062505a21389",
"packages": [
{
"name": "api-platform/core",
@@ -196,16 +196,16 @@
},
{
"name": "brick/math",
"version": "0.14.7",
"version": "0.14.8",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
"reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50"
"reference": "63422359a44b7f06cae63c3b429b59e8efcc0629"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/07ff363b16ef8aca9692bba3be9e73fe63f34e50",
"reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50",
"url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629",
"reference": "63422359a44b7f06cae63c3b429b59e8efcc0629",
"shasum": ""
},
"require": {
@@ -244,7 +244,7 @@
],
"support": {
"issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/0.14.7"
"source": "https://github.com/brick/math/tree/0.14.8"
},
"funding": [
{
@@ -252,7 +252,7 @@
"type": "github"
}
],
"time": "2026-02-07T10:57:35+00:00"
"time": "2026-02-10T14:33:43+00:00"
},
{
"name": "doctrine/cache",
@@ -527,16 +527,16 @@
},
{
"name": "doctrine/dbal",
"version": "3.10.4",
"version": "3.10.5",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868"
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868",
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef",
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef",
"shasum": ""
},
"require": {
@@ -557,9 +557,9 @@
"jetbrains/phpstorm-stubs": "2023.1",
"phpstan/phpstan": "2.1.30",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "9.6.29",
"slevomat/coding-standard": "8.24.0",
"squizlabs/php_codesniffer": "4.0.0",
"phpunit/phpunit": "9.6.34",
"slevomat/coding-standard": "8.27.1",
"squizlabs/php_codesniffer": "4.0.1",
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
},
@@ -621,7 +621,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.10.4"
"source": "https://github.com/doctrine/dbal/tree/3.10.5"
},
"funding": [
{
@@ -637,7 +637,7 @@
"type": "tidelift"
}
],
"time": "2025-11-29T10:46:08+00:00"
"time": "2026-02-24T08:03:57+00:00"
},
{
"name": "doctrine/deprecations",
@@ -1223,16 +1223,16 @@
},
{
"name": "doctrine/migrations",
"version": "3.9.5",
"version": "3.9.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/migrations.git",
"reference": "1b823afbc40f932dae8272574faee53f2755eac5"
"reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/migrations/zipball/1b823afbc40f932dae8272574faee53f2755eac5",
"reference": "1b823afbc40f932dae8272574faee53f2755eac5",
"url": "https://api.github.com/repos/doctrine/migrations/zipball/ffd8355cdd8505fc650d9604f058bf62aedd80a1",
"reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1",
"shasum": ""
},
"require": {
@@ -1306,7 +1306,7 @@
],
"support": {
"issues": "https://github.com/doctrine/migrations/issues",
"source": "https://github.com/doctrine/migrations/tree/3.9.5"
"source": "https://github.com/doctrine/migrations/tree/3.9.6"
},
"funding": [
{
@@ -1322,7 +1322,7 @@
"type": "tidelift"
}
],
"time": "2025-11-20T11:15:36+00:00"
"time": "2026-02-11T06:46:11+00:00"
},
{
"name": "doctrine/orm",
@@ -1971,16 +1971,16 @@
},
{
"name": "intervention/image",
"version": "3.11.6",
"version": "3.11.7",
"source": {
"type": "git",
"url": "https://github.com/Intervention/image.git",
"reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1"
"reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/image/zipball/5f6d27d9fd56312c47f347929e7ac15345c605a1",
"reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1",
"url": "https://api.github.com/repos/Intervention/image/zipball/2159bcccff18f09d2a392679b81a82c5a003f9bb",
"reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb",
"shasum": ""
},
"require": {
@@ -2027,7 +2027,7 @@
],
"support": {
"issues": "https://github.com/Intervention/image/issues",
"source": "https://github.com/Intervention/image/tree/3.11.6"
"source": "https://github.com/Intervention/image/tree/3.11.7"
},
"funding": [
{
@@ -2043,7 +2043,7 @@
"type": "ko_fi"
}
],
"time": "2025-12-17T13:38:29+00:00"
"time": "2026-02-19T13:11:17+00:00"
},
{
"name": "jms/metadata",
@@ -3972,6 +3972,71 @@
],
"time": "2024-07-26T12:31:22+00:00"
},
{
"name": "symfony/css-selector",
"version": "v7.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
"reference": "63b9f8c9b3c28c43ad06764c67fe092af2576d17"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/63b9f8c9b3c28c43ad06764c67fe092af2576d17",
"reference": "63b9f8c9b3c28c43ad06764c67fe092af2576d17",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\CssSelector\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Jean-François Simon",
"email": "jeanfrancois.simon@sensiolabs.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/css-selector/tree/v7.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-05-31T14:55:39+00:00"
},
{
"name": "symfony/dependency-injection",
"version": "v7.0.10",
@@ -13041,71 +13106,6 @@
],
"time": "2023-02-07T11:34:05+00:00"
},
{
"name": "symfony/css-selector",
"version": "v7.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
"reference": "63b9f8c9b3c28c43ad06764c67fe092af2576d17"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/63b9f8c9b3c28c43ad06764c67fe092af2576d17",
"reference": "63b9f8c9b3c28c43ad06764c67fe092af2576d17",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\CssSelector\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Jean-François Simon",
"email": "jeanfrancois.simon@sensiolabs.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/css-selector/tree/v7.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-05-31T14:55:39+00:00"
},
{
"name": "symfony/maker-bundle",
"version": "v1.62.1",
@@ -13200,16 +13200,16 @@
},
{
"name": "symfony/phpunit-bridge",
"version": "v7.4.3",
"version": "v7.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/phpunit-bridge.git",
"reference": "f933e68bb9df29d08077a37e1515a23fea8562ab"
"reference": "53c5a606cb4ae19c9466a5f8ffe60f61b0c93b5f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/f933e68bb9df29d08077a37e1515a23fea8562ab",
"reference": "f933e68bb9df29d08077a37e1515a23fea8562ab",
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/53c5a606cb4ae19c9466a5f8ffe60f61b0c93b5f",
"reference": "53c5a606cb4ae19c9466a5f8ffe60f61b0c93b5f",
"shasum": ""
},
"require": {
@@ -13261,7 +13261,7 @@
"testing"
],
"support": {
"source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.3"
"source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.7"
},
"funding": [
{
@@ -13281,7 +13281,7 @@
"type": "tidelift"
}
],
"time": "2025-12-09T15:33:45+00:00"
"time": "2026-03-04T13:54:41+00:00"
},
{
"name": "symfony/web-profiler-bundle",

View File

@@ -29,13 +29,14 @@ framework:
'App\Domain\Manga\Application\Command\RefreshMangaChapters': commands
# Events spécifiques (pour compatibilité, peuvent être supprimés si tous implémentent AsyncDomainEvent)
'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events
# ChapterScrapingStarted est synchrone pour que la notif "démarrage" arrive AVANT le scraping
'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events
'App\Domain\Manga\Domain\Event\MangaCreated': events
'App\Domain\Shared\Domain\Event\ChapterImported': events
'App\Domain\Shared\Domain\Event\VolumeImported': events
'App\Domain\Shared\Domain\Event\ChapterScraped': events
# Legacy messages (à garder si nécessaire)
'App\Message\DownloadChapter': commands

View File

@@ -34,11 +34,11 @@ framework:
assets:
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
#when@prod:
# webpack_encore:
# # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
# # Available in version 1.2
# cache: true
when@prod:
webpack_encore:
# Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
# Available in version 1.2
cache: true
#when@test:
# webpack_encore:

View File

@@ -1,14 +1,14 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
vue_app:
path: /vue/{req}
path: /{req}
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: 'vue/index.html.twig'
req: ''
requirements:
req: ".*"
req: "^(?!api/|legacy).*"
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

View File

@@ -126,7 +126,12 @@ services:
tags:
- { name: messenger.message_handler, bus: command.bus }
App\Domain\Scraping\Infrastructure\Service\CbzGenerator: ~
App\Domain\Shared\Domain\Contract\ImageStorageInterface:
alias: App\Domain\Shared\Infrastructure\Service\ImageStorageManager
App\Domain\Shared\Infrastructure\Service\ImageStorageManager:
arguments:
$storagePath: '%kernel.project_dir%/public/images'
# Shared Manga Path/File Manager
App\Domain\Shared\Domain\Contract\MangaPathManagerInterface:

View File

@@ -12,10 +12,8 @@ services:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
public: true
App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryCbzGenerator'
arguments:
$projectDir: '%kernel.project_dir%'
App\Domain\Shared\Domain\Contract\ImageStorageInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage'
public: true
App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface:

View File

@@ -2,38 +2,135 @@
namespace Deployer;
require 'recipe/symfony.php';
// require 'contrib/webpack_encore.php';
require 'contrib/npm.php';
// Config
set('nodejs_version', 'node_22.x');
set('keep_releases', '3');
set('repository', 'gitea@git.test.nestor-server.fr:Colgora/Mangarr.git');
set('webpack_encore/env', 'production');
set('composer_options', '--verbose --prefer-dist --no-progress --no-interaction --optimize-autoloader');
// GITEA_TOKEN injecté depuis le secret Gitea (scope: read:repository)
$giteaToken = getenv('GITEA_TOKEN') ?: throw new \RuntimeException('GITEA_TOKEN secret is required');
set('repository', "https://{$giteaToken}@git.homelab.nestor-server.fr/colgora/Mangarr.git");
set('keep_releases', 3);
set('composer_options', '--no-dev --optimize-autoloader --no-interaction --prefer-dist --ignore-platform-reqs --no-scripts');
set('shared_files', ['.env.local','var/log/prod.log']);
set('shared_dirs', ['config/secrets','public/cbz','public/tmp','public/images']);
// add('writable_dirs', []);
// Copier vendor/ depuis la release précédente (hard links, quasi instantané)
// node_modules est géré par le shared mount /srv/mangarr/shared/node_modules
set('copy_dirs', ['vendor']);
desc('Runs webpack encore build');
task('webpack_encore:build', function () {
run("cd {{release_path}} && npm run build");
});
// Pas de shared_files ni shared_dirs : tout est géré par les volumes Docker
set('shared_files', []);
set('shared_dirs', []);
set('writable_dirs', []);
desc('Run messenger consume');
task('messenger:consume', function () {
run("sudo supervisorctl restart messenger-consume:*");
});
host('mangarr.test.nestor-server.fr')
->set('remote_user', 'colgora')
->set('deploy_path', '/var/www/mangarr')
host('production')
->set('hostname', getenv('DEPLOY_HOST')) // Injecté depuis le secret Gitea
->set('remote_user', 'deploy') // User avec accès docker group
->set('deploy_path', '/srv/mangarr')
->set('branch', 'main');
// Créer les dossiers que Docker doit monter comme volumes (gitignorés, absents de la release)
task('deploy:prepare_dirs', function () {
run('mkdir -p {{release_path}}/var {{release_path}}/public/images {{release_path}}/public/cbz {{release_path}}/public/tmp');
});
// composer install via container éphémère (pas de PHP sur l'hôte requis)
// --user assure que vendor/ appartient au user deploy et non root
// Skip si composer.lock inchangé et vendor/ déjà populé (hard-linké depuis la release précédente)
task('deploy:vendors', function () {
$releaseDir = get('release_path');
$previousDir = get('previous_release');
if ($previousDir !== null) {
$lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1");
$vendorPopulated = test("[ -d $releaseDir/vendor/composer ]");
if ($lockUnchanged && $vendorPopulated) {
writeln('<info>deploy:vendors skipped — composer.lock unchanged</info>');
return;
}
}
run('docker run --rm --user $(id -u):$(id -g) -v {{release_path}}:/app -w /app composer:2 install {{composer_options}}');
});
// Build assets via container node éphémère
// 3 couches d'optimisation :
// 1. Skip total si aucun fichier front-end n'a changé (hard-link public/build/)
// 2. Skip npm install si package-lock.json inchangé (node_modules partagé persistant)
// 3. Cache npm et webpack persistants entre les releases
desc('Build Webpack Encore assets');
task('webpack_encore:build', function () {
$sharedDir = '/srv/mangarr/shared';
$sharedWebpackCache = "$sharedDir/webpack_cache";
$sharedNodeModules = "$sharedDir/node_modules";
$sharedNpmCache = "$sharedDir/npm_cache";
run("mkdir -p $sharedWebpackCache $sharedNodeModules $sharedNpmCache");
$releaseDir = get('release_path');
$previousDir = get('previous_release'); // null au 1er déploiement
// --- COUCHE 1 : skip total si aucun fichier front-end n'a changé ---
if ($previousDir !== null) {
$watchList = ['assets', 'templates', 'package.json', 'package-lock.json',
'webpack.config.js', 'postcss.config.js', 'tailwind.config.js'];
$diffChecks = implode(' && ', array_map(
fn($p) => "diff -rq --no-dereference $previousDir/$p $releaseDir/$p > /dev/null 2>&1",
$watchList
));
$hasPreviousBuild = test("[ -d $previousDir/public/build ] && [ -f $previousDir/public/build/manifest.json ]");
if ($hasPreviousBuild && test("($diffChecks)")) {
run("cp -al $previousDir/public/build $releaseDir/public/build");
writeln('<info>webpack_encore:build skipped — no front-end files changed</info>');
return;
}
}
// --- COUCHE 2 : skip npm install si package-lock.json inchangé ---
$needsNpmInstall = true;
if ($previousDir !== null) {
$lockUnchanged = test("diff -q $previousDir/package-lock.json $releaseDir/package-lock.json > /dev/null 2>&1");
$nmPopulated = test("[ -d $sharedNodeModules/.bin ]");
if ($lockUnchanged && $nmPopulated) {
$needsNpmInstall = false;
}
}
// --- COUCHE 3 : build docker avec caches persistants ---
$installCmd = $needsNpmInstall
? 'npm install --prefer-offline && npm run build'
: 'npm run build';
run("docker run --rm \
--user \$(id -u):\$(id -g) \
-v $releaseDir:/app \
-v $sharedNodeModules:/app/node_modules \
-v $sharedWebpackCache:/app/node_modules/.cache \
-v $sharedNpmCache:/npm_cache \
-e npm_config_cache=/npm_cache \
-e PUPPETEER_SKIP_DOWNLOAD=1 \
-w /app \
node:22-alpine \
sh -c '$installCmd'");
});
// Restart Docker containers (entrypoint gère les migrations automatiquement)
// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage
// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release.
desc('Restart Docker containers');
task('docker:restart', function () {
run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler');
run('docker restart mangarr');
run('docker exec mangarr php bin/console cache:clear --env=prod');
});
// Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin
// Le cache et les migrations sont gérés par l'entrypoint.sh au démarrage du container
task('deploy:cache:clear', function () {});
task('deploy:cache:warmup', function () {});
// Hooks
after('deploy:vendors', 'npm:install');
after('npm:install', 'webpack_encore:build');
after('deploy:vendors', 'database:migrate');
after('deploy:symlink', 'messenger:consume');
after('deploy:update_code', 'deploy:prepare_dirs');
after('deploy:prepare_dirs', 'deploy:copy_dirs');
after('deploy:vendors', 'webpack_encore:build');
after('deploy:symlink', 'docker:restart');
after('deploy:failed', 'deploy:unlock');

1965
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,7 @@
"react-router-dom": "^7.1.5",
"sortablejs": "^1.15.2",
"tailwindcss": "^3.2.7",
"vue-i18n": "^11.3.0",
"vuedraggable": "^2.24.3"
}
}

View File

@@ -65,7 +65,7 @@ return static function (Config $config): void {
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
->should(new NotHaveDependencyOutsideNamespace(
"App\Domain\\$domain",
array_merge($standardExceptions, $externalDependencies, ['App\Domain\Shared\Domain\Contract'])
array_merge($standardExceptions, $externalDependencies, ['App\Domain\Shared\Domain\Contract', 'App\Domain\Shared\Domain\Event'])
))
->because("la couche Application de $domain ne peut dépendre que de son propre domaine, des contrats partagés et des dépendances autorisées");

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Domain\Shared\Domain\Contract\NotificationInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:notify:test',
description: 'Envoie une notification de test via Mercure (utile en dev/prod pour vérifier le système)',
)]
class SendTestNotificationCommand extends Command
{
public function __construct(
private readonly NotificationInterface $notification
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('type', 't', InputOption::VALUE_REQUIRED, 'Type de notification : info, success, error, warning', 'info')
->addOption('message', 'm', InputOption::VALUE_REQUIRED, 'Message à envoyer', 'Notification de test depuis Mangarr');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$type = $input->getOption('type');
$message = $input->getOption('message');
$allowed = ['info', 'success', 'error', 'warning'];
if (!in_array($type, $allowed, true)) {
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
return Command::FAILURE;
}
match ($type) {
'success' => $this->notification->sendSuccess($message),
'error' => $this->notification->sendError($message),
'warning' => $this->notification->sendWarning($message),
default => $this->notification->sendInfo($message),
};
$output->writeln(sprintf('<info>[%s] Notification envoyée : %s</info>', strtoupper($type), $message));
return Command::SUCCESS;
}
}

View File

@@ -53,7 +53,7 @@ class MangaController extends AbstractController
$this->imageManager = new ImageManager(new Driver());
}
#[Route('/', name: 'app_manga')]
#[Route('/legacy', name: 'app_legacy')]
public function index(Request $request): Response
{
$sort = $request->query->get('sort', 'title');

View File

@@ -15,7 +15,7 @@ class SecurityController extends AbstractController
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] User $user = null): Response
{
if(!$user) {
if (!$user) {
return $this->json([
'error' => 'Invalid credentials'
], 401);

View File

@@ -8,7 +8,6 @@ use App\Form\ContentSourceType;
use App\Manager\AppSettingsManager;
use App\Manager\Toolbar\Factory\ToolbarFactory;
use App\Repository\ContentSourceRepository;
use App\Service\NotificationService;
use App\Service\Scraper\MangaScraperService;
use Doctrine\ORM\EntityManagerInterface;

View File

@@ -46,7 +46,7 @@ class TestController extends AbstractController
$changed = 0;
foreach ($mangas as $manga) {
//si getImageUrl() retourne un lien sous la forme d'une URL (https ou http)
if($manga->getImageUrl()) {
if ($manga->getImageUrl()) {
$imageUrls = $this->processAndSaveImage($manga->getImageUrl());
$manga->setThumbnailUrl($imageUrls['thumbnail']);
$this->mangaRepository->save($manga, true);

View File

@@ -8,5 +8,6 @@ final readonly class ConvertFileCommand
public string $filePath,
public string $originalFilename,
public int $fileSize
) {}
) {
}
}

View File

@@ -14,7 +14,8 @@ final readonly class ConvertFileCommandHandler
public function __construct(
private ConversionServiceInterface $conversionService
) {}
) {
}
public function handle(ConvertFileCommand $command): ConversionResponse
{

View File

@@ -11,7 +11,8 @@ final readonly class ConversionResponse
public string $outputFilename,
public int $originalFileSize,
public int $convertedFileSize
) {}
) {
}
public static function fromConversionResult(ConversionResult $result): self
{

View File

@@ -8,7 +8,8 @@ final readonly class ConversionRequest
private string $filePath,
private string $originalFilename,
private int $fileSize
) {}
) {
}
public function getFilePath(): string
{

View File

@@ -9,7 +9,8 @@ final readonly class ConversionResult
private string $outputFilename,
private int $originalFileSize,
private int $convertedFileSize
) {}
) {
}
public function getConvertedFilePath(): string
{

View File

@@ -17,7 +17,8 @@ final class ConvertFileController extends AbstractController
{
public function __construct(
private readonly ConvertFileCommandHandler $commandHandler
) {}
) {
}
public function __invoke(Request $request): Response
{
@@ -47,6 +48,7 @@ final class ConvertFileController extends AbstractController
// Retourner le fichier converti
$fileContent = file_get_contents($response->convertedFilePath);
@unlink($response->convertedFilePath);
return new Response(
content: $fileContent,

View File

@@ -8,5 +8,6 @@ readonly class ChapterEditData
public string $id,
public ?string $title = null,
public ?int $volume = null
) {}
) {
}
}

View File

@@ -8,5 +8,6 @@ readonly class CheckMonitoredMangas
{
public function __construct(
public ?DateTimeImmutable $since = null
) {}
) {
}
}

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