294 Commits

Author SHA1 Message Date
ext.jeremy.guillot@maxicoffee.domains
81fc713149 chore: supprimer les dépendances Twig/Stimulus/React/Turbo inutilisées
PHP : suppression de symfony/stimulus-bundle, ux-live-component, ux-react,
ux-turbo, twig/extra-bundle et symfony/form (plus utilisés depuis la
migration vers Vue.js SPA).

npm : suppression de @hotwired/stimulus, @hotwired/turbo, react, react-dom,
alpinejs, bootstrap, daisyui, sortablejs, vuedraggable et leurs types.
Corrige l'erreur de déploiement causée par vitest@^4.1.0 (introuvable)
requis par les anciens packages @symfony/ux-react et @symfony/ux-turbo v2.33.0.
2026-03-26 18:33:50 +01:00
ext.jeremy.guillot@maxicoffee.domains
69c6757cf8 fix: corriger l'erreur HTTP 400 sur les endpoints content-sources POST/PUT
- ContentSourceForm.vue : convertir testChapterNumber en float/null avant
  envoi (évite d'envoyer "" pour ?float, rejeté par Symfony 8 strict)
- UpsertContentSourceResource : ajouter collectDenormalizationErrors: true
  pour que les erreurs de type retournent 422 au lieu de 400 via le
  chemin input: de API Platform 4
- ContentSource entity : corriger setImageSelector(string) → setImageSelector(?string)
  cohérent avec la colonne nullable
- Ajouter les tests manquants (testChapterNumber float/null/chaîne vide)
  qui auraient détecté ces bugs plus tôt
2026-03-26 18:22:31 +01:00
ext.jeremy.guillot@maxicoffee.domains
21d8111734 fix: migrer les données manga.genres de PHP sérialisé vers JSON
La migration vers DBAL 4 a changé le type de colonne genres de
Types::ARRAY (PHP sérialisé) vers Types::JSON. Les données existantes
en base doivent être converties via preUp() avant l'ALTER TABLE.
2026-03-26 17:58:07 +01:00
ext.jeremy.guillot@maxicoffee.domains
5ed303612a feat: migrer vers Symfony 8, PHP 8.4 et les dépendances majeures associées
- PHP 8.3 → 8.4 (Dockerfile + composer.json)
- Symfony 7.0 → 8.0 (tous les composants symfony/*)
- API Platform 3.x → 4.x : migration openapiContext → openapi: new Operation(...)
- Doctrine DBAL 3 → 4 : suppression use_savepoints, replace prepare/executeQuery
- Doctrine ORM 2.x → 3.x : ClassMetadataInfo → ClassMetadata, setParameters → setParameter
- Doctrine Bundle 2.x → 3.x, Fixtures Bundle 3.x → 4.x
- zenstruck/foundry 1.x → 2.x : ModelFactory → PersistentObjectFactory, getDefaults → defaults
- phpmd/phpmd 2.x → 3.x-dev (seule version supportant Symfony 8)
- phparkitect 0.3 → 0.8 : NotDependsOnTheseNamespaces prend un array
- symfony/mercure-bundle 0.3 → 0.4, symfony/monolog-bundle 3 → 4
- Suppression de runtime/frankenphp-symfony (intégré nativement dans symfony/runtime 8)
- worker.Caddyfile : suppression de APP_RUNTIME (détection automatique Symfony 8)
- Routes errors.xml/wdt.xml/profiler.xml → .php (Symfony 8 supprime le XML)
- Types::ARRAY → Types::JSON dans Entity/Manga.php (DBAL 4 retire array type)
- Suppression de src/Schedule.php (doublon vide avec MonitoringSchedule)
- Tests : hydra:Collection → Collection, hydra:member → member (API Platform 4)
2026-03-26 17:55:12 +01:00
ext.jeremy.guillot@maxicoffee.domains
5a0888eb28 refactor: supprimer tout le code legacy MVC/Twig/Stimulus
Supprime toutes les couches pré-DDD pour ne garder que l'architecture
hexagonale (src/Domain/), les entités Doctrine et le front Vue.js SPA.

Supprimé :
- src/Controller/ (9 controllers Twig, garde SecurityController)
- src/Service/, src/Message/, src/MessageHandler/ (services et messages legacy)
- src/Manager/, src/Twig/, src/Form/ (UI legacy)
- src/Event/, src/EventListener/, src/EventSubscriber/QueueStatusSubscriber
- src/Client/MangadexClient.php (doublon du Domain)
- src/Interface/, src/Factory/, src/DataFixtures/, src/Scheduler/MainSchedule
- templates/ (tous sauf vue/ et base retiré — SecurityController = pur JSON)
- assets/controllers/ (20 Stimulus controllers), app.js, bootstrap.js, controllers.json

Modifié :
- config/routes.yaml : suppression du chargement des controllers legacy
- config/packages/messenger.yaml : suppression des routes legacy
- config/services.yaml : suppression des bindings legacy + entrées Domain\Import fantômes
- webpack.config.js : suppression entry 'app' et enableStimulusBridge
- src/Entity/Chapter.php : suppression #[Broadcast] (Turbo Streams legacy)

Déplacé :
- src/Factory/*.php → tests/Factory/ (namespace App\Tests\Factory)
2026-03-26 17:00:46 +01:00
d7e6bf56d0 Merge pull request 'fix(layout): corriger la barre blanche en bas sur mobile réel' (#33) from fix/mobile-dvh-viewport into main
All checks were successful
Deploy / deploy (push) Successful in 1m12s
Reviewed-on: #33
2026-03-26 16:35:29 +01:00
17d44f68e5 Merge branch 'main' into fix/mobile-dvh-viewport 2026-03-26 16:35:22 +01:00
ext.jeremy.guillot@maxicoffee.domains
90d6feee2d fix(layout): corriger la barre blanche en bas sur mobile réel
Sur un vrai appareil mobile, 100vh inclut le chrome du navigateur
(barre de navigation), ce qui cachait le contenu en bas.
- Remplace h-screen (100vh) par h-[100dvh] (dynamic viewport height)
- Ajoute viewport-fit=cover pour préparer le support safe-area iOS
2026-03-26 16:35:00 +01:00
0880a77546 Merge pull request 'fix(layout): corriger le scroll coupé sur mobile' (#32) from fix/mobile-scroll-layout into main
All checks were successful
Deploy / deploy (push) Successful in 1m12s
Reviewed-on: #32
2026-03-26 16:27:18 +01:00
ext.jeremy.guillot@maxicoffee.domains
9926da6730 fix(layout): corriger le scroll coupé sur mobile
RouterView n'avait pas de contrainte de hauteur dans le flex-col de main,
empêchant overflow-y-auto de se déclencher. Le contenu dépassait la hauteur
de main.overflow-hidden et était silencieusement tronqué sur mobile.
2026-03-26 16:25:36 +01:00
4c80aa6b42 Merge pull request 'fix(reader): corriger le scroll vers le haut bloqué en mode infini' (#31) from fix/reader-infinite-scroll-up into main
All checks were successful
Deploy / deploy (push) Successful in 1m12s
Reviewed-on: #31
2026-03-26 16:11:50 +01:00
c0307a9173 Merge branch 'main' into fix/reader-infinite-scroll-up 2026-03-26 16:11:43 +01:00
ext.jeremy.guillot@maxicoffee.domains
45f7e88024 fix(reader): corriger le scroll vers le haut bloqué en mode infini
Les IntersectionObserver utilisaient root: null (viewport) au lieu du
conteneur de scroll réel (.infinite-reader). Le rootMargin de 1000px
était donc calculé par rapport au viewport, causant un montage/démontage
des pages à des moments imprécis et des sauts de layout lors du scroll
vers le haut.

Supprime également scroll-behavior: smooth sur le conteneur, qui entrait
en conflit avec le scroll anchoring du navigateur lors des corrections de
position, donnant l'impression que le scroll redescendait tout seul.
2026-03-26 16:11:19 +01:00
507fac5b5e Merge pull request 'fix(reader): corriger le chevauchement des pages en mode scroll avec zoom' (#30) from fix/reader-zoom-overlap-mobile into main
All checks were successful
Deploy / deploy (push) Successful in 1m19s
Reviewed-on: #30
2026-03-26 15:58:06 +01:00
071e12a06c Merge branch 'main' into fix/reader-zoom-overlap-mobile 2026-03-26 15:57:51 +01:00
ext.jeremy.guillot@maxicoffee.domains
59f72339fa fix(reader): corriger le chevauchement des pages en mode scroll avec zoom
En mode scroll infini, le zoom était appliqué via transform: scale() qui
n'affecte pas le flux de mise en page. Les pages se chevauchaient visuellement
quand le zoom était modifié. Passage à la propriété CSS zoom dans les deux
modes pour un comportement de layout correct.

Met aussi à jour le calcul de hauteur des placeholders pour inclure le
facteur de zoom et éviter les sauts de layout lors du chargement paresseux.
2026-03-26 15:56:18 +01:00
3963efa986 Merge pull request 'feat(system): page Status avec endpoint API Platform et composants Vue' (#29) from feat/system-status-page into main
All checks were successful
Deploy / deploy (push) Successful in 2m33s
Reviewed-on: #29
2026-03-17 22:05:27 +01:00
ext.jeremy.guillot@maxicoffee.domains
ca8791cc0d feat(system): page Status avec endpoint API Platform et composants Vue
- Nouveau domaine System/Domain/Model/SystemStatus (value object)
- QueryHandler agrégeant métriques mangas, chapitres, jobs (global/24h/7j), stockage et sources
- Endpoint GET /api/system/status via API Platform (singleton)
- Calcul de l'espace disque par RecursiveDirectoryIterator sur public/images
- Page Vue /system/status avec 6 cards (Mangas, Chapitres, Jobs, Stockage, Sources, Système)
- Nettoyage du router : suppression des PlaceholderComponent et routes placeholder
- Sidebar : suppression des entrées sans page réelle
2026-03-17 22:04:48 +01:00
c2b55e9018 Merge pull request 'feat/activity-realtime-mercure' (#28) from feat/activity-realtime-mercure into main
All checks were successful
Deploy / deploy (push) Successful in 2m39s
Reviewed-on: #28
2026-03-17 16:30:32 +01:00
ext.jeremy.guillot@maxicoffee.domains
07d1b2daed feat(activity): mises à jour temps réel des jobs via Mercure
- Ajoute jobId dans ChapterScrapingStarted et ChapterScrapingFailed
- Publie job.created (PENDING) depuis ScrapeChapterStateProcessor
- Publie job.status_changed (in_progress/completed/failed) depuis ScrapingEventSubscriber
- Gère job.created et job.status_changed dans activityStore : ajout instantané et suppression différée (1.5s)
2026-03-17 16:29:47 +01:00
a7e6879e83 Merge pull request 'refactor(scraping): job PENDING dès le POST HTTP, handler sans Doctrine' (#27) from refactor/scraping-ddd-pending-job into main
All checks were successful
Deploy / deploy (push) Successful in 2m29s
Reviewed-on: #27
2026-03-17 15:38:55 +01:00
ext.jeremy.guillot@maxicoffee.domains
fa035bfbfa refactor(scraping): job PENDING dès le POST HTTP, handler sans Doctrine
- ScrapingJob: mangaId/chapterNumber/sourceId optionnels (nullable) pour
  permettre la création en PENDING sans lookup DB dans le StateProcessor
- ScrapeChapter: ajoute jobId (pré-généré par le StateProcessor)
- ScrapeChapterStateProcessor: crée et persiste le job PENDING avant
  dispatch; injecte JobRepositoryInterface uniquement
- ScrapeChapterHandler: supprime EntityManagerInterface, beginTransaction/
  commit/rollback; charge le job existant via jobId, complete() sur succès
  seulement, fail() si toutes les sources échouent
- ScrapeChapterHandlerTest: pré-crée le job, passe jobId dans la commande,
  supprime le mock EntityManagerInterface
- ScrapeChapterTest: accès aux messages via static InMemoryMessageBus,
  vérifie la présence du jobId dans la commande dispatchée
2026-03-17 15:33:20 +01:00
ext.jeremy.guillot@maxicoffee.domains
ec4a8be934 feat(system): ajouter filtre par statut dans la page Logs
All checks were successful
Deploy / deploy (push) Successful in 2m30s
- Filtre toolbar : Échecs / Terminés / Tous (défaut : Échecs)
- Badge statut sur chaque LogItem (vert Terminé / rouge Échec)
- deleteAllLogs respecte le filtre actif
2026-03-16 15:00:12 +01:00
8443120c2f Merge pull request 'feat(system): implémenter la page Logs des erreurs de scraping' (#26) from feat/system-logs into main
All checks were successful
Deploy / deploy (push) Successful in 2m32s
Reviewed-on: #26
2026-03-16 14:50:14 +01:00
7a8f749f3f Merge branch 'main' into feat/system-logs 2026-03-16 14:50:04 +01:00
ext.jeremy.guillot@maxicoffee.domains
670e3f5315 feat(system): implémenter la page Logs des erreurs de scraping
- Nouveau domaine `system` avec `logsStore` (Pinia) filtré sur
  status=failed&type=scraping_job, tri, pagination et suppression
- Composant `LogItem` : affiche titre manga, chapitre, date, durée,
  domaine source (lien vers page d'édition), badge type scraping,
  slug utilisé, message d'erreur expandable
- Page `LogsPage` : toolbar avec badge total, dropdown tri, rafraîchir,
  tout supprimer ; charge les ContentSources pour enrichir l'affichage
- Route /system/logs branchée sur LogsPage
- ApiJobRepository : ajout du paramètre `type` dans getJobs
- Job entity : ajout des champs startedAt et completedAt
2026-03-16 14:43:19 +01:00
4398170989 Merge pull request 'feat(setting): implémenter la suppression d'une ContentSource' (#25) from feat/delete-content-source into main
All checks were successful
Deploy / deploy (push) Successful in 2m57s
Reviewed-on: #25
2026-03-16 00:27:59 +01:00
ext.jeremy.guillot@maxicoffee.domains
fc4ab68e8b feat(setting): implémenter la suppression d'une ContentSource
- Ajoute DeleteContentSourceCommand + CommandHandler (CQRS)
- Expose DELETE /api/content-sources/{id} via API Platform (Resource, Provider, Processor)
- Ajoute 2 tests Feature (204 succès, 404 not found)
- Frontend : méthode delete() dans le repository, action deleteSource() dans le store
- Nouveau composant ContentSourceDeleteModal (modale de confirmation)
- Bouton Supprimer dans la toolbar de ScrapperEdit (visible en mode édition uniquement)
2026-03-16 00:27:31 +01:00
36f873aaca Merge pull request 'feat/scrapers-content-sources-healthcheck' (#24) from feat/scrapers-content-sources-healthcheck into main
All checks were successful
Deploy / deploy (push) Successful in 3m17s
Reviewed-on: #24
2026-03-16 00:11:52 +01:00
ext.jeremy.guillot@maxicoffee.domains
874003eb35 fix(scraping): corriger les 403 sur les images avec protection anti-hotlink
- Ajouter le header Referer (origin de l'image) dans ImageDownloader pour les téléchargements backend
- Ajouter referrerpolicy="no-referrer" sur les <img> de la modale de test pour les previews navigateur
2026-03-16 00:11:17 +01:00
ext.jeremy.guillot@maxicoffee.domains
01474c264b feat(scraping): implémenter le health check de tous les scrapers
- Commande CheckAllScrapersHealth + handler avec ports dédiés
- Value Object ContentSourceHealthCheckData
- Resource API Platform et State Processor
- Adapters InMemory et tests unitaires + fonctionnels
2026-03-16 00:11:17 +01:00
ext.jeremy.guillot@maxicoffee.domains
795cbeccc3 feat(setting): étendre ContentSource avec champs de test et domain model
- Ajouter testSlug, testChapterNumber, baseUrl sur ContentSource (entité, domain model, migration)
- Exposer ces champs dans les Resources, Processors, Providers et Mapper
- Mettre à jour store Pinia, repository API et composants Vue (form, card, liste)
2026-03-16 00:11:17 +01:00
b0ce36096f Merge pull request 'feat(ui): harmoniser les pages Scrapers sur le design system Mangarr' (#23) from feat/ui-scrapers-harmonization into main
All checks were successful
Deploy / deploy (push) Successful in 2m58s
Reviewed-on: #23
2026-03-15 22:52:56 +01:00
ext.jeremy.guillot@maxicoffee.domains
da8a19cbcb feat(ui): harmoniser les pages Scrapers sur le design system Mangarr
- Layout canonique px-6 py-8 + sections border-t (suppression container mx-auto)
- Toolbar : label titre + bouton retour (ScrapperEdit) + boutons actions (ScrapperConfigurations)
- Bouton submit déplacé dans la toolbar droite via defineExpose/ref
- ContentSourceForm aplati (suppression du wrapper carte et du header)
- Séparation des sections du formulaire par border-t
- Suppression de tous les rounded-* sur les 4 composants
- Suppression du bloc debug "aucune source" et du h1 volant
2026-03-15 22:52:23 +01:00
ext.jeremy.guillot@maxicoffee.domains
367b361eef fix(manga): afficher la plage de chapitres au lieu du numéro de volume dans la liste
All checks were successful
Deploy / deploy (push) Successful in 2m58s
Pour les chapitres regroupés en volume (isVolumeGroup), la colonne "#" affichait
"Vol. X" au lieu du numéro/plage de chapitres. Remplacé par volumeChaptersRange.
2026-03-15 22:21:19 +01:00
ext.jeremy.guillot@maxicoffee.domains
9c5ae4bf16 fix(scheduler): désactiver MainSchedule legacy au profit de MonitoringSchedule DDD
All checks were successful
Deploy / deploy (push) Successful in 2m53s
MainSchedule (toutes les 6h) et MonitoringSchedule (toutes les 2h) tournaient
en parallèle sur les mêmes mangas surveillés, causant des doubles appels MangaDex
et des doublons de scraping.
2026-03-15 22:08:46 +01:00
ext.jeremy.guillot@maxicoffee.domains
6b58e94fc3 fix(manga): corriger le conflit de shortName sur MangaDiscoverResource
All checks were successful
Deploy / deploy (push) Successful in 2m56s
2026-03-15 21:56:41 +01:00
e78bc890ef Merge pull request 'feat(manga): implémenter la page Découvrir avec recommandations MangaDex' (#22) from feat/discover-page into main
All checks were successful
Deploy / deploy (push) Successful in 2m50s
Reviewed-on: #22
2026-03-15 21:44:43 +01:00
47c33d549b Merge branch 'main' into feat/discover-page 2026-03-15 21:44:28 +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
1478b460ba Merge pull request 'style(manga): refondre la page d'ajout de manga sur le design system' (#21) from style/add-manga-ui-redesign into main
All checks were successful
Deploy / deploy (push) Successful in 2m45s
Reviewed-on: #21
2026-03-15 20:56:29 +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
4cd277aec7 Merge pull request 'fix(migration): DROP INDEX IF EXISTS pour messenger_messages' (#2) from feat/chapter-entity-image-storage into main
All checks were successful
Build and Deploy / deploy (push) Successful in 1m36s
Reviewed-on: #2
2026-03-09 19:36:19 +01:00
ext.jeremy.guillot@maxicoffee.domains
640d1cec82 fix(migration): DROP INDEX IF EXISTS pour messenger_messages
Les index idx_available_at/idx_delivered_at/idx_queue_available/idx_queue_name
n'existent pas sur tous les environnements. IF EXISTS évite l'erreur 42704.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:33:26 +01:00
02760effe6 Merge pull request 'feat/chapter-entity-image-storage' (#1) from feat/chapter-entity-image-storage into main
All checks were successful
Build and Deploy / deploy (push) Successful in 1m7s
Reviewed-on: #1
2026-03-09 19:25:22 +01:00
ext.jeremy.guillot@maxicoffee.domains
b52b27189d docs(claude): mise à jour skill testing-strategy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:17:12 +01:00
ext.jeremy.guillot@maxicoffee.domains
ff451855a7 fix(manga): ChapterResponse.createdAt en string RFC3339
- ChapterResponse expose createdAt comme string formatée (RFC3339)
- GetMangaChaptersHandler formate la date à la construction du DTO
- GetMangaChaptersStateProvider adapté en conséquence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:16:26 +01:00
ext.jeremy.guillot@maxicoffee.domains
2c051351a8 refactor(manga): Chapter entité DDD de Manga + AggregateRoot
- Ajoute AggregateRoot dans Shared (domain events + pull pattern)
- Manga extends AggregateRoot, devient vrai aggregate root DDD
- Chapter passe de readonly à entité mutable avec MangaId VO
- Manga expose les méthodes domaine pour toute mutation de chapitre :
  addChapter, updateChapterTitle/Volume/Pages, hideChapter, removeChapterPages
- Supprime saveChapter/updateChapter/deleteChapter de MangaRepositoryInterface
- save(Manga) gère désormais la persistance des chapitres via pull pattern
- Tous les handlers/listeners passent par l'agrégat (plus d'accès direct)
- phparkitect autorise AggregateRoot dans les couches Domain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:15:11 +01:00
ext.jeremy.guillot@maxicoffee.domains
a4b3d8a5f1 test(manga): ajout test regression GET /api/mangas avec chapitres
Détecte le crash EAGER loading Doctrine si la colonne pages_directory
est absente de la table chapter (SQLSTATE 42703).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:07:34 +01:00
ext.jeremy.guillot@maxicoffee.domains
c50f1638ee refactor(manga): merge ChapterRepositoryInterface into MangaRepositoryInterface + pagesDirectory
- Supprime ChapterRepositoryInterface du domaine Manga (et ses implémentations
  LegacyChapterRepository et InMemoryChapterRepository)
- Déplace toutes les méthodes chapter vers MangaRepositoryInterface avec nommage
  explicite (findChapterById, findVisibleChapterById, updateChapter, deleteChapter, etc.)
- Remplace cbzPath par pagesDirectory + pageCount dans le modèle Chapter
  (transition : toChapterDomain conserve un fallback cbzPath pour les données existantes,
  updateChapter synchronise les deux colonnes jusqu'à la Phase 4)
- Ajoute la migration Doctrine (pages_directory, page_count sur la table chapter)
- Met à jour tous les handlers, listeners, query handlers et state providers du domaine
  Manga pour injecter uniquement MangaRepositoryInterface
- Adapte les tests unitaires et InMemoryMangaRepository avec les nouvelles méthodes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:54:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
dae215dd3d feat: ajout de claude + correction des tests
All checks were successful
Build and Deploy / deploy (push) Successful in 9m36s
2026-03-09 17:09:31 +01:00
ext.jeremy.guillot@maxicoffee.domains
b5a832fbbc fix: delete manga
All checks were successful
Build and Deploy / deploy (push) Successful in 1m2s
2026-02-11 16:27:11 +01:00
ext.jeremy.guillot@maxicoffee.domains
f75b535426 fix: .env.example MESSENGER_DSN
All checks were successful
Build and Deploy / deploy (push) Successful in 52s
2026-02-11 16:13:01 +01:00
ext.jeremy.guillot@maxicoffee.domains
74e321bc50 fix: .env.example CORS_ALLOW_ORIGIN
All checks were successful
Build and Deploy / deploy (push) Successful in 50s
2026-02-11 16:06:01 +01:00
ext.jeremy.guillot@maxicoffee.domains
20f1211d5b fix: .env.example placeholders
All checks were successful
Build and Deploy / deploy (push) Successful in 1m38s
2026-02-11 16:00:38 +01:00
ext.jeremy.guillot@maxicoffee.domains
eafcc58d84 feat: cp du env.example
Some checks failed
Build and Deploy / deploy (push) Failing after 2m54s
2026-02-11 15:53:17 +01:00
ext.jeremy.guillot@maxicoffee.domains
c18f3653b8 feat: ignore .env
Some checks failed
Build and Deploy / deploy (push) Failing after 25s
2026-02-08 23:28:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
ec8a45a500 fix: test deploy images
All checks were successful
Build and Deploy / deploy (push) Successful in 54s
2026-02-08 23:11:17 +01:00
ext.jeremy.guillot@maxicoffee.domains
889646afda fix: test deploy images
All checks were successful
Build and Deploy / deploy (push) Successful in 1m36s
2026-02-08 23:02:02 +01:00
ext.jeremy.guillot@maxicoffee.domains
af84deadd2 feat: test success
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
2026-02-08 22:53:56 +01:00
ext.jeremy.guillot@maxicoffee.domains
4d18c45af1 fix: test deploy
All checks were successful
Build and Deploy / deploy (push) Successful in 1m28s
2026-02-08 22:50:36 +01:00
ext.jeremy.guillot@maxicoffee.domains
8d261a9de3 feat: deploy
All checks were successful
Build and Deploy / deploy (push) Successful in 2m35s
2026-02-08 22:45:57 +01:00
ext.jeremy.guillot@maxicoffee.domains
8bebde2f58 feat: deploy
Some checks failed
Build and Deploy / deploy (push) Failing after 1s
2026-02-08 22:43:22 +01:00
ext.jeremy.guillot@maxicoffee.domains
5a3e68fa2a fix: assets
Some checks failed
Build and Deploy / deploy (push) Failing after 0s
2026-02-08 22:21:33 +01:00
ext.jeremy.guillot@maxicoffee.domains
c03cad6028 fix: Dockerfile DATABASE_URL
Some checks failed
Build and Deploy / deploy (push) Failing after 0s
2026-02-08 22:04:54 +01:00
ext.jeremy.guillot@maxicoffee.domains
03b0e5a34f fix: Dockerfile DATABASE_URL
Some checks failed
Build and Deploy / deploy (push) Failing after 1s
2026-02-08 22:04:00 +01:00
ext.jeremy.guillot@maxicoffee.domains
d8f8984192 fix: Dockerfile npm install
Some checks failed
Build and Deploy / deploy (push) Failing after 1s
2026-02-08 21:59:55 +01:00
ext.jeremy.guillot@maxicoffee.domains
58f68541f4 fix: composer.lock sync
Some checks failed
Build and Deploy / deploy (push) Failing after 1s
2026-02-08 21:56:38 +01:00
ext.jeremy.guillot@maxicoffee.domains
f472e250eb fix: composer.lock sync
Some checks failed
Build and Deploy / deploy (push) Failing after 0s
2026-02-08 21:54:59 +01:00
ext.jeremy.guillot@maxicoffee.domains
89b074113c fix: build
Some checks failed
Build and Deploy / deploy (push) Failing after 0s
2026-02-08 21:52:56 +01:00
ext.jeremy.guillot@maxicoffee.domains
134b4679ae fix: package-lock sync
Some checks failed
Build and Deploy / deploy (push) Failing after 0s
2026-02-08 21:47:30 +01:00
ext.jeremy.guillot@maxicoffee.domains
fb6a61d5b6 feat: deploy
Some checks failed
Build and Deploy / deploy (push) Failing after 21s
2026-02-08 21:35:13 +01:00
ext.jeremy.guillot@maxicoffee.domains
21a87a3eb3 feat: update readme 2026-02-08 18:17:44 +01:00
ext.jeremy.guillot@maxicoffee.domains
ffceda606f feat: commit before changing gitea 2026-02-08 17:58:01 +01:00
ext.jeremy.guillot@maxicoffee.domains
b05bd98f63 feat: affichage des cartes lors de l'analyse 2025-10-16 15:44:01 +02:00
ext.jeremy.guillot@maxicoffee.domains
9e7f7b4cfc fix: more patterns 2025-10-16 14:35:58 +02:00
ext.jeremy.guillot@maxicoffee.domains
50b33f53d7 fix: fix search 2025-10-15 16:31:58 +02:00
ext.jeremy.guillot@maxicoffee.domains
3170a7c60e feat: analyse import + all tests fixed 2025-10-15 16:14:15 +02:00
ext.jeremy.guillot@maxicoffee.domains
fbe9619224 fix: warnings navigateur 2025-08-01 15:22:54 +02:00
ext.jeremy.guillot@maxicoffee.domains
8d14676656 feat: amélioration de la gestion des messages dans le Makefile avec la séparation des commandes et des événements. Mise à jour des services pour intégrer un nouvel EventDispatcher et réorganisation des imports dans les fichiers concernés. Gestion des exceptions ajoutée dans le provider Mangadex pour une meilleure robustesse. 2025-08-01 15:14:12 +02:00
ext.jeremy.guillot@maxicoffee.domains
bec1572fcb feat: refonte de la gestion des événements de création de mangas en remplaçant le MessageBus par un EventDispatcher. Ajout d'un écouteur d'événements MangaCreated pour gérer la récupération des chapitres après la création d'un manga. Implémentation d'un EventDispatcher basé sur Symfony Messenger. 2025-07-31 16:11:16 +02:00
ext.jeremy.guillot@maxicoffee.domains
f1eb97f156 refactor: réorganisation des imports dans AddManga.vue pour une meilleure lisibilité et mise à jour de MangaCreatedListener pour utiliser MangaId lors de la création de chapitres. Suppression de l'appel à fetchMangaChapters après la création d'un manga. 2025-07-23 16:54:11 +02:00
ext.jeremy.guillot@maxicoffee.domains
f09f744a9b feat: ajout de la fonctionnalité de suppression de mangas, incluant une modale de confirmation pour l'utilisateur, la gestion des erreurs et l'intégration avec l'API pour supprimer les mangas et leurs chapitres associés. Mise à jour des composants Vue et ajout de tests pour valider cette nouvelle fonctionnalité. 2025-07-23 16:42:54 +02:00
ext.jeremy.guillot@maxicoffee.domains
7f9d583c94 feat: ajout de la gestion de l'expansion des volumes dans les composants MangaVolume et MangaVolumeList. Intégration de la synchronisation de l'état d'expansion avec les props, ainsi que des méthodes pour étendre ou réduire tous les volumes. Amélioration de l'interface utilisateur pour une navigation plus fluide entre les volumes. 2025-07-23 16:08:20 +02:00
ext.jeremy.guillot@maxicoffee.domains
330a0fac34 feat: refonte de la modale de gestion des chapitres avec un design Material Design, ajout de nouvelles fonctionnalités pour la séparation des volumes, et amélioration de l'interface utilisateur. Intégration de nouveaux composants pour une meilleure expérience utilisateur lors de la gestion des chapitres et des volumes. 2025-07-23 16:00:49 +02:00
ext.jeremy.guillot@maxicoffee.domains
be283833e9 feat: amélioration du service de téléchargement d'images, ajout de la gestion des types de contenu, création de fichiers temporaires pour le traitement des images, et détection des formats d'image. La sauvegarde des images est maintenant garantie avec l'extension JPG. Gestion des erreurs améliorée lors de la création et de la sauvegarde des ressources d'image. 2025-07-23 15:11:52 +02:00
ext.jeremy.guillot@maxicoffee.domains
551db0bf77 feat: ajout d'une modale de gestion des chapitres, permettant la création, l'édition et le déplacement de chapitres. Mise à jour de l'API pour gérer les modifications en lot des chapitres, ainsi que l'intégration de tests pour valider cette nouvelle fonctionnalité. Amélioration de l'interface utilisateur pour une gestion plus fluide des chapitres. 2025-07-23 14:25:17 +02:00
ext.jeremy.guillot@maxicoffee.domains
00d63dffeb feat: ajout de la fonctionnalité de monitoring des mangas, incluant l'activation et la désactivation du suivi, la synchronisation des chapitres, et la mise à jour de l'API pour gérer ces nouvelles actions. Création de nouveaux composants Vue pour le rafraîchissement des chapitres et l'affichage des notifications. Intégration de tests unitaires pour valider le bon fonctionnement de ces fonctionnalités. 2025-07-22 15:57:25 +02:00
ext.jeremy.guillot@maxicoffee.domains
d9e78b5229 feat: ajout de la fonctionnalité de conversion de fichiers CBR en CBZ, intégration d'un nouveau store pour gérer l'état de conversion, création de composants Vue pour l'upload de fichiers et le suivi de la progression, ainsi que la mise à jour de l'API pour gérer les conversions. Amélioration de la documentation API pour inclure les nouveaux endpoints et formats de fichiers supportés. 2025-07-16 11:33:28 +02:00
ext.jeremy.guillot@maxicoffee.domains
7a05934116 feat: ajout de la fonctionnalité de conversion de fichiers de bande dessinée, permettant la conversion de fichiers CBR en CBZ. Intégration d'un service de conversion, d'une API pour gérer les téléchargements, et mise en place de validations pour les fichiers uploadés. Tests unitaires ajoutés pour garantir le bon fonctionnement de cette nouvelle fonctionnalité. 2025-07-14 16:44:18 +02:00
ext.jeremy.guillot@maxicoffee.domains
b4bfa48d00 feat: ajout de la pagination et des filtres dans le store d'activités, mise à jour des composants pour gérer l'affichage des jobs, et amélioration de la gestion des états des jobs. Intégration d'une nouvelle composante de pagination pour une navigation optimisée. 2025-07-13 13:22:42 +02:00
ext.jeremy.guillot@maxicoffee.domains
b456f9304d feat: ajout d'une nouvelle infrastructure de scraping avec des scrapers pour HTML, HTML avancé et JavaScript, ainsi qu'une factory pour gérer leur création et leur sélection. Mise à jour des gestionnaires de commandes pour intégrer cette nouvelle architecture et améliorer la gestion des erreurs lors du scraping des chapitres. 2025-07-08 15:30:22 +02:00
ext.jeremy.guillot@maxicoffee.domains
cbb62989d4 feat: ajout de la fonctionnalité de test de configuration de scraper, incluant la mise à jour de l'API pour tester les configurations en temps réel, la gestion des erreurs détaillées et l'intégration des tests unitaires pour valider le bon fonctionnement de cette nouvelle fonctionnalité. 2025-07-06 17:01:04 +02:00
ext.jeremy.guillot@maxicoffee.domains
ee2a9b3750 feat: ajout de la fonctionnalité de récupération des chapitres de manga, avec mise à jour de l'API et des composants pour gérer la récupération asynchrone des chapitres, ainsi que des améliorations dans la gestion des erreurs et des tests associés. 2025-07-06 16:20:15 +02:00
ext.jeremy.guillot@maxicoffee.domains
5a5569cf2c feat: ajout de la gestion des doubles pages pour le lecteur, incluant des paramètres de détection automatique, des modes d'affichage et des préférences sauvegardées. Amélioration de l'interface utilisateur pour intégrer ces nouvelles fonctionnalités. 2025-07-06 15:55:55 +02:00
ext.jeremy.guillot@maxicoffee.domains
a6ca8a2c9a feat: ajout de la gestion des slugs alternatifs pour le scraping des chapitres, mise à jour du service de scraping pour essayer plusieurs slugs, et amélioration de la configuration des services pour le dépôt de chapitres et le service de fichiers. 2025-07-03 18:41:13 +02:00
ext.jeremy.guillot@maxicoffee.domains
9255509042 feat: ajout de la fonctionnalité d'édition des mangas, incluant la création d'un modal d'édition, la mise à jour de l'API pour gérer les modifications, et l'intégration de la logique de gestion des erreurs. Tests ajoutés pour valider le bon fonctionnement de l'édition. 2025-06-30 20:00:09 +02:00
ext.jeremy.guillot@maxicoffee.domains
896c57ac34 feat: amélioration de l'interface utilisateur des composants MangaHeader, MangaVolume et MangaVolumeList, avec des ajustements de style pour une meilleure réactivité et une expérience utilisateur optimisée sur mobile. Ajout de la gestion de la taille de la fenêtre pour adapter l'affichage des éléments. 2025-06-29 23:59:02 +02:00
ext.jeremy.guillot@maxicoffee.domains
d23c82631e feat: ajout de la fonctionnalité de téléchargement des volumes de manga, avec mise à jour de l'API et des composants pour gérer l'indicateur de chargement et le téléchargement des fichiers. 2025-06-29 23:35:22 +02:00
ext.jeremy.guillot@maxicoffee.domains
17f9feea7b feat: ajout des fonctionnalités de téléchargement et de masquage des chapitres, avec mise à jour des composants et de l'API pour gérer ces actions. 2025-06-29 23:25:33 +02:00
ext.jeremy.guillot@maxicoffee.domains
8692fa14c6 feat: ajout de la fonctionnalité de suppression des chapitres avec mise à jour de l'API et des composants associés pour gérer la suppression des chapitres et des fichiers CBZ. 2025-06-29 23:04:57 +02:00
ext.jeremy.guillot@maxicoffee.domains
37e1b202c2 feat: ajout de la gestion des commandes pour la suppression des fichiers CBZ et des chapitres, avec création des gestionnaires et des ressources API correspondantes 2025-06-29 18:33:33 +02:00
ext.jeremy.guillot@maxicoffee.domains
7fe4ac0d3b refactor: remplacement du gestionnaire de commandes SymfonySetMangaPreferredSourcesHandler par SetMangaPreferredSourcesHandler pour simplifier le traitement des sources préférées des mangas 2025-06-29 16:19:54 +02:00
ext.jeremy.guillot@maxicoffee.domains
a00858ae6e feat: mise à jour de la gestion des sources de contenu pour permettre l'importation et la mise à jour des sources existantes, avec ajout de la méthode findByBaseUrl dans le repository. 2025-06-29 16:08:53 +02:00
ext.jeremy.guillot@maxicoffee.domains
dac2f91998 feat: ajout de la gestion des sources de contenu avec création de composants, formulaires et API pour l'importation, l'exportation et la configuration des sources de scraping. 2025-06-27 16:40:48 +02:00
ext.jeremy.guillot@maxicoffee.domains
32b4e4fbb2 feat: ajout de la gestion des sources de contenu avec des commandes et des gestionnaires pour l'importation, la mise à jour et l'exportation, ainsi que la création des ressources API correspondantes. 2025-06-26 23:24:13 +02:00
ext.jeremy.guillot@maxicoffee.domains
ebcca466a9 feat: ajout de la gestion de l'auto-hide du header et amélioration de la réactivité des composants en fonction de la taille de la fenêtre, ainsi que des optimisations CSS pour une meilleure expérience utilisateur sur mobile. 2025-06-26 22:59:21 +02:00
ext.jeremy.guillot@maxicoffee.domains
4848a1736f feat: rework des preferredSources 2025-06-26 15:44:42 +02:00
ext.jeremy.guillot@maxicoffee.domains
4dc6e5cfab fix: preferred chapter fix 2025-06-26 14:51:00 +02:00
ext.jeremy.guillot@maxicoffee.domains
d753761556 feat: amélioration de l'harmonisation des volumes des chapitres en ajoutant une méthode pour remplir les trous de volumes manquants, et refactorisation de la création de chapitres avec un nouveau volume. 2025-06-20 15:43:10 +02:00
ext.jeremy.guillot@maxicoffee.domains
75f8e1686c feat: ajout de la gestion des sources préférées pour les mangas, incluant la récupération et la configuration des sources via l'API, ainsi que l'intégration d'une modale pour l'interface utilisateur. 2025-06-20 15:33:54 +02:00
ext.jeremy.guillot@maxicoffee.domains
15d92d1aff fix: mangadex includeUnavailable 2025-06-06 15:59:14 +02:00
ext.jeremy.guillot@maxicoffee.domains
05dd7262eb feat: amélioration de la navigation du Reader + correction affichage des chapitres non visibles 2025-06-06 15:46:44 +02:00
ext.jeremy.guillot@maxicoffee.domains
72d7c233f7 feat: ajout de classes CSS pour rendre la barre d'outils dans MangaDetails collante et amélioration de l'organisation des imports pour une meilleure lisibilité. 2025-04-05 12:17:12 +02:00
ext.jeremy.guillot@maxicoffee.domains
cfa2214db5 feat: ajout du mode sombre dans la configuration de Tailwind et réorganisation des imports dans le composant MangaList pour une meilleure lisibilité. 2025-04-05 12:15:20 +02:00
ext.jeremy.guillot@maxicoffee.domains
c0bd9c69b1 feat: mise à jour de la gestion des chapitres en remplaçant les types d'identifiants par des flottants pour une meilleure cohérence, ajout de la documentation pour les méthodes de recherche de chapitres, et amélioration de la gestion des exceptions lors de la récupération des chapitres. 2025-04-05 11:43:40 +02:00
ext.jeremy.guillot@maxicoffee.domains
5928cfd5f0 feat: ajout de la gestion des chapitres dans le store Manga avec des actions pour charger et mettre à jour la disponibilité des chapitres, intégration d'un écouteur Mercure pour les mises à jour en temps réel, et amélioration des composants d'interface utilisateur pour gérer les états de chargement et d'erreur. 2025-04-04 16:06:32 +02:00
ext.jeremy.guillot@maxicoffee.domains
e51712a800 feat: ajout de la recherche de chapitres dans le store Manga et mise à jour de l'API pour récupérer les chapitres, ainsi que des ajustements dans la configuration de Tailwind et la suppression de l'entrée React dans Webpack. 2025-04-03 16:55:48 +02:00
ext.jeremy.guillot@maxicoffee.domains
b187f3e153 feat: mise à jour de la documentation API pour le filtrage par statut, ajout de la prise en charge des tableaux et amélioration des descriptions des propriétés liées aux chapitres. 2025-04-03 16:34:58 +02:00
ext.jeremy.guillot@maxicoffee.domains
c9f1771522 feat: refactorisation de la gestion du scraping des chapitres en remplaçant les identifiants de manga et de chapitre par un identifiant de chapitre unique, amélioration de la récupération des sources préférées et ajout de la gestion des erreurs pour les échecs de scraping. 2025-04-03 16:34:30 +02:00
ext.jeremy.guillot@maxicoffee.domains
e29433bb0c chore: suppression du front en react 2025-04-03 15:40:07 +02:00
ext.jeremy.guillot@maxicoffee.domains
c813368e2b feat: mise à jour des composants de l'interface utilisateur pour uniformiser la hauteur minimale, amélioration de la structure des classes CSS dans Divider, Toolbar, ToolbarButton, ToolbarDropdown et ToolbarLabel. 2025-04-03 15:36:37 +02:00
ext.jeremy.guillot@maxicoffee.domains
68fed587be feat: ajout d'une barre d'outils dans le composant MangaDetails, intégration de nouveaux boutons avec des actions configurables, et amélioration de la structure des composants de la barre d'outils pour une meilleure expérience utilisateur. 2025-04-03 15:15:18 +02:00
ext.jeremy.guillot@maxicoffee.domains
fcfbf140a3 feat: amélioration de la gestion des erreurs et des états de chargement dans le composant MangaDetails, ajout de sections pour les volumes et mise à jour des conditions d'affichage pour une meilleure expérience utilisateur. 2025-04-01 16:19:14 +02:00
ext.jeremy.guillot@maxicoffee.domains
d8e1f3a0cb fix: correction de la récupération des données dans ApiJobRepository pour renvoyer directement les éléments au lieu d'un tableau d'objets, et ajustement des commentaires dans le store d'activité pour clarifier l'affichage des jobs actifs. 2025-04-01 16:11:38 +02:00
ext.jeremy.guillot@maxicoffee.domains
0111f1b5f1 feat: ajout de la gestion des chapitres de manga, incluant la récupération et la sauvegarde des chapitres en français et en anglais, ainsi que l'optimisation de la logique de sauvegarde pour éviter les doublons 2025-04-01 16:01:55 +02:00
ext.jeremy.guillot@maxicoffee.domains
34dfa57dc0 feat: ajout de la gestion des clics sur les mangas dans le composant HomePage, permettant la navigation vers la page de détails du manga sélectionné 2025-03-31 16:55:49 +02:00
ext.jeremy.guillot@maxicoffee.domains
9950d7ff84 feat: ajout de la fonctionnalité de réinitialisation des résultats de recherche dans le store Manga, mise à jour des routes pour une meilleure structure, et amélioration de l'affichage des mangas dans les composants MangaCard et MangaList avec des liens RouterLink 2025-03-31 16:50:03 +02:00
ext.jeremy.guillot@maxicoffee.domains
a172e224c1 refactor: amélioration de la structure du composant MangaCard avec des ajustements de style et de mise en page, y compris l'utilisation de thumbnailUrl et l'optimisation des classes CSS 2025-03-31 16:12:22 +02:00
ext.jeremy.guillot@maxicoffee.domains
f06e6c1f61 fix: ajout de la classe line-clamp pour limiter l'affichage de la description des mangas à 5 lignes dans le composant MangaHeader 2025-03-31 16:06:32 +02:00
ext.jeremy.guillot@maxicoffee.domains
787ba6caad fix: ajout de la propriété line-clamp pour améliorer l'affichage des titres de mangas dans le composant MangaList 2025-03-31 15:49:08 +02:00
ext.jeremy.guillot@maxicoffee.domains
b1b5177d4e feat: ajout de la fonctionnalité de recherche et d'ajout de mangas, avec mise à jour du store pour gérer les états de recherche et d'ajout, ainsi que création d'une nouvelle page AddManga pour l'interface utilisateur 2025-03-30 18:06:46 +02:00
ext.jeremy.guillot@maxicoffee.domains
77f05b287c feat: ajout du composant MangaList pour afficher les mangas en mode liste et mise à jour de HomePage pour intégrer ce nouveau mode de vue 2025-03-30 17:18:37 +02:00
ext.jeremy.guillot@maxicoffee.domains
71242433e6 feat: intégration de @tanstack/vue-query pour la gestion des requêtes dans l'application Vue, ajout de nouveaux composables pour les chapitres et les détails des mangas, et mise à jour du store pour une meilleure gestion des états de chargement et d'erreur 2025-03-30 16:58:05 +02:00
ext.jeremy.guillot@maxicoffee.domains
fd2d3cd640 feat: ajout de la gestion des jobs avec création, récupération, suppression et filtrage via l'API, incluant des entités, des composants Vue.js et des mises à jour de la documentation API 2025-03-30 16:14:17 +02:00
ext.jeremy.guillot@maxicoffee.domains
4d1d5b9f21 fix: suppression d'une exception de test dans le gestionnaire de chapitres pour améliorer la gestion des requêtes de scraping 2025-03-29 17:23:41 +01:00
ext.jeremy.guillot@maxicoffee.domains
d7ccc1e603 feat: ajout de la gestion des jobs avec création, récupération et filtrage via l'API, incluant des entités et des mappers pour les échecs et les jobs 2025-03-29 15:15:14 +01:00
ext.jeremy.guillot@maxicoffee.domains
d7088b14c2 feat: refonte du gestionnaire de chapitres pour intégrer la génération de fichiers CBZ, le téléchargement d'images en lot et la gestion des requêtes de scraping, avec mise à jour des interfaces et des modèles associés 2025-03-28 20:42:21 +01:00
ext.jeremy.guillot@maxicoffee.domains
cdee6f77fc feat: mise à jour des règles de configuration pour inclure des chemins supplémentaires dans les fichiers architecture.mdc et front_vue.mdc 2025-03-28 17:43:54 +01:00
ext.jeremy.guillot@maxicoffee.domains
54b5641947 feat: mise à jour de la gestion de la disponibilité des chapitres dans les composants MangaChapter et MangaDetails, remplaçant isDownloaded par isAvailable pour une meilleure clarté 2025-03-28 15:36:34 +01:00
ext.jeremy.guillot@maxicoffee.domains
2f73d3d42d feat: redirection de la route principale vers la page des mangas 2025-03-28 15:23:33 +01:00
ext.jeremy.guillot@maxicoffee.domains
7051bf5274 feat: ajout de la gestion des URL d'image et de miniature dans les réponses des mangas, avec mise à jour des classes et des tests associés 2025-03-28 15:05:13 +01:00
ext.jeremy.guillot@maxicoffee.domains
6ea24deacf feat: ajout de la gestion de la disponibilité des chapitres dans les réponses et les modèles, avec mise à jour des classes concernées 2025-03-27 11:15:36 +01:00
ext.jeremy.guillot@maxicoffee.domains
346fede878 feat: mise à jour des règles de configuration pour l'API Platform et ajout de nouveaux composants pour le lecteur, incluant la gestion des pages infinies et des contrôles de lecture 2025-03-27 10:59:53 +01:00
ext.jeremy.guillot@maxicoffee.domains
d123166dcb refactor: simplification du store de lecteur en supprimant les logs de débogage et en optimisant les getters pour une meilleure lisibilité 2025-03-26 23:08:12 +01:00
ext.jeremy.guillot@maxicoffee.domains
5e0fc96cd1 chore: suppression du fichier de résultats de test PHPUnit 2025-03-26 22:56:52 +01:00
ext.jeremy.guillot@maxicoffee.domains
85abca7906 feat: ajout du lecteur de chapitres avec gestion des pages, des modes de lecture et des paramètres de zoom 2025-03-26 22:52:48 +01:00
ext.jeremy.guillot@maxicoffee.domains
bf8ca79290 feat: ajout du fichier de configuration pour les paramètres de Symfony dans VSCode 2025-03-26 22:00:38 +01:00
ext.jeremy.guillot@maxicoffee.domains
7c7b65128d feat: ajout d'une nouvelle route pour les mangas et mise à jour de la Sidebar pour rediriger vers cette route 2025-03-26 21:52:48 +01:00
ext.jeremy.guillot@maxicoffee.domains
22cf4eb186 feat: ajout des composants MangaChapter, MangaChapterList, MangaHeader, MangaVolume, MangaVolumeList et mise à jour de la page MangaDetails pour une meilleure gestion des chapitres et volumes de manga 2025-03-26 21:41:59 +01:00
ext.jeremy.guillot@maxicoffee.domains
eeb8447d7a refactor: amélioration de la Sidebar.vue avec suppression de MenuItem.vue et mise à jour de MenuGroup.vue pour une gestion simplifiée des éléments de menu 2025-03-26 20:25:33 +01:00
ext.jeremy.guillot@maxicoffee.domains
53365df456 feat: finalisation de la Sidebar.vue 2025-03-26 18:29:05 +01:00
ext.jeremy.guillot@maxicoffee.domains
d9e935f7de feat: ajout d'une route GetMangaByIdHandler.php et fix de la SearchBar.vue 2025-03-25 22:44:26 +01:00
ext.jeremy.guillot@maxicoffee.domains
ed0a075a6c feat: route config pour les front 2025-03-24 18:21:07 +01:00
ext.jeremy.guillot@maxicoffee.domains
41dc3c51aa feat: page MangaDetails en vue.js 2025-03-24 18:01:24 +01:00
ext.jeremy.guillot@maxicoffee.domains
bee8572dc5 feat: debut d'un front vue.js + ajout de cursorrules 2025-03-24 17:04:46 +01:00
ext.jeremy.guillot@maxicoffee.domains
ca9a74fe69 feat: debut du domain Shared avec Contracts et Jobs + rules pour cursor 2025-03-24 14:56:18 +01:00
ext.jeremy.guillot@maxicoffee.domains
19a697c712 feat: ajout de rules pour cursor 2025-03-23 17:35:27 +01:00
ext.jeremy.guillot@maxicoffee.domains
fe92e53be7 feat: suite des rêgles de phparkitect + début d'un domain Shared avec les interfaces CQRS 2025-03-22 17:48:19 +01:00
ext.jeremy.guillot@maxicoffee.domains
e444d79101 feat: ajout de phparkitect + début de config pour les domains 2025-03-22 17:16:26 +01:00
ext.jeremy.guillot@maxicoffee.domains
4f4f86fb91 feat: front update 2025-03-22 15:38:05 +01:00
ext.jeremy.guillot@maxicoffee.domains
7303d63198 feat: ajout de la description et de la date d'ajout dans le endpoint MangaList 2025-02-17 15:15:05 +01:00
ext.jeremy.guillot@maxicoffee.domains
140cc14316 feat: SPA pour les pages existantes 2025-02-17 14:50:36 +01:00
ext.jeremy.guillot@maxicoffee.domains
668702b1fb feat: Reader working, some work still need to be done 2025-02-17 12:02:56 +01:00
ext.jeremy.guillot@maxicoffee.domains
33f5a5568a feat: GetPage endpoint 2025-02-16 18:22:20 +01:00
ext.jeremy.guillot@maxicoffee.domains
55945adc53 feat: Reader beginning 2025-02-16 16:15:42 +01:00
ext.jeremy.guillot@maxicoffee.domains
e90c0a140e feat: Rangement des endpoints dans la doc Api Platform 2025-02-12 17:18:16 +01:00
ext.jeremy.guillot@maxicoffee.domains
30d26f530d feat: Ajout d'un endpoint getBySlug 2025-02-12 16:55:44 +01:00
ext.jeremy.guillot@maxicoffee.domains
504c62c155 feat: Renommage de GetManga à GetMangaById + ajout de axios 2025-02-12 16:41:11 +01:00
ext.jeremy.guillot@maxicoffee.domains
666636e5bf feat: Ajout de React pour le front, début de refonte du front 2025-02-12 16:12:01 +01:00
ext.jeremy.guillot@maxicoffee.domains
73774f84ff feat: event listener sur MangaCreated pour ajouter les chapitres à la création 2025-02-11 18:28:30 +01:00
ext.jeremy.guillot@maxicoffee.domains
879b8fa2dc feat: endpoint FetchMangaChapters et tests 2025-02-11 18:00:49 +01:00
ext.jeremy.guillot@maxicoffee.domains
3dc0a0b406 feat: endpoint pour la création d'un manga directement via l'api 2025-02-11 15:59:53 +01:00
ext.jeremy.guillot@maxicoffee.domains
4017cabff2 feat: Image saving for manga creation 2025-02-11 00:40:47 +01:00
ext.jeremy.guillot@maxicoffee.domains
50080f9779 feat: CreateMangaFromMangadex endpoint + tests, missing image saving 2025-02-11 00:10:54 +01:00
ext.jeremy.guillot@maxicoffee.domains
ae0eac3197 feat: SearchManga endpoint + tests 2025-02-10 21:33:34 +01:00
ext.jeremy.guillot@maxicoffee.domains
6667cc224b feat: GetChapters endpoint + tests 2025-02-10 20:07:24 +01:00
ext.jeremy.guillot@maxicoffee.domains
2f615a4936 feat: GetManga endpoint + tests 2025-02-10 19:40:47 +01:00
ext.jeremy.guillot@maxicoffee.domains
e3d380eadd feat: GetMangaList endpoint + tests + test db 2025-02-10 19:21:14 +01:00
ext.jeremy.guillot@maxicoffee.domains
073439163b feat: finalizing Scraping endpoint 2025-02-10 17:28:49 +01:00
ext.jeremy.guillot@maxicoffee.domains
0374ab0e46 feat: scraping endpoints, job persistence, firsts unit tests, legacy entities usage 2025-02-07 11:56:51 +01:00
ext.jeremy.guillot@maxicoffee.domains
c55cd62ec7 fix: phpcs-fixer 2025-02-05 21:32:04 +01:00
ext.jeremy.guillot@maxicoffee.domains
ba874480ee feat: getDispatchedMessages in InMemoryMessageBus 2025-02-05 16:57:20 +01:00
ext.jeremy.guillot@maxicoffee.domains
6bc3696190 feat: firsts endpoints and firsts tests 2025-02-05 16:54:13 +01:00
ext.jeremy.guillot@maxicoffee.domains
89570ad951 feat: firsts unit tests for ScrapeChapterHandler.php 2025-02-03 10:38:53 +01:00
ext.jeremy.guillot@maxicoffee.domains
21fcdd1084 Merge branch 'main' into ddd_test 2025-02-02 18:10:03 +01:00
ext.jeremy.guillot@maxicoffee.domains
52441c26da Merge remote-tracking branch 'origin/ddd_test' into ddd_test
# Conflicts:
#	deploy.php
2025-02-02 18:07:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
0482ec9f7f fix: listening on port 8081 + deployer 2025-02-02 18:05:08 +01:00
ext.jeremy.guillot@maxicoffee.domains
9318d0a9a0 fix: cbz path is now in public/cbz 2025-02-01 22:41:41 +01:00
ext.jeremy.guillot@maxicoffee.domains
447f1fbe84 fix: mercure credentials for prod 2025-02-01 19:08:50 +01:00
ext.jeremy.guillot@maxicoffee.domains
59cf4cd3c1 fix: mercureUrl for prod 2025-02-01 18:53:27 +01:00
ext.jeremy.guillot@maxicoffee.domains
d62907a38c fix: mercureUrl for prod 2025-02-01 18:40:59 +01:00
ext.jeremy.guillot@maxicoffee.domains
c6bd6ba549 fix: mercureUrl for prod 2025-02-01 18:16:46 +01:00
ext.jeremy.guillot@maxicoffee.domains
97d7bcf061 feat: suite du passage en DDD de Scraping 2025-02-01 17:03:28 +01:00
ext.jeremy.guillot@maxicoffee.domains
0e3d72cc5e feat: debut rerefonte DDD CQRS 2025-02-01 17:03:28 +01:00
ThysTips
d4142012ec fix: npm deployment script
Signed-off-by: ThysTips <contact@antoinethys.com>
2025-02-01 17:02:55 +01:00
ThysTips
8811d3dd5e build: Add php deployer
Signed-off-by: ThysTips <contact@antoinethys.com>
2025-02-01 16:50:51 +01:00
ext.jeremy.guillot@maxicoffee.domains
0a8e6786a8 feat: suite du passage en DDD de Scraping 2025-02-01 15:28:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
0c8ca6cca9 feat: debut rerefonte DDD CQRS 2025-02-01 15:28:58 +01:00
ThysTips
8f7b5d71c5 build: Add php deployer
Signed-off-by: ThysTips <contact@antoinethys.com>
2025-02-01 15:27:32 +01:00
ext.jeremy.guillot@maxicoffee.domains
2941bbecd1 Previous chapter fix
Chapter not found now redirect to chapters_show
2024-10-04 10:27:59 +02:00
ext.jeremy.guillot@maxicoffee.domains
5f15d14ae1 Convertion des images webp et png vers jpeg 2024-09-30 22:16:20 +02:00
788 changed files with 60118 additions and 12528 deletions

View File

@@ -0,0 +1,208 @@
---
name: api-platform
description: Conventions API Platform du projet Mangarr — brancher un State Processor sur une Command, un State Provider sur une Query, nommage des Resources, gestion des DTOs. Utiliser quand on crée ou modifie une Resource, un State Processor/Provider, ou un DTO API Platform.
allowed-tools: Read, Grep, Glob
---
# API Platform — Mangarr
Tout le code API Platform vit dans `Infrastructure/ApiPlatform/` du domaine concerné.
```
Infrastructure/ApiPlatform/
Resource/
{FeatureName}Resource.php ← classe vide avec attribut #[ApiResource]
State/
Processor/
{DoSomething}Processor.php ← implémente ProcessorInterface → Command
Provider/
{GetSomething}StateProvider.php ← implémente ProviderInterface → Query
Dto/
{Name}.php ← données entrantes ou sortantes
Controller/
{Action}Controller.php ← uniquement pour cas non-standards
```
---
## Resource
Classe **vide** — elle ne contient que l'attribut `#[ApiResource]`. Aucune logique.
```php
// Infrastructure/ApiPlatform/Resource/MangaResource.php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Delete;
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaDetail;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaStateProvider;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\CreateMangaProcessor;
#[ApiResource(
shortName: 'Manga',
operations: [
new Get(
uriTemplate: '/mangas/by-id/{id}',
provider: GetMangaStateProvider::class,
output: MangaDetail::class,
),
new Post(
uriTemplate: '/mangas',
input: CreateMangaDto::class,
processor: CreateMangaProcessor::class,
),
new Delete(
uriTemplate: '/mangas/{id}',
provider: DeleteMangaProvider::class, // ← provider requis pour Delete
processor: DeleteMangaProcessor::class,
),
]
)]
class MangaResource {}
```
**Règles Resource :**
- `shortName` = nom du concept métier (ex: `'Manga'`, `'Chapter'`).
- `uriTemplate` explicite (pas de génération automatique depuis le nom de classe).
- `output` = DTO de sortie, `input` = DTO d'entrée.
- `provider` et `processor` référencés par `::class`.
---
## State Processor → Command (écriture)
```php
// Infrastructure/ApiPlatform/State/Processor/{DoSomething}Processor.php
namespace App\Domain\{Domain}\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\{Domain}\Application\Command\{DoSomething};
use App\Domain\{Domain}\Application\CommandHandler\{DoSomething}Handler;
use App\Domain\{Domain}\Domain\Exception\{Something}NotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class {DoSomething}Processor implements ProcessorInterface
{
public function __construct(
private {DoSomething}Handler $handler,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
try {
$this->handler->handle(new {DoSomething}(
$uriVariables['id'] ?? $data->someField,
));
} catch ({Something}NotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}
```
**Règles Processor :**
- Injecte le **Handler concret** (pas une interface, car l'Infrastructure peut dépendre de l'Application).
- Traduit les Domain Exceptions en HTTP Exceptions Symfony (`NotFoundHttpException`, `UnprocessableEntityHttpException`…).
- Retourne `void` pour les opérations sans corps de réponse, ou le DTO de sortie si nécessaire.
---
## State Provider → Query (lecture)
```php
// Infrastructure/ApiPlatform/State/Provider/{GetSomething}StateProvider.php
namespace App\Domain\{Domain}\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\{Domain}\Application\Query\{GetSomething};
use App\Domain\{Domain}\Application\QueryHandler\{GetSomething}Handler;
use App\Domain\{Domain}\Infrastructure\ApiPlatform\Dto\{Something}Detail;
readonly class {GetSomething}StateProvider implements ProviderInterface
{
public function __construct(
private {GetSomething}Handler $handler,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): {Something}Detail
{
$query = new {GetSomething}($uriVariables['id']);
$response = $this->handler->handle($query);
return new {Something}Detail(
id: $response->id,
title: $response->title,
// mapper Response → DTO ici
);
}
}
```
**Règles Provider :**
- Construit la Query depuis `$uriVariables` et/ou `$context['filters']`.
- Mappe le `Response` (Application) vers un **DTO** (Infrastructure) — ne jamais retourner un Response directement.
- Pour les collections, retourner un tableau ou un objet `Paginator`.
---
## DTOs
Les DTOs sont des classes de données spécifiques à la couche HTTP. Ils ne doivent pas contenir de logique.
```php
// Infrastructure/ApiPlatform/Dto/MangaDetail.php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
readonly class MangaDetail
{
public function __construct(
public string $id,
public string $title,
public string $slug,
public ?string $imageUrl,
// ...
) {}
}
```
**Règles DTO :**
- `readonly class`.
- Uniquement des types PHP natifs.
- **DTO d'entrée** : contient les champs que le client envoie (`input:`).
- **DTO de sortie** : contient les champs que l'API retourne (`output:`).
- Ne jamais réutiliser un `Response` Application comme DTO API Platform (couches séparées).
---
## Conventions de nommage
| Fichier | Pattern de nom | Exemple |
|------------------------------------|----------------------------------------|------------------------------------|
| Resource (opération GET) | `{Concept}Resource.php` | `MangaResource.php` |
| Resource (opération spécifique) | `{Action}{Concept}Resource.php` | `CreateMangaResource.php` |
| Processor | `{DoSomething}Processor.php` | `CreateMangaProcessor.php` |
| Provider (GET item) | `Get{Concept}StateProvider.php` | `GetMangaStateProvider.php` |
| Provider (GET collection) | `Get{Concept}ListStateProvider.php` | `GetMangaListStateProvider.php` |
| Provider (Delete, nécessite item) | `Delete{Concept}Provider.php` | `DeleteMangaProvider.php` |
| DTO de sortie (item) | `{Concept}Detail.php` | `MangaDetail.php` |
| DTO de sortie (liste) | `{Concept}ListItem.php` | `MangaListItem.php` |
| DTO de sortie (collection) | `{Concept}Collection.php` | `MangaCollection.php` |
---
## Flux complet : une opération POST
```
HTTP POST /mangas
→ CreateMangaResource (#[ApiResource] avec processor:)
→ CreateMangaProcessor::process($data, ...)
→ CreateMangaFromMangadexHandler::handle(new CreateMangaFromMangadex(...))
→ Domain : Manga::__construct(...) + invariants
→ MangaRepositoryInterface::save($manga)
→ MessageBus::dispatch(new MangaCreated(...))
```

View File

@@ -0,0 +1,208 @@
---
name: cqrs
description: Patterns CQRS du projet Mangarr — templates Command/CommandHandler et Query/QueryHandler, enregistrement dans Symfony Messenger, séparation read/write model. Utiliser quand on crée ou modifie un use case (Command ou Query) et son handler.
allowed-tools: Read, Grep, Glob
---
# CQRS — Mangarr
## Principe
- **Command** : intention de modifier l'état. Retourne `void`.
- **Query** : lecture seule. Retourne un `Response` objet (jamais une entité Doctrine).
- Les handlers vivent dans `Application/`, jamais dans `Infrastructure/` directement.
- Les handlers sont `readonly class` et reçoivent leurs dépendances via le constructeur (autowiring).
---
## Template Command
```php
// src/Domain/{Domain}/Application/Command/{DoSomething}.php
namespace App\Domain\{Domain}\Application\Command;
readonly class {DoSomething}
{
public function __construct(
public string $someId,
public string $someValue,
// scalaires ou tableaux uniquement — pas d'objets du Domain
) {}
}
```
## Template CommandHandler
```php
// src/Domain/{Domain}/Application/CommandHandler/{DoSomething}Handler.php
namespace App\Domain\{Domain}\Application\CommandHandler;
use App\Domain\{Domain}\Application\Command\{DoSomething};
use App\Domain\{Domain}\Domain\Contract\Repository\{Name}RepositoryInterface;
use App\Domain\{Domain}\Domain\Event\{SomethingHappened};
use App\Domain\{Domain}\Domain\Model\{Aggregate};
use Ramsey\Uuid\Uuid;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class {DoSomething}Handler
{
public function __construct(
private {Name}RepositoryInterface $repository,
// autres interfaces Domain uniquement
private MessageBusInterface $messageBus, // si event à dispatcher
) {}
public function handle({DoSomething} $command): void
{
// 1. Reconstruire/créer l'Aggregate via Value Objects
$aggregate = new {Aggregate}(
new {AggregateId}(Uuid::uuid4()->toString()),
// ...
);
// 2. Appeler la méthode métier (invariants dans le Domain)
$aggregate->doSomething();
// 3. Persister via l'interface repository
$this->repository->save($aggregate);
// 4. Dispatcher un Domain Event si nécessaire
$this->messageBus->dispatch(new {SomethingHappened}($aggregate->getId()->getValue()));
}
}
```
**Règles CommandHandler :**
- N'injecte que des interfaces définies dans `Domain/Contract/` (jamais une classe concrète Infrastructure).
- Exception autorisée : `MessageBusInterface` de Symfony Messenger.
- Ne retourne jamais de données (`void`).
---
## Template Query
```php
// src/Domain/{Domain}/Application/Query/{GetSomething}.php
namespace App\Domain\{Domain}\Application\Query;
readonly class {GetSomething}
{
public function __construct(
public string $id,
// critères de filtrage en scalaires
) {}
}
```
## Template QueryHandler
```php
// src/Domain/{Domain}/Application/QueryHandler/{GetSomething}Handler.php
namespace App\Domain\{Domain}\Application\QueryHandler;
use App\Domain\{Domain}\Application\Query\{GetSomething};
use App\Domain\{Domain}\Application\Response\{Something}Response;
use App\Domain\{Domain}\Domain\Contract\Repository\{Name}RepositoryInterface;
use App\Domain\{Domain}\Domain\Exception\{Something}NotFoundException;
readonly class {GetSomething}Handler
{
public function __construct(
private {Name}RepositoryInterface $repository,
) {}
public function handle({GetSomething} $query): {Something}Response
{
$aggregate = $this->repository->findById($query->id);
if (!$aggregate) {
throw new {Something}NotFoundException();
}
return new {Something}Response(
id: $aggregate->getId()->getValue(),
// mapper les Value Objects vers scalaires ici
);
}
}
```
## Template Response
```php
// src/Domain/{Domain}/Application/Response/{Something}Response.php
namespace App\Domain\{Domain}\Application\Response;
readonly class {Something}Response
{
public function __construct(
public string $id,
public string $title,
// scalaires et tableaux uniquement — jamais d'objets Domain
) {}
}
```
**Règles Response :**
- `readonly class`.
- Uniquement des types PHP natifs (`string`, `int`, `float`, `bool`, `array`, `?string`…).
- C'est le **read model** — il ne sert qu'à transporter des données vers l'Infrastructure.
---
## Enregistrement dans Symfony Messenger
### Command via bus synchrone
Les handlers sont auto-découverts par autowiring. Aucune configuration supplémentaire pour les CommandHandlers Application purs.
Pour les handlers Symfony Messenger (traitement asynchrone ou via `command.bus`), créer un wrapper dans `Infrastructure/CommandHandler/` :
```php
// src/Domain/{Domain}/Infrastructure/CommandHandler/Symfony{DoSomething}Handler.php
namespace App\Domain\{Domain}\Infrastructure\CommandHandler;
use App\Domain\{Domain}\Application\Command\{DoSomething};
use App\Domain\{Domain}\Application\CommandHandler\{DoSomething}Handler;
readonly class Symfony{DoSomething}Handler
{
public function __construct(
private {DoSomething}Handler $handler,
) {}
public function __invoke({DoSomething} $command): void
{
$this->handler->handle($command);
}
}
```
Déclarer dans `config/services.yaml` :
```yaml
App\Domain\{Domain}\Infrastructure\CommandHandler\Symfony{DoSomething}Handler:
tags:
- { name: messenger.message_handler, bus: command.bus }
```
### Buses disponibles
| Bus / Transport | Usage |
|------------------|------------------------------------|
| `command.bus` | Commands synchrones ou async |
| `events` | Domain Events (async) |
| `commands` | Messages async (ex: scraping) |
| `async` | Scheduler (tâches planifiées) |
---
## Séparation Read Model / Write Model
| Write Model | Read Model |
|--------------------------------------|-------------------------------------------|
| `Domain/Model/{Aggregate}.php` | `Application/Response/{Name}Response.php` |
| Contient les invariants métier | Contient uniquement des données aplaties |
| Manipulé par les CommandHandlers | Retourné par les QueryHandlers |
| Persisté via `RepositoryInterface` | Jamais persisté directement |
Ne jamais retourner un Aggregate depuis un QueryHandler — toujours mapper vers une Response.

View File

@@ -0,0 +1,144 @@
---
name: ddd-core
description: Règles DDD du projet Mangarr — Aggregates, Value Objects immutables, Domain Events, invariants. Utiliser quand on crée ou modifie un Model, Value Object, Event ou Exception dans src/Domain/*/Domain/.
allowed-tools: Read, Grep, Glob
---
# Règles DDD — Couche Domain
## Emplacement
```
src/Domain/{DomainName}/Domain/
Model/
{AggregateName}.php
ValueObject/
{VoName}.php
Event/
{SomethingHappened}.php
Exception/
{Something}Exception.php
Contract/
Repository/
{Name}RepositoryInterface.php
Service/
{Name}Interface.php
Client/
{Name}ClientInterface.php
```
## Aggregates
- Classe normale (pas `readonly`), propriétés `private`.
- Le constructeur prend des **Value Objects**, jamais des scalaires bruts pour les identifiants et concepts métier.
- **Aucune annotation Doctrine** dans le Model — c'est la responsabilité du Repository (Infrastructure).
- Les méthodes métier protègent les invariants et lèvent des **Domain Exceptions** (jamais des exceptions génériques).
- Les setters publics sont interdits. Exposer des méthodes métier explicites (`updateImageUrls()`, `enableMonitoring()`, etc.).
```php
// ✅ Correct
class Manga
{
public function __construct(
private MangaId $id,
private MangaTitle $title,
private MangaSlug $slug,
// ...
) {}
public function updateImageUrls(ImageUrls $imageUrls): void
{
$this->imageUrls = $imageUrls;
}
}
// ❌ Interdit
class Manga
{
public string $title; // propriété publique
#[ORM\Column] // annotation Doctrine dans le Domain
private string $title;
public function setTitle(string $title): void {} // setter générique
}
```
## Value Objects
- Toujours `readonly class`.
- Valider dans le constructeur, lever une **Domain Exception** si invalide.
- Exposer `getValue()` pour récupérer la valeur primitive.
- Jamais de dépendance externe (pas de Symfony, pas de Doctrine).
```php
readonly class MangaTitle
{
public function __construct(public readonly string $value)
{
if (empty(trim($value))) {
throw new InvalidMangaTitleException('Title cannot be empty');
}
}
public function getValue(): string
{
return $this->value;
}
}
```
Valeurs composées (ex: chemins d'images) → Value Object avec plusieurs propriétés :
```php
readonly class ImageUrls
{
public function __construct(
private string $full,
private string $thumbnail,
) {}
public function getFull(): string { return $this->full; }
public function getThumbnail(): string { return $this->thumbnail; }
}
```
## Domain Events
- Nommés au **passé** : `MangaCreated`, `ChapterImported`, `MonitoringEnabled`.
- `readonly class`, transportent uniquement des scalaires (pas d'objets du Domain).
- Placés dans `Domain/Event/`.
- Dispatchés depuis le **CommandHandler** (Application), jamais depuis le Domain lui-même.
- Le bus utilisé est `MessageBusInterface` de Symfony Messenger (autorisé dans Application, pas dans Domain).
```php
readonly class MangaCreated
{
public function __construct(
public string $mangaId,
public string $externalId,
) {}
}
```
## Domain Exceptions
- Étendent `DomainException` ou `\RuntimeException` selon le cas.
- Nommées avec le suffixe `Exception` ou `NotFoundException`.
- Localisées dans `Domain/Exception/`.
```php
class MangaNotFoundException extends \DomainException
{
public function __construct()
{
parent::__construct('Manga not found');
}
}
```
## Règles d'invariants PHPArkitect (enforced automatiquement)
- `App\Domain\{X}\Domain`**aucune dépendance** en dehors de son propre namespace.
- Exceptions autorisées : `DateTimeImmutable`, `RuntimeException`, `Exception`, `DomainException`, `InvalidArgumentException`, `Throwable`, `Symfony\Component\HttpKernel\Exception`.
- `Ramsey\Uuid` et `Symfony\Component\Messenger` : autorisés uniquement en **Application**, pas en Domain.
Vérification : `make phparkitect`

View File

@@ -0,0 +1,139 @@
---
name: hexagonal-arch
description: Architecture hexagonale du projet Mangarr — structure exacte des dossiers, règles d'import strictes par couche, nommage ports (interfaces) vs adapters (implémentations). Utiliser quand on crée un nouveau domaine, un nouveau fichier, ou qu'on vérifie les dépendances entre couches.
allowed-tools: Read, Grep, Glob, Bash
---
# Architecture Hexagonale — Mangarr
## Structure canonique d'un domaine
```
src/Domain/{DomainName}/
Domain/ ← NOYAU pur, 0 dépendance framework
Model/
{Aggregate}.php
ValueObject/
{VoName}.php
Event/
{SomethingHappened}.php
Exception/
{Something}Exception.php
Contract/ ← PORTS (interfaces only)
Repository/
{Name}RepositoryInterface.php
Service/
{Name}Interface.php
Client/
{Name}ClientInterface.php
Application/ ← Use cases, orchestre le Domain
Command/
{DoSomething}.php
CommandHandler/
{DoSomething}Handler.php
Query/
{GetSomething}.php
QueryHandler/
{GetSomething}Handler.php
Response/
{Something}Response.php
EventListener/
{SomethingHappened}EventListener.php
Infrastructure/ ← ADAPTERS (implémentations concrètes)
Persistence/
Repository/
{Name}Repository.php ← implémente Domain/Contract/Repository/
ApiPlatform/
Resource/
{FeatureName}Resource.php
State/
Processor/
{DoSomething}Processor.php
Provider/
{GetSomething}StateProvider.php
Dto/
{Name}.php
Service/
{ServiceName}.php ← implémente Domain/Contract/Service/
Client/
{ClientName}.php ← implémente Domain/Contract/Client/
CommandHandler/ ← handlers Symfony Messenger (wrappent l'Application)
Symfony{DoSomething}Handler.php
```
## Domaines du projet
| Domaine | Responsabilité |
|-------------|-----------------------------------------------------|
| `Manga` | Catalogue mangas, chapitres, métadonnées |
| `Scraping` | Téléchargement de chapitres depuis les sources |
| `Conversion`| Conversion de formats (CBR→CBZ, génération CBZ) |
| `Reader` | Lecture de chapitres |
| `Setting` | Configuration applicative |
| `Shared` | Contrats transverses (`EventDispatcherInterface`, `MangaPathManagerInterface`, etc.) |
## Règles d'import strictes
### Domain (noyau)
```
✅ Peut importer : son propre namespace uniquement
+ exceptions PHP standard
❌ Interdit : Symfony\*, Doctrine\*, Ramsey\Uuid, tout autre domaine
```
### Application
```
✅ Peut importer : son propre Domain (App\Domain\{X}\Domain\*)
App\Domain\Shared\Domain\Contract\*
Symfony\Component\Messenger\*
Ramsey\Uuid\*
❌ Interdit : son propre Infrastructure (App\Domain\{X}\Infrastructure\*)
Doctrine\*, tout autre domaine
```
### Infrastructure
```
✅ Peut importer : tout (Symfony, Doctrine, API Platform, etc.)
son Application et son Domain
❌ Convention : ne pas contenir de logique métier (déléguer à Application)
```
## Ports vs Adapters — nommage
| Concept | Localisation | Suffixe | Exemple |
|-----------|--------------------------------------|-----------------|---------------------------------|
| Port | `Domain/Contract/Repository/` | `Interface` | `MangaRepositoryInterface` |
| Port | `Domain/Contract/Service/` | `Interface` | `ImageProcessorInterface` |
| Port | `Domain/Contract/Client/` | `Interface` | `MangadexClientInterface` |
| Adapter | `Infrastructure/Persistence/` | `Repository` | `LegacyChapterRepository` |
| Adapter | `Infrastructure/Service/` | *(nom libre)* | `ImageProcessor` |
| Adapter | `Infrastructure/Client/` | `Client` | `MangadexClient` |
Le binding port → adapter se déclare dans `config/services.yaml` :
```yaml
App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface:
alias: App\Domain\Manga\Infrastructure\Persistence\Repository\LegacyMangaRepository
```
## Shared Domain
Les contrats transverses vivent dans `src/Domain/Shared/Domain/Contract/` :
- `CommandInterface`, `QueryInterface`, `ResponseInterface` — marqueurs
- `CommandHandlerInterface`, `QueryHandlerInterface` — handlers génériques
- `EventDispatcherInterface` — dispatch d'événements domain
- `MangaPathManagerInterface` — gestion des chemins de fichiers
- `FileUploadInterface`, `NotificationInterface` — services transverses
`App\Domain\Shared` **ne dépend de personne** (règle PHPArkitect).
## Checklist avant de créer un fichier
1. Dans quelle couche va ce fichier ? (Domain / Application / Infrastructure)
2. Ce fichier va-t-il importer quelque chose d'interdit pour cette couche ?
3. Si c'est une implémentation concrète → existe-t-il déjà une interface (port) dans `Domain/Contract/` ?
4. Si c'est une nouvelle interface → est-elle dans `Domain/Contract/` et non dans Infrastructure ?
5. Le binding alias est-il déclaré dans `config/services.yaml` ?
Vérification automatique : `make phparkitect`

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,258 @@
---
name: testing-strategy
description: Stratégie de tests du projet Mangarr — pyramide adaptée à l'archi DDD/Hexa. Tests unitaires purs sur le Domain/Application (sans framework), adapters InMemory, tests fonctionnels API. Utiliser quand on crée ou modifie des tests, ou qu'on discute de la couverture à implémenter.
allowed-tools: Read, Grep, Glob
---
# Stratégie de tests — Mangarr
## Pyramide
```
┌─────────────────────────────┐
│ Tests Fonctionnels (API) │ ← peu nombreux, coûteux
│ tests/Functional/ │ zenstruck/browser + BrowserKit
├─────────────────────────────┤
│ Tests d'Intégration │ ← adapters Doctrine, clients HTTP
│ tests/Domain/*/Adapter/ │ zenstruck/foundry + DAMA
├─────────────────────────────┤
│ Tests Unitaires (Domain) │ ← majorité, rapides, sans framework
│ tests/Domain/*/Application/ │ PHPUnit pur, InMemory adapters
└─────────────────────────────┘
```
---
## 1. Tests Unitaires — Application Layer (CommandHandlers, QueryHandlers)
**Localisation :** `tests/Domain/{Domain}/Application/CommandHandler/` et `QueryHandler/`
**Principe :** Aucune dépendance au framework. On injecte des **adapters InMemory** à la place des vraies implémentations Infrastructure.
### Structure d'un test CommandHandler
```php
// tests/Domain/Manga/Application/CommandHandler/CreateMangaHandlerTest.php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\CreateManga;
use App\Domain\Manga\Application\CommandHandler\CreateMangaHandler;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor;
use App\Tests\Shared\Adapter\InMemoryMessageBus;
use PHPUnit\Framework\TestCase;
class CreateMangaHandlerTest extends TestCase
{
private InMemoryMangaRepository $repository;
private CreateMangaHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryMangaRepository();
$this->handler = new CreateMangaHandler(
$this->repository,
new InMemoryImageProcessor(),
new InMemoryMessageBus(),
);
}
public function testHandleSuccess(): void
{
// Arrange
$command = new CreateManga(
title: 'One Piece',
slug: 'one-piece',
// ...
);
// Act
$this->handler->handle($command);
// Assert
$saved = $this->repository->findAll()[0];
$this->assertEquals('One Piece', $saved->getTitle()->getValue());
}
public function testThrowsWhenInvalid(): void
{
$this->expectException(\RuntimeException::class);
$this->handler->handle(new CreateManga(title: '', /* ... */));
}
}
```
### Structure d'un test QueryHandler
```php
// tests/Domain/Manga/Application/QueryHandler/GetMangaByIdHandlerTest.php
class GetMangaByIdHandlerTest extends TestCase
{
private InMemoryMangaRepository $repository;
private GetMangaByIdHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryMangaRepository();
$this->handler = new GetMangaByIdHandler($this->repository);
}
public function testThrowsWhenNotFound(): void
{
$this->expectException(MangaNotFoundException::class);
$this->handler->handle(new GetMangaById('non-existent'));
}
public function testReturnsMappedResponse(): void
{
// Arrange — construire l'Aggregate directement avec Value Objects
$manga = new Manga(
id: new MangaId('123'),
title: new MangaTitle('One Piece'),
// ...
);
$this->repository->save($manga);
// Act
$response = $this->handler->handle(new GetMangaById('123'));
// Assert — vérifier les scalaires du Response
$this->assertEquals('123', $response->id);
$this->assertEquals('One Piece', $response->title);
}
protected function tearDown(): void
{
$this->repository->clear();
}
}
```
---
## 2. Adapters InMemory
**Localisation :** `tests/Domain/{Domain}/Adapter/`
Chaque interface de `Domain/Contract/` a son adapter InMemory dans les tests. Ces adapters stockent les données en mémoire (`array`).
### Structure d'un InMemory Repository
```php
// tests/Domain/Manga/Adapter/InMemoryMangaRepository.php
namespace App\Tests\Domain\Manga\Adapter;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Manga;
class InMemoryMangaRepository implements MangaRepositoryInterface
{
/** @var array<string, Manga> */
private array $mangas = [];
public function save(Manga $manga): void
{
$this->mangas[$manga->getId()->getValue()] = $manga;
}
public function findById(string $id): ?Manga
{
return $this->mangas[$id] ?? null;
}
public function findAll(): array
{
return array_values($this->mangas);
}
public function clear(): void
{
$this->mangas = [];
}
// ... implémenter toutes les méthodes de l'interface
}
```
### Adapters InMemory disponibles (existants)
| Adapter | Interface implémentée |
|----------------------------------|------------------------------------------|
| `InMemoryMangaRepository` | `MangaRepositoryInterface` |
| `InMemoryImageProcessor` | `ImageProcessorInterface` |
| `InMemoryMangadexClient` | `MangadexClientInterface` |
| `InMemoryMangaProvider` | `MangaProviderInterface` |
| `InMemoryPathManager` | `MangaPathManagerInterface` |
| `InMemoryMessageBus` | `MessageBusInterface` |
Quand on crée une nouvelle interface dans `Domain/Contract/`, **créer l'adapter InMemory correspondant** avant d'écrire les tests.
---
## 3. Tests Fonctionnels API
**Localisation :** `tests/Functional/`
Utilisent `zenstruck/browser` + `BrowserKitBrowser` avec le conteneur Symfony complet. Les données sont gérées par `zenstruck/foundry` (Factories) et `DAMA\DoctrineTestBundle` (rollback automatique après chaque test).
```php
// tests/Functional/SomeEndpointTest.php
namespace App\Tests\Functional;
use Zenstruck\Browser\Test\HasBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class SomeEndpointTest extends WebTestCase
{
use HasBrowser;
public function testGetManga(): void
{
$this->browser()
->get('/api/mangas/by-id/some-uuid')
->assertStatus(200)
->assertJson()
->assertJsonMatches('title', 'One Piece');
}
public function testCreateManga(): void
{
$this->browser()
->post('/api/mangas', [
'json' => ['externalId' => 'abc-123'],
])
->assertStatus(204);
}
}
```
---
## Commandes
```bash
make test # tous les tests
make test f="CreateMangaHandlerTest" # un test par nom de classe
make test c="--group unit" # par groupe
make test c="--stop-on-failure" # s'arrêter au premier échec
```
---
## Checklist par feature
Quand on implémente une nouvelle feature, les tests à écrire dans l'ordre :
1. **Test du CommandHandler/QueryHandler** (unitaire, `TestCase` pur)
- Cas nominal (happy path)
- Cas d'erreur (not found, invalide…)
- Vérification que le repository est bien appelé
2. **Test de la Value Object** si une nouvelle VO est créée
- Validation des invariants (cas invalides)
- `getValue()` retourne la bonne valeur
3. **Test fonctionnel de l'endpoint** (si API Platform)
- Codes HTTP corrects (200, 201, 204, 404…)
- Structure JSON de la réponse
Ne pas tester les Processors/Providers API Platform en unitaire (trop de couplage framework) — les couvrir via les tests fonctionnels.

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

@@ -0,0 +1,224 @@
---
description:
globs: *.php
alwaysApply: false
---
```
Domain/Manga/Infrastructure/ApiPlatform/
├── Resource/ # Resources API par opération
│ └── GetMangaResource.php # Resources pour l'opération Get
│ └── CreateMangaResource.php # Resources pour l'opération Create
├── State/ # Providers et Processors par opération
├── Provider/ # State Providers
│ └── GetMangaStateProvider.php
└── Processor/ # State Processors
└── CreateMangaStateProcessor.php
```
## Règles d'Organisation
### 1. Resources
- Localisation : `Infrastructure/ApiPlatform/Resource/`
- Principes :
- Une Resource par Operation
- Validation des données avec les attributs Symfony dans la Resource
- Documentation exhaustive avec les attributs PHP 8
- Nommage : `{Operation}Resource`
- Contient tous les attributs nécessaires en public
- Doit implémenter les interfaces de validation appropriées
### 2. State Providers
- Localisation : `Infrastructure/ApiPlatform/State/Provider/`
- Principes :
- Un Provider par Operation de type Query
- Utilise les QueryHandler du domaine
- Convertit la Response du QueryHandler en Resource
- Renvoie toujours une Resource
- Nommage : `{Operation}StateProvider`
### 3. State Processors
- Localisation : `Infrastructure/ApiPlatform/State/Processor/`
- Principes :
- Un Processor par Operation de type Command
- Utilise les CommandHandler du domaine
- Convertit la Resource en Command
- Renvoie uniquement un code HTTP
- Nommage : `{Operation}StateProcessor`
## Exemples de Code
### 1. Resource API
```php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'Manga',
operations: [
new Get(
uriTemplate: '/mangas/{id}',
provider: GetMangaStateProvider::class,
output: GetMangaResource::class,
description: 'Récupère un manga par son identifiant'
)
]
)]
class GetMangaResource
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Uuid]
public readonly string $id,
#[Assert\NotBlank]
public readonly string $title,
public readonly ?string $description = null,
#[Assert\NotBlank]
#[Assert\All([
new Assert\Type('string')
])]
public readonly array $authors = [],
#[Assert\Url]
public readonly ?string $coverUrl = null
) {}
}
```
### 2. State Provider
```php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource\CreateManga;
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Manga\Application\Query\GetMangaByIdQuery;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\GetMangaResource;
use Symfony\Component\Messenger\MessageBusInterface;
class GetMangaStateProvider implements ProviderInterface
{
public function __construct(
private readonly MessageBusInterface $queryBus
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?GetMangaResource
{
$query = new GetMangaByIdQuery($uriVariables['id']);
$response = $this->queryBus->dispatch($query);
if (null === $response) {
return null;
}
return new GetMangaResource(
id: $response->id,
title: $response->title,
description: $response->description,
authors: $response->authors,
coverUrl: $response->coverUrl
);
}
}
```
### 3. Resource CreateManga
```php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\CreateMangaStateProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'Manga',
operations: [
new Post(
uriTemplate: '/mangas',
processor: CreateMangaStateProcessor::class,
input: CreateMangaResource::class,
status: 201,
description: 'Crée un nouveau manga'
)
]
)]
class CreateMangaResource
{
public function __construct(
#[Assert\NotBlank(message: 'Le titre est obligatoire')]
#[Assert\Length(min: 1, max: 255)]
public readonly string $title,
#[Assert\Length(max: 1000)]
public readonly ?string $description = null,
#[Assert\NotNull]
#[Assert\Count(min: 1, max: 10)]
#[Assert\All([
new Assert\Type('string'),
new Assert\Length(min: 1, max: 100)
])]
public readonly array $authors = [],
#[Assert\Url]
#[Assert\Length(max: 255)]
public readonly ?string $coverUrl = null
) {}
}
```
### 4. State Processor
```php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\CreateMangaCommand;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\CreateMangaResource;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
class CreateMangaStateProcessor implements ProcessorInterface
{
public function __construct(
private readonly MessageBusInterface $commandBus
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): int
{
assert($data instanceof CreateMangaResource);
$command = new CreateMangaCommand(
title: $data->title,
description: $data->description,
authors: $data->authors,
coverUrl: $data->coverUrl
);
$this->commandBus->dispatch($command);
return Response::HTTP_CREATED;
}
}
```
## Bonnes Pratiques
### 1. Documentation
- Documentation exhaustive des endpoints
- Description claire des paramètres
- Exemples de requêtes/réponses
- Documentation des codes d'erreur
### 2. Validation
- Validation dans les Resources uniquement
- Groupes de validation par contexte
- Messages d'erreur explicites
- Validation des types et formats

View File

@@ -0,0 +1,210 @@
---
description:
globs: *.php,src/*
alwaysApply: false
---
# Architecture Hexagonale de Mangarr
## Structure Générale
L'application suit une architecture hexagonale (ports & adapters) avec une séparation claire des responsabilités. Le code métier est organisé en domaines distincts dans le dossier `src/Domain/`.
```
src/
└── Domain/
├── Shared/ # Code partagé entre les domaines
├── Manga/ # Domaine de gestion des mangas
├── Reader/ # Domaine de lecture
└── Scraping/ # Domaine de scraping
```
## Organisation des Domaines
Chaque domaine suit la même structure hexagonale :
```
Domain/Manga/
├── Domain/ # Cœur métier
│ ├── Entity/ # Entités du domaine
│ ├── ValueObject/ # Objets de valeur
│ ├── Event/ # Événements du domaine
│ └── Exception/ # Exceptions métier
├── Application/ # Cas d'utilisation
│ ├── Command/ # Commandes (DTO)
│ ├── CommandHandler/# Gestionnaires de commandes
│ ├── Query/ # Requêtes (DTO)
│ ├── QueryHandler/ # Gestionnaires de requêtes
│ └── Response/ # Objets de réponse (DTO)
└── Infrastructure/ # Adaptateurs
├── Repository/ # Implémentation des repositories
├── Service/ # Services techniques
└── Persistence/ # Persistence des données
```
## Règles d'Architecture
### 1. Règles Générales
- Tout le code métier doit résider dans le namespace `App\Domain`
- Les dépendances externes doivent être limitées et explicitement autorisées
- Les exceptions standards et utilitaires autorisés :
- `DateTimeImmutable`
- `RuntimeException`
- `Exception`
- `DomainException`
- `Symfony\Component\HttpKernel\Exception`
- `InvalidArgumentException`
### 2. Domaine Shared
- Le domaine `Shared` ne doit dépendre d'aucun autre domaine
- Il contient les contrats et les types partagés entre les domaines
- Exemple : `App\Domain\Shared\Contract\UuidInterface`
### 3. Couche Domain
- Ne doit dépendre que d'elle-même et du domaine Shared
- Contient la logique métier pure
- Ne doit pas avoir de dépendances externes
- Structure des composants :
- Les `Entity` sont les objets métier principaux
- Les `ValueObject` sont immuables et s'auto-valident
- Les `Event` représentent les changements d'état du domaine
- Les `Exception` définissent les erreurs métier spécifiques
### 4. Couche Application
- Peut dépendre de son propre domaine et du domaine Shared
- Peut utiliser les dépendances externes autorisées :
- `Symfony\Component\Messenger`
- `Ramsey\Uuid`
- Ne doit JAMAIS dépendre de la couche Infrastructure
- Structure des composants :
- Les `Query` sont des DTO (Data Transfer Objects) en lecture seule
- Les `Command` sont des DTO pour les modifications
- Les `QueryHandler` doivent :
- Implémenter `QueryHandlerInterface`
- Prendre une seule `Query` en paramètre
- Retourner une `Response`
- Les `CommandHandler` doivent :
- Implémenter `CommandHandlerInterface`
- Prendre une seule `Command` en paramètre
- Ne pas retourner de valeur (void)
- Les `Response` sont des DTO immuables pour les résultats de requêtes
### 5. Couche Infrastructure
- Implémente les interfaces définies dans le domaine
- Peut dépendre de toutes les couches de son domaine
- Contient les adaptateurs pour les services externes
- Structure des composants :
- Les `Repository` implémentent les interfaces du domaine
- Les `Service` fournissent des fonctionnalités techniques
- La `Persistence` gère le stockage des données
## Flux de Dépendances
```
Infrastructure → Application → Domain
↓ ↓ ↓
External Shared Shared
```
## Validation
Les règles d'architecture sont validées par phparkitect. Les violations de ces règles entraîneront une erreur lors de la validation.
## Exemples de Code
### Domain Layer
```php
namespace App\Domain\Manga\Domain\Entity;
class Manga
{
private MangaId $id;
private Title $title;
private Description $description;
public function __construct(MangaId $id, Title $title)
{
$this->id = $id;
$this->title = $title;
}
}
```
### Application Layer
```php
namespace App\Domain\Manga\Application\Query;
readonly class GetMangaByIdQuery
{
public function __construct(
public string $id
) {}
}
namespace App\Domain\Manga\Application\QueryHandler;
class GetMangaByIdQueryHandler implements QueryHandlerInterface
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
) {}
public function __invoke(GetMangaByIdQuery $query): MangaResponse
{
$manga = $this->mangaRepository->get($query->id);
return new MangaResponse($manga);
}
}
namespace App\Domain\Manga\Application\Command;
readonly class CreateMangaCommand
{
public function __construct(
public string $title,
public ?string $description = null,
) {}
}
namespace App\Domain\Manga\Application\CommandHandler;
class CreateMangaCommandHandler implements CommandHandlerInterface
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
) {}
public function __invoke(CreateMangaCommand $command): void
{
$manga = Manga::create($command->title, $command->description);
$this->mangaRepository->save($manga);
}
}
namespace App\Domain\Manga\Application\Response;
readonly class MangaResponse
{
public function __construct(
public string $id,
public string $title,
public ?string $description
) {}
public static function fromEntity(Manga $manga): self
{
return new self(
$manga->getId()->toString(),
$manga->getTitle()->value(),
$manga->getDescription()?->value()
);
}
}
```
### Infrastructure Layer
```php
namespace App\Domain\Manga\Infrastructure\Repository;
use App\Domain\Manga\Domain\Repository\MangaRepositoryInterface;
class DoctrineMangaRepository implements MangaRepositoryInterface
{
// Implementation
}
```

View File

@@ -0,0 +1,54 @@
---
description:
globs:
alwaysApply: true
---
# Contexte Métier de Mangarr
## Objectif Principal
Mangarr est une application de gestion et d'automatisation pour la collection de mangas, inspirée par Sonarr. Elle permet aux utilisateurs de suivre, télécharger et organiser automatiquement leurs mangas depuis différentes sources en ligne.
## Fonctionnalités Principales
### 1. Gestion de la Bibliothèque
- Suivi des séries de mangas
- Organisation automatique des chapitres
- Gestion des métadonnées (titres, auteurs, descriptions, couvertures)
### 2. Automatisation
- Scraping automatique des nouvelles sorties
- Téléchargement automatique des nouveaux chapitres
- Notifications lors de nouvelles sorties
### 3. Sources et Scraping
- Support de multiples sources de mangas en ligne
- Système de scraping modulaire et extensible
- Gestion des priorités des sources
### 4. Interface Utilisateur
- Téléchargement des chapitres en .cbz pour l'utilisateur
- Calendrier des sorties
- État des téléchargements
- Configuration des préférences
- Recherche et découverte de nouveaux mangas
### 5. Intégration
- API RESTful pour l'intégration avec d'autres services
- Support des lecteurs de manga externes
- Export/Import de la bibliothèque
## Règles Métier Importantes
1. Un manga peut avoir plusieurs sources disponibles
2. Les chapitres doivent être uniques (pas de doublons)
3. Les métadonnées doivent être cohérentes entre les sources
4. Le système doit respecter les limitations des sites sources
5. La qualité des scans doit être vérifiée avant l'archivage
## Architecture
L'application suit une architecture modulaire avec :
- Backend en PHP, Symfony pour le scraping et la gestion
- Frontend moderne pour l'interface utilisateur
- Base de données pour le stockage des métadonnées
- Système de files d'attente pour les téléchargements
- Cache pour optimiser les performances

147
.cursor/rules/commands.mdc Normal file
View File

@@ -0,0 +1,147 @@
---
description:
globs:
alwaysApply: true
---
# Commandes Makefile de Mangarr
Toujours chercher si une commande est disponible dans [Makefile](mdc:Makefile).
## Structure Générale
Le Makefile est organisé en plusieurs sections distinctes :
- Docker 🐳
- Composer 🧙
- Symfony 🎵
- Webpack Encore 📦
## Variables Principales
```makefile
# Exécutables Docker
DOCKER_COMP = docker compose
DOCKER_COMP_EXEC = $(DOCKER_COMP) exec
# Conteneurs
PHP_CONT = $(DOCKER_COMP_EXEC) php
NODE_CONT = $(DOCKER_COMP_EXEC) node
# Exécutables dans les conteneurs
PHP = $(PHP_CONT) php
COMPOSER = $(PHP_CONT) composer
SYMFONY = $(PHP) bin/console
SF_MEMORY = $(PHP) -d memory_limit=256M bin/console
```
## Bonnes Pratiques
### 1. Organisation des Commandes
- Regrouper les commandes par catégorie avec des commentaires clairs
- Utiliser des variables pour les commandes répétitives
- Documenter chaque commande avec `##` pour l'aide automatique
- Préfixer les commandes internes avec `_` (exemple: `_check-deps`)
### 2. Paramètres et Options
- Utiliser la syntaxe `make command p=value` pour les paramètres
- Documenter les paramètres possibles dans les commentaires
- Utiliser `?=` pour les valeurs par défaut modifiables
### 3. Dépendances
- Définir clairement les dépendances entre les commandes
- Utiliser des commandes composées pour les tâches complexes
- Éviter les dépendances circulaires
### 4. Documentation
- Chaque commande doit avoir une description avec `##`
- Inclure des exemples d'utilisation pour les commandes complexes
- Utiliser la commande `help` pour afficher la documentation
## Commandes Disponibles
### Docker 🐳
```makefile
build: ## Construit les images Docker
up: ## Démarre les conteneurs
start: ## Démarre les conteneurs en mode détaché
down: ## Arrête et supprime les conteneurs
logs: ## Affiche les logs en temps réel
sh: ## Se connecte au conteneur PHP
```
### Composer 🧙
```makefile
composer: ## Exécute une commande composer (c=command)
vendor: ## Installe les dépendances
```
### Symfony 🎵
```makefile
sf: ## Liste/exécute les commandes Symfony (c=command)
cc: ## Vide le cache
migration: ## Crée une nouvelle migration
fixtures: ## Charge les fixtures
consume: ## Consomme les messages de la queue
```
### Webpack Encore 📦
```makefile
npm-install: ## Installe les dépendances npm
npm-run: ## Lance le serveur de développement
npm-watch: ## Surveille les changements
```
## Exemples d'Utilisation
### 1. Installation du Projet
```bash
make install # Construit et démarre les conteneurs, installe les dépendances
```
### 2. Développement Quotidien
```bash
make start # Démarre les conteneurs
make npm-watch # Lance la compilation des assets
make consume # Démarre les workers
```
### 3. Commandes avec Paramètres
```bash
make composer c="require symfony/orm-pack" # Ajoute une dépendance
make sf c="make:entity" # Crée une entité
make test f="ScrapeChapterHandlerTest" # Lance un test spécifique
```
## Ajout de Nouvelles Commandes
### 1. Structure de Base
```makefile
command-name: ## Description de la commande
@$(DOCKER_COMP) ... # Commande à exécuter
```
### 2. Avec Paramètres
```makefile
command-with-param: ## Description (p=value)
@$(eval p ?=)
@$(DOCKER_COMP) ... $(p)
```
### 3. Commande Composée
```makefile
full-install: build start vendor npm-install ## Description complète
```
## Maintenance
### 1. Nettoyage
- Supprimer les commandes obsolètes
- Mettre à jour les descriptions
- Vérifier les dépendances
### 2. Documentation
- Maintenir la section d'aide à jour
- Ajouter des exemples pour les nouvelles commandes
- Documenter les changements importants
### 3. Tests
- Tester les nouvelles commandes
- Vérifier les dépendances
- Valider les paramètres

183
.cursor/rules/front_vue.mdc Normal file
View File

@@ -0,0 +1,183 @@
---
description:
globs: *.vue,*.js,assets/vue/app/*,assets/vue/app/**/*
alwaysApply: false
---
# Architecture Frontend Vue.js
## Introduction
En tant que développeur front-end expérimenté spécialisé en Vue.js, vous devez suivre les meilleures pratiques et standards de développement établis pour ce projet. Votre expertise en Vue.js, TypeScript, et votre maîtrise des patterns de conception modernes sont essentiels pour maintenir une base de code cohérente et maintenable.
## Stack Technique
- **Framework Principal**: Vue.js 3.x avec Composition API
- **Store Management**: Pinia 3.x
- **Routage**: Vue Router 4.x
- **Styling**:
- TailwindCSS 4.x pour les utilitaires CSS
- HeadlessUI pour les composants accessibles
- Heroicons pour l'iconographie
- **Build Tool**: Vite
- **Testing**: Vitest avec Vue Test Utils
- **Linting & Formatting**:
- ESLint avec la configuration Vue.js
- Prettier pour le formatage
## Conventions de Nommage
- **Composants**: PascalCase (ex: `MangaCard.vue`, `SearchBar.vue`)
- **Fichiers**:
- Composants: PascalCase avec extension .vue
- Utilitaires: camelCase avec extension .js/.ts
- Tests: PascalCase.spec.ts
- **Props**: camelCase dans le template, PascalCase dans le script
- **Events**: kebab-case dans le template, camelCase dans le script
- **Stores**: camelCase avec suffixe "Store" (ex: `mangaStore.js`)
- **Composables**: camelCase avec préfixe "use" (ex: `useSearch.js`)
## Structure Générale
L'application Vue.js suit une architecture hexagonale (ports & adapters) avec une séparation claire des responsabilités. Le code est organisé en domaines distincts dans le dossier `assets/vue/`.
Pour ce qui est du style, on utilise TailwindCss, Headlessui et Heroicons.
```
assets/vue/
├── app/
│ ├── shared/ # Code partagé entre les domaines
│ │ ├── components/ # Composants réutilisables
│ │ │ ├── ui/ # Composants UI génériques (boutons, inputs, etc.)
│ │ │ └── layout/ # Layouts réutilisables
│ │ ├── composables/ # Composables Vue partagés
│ │ ├── plugins/ # Plugins Vue (router, pinia, etc.)
│ │ └── utils/ # Utilitaires partagés
│ │
│ ├── domain/ # Domaines métier
│ │ ├── manga/ # Domaine Manga
│ │ │ ├── application/ # Cas d'utilisation
│ │ │ │ ├── commands/ # Commands & CommandHandlers
│ │ │ │ ├── queries/ # Queries & QueryHandlers
│ │ │ │ └── store/ # Store Pinia du domaine
│ │ │ ├── domain/ # Cœur métier
│ │ │ │ ├── entities/ # Entités
│ │ │ │ ├── value-objects/# Objets valeur
│ │ │ │ └── services/ # Services métier
│ │ │ ├── infrastructure/ # Adaptateurs
│ │ │ │ ├── api/ # Client API
│ │ │ │ └── repository/ # Implémentation repository
│ │ │ └── presentation/ # Interface utilisateur
│ │ │ ├── components/ # Composants spécifiques au domaine
│ │ │ ├── composables/ # Composables spécifiques
│ │ │ └── pages/ # Pages du domaine
│ │ │
│ │ ├── reader/ # Domaine Reader (même structure)
│ │ └── scraping/ # Domaine Scraping (même structure)
│ │
│ └── router/ # Configuration du routeur
│ └── index.js # Point d'entrée du routeur
```
## Contrat d'API
Le contrat d'API complet est disponible dans le fichier [api-docs.json](mdc:public/api-docs.json). Ce fichier contient la documentation OpenAPI de toutes les routes disponibles et leurs schémas.
## Règles d'Architecture
### 1. Règles Générales
- Chaque domaine est isolé et ne dépend que de lui-même et du domaine `shared`
- Les dépendances externes sont gérées via les adaptateurs dans l'infrastructure
- L'application est une SPA (Single Page Application) sans rechargement de page
- Utilisation de Vue Router pour la navigation côté client
- Gestion d'état avec Pinia organisée par domaine
### 2. Couche Domain
- Contient les entités et la logique métier pure
- Ne dépend d'aucune bibliothèque externe sauf Vue.js
- Les entités sont des classes JavaScript standard
- Exemple :
```javascript
export class Manga {
constructor({ id, title, description = null }) {
this.id = id;
this.title = title;
this.description = description;
}
static create(data) {
return new Manga(data);
}
}
```
### 3. Couche Application
- Gère les cas d'utilisation via les stores Pinia
- Coordonne les interactions entre l'UI et le domaine
- Transforme les données du domaine pour l'UI
- Exemple de store :
```javascript
export const useMangaStore = defineStore('manga', {
state: () => ({
mangas: [],
loading: false,
error: null
}),
actions: {
async fetchMangas() {
// Logique de chargement
}
}
});
```
### 4. Couche Infrastructure
- Gère la communication avec l'API
- Isole les dépendances externes
- Exemple d'API client :
```javascript
export class MangaApi {
static async fetchAll() {
const response = await fetch('/api/mangas');
return response.json();
}
}
```
### 5. Couche Présentation
- Composants Vue.js spécifiques au domaine
- Utilise les composants UI partagés
- Communique avec la couche application via les stores
- Exemple de composant :
```vue
<template>
<div class="manga-list">
<MangaCard v-for="manga in mangas" :key="manga.id" :manga="manga" />
</div>
</template>
```
## Bonnes Pratiques
### 1. Composants
- Utiliser la Composition API pour la logique
- Séparer les composants UI génériques des composants métier
- Favoriser les props et events pour la communication parent-enfant
- Utiliser les stores pour la communication entre composants distants
### 2. État
- Un store Pinia par domaine
- Actions asynchrones dans les stores
- Getters pour les données dérivées
- État local dans les composants quand possible
### 3. Router
- Routes organisées par domaine
- Lazy loading des composants de page
- Navigation programmatique via le router
- Guards pour la protection des routes
### 4. Style
- Utilisation de Tailwind CSS
- Classes utilitaires pour le style
- Composants Headless UI pour l'accessibilité
- Design system cohérent via les composants partagés
## Validation
Les règles d'architecture peuvent être validées par des outils comme :
- ESLint pour les règles de code
- Tests unitaires pour les composants
- Tests d'intégration pour les stores

163
.cursor/rules/jobs.mdc Normal file
View File

@@ -0,0 +1,163 @@
---
description:
globs: *.php
alwaysApply: false
---
# Architecture des Jobs dans Mangarr
## Vue d'ensemble
Le système de jobs de Mangarr est conçu pour gérer les tâches asynchrones et de longue durée de manière uniforme à travers tous les domaines. Il est basé sur une architecture centralisée dans le domaine `Shared` et peut être étendu par chaque domaine spécifique.
## Structure
```
src/Domain/Shared/
├── Domain/
│ ├── Model/
│ │ ├── Job.php # Classe abstraite de base
│ │ ├── JobStatus.php # États possibles d'un job
│ │ └── FailedJob.php # Représentation d'un job échoué
│ ├── Contract/
│ │ ├── JobRepositoryInterface.php
│ │ └── FailedJobRepositoryInterface.php
│ └── Exception/
│ ├── JobNotFoundException.php
│ └── JobNotRetryableException.php
└── Infrastructure/
├── Persistence/
│ ├── Entity/
│ │ ├── JobEntity.php
│ │ └── FailedJobEntity.php
│ └── Repository/
│ ├── DoctrineJobRepository.php
│ └── DoctrineFailedJobRepository.php
└── Service/
└── JobRetryService.php
```
## Cycle de Vie d'un Job
### États Possibles
```php
enum JobStatus: string
{
case PENDING = 'pending'; // Job créé, en attente d'exécution
case IN_PROGRESS = 'in_progress';// Job en cours d'exécution
case COMPLETED = 'completed'; // Job terminé avec succès
case FAILED = 'failed'; // Job échoué définitivement
case CANCELLED = 'cancelled'; // Job annulé manuellement
}
```
### Transitions d'États
1. `PENDING` → `IN_PROGRESS` : Lors du démarrage du job
2. `IN_PROGRESS` → `COMPLETED` : Lorsque le job se termine avec succès
3. `IN_PROGRESS` → `FAILED` : Lorsque le job échoue et atteint le nombre maximum de tentatives
4. `IN_PROGRESS` → `PENDING` : Lorsque le job échoue mais peut être réessayé
5. Tout état → `CANCELLED` : Lorsque le job est annulé manuellement
## Création d'un Nouveau Type de Job
1. **Créer une classe de job spécifique**
```php
class MyCustomJob extends Job
{
public function __construct(
string $id,
public readonly string $someData,
public readonly array $additionalData = []
) {
parent::__construct($id, 'my_custom_job');
}
}
```
2. **Définir le Handler**
```php
class MyCustomJobHandler
{
public function __invoke(MyCustomJob $job): void
{
try {
$job->start();
// Logique métier
$job->complete();
} catch (\Exception $e) {
$job->fail($e->getMessage());
}
}
}
```
## Gestion des Échecs
### Retry Automatique
- Un job peut être réessayé tant que `$attempts < $maxAttempts`
- Lors d'un échec, si des tentatives sont encore possibles, le statut redevient `PENDING`
- Les informations d'échec sont conservées dans `FailedJob`
### Informations de Debug
Chaque job contient :
- `failureReason` : La raison de l'échec
- `attempts` : Nombre de tentatives effectuées
- `context` : Données contextuelles pour le debug
- `createdAt`, `startedAt`, `completedAt` : Timestamps pour le suivi
## Bonnes Pratiques
### 1. Création de Jobs
```php
$job = new MyCustomJob(
id: Uuid::v4(),
someData: 'data',
additionalData: ['key' => 'value']
);
```
### 2. Gestion du Contexte
```php
$job->context['important_info'] = 'value';
$job->context['debug_data'] = $debugInfo;
```
### 3. Retry Manuel
```php
if ($failedJob->canBeRetried()) {
$job->attempts = 0;
$job->status = JobStatus::PENDING;
$jobRepository->save($job);
}
```
### 4. Monitoring
- Utiliser `findByStatus()` pour surveiller les jobs par état
- Utiliser `findFailedJobs()` pour vérifier les échecs
- Consulter `FailedJob` pour les détails des échecs
## Règles Importantes
1. **Idempotence**
- Les jobs doivent être idempotents
- Gérer les cas de réexécution
- Vérifier l'état avant les opérations
2. **Contexte**
- Toujours fournir un contexte utile
- Inclure les IDs des entités concernées
- Ajouter des informations de debug pertinentes
3. **Durée**
- Les jobs doivent être de longue durée
- Pour les opérations courtes, utiliser des appels directs
- Prévoir des timeouts appropriés
4. **Statut**
- Ne jamais modifier le statut directement
- Utiliser les méthodes `start()`, `complete()`, `fail()`, `cancel()`
- Toujours sauvegarder après un changement de statut
5. **Échecs**
- Capturer et logger toutes les exceptions
- Fournir des messages d'erreur explicites
- Conserver le contexte d'échec pour le debug

View File

@@ -0,0 +1,302 @@
---
description:
globs: *.php
alwaysApply: false
---
# Persistence dans l'Architecture Hexagonale
## Structure de la Persistence
```
Domain/Manga/
└── Infrastructure/
└── Persistence/
├── Repository/ # Implémentations des repositories
│ └── DoctrineMangaRepository.php
├── Entity/ # Entités Doctrine
│ └── MangaEntity.php
└── Mapper/ # Mappers Domain <-> Entity
└── MangaMapper.php
```
## Règles d'Organisation
### 1. Repositories
- Localisation : `Infrastructure/Persistence/Repository/`
- Principes :
- Un repository par agrégat du domaine
- Implémente l'interface du domaine
- Utilise un mapper dédié
- Gère uniquement la persistence
- Pas de logique métier
- Nommage : `Doctrine{Aggregate}Repository`
### 2. Entités
- Localisation : `Infrastructure/Persistence/Entity/`
- Principes :
- Une entité par agrégat du domaine
- Uniquement des getters/setters
- Pas de logique métier
- Nommage : `{Aggregate}Entity`
- Suffixe `Entity` obligatoire pour éviter la confusion avec les modèles du domaine
### 3. Mappers
- Localisation : `Infrastructure/Persistence/Mapper/`
- Principes :
- Un mapper par agrégat
- Conversion bidirectionnelle Domain <-> Entity
- Gestion des Value Objects
- Nommage : `{Aggregate}Mapper`
## Exemples de Code
### 1. Query et Repository
```php
namespace App\Domain\Manga\Application\Query;
readonly class GetMangaListQuery
{
public function __construct(
public int $page = 1,
public int $limit = 20,
public string $sortBy = 'title',
public string $sortOrder = 'asc',
public ?string $search = null,
public array $genres = []
) {
if ($this->page < 1) {
throw new \InvalidArgumentException('Page must be greater than 0');
}
if ($this->limit < 1) {
throw new \InvalidArgumentException('Limit must be greater than 0');
}
}
public function getOffset(): int
{
return ($this->page - 1) * $this->limit;
}
}
namespace App\Domain\Manga\Domain\Repository;
interface MangaRepositoryInterface
{
public function findByQuery(GetMangaListQuery $query): array;
public function count(GetMangaListQuery $query): int;
}
namespace App\Domain\Manga\Infrastructure\Persistence\Repository;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Infrastructure\Persistence\Entity\MangaEntity;
use App\Domain\Manga\Infrastructure\Persistence\Mapper\MangaMapper;
use Doctrine\ORM\EntityManagerInterface;
readonly class DoctrineMangaRepository implements MangaRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private MangaMapper $mapper
) {}
public function findByQuery(GetMangaListQuery $query): array
{
$qb = $this->entityManager->createQueryBuilder()
->select('m')
->from(MangaEntity::class, 'm');
if ($query->search) {
$qb->andWhere('m.title LIKE :search')
->setParameter('search', '%' . $query->search . '%');
}
if (!empty($query->genres)) {
$qb->andWhere('m.genres && :genres')
->setParameter('genres', $query->genres);
}
$qb->orderBy('m.' . $query->sortBy, $query->sortOrder)
->setFirstResult($query->getOffset())
->setMaxResults($query->limit);
return array_map(
fn (MangaEntity $entity) => $this->mapper->toDomain($entity),
$qb->getQuery()->getResult()
);
}
public function count(GetMangaListQuery $query): int
{
$qb = $this->entityManager->createQueryBuilder()
->select('COUNT(m.id)')
->from(MangaEntity::class, 'm');
if ($query->search) {
$qb->andWhere('m.title LIKE :search')
->setParameter('search', '%' . $query->search . '%');
}
if (!empty($query->genres)) {
$qb->andWhere('m.genres && :genres')
->setParameter('genres', $query->genres);
}
return $qb->getQuery()->getSingleScalarResult();
}
public function findById(string $id): ?Manga
{
$entity = $this->entityManager->find(MangaEntity::class, $id);
return $entity ? $this->mapper->toDomain($entity) : null;
}
public function save(Manga $manga): void
{
$entity = $this->mapper->toEntity($manga);
$this->entityManager->persist($entity);
$this->entityManager->flush();
// Met à jour l'ID du modèle du domaine si nécessaire
if ($entity->getId() && $manga->getId() === null) {
$manga->updateId($entity->getId());
}
}
}
```
### 2. Entity
```php
namespace App\Domain\Manga\Infrastructure\Persistence\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'manga')]
class MangaEntity
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'json')]
private array $authors = [];
#[ORM\Column(length: 255, nullable: true)]
private ?string $coverUrl = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
// Getters
public function getId(): ?int { return $this->id; }
public function getTitle(): string { return $this->title; }
public function getDescription(): ?string { return $this->description; }
public function getAuthors(): array { return $this->authors; }
public function getCoverUrl(): ?string { return $this->coverUrl; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
// Setters (fluent interface)
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
public function setDescription(?string $description): self
{
$this->description = $description;
return $this;
}
// ... autres setters
}
```
### 3. Mapper
```php
namespace App\Domain\Manga\Infrastructure\Persistence\Mapper;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\Title;
use App\Domain\Manga\Infrastructure\Persistence\Entity\MangaEntity;
readonly class MangaMapper
{
public function toDomain(MangaEntity $entity): Manga
{
return new Manga(
id: new MangaId((string) $entity->getId()),
title: new Title($entity->getTitle()),
description: $entity->getDescription(),
authors: $entity->getAuthors(),
coverUrl: $entity->getCoverUrl(),
createdAt: $entity->getCreatedAt()
);
}
public function toEntity(Manga $manga): MangaEntity
{
$entity = new MangaEntity();
$entity->setTitle($manga->getTitle()->value())
->setDescription($manga->getDescription())
->setAuthors($manga->getAuthors())
->setCoverUrl($manga->getCoverUrl());
return $entity;
}
}
```
## Bonnes Pratiques
### 1. Gestion des Erreurs
- Convertir les exceptions Doctrine en exceptions du domaine
- Ne pas exposer les détails de l'infrastructure
- Gérer les cas d'erreur spécifiques (contraintes uniques, etc.)
```php
namespace App\Domain\Manga\Infrastructure\Persistence\Exception;
class PersistenceException extends \RuntimeException
{
public static function entityNotFound(string $id): self
{
return new self(sprintf('Entity with id %s not found', $id));
}
public static function uniqueConstraintViolation(string $field): self
{
return new self(sprintf('Entity with %s already exists', $field));
}
}
```
### 2. Performance
- Utiliser les bonnes stratégies de chargement (EAGER vs LAZY)
- Optimiser les requêtes avec des QueryBuilder
- Paginer les résultats
- Utiliser le cache quand nécessaire
### 3. Tests
- Créer des repositories In-Memory pour les tests
- Utiliser SQLite en mémoire pour les tests d'intégration
- Tester les cas d'erreur
- Vérifier les contraintes de base de données

202
.cursor/rules/tests.mdc Normal file
View File

@@ -0,0 +1,202 @@
---
description:
globs: *.php
alwaysApply: false
---
# Tests de Mangarr
## Structure des Tests
L'application suit une organisation stricte des tests reflétant l'architecture hexagonale :
```
tests/
├── Domain/ # Tests unitaires par domaine
│ ├── Manga/
│ │ ├── Application/ # Tests des cas d'utilisation
│ │ ├── Domain/ # Tests du cœur métier
│ │ └── Adapter/ # Implémentations InMemory des ports
│ ├── Reader/
│ │ └── ...
│ └── Scraping/
│ └── ...
├── Feature/ # Tests fonctionnels par domaine
│ ├── Manga/
│ ├── Reader/
│ └── Scraping/
├── Shared/ # Tests et adapters partagés
│ └── Adapter/ # Adapters partagés entre domaines
└── Fixtures/ # Fixtures de test partagées
```
## Règles de Test
### 1. Tests Unitaires (Domain)
- Localisation : `tests/Domain/NomDuDomain/`
- Principes :
- Tester chaque composant de manière isolée
- Éviter l'utilisation de mocks
- Utiliser des adapters InMemory
- Nommer les classes de test avec le suffixe `Test`
### 2. Adapters de Test
- Localisation : `tests/Domain/NomDuDomain/Adapter/`
- Principes :
- Implémenter les interfaces du domaine
- Stocker les données dans des tableaux
- Préfixer les classes avec `InMemory`
- Si utilisé par plusieurs domaines → déplacer dans `tests/Shared/Adapter/`
### 3. Tests Fonctionnels (Feature)
- Localisation : `tests/Feature/NomDuDomain/`
- Principes :
- Tester les endpoints HTTP
- Utiliser le trait `ResetDatabase`
- Tester le flux complet
- Nommer les classes avec le suffixe `Test`
## Exemples de Code
### 1. Adapter InMemory
```php
namespace Tests\Domain\Manga\Adapter;
use App\Domain\Manga\Domain\Entity\Manga;
use App\Domain\Manga\Domain\Repository\MangaRepositoryInterface;
class InMemoryMangaRepository implements MangaRepositoryInterface
{
/** @var array<string, Manga> */
private array $mangas = [];
public function save(Manga $manga): void
{
$this->mangas[$manga->getId()->toString()] = $manga;
}
public function get(string $id): ?Manga
{
return $this->mangas[$id] ?? null;
}
public function clear(): void
{
$this->mangas = [];
}
}
```
### 2. Test Unitaire
```php
namespace Tests\Domain\Manga\Application;
use App\Domain\Manga\Application\Command\CreateMangaCommand;
use App\Domain\Manga\Application\CommandHandler\CreateMangaCommandHandler;
use Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use PHPUnit\Framework\TestCase;
class CreateMangaCommandHandlerTest extends TestCase
{
private InMemoryMangaRepository $repository;
private CreateMangaCommandHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryMangaRepository();
$this->handler = new CreateMangaCommandHandler($this->repository);
}
public function test_it_creates_manga(): void
{
// Given
$command = new CreateMangaCommand('One Piece');
// When
$this->handler->__invoke($command);
// Then
$mangas = $this->repository->findAll();
$this->assertCount(1, $mangas);
$this->assertEquals('One Piece', $mangas[0]->getTitle()->value());
}
}
```
### 3. Test Fonctionnel
```php
namespace Tests\Feature\Manga;
use Tests\Shared\ResetDatabase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class CreateMangaTest extends WebTestCase
{
use ResetDatabase;
public function test_it_creates_manga_through_api(): void
{
// Given
$client = static::createClient();
$data = ['title' => 'One Piece'];
// When
$client->request('POST', '/api/mangas', [], [], [], json_encode($data));
// Then
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['title' => 'One Piece']);
}
}
```
### 4. Adapter Partagé
```php
namespace Tests\Shared\Adapter;
use App\Domain\Shared\Contract\MessageBusInterface;
class InMemoryMessageBus implements MessageBusInterface
{
/** @var array<object> */
private array $messages = [];
public function dispatch(object $message): void
{
$this->messages[] = $message;
}
public function getDispatchedMessages(): array
{
return $this->messages;
}
public function clear(): void
{
$this->messages = [];
}
}
```
## Bonnes Pratiques
### 1. Organisation des Tests
- Un test par classe
- Regrouper les tests par fonctionnalité
- Suivre la même structure que le code source
- Utiliser des données de test explicites
### 2. Nommage
- Classes de test : `{ClassTestée}Test`
- Méthodes de test : `test_it_{comportement_testé}`
- Adapters : `InMemory{Interface}`
### 3. Assertions
- Utiliser des assertions spécifiques
- Vérifier les états plutôt que les interactions
- Tester les cas d'erreur
- Tester les cas limites
### 4. Données de Test
- Utiliser des fixtures pour les données complexes
- Créer des données spécifiques au test quand possible
- Éviter les dépendances entre tests
- Nettoyer l'état après chaque test

View File

@@ -30,3 +30,4 @@ vendor/
.env.local .env.local
.env.local.php .env.local.php
.env.test .env.test
.env

3
.env
View File

@@ -51,5 +51,4 @@ MERCURE_JWT_SECRET="Mangarr-JWT-Secret"
###< symfony/mercure-bundle ### ###< symfony/mercure-bundle ###
#Custom #Custom
MANGA_DATA_PATH=/mnt/c/Users/jerem/Mangas MANGA_DATA_PATH=/home/ext.jeremy.guillot@maxicoffee.domains/Mangarr
IMAGE_DATA_PATH=/mnt/c/Users/jerem/MangasImages

View File

@@ -22,3 +22,21 @@ POSTGRES_VERSION=16
DATABASE_URL="postgresql://%env(resolve:POSTGRES_USER)%:%env(resolve:POSTGRES_PASSWORD)%@%env(resolve:POSTGRES_HOST)%/%env(resolve:POSTGRES_DB)%?serverVersion=%env(resolve:POSTGRES_VERSION)%&charset=utf8" DATABASE_URL="postgresql://%env(resolve:POSTGRES_USER)%:%env(resolve:POSTGRES_PASSWORD)%@%env(resolve:POSTGRES_HOST)%/%env(resolve:POSTGRES_DB)%?serverVersion=%env(resolve:POSTGRES_VERSION)%&charset=utf8"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
###> symfony/mercure-bundle ###
# See https://symfony.com/doc/current/mercure.html#configuration
# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
MERCURE_URL=http://mercure/.well-known/mercure
# The public URL of the Mercure hub, used by the browser to connect
MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure
# The secret used to sign the JWTs
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
###< symfony/mercure-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###

View File

@@ -5,5 +5,6 @@ SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
POSTGRES_DB=app # Configuration PostgreSQL pour les tests
POSTGRE_VERSION=16 POSTGRES_DB=app_test
POSTGRES_VERSION=16

View File

@@ -0,0 +1,42 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup SSH
run: |
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: Deploy via Deployer
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
# 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)
# Copier les sources et les clés SSH dans le container
docker cp "$PWD/." "$CONTAINER:/app/"
docker cp "$HOME/.ssh/." "$CONTAINER:/root/.ssh/"
# Démarrer et attendre la fin
docker start -a "$CONTAINER"
EXIT_CODE=$?
docker rm "$CONTAINER" || true
exit $EXIT_CODE

11
.gitignore vendored
View File

@@ -23,6 +23,7 @@
###> phpunit/phpunit ### ###> phpunit/phpunit ###
/phpunit.xml /phpunit.xml
.phpunit.result.cache .phpunit.result.cache
.phpunit.cache/*
###< phpunit/phpunit ### ###< phpunit/phpunit ###
###> symfony/webpack-encore-bundle ### ###> symfony/webpack-encore-bundle ###
@@ -35,3 +36,13 @@ yarn-error.log
/public/manga-images/ /public/manga-images/
/public/cbz/ /public/cbz/
/public/images/ /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/

16
.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"printWidth": 120,
"trailingComma": "none",
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "strict",
"singleAttributePerLine": false,
"jsxSingleQuote": false,
"jsxBracketSameLine": true,
"vueIndentScriptAndStyle": true,
"bracketSameLine": true
}

25
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,25 @@
{
"symfony-vscode.shellExecutable": "/bin/bash",
"symfony-vscode.shellCommand": "docker exec mangarr-php-1 /bin/sh -c 'cd / && php \"$@\"' -- ",
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#2f7c47",
"activityBar.background": "#2f7c47",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#422c74",
"activityBarBadge.foreground": "#e7e7e7",
"commandCenter.border": "#e7e7e799",
"sash.hoverBorder": "#2f7c47",
"statusBar.background": "#215732",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#2f7c47",
"statusBarItem.remoteBackground": "#215732",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#215732",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#21573299",
"titleBar.inactiveForeground": "#e7e7e799",
"activityBar.activeBorder": "#422c74"
},
"peacock.color": "#215732"
}

110
CLAUDE.md Normal file
View File

@@ -0,0 +1,110 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
Mangarr is a Symfony 7.0 manga management/reader application. It scrapes manga chapters from various sources, stores them as CBZ files, and provides a reader interface. It runs on FrankenPHP inside Docker.
## Common Commands
All commands run via Docker through the Makefile. Use `make help` to see all available targets.
```bash
make start # Start Docker containers
make stop # Stop containers
make install # Build images, start containers, install deps (first-time setup)
make logs # Follow container logs
make sh # Shell into the PHP container
```
**PHP / Symfony:**
```bash
make sf c="about" # Run any Symfony console command
make cc # Clear cache
make vendor # Install Composer dependencies
make migration-migrate # Run pending migrations
make fixtures-load # Load fixtures (drops and recreates data)
```
**Testing:**
```bash
make test # Run all tests
make test f="ScrapeChapterHandlerTest" # Run a specific test by class name
make test c="--group e2e" # Pass phpunit options
```
**Code Quality:**
```bash
make phpcs # Fix code style (PSR-12 via php-cs-fixer)
make phpmd # Run PHP Mess Detector
make quality # Run both phpmd and phpcs
make phparkitect # Check architectural rules
```
**Frontend:**
```bash
make npm-run # Build assets once (dev)
make npm-watch # Watch and rebuild assets
make npm-add p=pkg # Add an npm dependency
```
**Messenger workers** (run in separate terminals):
```bash
make consume-commands # Process command.bus messages
make consume-events # Process domain events
make consume-schedule # Process scheduled tasks
```
## Architecture
The project uses **Domain-Driven Design** with strict layer separation enforced by PHPArkitect (`phparkitect.php`).
### Domain Structure
```
src/Domain/
{DomainName}/
Domain/ # Pure domain: Models, Contracts (interfaces), Events, Exceptions
Application/ # Use cases: Commands, Queries, CommandHandlers, QueryHandlers, Responses
Infrastructure/ # Framework: Persistence, API Platform State, Clients, Services
Shared/ # Cross-domain contracts and infrastructure (MangaPathManagerInterface, EventDispatcherInterface, etc.)
```
**Business domains:** `Manga`, `Reader`, `Scraping`, `Conversion`, `Setting` (+ `Shared`)
**Architectural rules enforced:**
- `Domain` layer has no outside dependencies (only std exceptions)
- `Application` layer may depend on its own Domain + `App\Domain\Shared\Domain\Contract` + `Symfony\Messenger` + `Ramsey\Uuid`; never on Infrastructure
- `Shared` depends on nothing outside itself
### Outside Domain (`src/`)
- `src/Entity/` — Doctrine ORM entities (legacy, used by repositories)
- `src/Controller/` — Symfony HTTP controllers
- `src/ApiResource/` — API Platform resource definitions + `OpenApiFactoryDecorator`
- `src/Service/` — Legacy services (being migrated into Domain)
- `src/Message/` + `src/MessageHandler/` — Legacy Messenger messages (outside DDD)
### Frontend
- `assets/controllers/` — Stimulus controllers (one per UI interaction)
- `assets/vue/app/` — Vue.js SPA mounted at `/vue/*`
- Tailwind CSS via PostCSS, bundled with Webpack Encore
- Mercure for real-time updates (queue status, download progress)
### Key Infrastructure
- **Database:** PostgreSQL 16 via Doctrine ORM; Adminer on port 8080
- **Scraping:** `scrapers.json` defines per-site CSS selectors; `HtmlScraper` and `JavascriptScraper` (Panther) strategies
- **File storage:** CBZ files stored at `MANGA_DATA_PATH` (default `~/Mangas`); images at `IMAGE_DATA_PATH`
- **External API:** MangaDex client for metadata (`MANGADEX_CLIENT_ID/SECRET/USERNAME/PASSWORD` env vars)
- **Messenger buses:** `command.bus` (sync commands), `events` transport, `commands` transport, `async` transport (scheduler)
### Adding a New Domain Feature
1. Define contracts (interfaces) in `Domain/{Name}/Domain/Contract/`
2. Write Command/Query + Handler in `Domain/{Name}/Application/`
3. Implement interfaces in `Domain/{Name}/Infrastructure/`
4. Register infrastructure aliases in `config/services.yaml`
5. Run `make phparkitect` to validate layer boundaries

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

@@ -1,7 +1,7 @@
#syntax=docker/dockerfile:1.4 #syntax=docker/dockerfile:1.4
# Versions # Versions
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
# The different stages of this Dockerfile are meant to be built into separate images # The different stages of this Dockerfile are meant to be built into separate images
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage # https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
@@ -68,6 +68,19 @@ ENTRYPOINT ["docker-entrypoint"]
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1 HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ] 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 # Dev FrankenPHP image
FROM frankenphp_base AS frankenphp_dev FROM frankenphp_base AS frankenphp_dev
@@ -85,6 +98,26 @@ COPY --link frankenphp/conf.d/app.dev.ini $PHP_INI_DIR/conf.d/
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ] CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ]
# Composer dependencies (needed for Symfony UX assets referenced in package.json)
FROM composer:2 AS composer_deps
WORKDIR /app
COPY --link composer.* symfony.* ./
RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress --ignore-platform-reqs
# Stage Node.js pour compiler les assets (Webpack Encore)
FROM node:22-alpine AS node_build
WORKDIR /app
COPY --link package.json package-lock.json ./
COPY --from=composer_deps /app/vendor/symfony/ux-live-component/assets ./vendor/symfony/ux-live-component/assets
COPY --from=composer_deps /app/vendor/symfony/ux-react/assets ./vendor/symfony/ux-react/assets
COPY --from=composer_deps /app/vendor/symfony/ux-turbo/assets ./vendor/symfony/ux-turbo/assets
RUN npm install
COPY --link assets ./assets
COPY --link webpack.config.js ./
COPY --link tailwind.config.js postcss.config.js ./
COPY --link templates ./templates
RUN npm run build
# Prod FrankenPHP image # Prod FrankenPHP image
FROM frankenphp_base AS frankenphp_prod FROM frankenphp_base AS frankenphp_prod
@@ -103,11 +136,15 @@ RUN set -eux; \
# copy sources # copy sources
COPY --link . ./ COPY --link . ./
RUN rm -Rf frankenphp/ RUN rm -Rf frankenphp/ && \
test -f .env || cp .env.example .env
# Copier les assets compilés depuis le stage Node.js
COPY --from=node_build /app/public/build ./public/build
RUN set -eux; \ RUN set -eux; \
mkdir -p var/cache var/log; \ mkdir -p var/cache var/log; \
composer dump-autoload --classmap-authoritative --no-dev; \ composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \ composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \ DATABASE_URL="postgresql://dummy:dummy@dummy:5432/dummy?serverVersion=15&charset=utf8" composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync; chmod +x bin/console; sync;

View File

@@ -17,7 +17,7 @@ SF_MEMORY = $(PHP) -d memory_limit=256M bin/console
# Misc # Misc
.DEFAULT_GOAL = help .DEFAULT_GOAL = help
.PHONY : help build start install down stop logs sh composer vendor sf cc test phpmd phpcs quality fix-permissions controller entity migration migration-diff migration-migrate form crud fixtures command auth subscriber state-processor state-provider npm-install npm-run npm-watch .PHONY : help build start install down stop logs sh composer vendor sf cc test phpmd phpcs quality fix-permissions controller entity migration migration-diff migration-migrate form crud fixtures command auth subscriber state-processor state-provider npm-install npm-run npm-watch openapi
## —— 🎵 🐳 The Symfony Docker Makefile 🐳 🎵 —————————————————————————————————— ## —— 🎵 🐳 The Symfony Docker Makefile 🐳 🎵 ——————————————————————————————————
help: ## Outputs this help screen help: ## Outputs this help screen
@@ -27,6 +27,12 @@ help: ## Outputs this help screen
build: ## Builds the Docker images build: ## Builds the Docker images
@$(DOCKER_COMP) build --pull --no-cache @$(DOCKER_COMP) build --pull --no-cache
phparkitect: ## Vérifie l'architecture avec PHPArkitect
@$(PHP_CONT) vendor/bin/phparkitect check
up: ## Start the docker hub
@$(DOCKER_COMP) up -d
start: ## Start the docker hub in detached mode (no logs) start: ## Start the docker hub in detached mode (no logs)
@$(DOCKER_COMP) up --pull always -d --wait @$(DOCKER_COMP) up --pull always -d --wait
@@ -44,9 +50,10 @@ logs: ## Show live logs
sh: ## Connect to the FrankenPHP container sh: ## Connect to the FrankenPHP container
@$(PHP_CONT) sh @$(PHP_CONT) sh
test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure" test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure", or "f=" to specify a test file, example: make test f="ScrapeChapterHandlerTest"
@$(eval c ?=) @$(eval c ?=)
@$(DOCKER_COMP) exec -e APP_ENV=test php bin/phpunit $(c) @$(eval f ?=)
@$(DOCKER_COMP) exec -e APP_ENV=test php bin/phpunit $(c) $(if $(f),--filter=$(f),)
phpmd: ## Start PHP Mess Detector phpmd: ## Start PHP Mess Detector
@if ! $(DOCKER_COMP) exec php vendor/bin/phpmd src/ text phpmd.xml -v; then \ @if ! $(DOCKER_COMP) exec php vendor/bin/phpmd src/ text phpmd.xml -v; then \
@@ -138,8 +145,18 @@ twig-extension: ## Create a new twig extension
stimulus: ## Create a new stimulus controller stimulus: ## Create a new stimulus controller
@$(SYMFONY) make:stimulus-controller @$(SYMFONY) make:stimulus-controller
consume: ## Consume messages notify-test: ## Envoie les 4 types de notifications de test avec 2s d'intervalle
@$(SYMFONY) messenger:consume async -vv @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
consume-events: ## Consume events messages
@$(SYMFONY) messenger:consume events -vv
consume-schedule: ## Consume schedule messages consume-schedule: ## Consume schedule messages
@$(SYMFONY) messenger:consume async -vv scheduler_default @$(SYMFONY) messenger:consume async -vv scheduler_default
@@ -147,6 +164,9 @@ consume-schedule: ## Consume schedule messages
message: ## Create a new message and handler message: ## Create a new message and handler
@$(SYMFONY) make:message @$(SYMFONY) make:message
openapi: ## Exporter la documentation OpenAPI en JSON
@$(SYMFONY) api:openapi:export --output=public/api-docs.json
## —— Webpack Encore ————————————————————————————————————————————————————————————— ## —— Webpack Encore —————————————————————————————————————————————————————————————
npm-install: ## Install npm dependencies npm-install: ## Install npm dependencies
@$(DOCKER_COMP) exec node npm install --force @$(DOCKER_COMP) exec node npm install --force

View File

@@ -13,7 +13,7 @@ Avant de commencer, assurez-vous que les outils suivants sont installés sur vot
Pour mettre en place le projet, suivez ces étapes : Pour mettre en place le projet, suivez ces étapes :
1. Clonez le dépôt du projet : 1. Clonez le dépôt du projet :
```git clone git@bitbucket.org:tkm_rd/tkm-symfony.git``` ```git clone git@git.homelab.nestor-server.fr:2222/colgora/Mangarr.git```
2. Copiez le fichier `.env.example` en `.env` : 2. Copiez le fichier `.env.example` en `.env` :
```cp .env.example .env``` ```cp .env.example .env```

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

@@ -1,15 +0,0 @@
import './bootstrap.js';
import '@fortawesome/fontawesome-free/js/all.js';
/*
* Welcome to your app's main JavaScript file!
*
* We recommend including the built version of this JavaScript file
* (and its CSS file) in your base layout (base.html.twig).
*/
// any CSS you import will output into a single css file (app.css in this case)
import './styles/app.scss';
// start the Stimulus application
import './bootstrap';

35
assets/bootstrap.js vendored
View File

@@ -1,35 +0,0 @@
import { startStimulusApp } from '@symfony/stimulus-bridge';
// Registers Stimulus controllers from controllers.json and in the controllers/ directory
export const app = startStimulusApp(require.context(
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
true,
/\.[jt]sx?$/
));
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
//DEBUG TURBO
// import * as Turbo from "@hotwired/turbo"
//
// Turbo.session.drive = false
// Turbo.start()
//
// // Écouteurs existants
// document.addEventListener("turbo:before-stream-render", (event) => {
// console.log("Before stream render", event.target);
// });
//
// document.addEventListener("turbo:stream-render", (event) => {
// console.log("Stream rendered", event.target);
// });
//
// // Nouvel écouteur pour les événements de création
// document.addEventListener("turbo:before-fetch-request", (event) => {
// console.log("Before fetch request", event.detail.fetchOptions);
// });
//
// document.addEventListener("turbo:before-fetch-response", (event) => {
// console.log("Before fetch response", event.detail.fetchResponse);
// });

View File

@@ -1,24 +0,0 @@
{
"controllers": {
"@symfony/ux-live-component": {
"live": {
"enabled": true,
"fetch": "eager",
"autoimport": {
"@symfony/ux-live-component/dist/live.min.css": true
}
}
},
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": true,
"fetch": "eager"
}
}
},
"entrypoints": []
}

View File

@@ -1,54 +0,0 @@
import {Controller} from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['activity']
// ...
async connect() {
try {
const response = await fetch(`/activity/status`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
// Handle the response data as needed
this.activityTarget.innerHTML = data.length;
if (data.length > 0) {
this.activityTarget.classList.remove('hidden');
}
} catch (error) {
console.error('Error:', error);
}
const mercureHubUrl = 'https://localhost/.well-known/mercure';
const eventSource = new EventSource(`${mercureHubUrl}?topic=activity`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.processing !== undefined && data.pending !== undefined) {
let totalActivities = data.processing.length + data.pending.length;
this.activityTarget.innerHTML = totalActivities;
if (totalActivities > 0) {
this.activityTarget.classList.remove('hidden');
}else if (totalActivities === 0) {
this.activityTarget.classList.add('hidden');
}
}
};
eventSource
.onerror = (event) => {
console.error('EventSource failed:', event);
};
}
}

View File

@@ -1,14 +0,0 @@
// assets/controllers/addmanga_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
index: Number
}
openModal(event) {
event.preventDefault()
const openEvent = new CustomEvent(`openAddMangaModal${this.indexValue}`)
document.dispatchEvent(openEvent)
}
}

View File

@@ -1,60 +0,0 @@
import {Controller} from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['alert', 'icon', 'message']
connect() {
window.addEventListener('alert:show', this.showAlert.bind(this));
}
// ...
showAlert(event) {
const detail = event.detail;
const message = detail.message;
const level = detail.level;
let alertClass = "";
let iconClass = "";
switch (level) {
case 'success':
alertClass = "bg-green-500";
iconClass = "fa-circle-check";
break;
case 'warning':
alertClass = "bg-yellow-500";
iconClass = "fa-circle-exclamation";
break;
case 'error':
alertClass = "bg-red-500";
iconClass = "fa-circle-xmark";
break;
case 'info':
default:
alertClass = "bg-blue-500";
iconClass = "fa-circle-info";
break;
}
this.messageTarget.innerHTML = message;
this.alertTarget.classList.add(alertClass);
this.iconTarget.classList.add(iconClass);
this.alertTarget.style.display = "block";
setTimeout(() => {
this.alertTarget.style.opacity = 0;
setTimeout(() => {
this.alertTarget.style.display = 'none';
this.alertTarget.classList.remove(alertClass);
this.alertTarget.style.opacity = 1;
this.iconTarget.classList.remove(iconClass);
this.messageTarget.innerHTML = message;
}, 1000);
}, 3000);
}
}

View File

@@ -1,45 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['progressBar', 'progressText']
static values = {
chapterId: Number
}
connect() {
this.currentPage = 0;
this.totalPages = 0;
const mercureHubUrl = 'https://localhost/.well-known/mercure';
this.eventSource = new EventSource(`${mercureHubUrl}?topic=activity`);
this.eventSource.onmessage = this.handleMessage.bind(this);
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
}
}
handleMessage(event) {
const data = JSON.parse(event.data);
if (data.status === "scrapping.progress" && data.chapterId === this.chapterIdValue) {
this.handleProgressUpdate(data);
}
}
handleProgressUpdate(data) {
this.currentPage = data.pageIndex;
this.totalPages = data.totalPages;
this.updateProgressBar();
}
updateProgressBar() {
const progress = (this.currentPage / this.totalPages) * 100;
this.progressBarTarget.style.width = `${progress}%`;
this.progressTextTarget.textContent = `${this.currentPage} / ${this.totalPages}`;
}
}

View File

@@ -1,26 +0,0 @@
import {Controller} from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['container', 'template', 'item'];
connect() {
this.index = this.itemTargets.length;
}
add(event) {
event.preventDefault();
const template = this.templateTarget.innerHTML.replace(/__name__/g, this.index);
this.containerTarget.insertAdjacentHTML('beforeend', template);
this.index++;
}
remove(event) {
event.preventDefault();
event.target.closest('.collection-item').remove();
}
}

View File

@@ -1,69 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['icon']
static values = {
url: String
}
connect() {
this.defaultIconClass = this.iconTarget.classList.value;
}
async download(event) {
event.preventDefault();
// Change the icon to a loader
this.iconTarget.classList.remove("fa-download", "fa-search");
this.iconTarget.classList.add("fa-spinner", "fa-spin");
try {
const response = await fetch(this.urlValue, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.includes("application/json")) {
const data = await response.json();
if (data.error) {
this.dispatchAlert(data.error, 'error');
} else if (data.success) {
this.dispatchAlert(data.success, 'success');
}
} else {
// C'est un fichier à télécharger
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
const contentDisposition = response.headers.get('Content-Disposition');
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
const matches = filenameRegex.exec(contentDisposition);
let filename = 'download';
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, '');
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
}
} finally {
// Revert the icon back to the original one
this.iconTarget.classList.value = this.defaultIconClass;
}
}
dispatchAlert(message, level) {
const event = new CustomEvent('alert:show', {
detail: { message: message, level: level }
});
window.dispatchEvent(event);
}
}

View File

@@ -1,45 +0,0 @@
// assets/controllers/dropdown_controller.js
import {Controller} from "@hotwired/stimulus"
import {useClickOutside} from "stimulus-use"
export default class extends Controller {
static targets = ["button", "menu"]
connect() {
useClickOutside(this)
}
toggle(event) {
this.menuTarget.classList.toggle('hidden')
if (!this.menuTarget.classList.contains('hidden')) {
this.positionMenu()
}
}
clickOutside(event) {
this.menuTarget.classList.add('hidden')
}
positionMenu() {
const buttonRect = this.buttonTarget.getBoundingClientRect()
const menuRect = this.menuTarget.getBoundingClientRect()
const spaceRight = window.innerWidth - buttonRect.right
const spaceBottom = window.innerHeight - buttonRect.bottom
if (spaceRight < menuRect.width && buttonRect.left > menuRect.width) {
this.menuTarget.style.left = 'auto'
this.menuTarget.style.right = '0'
} else {
this.menuTarget.style.left = '0'
this.menuTarget.style.right = 'auto'
}
if (spaceBottom < menuRect.height && buttonRect.top > menuRect.height) {
this.menuTarget.style.top = 'auto'
this.menuTarget.style.bottom = '100%'
} else {
this.menuTarget.style.top = '100%'
this.menuTarget.style.bottom = 'auto'
}
}
}

View File

@@ -1,16 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}

View File

@@ -1,51 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ["checkbox", "modal", "modalContent"]
toggleAllCheckboxes(event) {
this.checkboxTargets.forEach(checkbox => {
checkbox.checked = event.target.checked;
});
}
updateMangaInfo(event) {
const select = event.target;
const selectedOption = select.options[select.selectedIndex];
const mangaInfo = JSON.parse(selectedOption.dataset.mangaInfo);
}
showDetails(event) {
const fileId = event.currentTarget.dataset.fileId;
const select = document.querySelector(`select[name="manga_slug[${fileId}]"]`);
const mangaInfo = JSON.parse(select.options[select.selectedIndex].dataset.mangaInfo);
this.modalContentTarget.innerHTML = `
<h3 class="text-lg leading-6 font-medium text-gray-900">${mangaInfo.title}</h3>
<div class="mt-2">
<p><strong>Author:</strong> ${mangaInfo.author || 'N/A'}</p>
<p><strong>Publication Year:</strong> ${mangaInfo.publicationYear || 'N/A'}</p>
<p><strong>Genres:</strong> ${mangaInfo.genres ? mangaInfo.genres.join(', ') : 'N/A'}</p>
<p><strong>Description:</strong> ${this.truncate(mangaInfo.description || 'N/A', 200)}</p>
</div>
`;
this.modalTarget.classList.remove('hidden');
}
closeModal() {
this.modalTarget.classList.add('hidden');
}
confirmSelected(event) {
const selectedFiles = this.checkboxTargets.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value);
if (selectedFiles.length === 0) {
event.preventDefault();
alert('Veuillez sélectionner au moins un fichier à importer.');
}
}
truncate(str, length) {
return str.length > length ? str.substring(0, length) + '...' : str;
}
}

View File

@@ -1,21 +0,0 @@
// assets/controllers/loading_button_controller.js
import {Controller} from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["text", "loader"];
static values = {form: String};
startLoading(event) {
event.preventDefault();
this.textTarget.classList.add("hidden");
this.loaderTarget.classList.remove("hidden");
this.element.disabled = true;
if (this.hasFormValue) {
const form = document.getElementById(this.formValue);
if (form) {
form.submit();
}
}
}
}

View File

@@ -1,10 +0,0 @@
// assets/controllers/menu_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ["sidebar"]
toggleMenu() {
this.sidebarTarget.classList.toggle('-translate-x-full')
}
}

View File

@@ -1,33 +0,0 @@
import {Controller} from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
// ...
connect() {
const topic = this.data.get('topic');
const mercureHubUrl = 'https://localhost/.well-known/mercure';
const eventSource = new EventSource(`${mercureHubUrl}?topic=${topic}`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received Mercure update:', data);
this.dispatchAlert(data.message, data.status);
};
eventSource.onerror = (event) => {
console.error('EventSource failed:', event);
};
}
dispatchAlert(message, level) {
const event = new CustomEvent('alert:show', {
detail: { message: message, level: level }
});
window.dispatchEvent(event);
}
}

View File

@@ -1,37 +0,0 @@
// assets/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal"]
static values = {
openTrigger: String,
closeTrigger: String
}
connect() {
if (this.hasOpenTriggerValue) {
document.addEventListener(this.openTriggerValue, this.open.bind(this))
}
if (this.hasCloseTriggerValue) {
document.addEventListener(this.closeTriggerValue, this.close.bind(this))
}
}
disconnect() {
if (this.hasOpenTriggerValue) {
document.removeEventListener(this.openTriggerValue, this.open.bind(this))
}
if (this.hasCloseTriggerValue) {
document.removeEventListener(this.closeTriggerValue, this.close.bind(this))
}
}
open() {
console.log("Opening modal...")
this.modalTarget.classList.remove('hidden')
}
close() {
this.modalTarget.classList.add('hidden')
}
}

View File

@@ -1,101 +0,0 @@
// assets/controllers/preferred-sources_controller.js
import {Controller} from "@hotwired/stimulus"
import Sortable from 'sortablejs'
export default class extends Controller {
static targets = ["preferredList", "availableList"]
static values = {
mangaId: Number,
preferredSources: Array,
allSources: Array
}
connect() {
this.initSortable()
}
initSortable() {
new Sortable(this.preferredListTarget, {
animation: 150,
ghostClass: 'bg-gray-300',
onEnd: this.handleDragEnd.bind(this)
})
}
handleDragEnd() {
this.updatePreferredSources()
}
addSource(event) {
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
if (!this.preferredSourcesValue.includes(sourceId)) {
this.preferredSourcesValue = [...this.preferredSourcesValue, sourceId]
this.updateLists()
this.save()
}
}
removeSource(event) {
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
this.preferredSourcesValue = this.preferredSourcesValue.filter(id => id !== sourceId)
this.updateLists()
this.save()
}
updatePreferredSources() {
this.preferredSourcesValue = Array.from(this.preferredListTarget.children).map(li => parseInt(li.dataset.id))
this.save()
}
updateLists() {
this.preferredListTarget.innerHTML = this.preferredSourcesValue
.map(id => this.allSourcesValue.find(s => s.id === id))
.map(source => this.sourceTemplate(source, true))
.join('')
this.availableListTarget.innerHTML = this.allSourcesValue
.filter(source => !this.preferredSourcesValue.includes(source.id))
.map(source => this.sourceTemplate(source, false))
.join('')
this.initSortable()
}
sourceTemplate(source, isPreferred) {
return `
<li data-id="${source.id}" draggable="true" class="flex items-center justify-between p-2 bg-gray-100 rounded ${isPreferred ? 'cursor-move' : ''}">
<span>${source.name}</span>
<button type="button" data-action="preferred-sources#${isPreferred ? 'removeSource' : 'addSource'}" data-source-id="${source.id}" class="text-${isPreferred ? 'red' : 'green'}-500 hover:text-${isPreferred ? 'red' : 'green'}-700">
<i class="fas fa-${isPreferred ? 'times' : 'plus'}"></i>
</button>
</li>
`
}
async save() {
try {
const response = await fetch(`/manga/${this.mangaIdValue}/preferred-sources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
preferredSources: this.preferredSourcesValue
})
})
if (response.ok) {
console.log('Preferred sources saved successfully')
// Optionally show a success message
} else {
console.error('Error saving preferred sources')
// Optionally show an error message
}
} catch (error) {
console.error('Error:', error)
// Optionally show an error message
}
}
}

View File

@@ -1,127 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['pageContainer', 'currentPage', 'chapterSelect', 'readingModeButton']
static values = {
mangaSlug: String,
chapterNumber: Number,
totalPages: Number,
currentPage: { type: Number, default: 1 },
readingMode: { type: String, default: 'horizontal' }
}
connect() {
this.loadChapters();
this.loadPages();
}
async loadChapters() {
try {
const response = await fetch(`/api/chapters/${this.mangaSlugValue}`);
const chapters = await response.json();
this.chapterSelectTarget.innerHTML = chapters.map(chapter =>
`<option value="${chapter.number}" ${chapter.number === this.chapterNumberValue ? 'selected' : ''}>
Chapitre ${chapter.number}
</option>`
).join('');
} catch (error) {
console.error('Error loading chapters:', error);
}
}
async loadPages() {
this.pageContainerTarget.innerHTML = '';
if (this.readingModeValue === 'horizontal') {
await this.loadPage(this.currentPageValue);
} else {
for (let i = 1; i <= this.totalPagesValue; i++) {
await this.loadPage(i, true);
}
}
}
async loadPage(pageNumber, isVertical = false) {
const response = await fetch(`/api/read/${this.mangaSlugValue}/${this.chapterNumberValue}/${pageNumber}`);
const pageContent = await response.text();
const img = document.createElement('img');
img.src = `data:image/jpeg;base64,${pageContent}`;
img.alt = `Page ${pageNumber}`;
img.classList.add('shadow-lg', 'w-full', 'h-auto');
if (this.readingModeValue === 'horizontal') {
img.classList.add('cursor-pointer');
img.dataset.action = 'click->reader#pageClick';
this.pageContainerTarget.innerHTML = '';
}
if (isVertical) {
img.loading = 'lazy';
img.classList.add('mb-4');
}
this.pageContainerTarget.appendChild(img);
if (!isVertical) {
this.currentPageTarget.textContent = pageNumber;
this.currentPageValue = pageNumber;
}
}
pageClick(event) {
if (this.readingModeValue === 'horizontal') {
const pageWidth = event.target.offsetWidth;
const clickX = event.offsetX;
if (clickX < pageWidth / 2) {
this.previousPage();
} else {
this.nextPage();
}
}
}
previousPage() {
if (this.currentPageValue > 1) {
this.loadPage(this.currentPageValue - 1);
} else {
this.previousChapter();
}
}
nextPage() {
if (this.currentPageValue < this.totalPagesValue) {
this.loadPage(this.currentPageValue + 1);
} else {
this.nextChapter();
}
}
async previousChapter() {
const response = await fetch(`/api/previous-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`);
const previousChapter = await response.json();
if (previousChapter) {
window.location.href = `/read/${this.mangaSlugValue}/${previousChapter.number}`;
}
}
async nextChapter() {
const response = await fetch(`/api/next-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`);
const nextChapter = await response.json();
if (nextChapter) {
window.location.href = `/read/${this.mangaSlugValue}/${nextChapter.number}`;
}
}
changeChapter(event) {
const selectedChapterNumber = event.target.value;
window.location.href = `/read/${this.mangaSlugValue}/${selectedChapterNumber}`;
}
toggleReadingMode() {
this.readingModeValue = this.readingModeValue === 'horizontal' ? 'vertical' : 'horizontal';
this.readingModeButtonTarget.textContent = this.readingModeValue === 'horizontal' ? 'Passer en mode vertical' : 'Passer en mode horizontal';
this.loadPages();
}
}

View File

@@ -1,76 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['form', 'testForm', 'imageSelector', 'nextPageSelector', 'testResults', 'scrapingType']
connect() {
}
async saveConfiguration(event) {
event.preventDefault();
this.formTarget.submit();
}
async testConfiguration(event) {
event.preventDefault();
const formData = new FormData(this.formTarget);
const testFormData = new FormData(this.testFormTarget);
for (let [key, value] of formData.entries()) {
const cleanKey = key.replace(/^content_source\[(.+)]$/, '$1');
testFormData.append(`content_source[${cleanKey}]`, value);
}
try {
const response = await fetch(this.testFormTarget.action, {
method: 'POST',
body: testFormData
});
const result = await response.json();
if (result.success) {
this.displayTestResults(result.data);
} else {
this.displayError(result.message, result.errors);
}
} catch (error) {
console.log(error)
this.displayError('An error occurred while testing the configuration');
}
}
displayTestResults(data) {
let html = '<h3 class="text-xl font-semibold mb-4">Test Results</h3>';
html += '<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">';
data.forEach(page => {
html += `
<div class="border rounded-lg p-2 flex flex-col items-center">
<img src="${page.image_url}" alt="Page ${page.page_number}" class="w-full h-48 object-cover mb-2">
<p class="text-sm font-medium">Page ${page.page_number}</p>
</div>
`;
});
html += '</div>';
this.testResultsTarget.innerHTML = html;
}
displayError(message, errors = []) {
let errorHtml = `
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<strong class="font-bold">Error:</strong>
<span class="block sm:inline">${message}</span>
`;
if (errors.length > 0) {
errorHtml += '<ul class="list-disc list-inside mt-2">';
errors.forEach(error => {
errorHtml += `<li>${error}</li>`;
});
errorHtml += '</ul>';
}
errorHtml += '</div>';
this.testResultsTarget.innerHTML = errorHtml;
}
}

View File

@@ -1,81 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
// ...
static targets = ["textarea", "submitButton"]
connect() {
document.addEventListener('openImportModal', this.prepareImportModal.bind(this));
document.addEventListener('openExportModal', this.prepareExportModal.bind(this));
}
disconnect() {
document.removeEventListener('openImportModal', this.prepareImportModal.bind(this));
document.removeEventListener('openExportModal', this.prepareExportModal.bind(this));
}
async prepareExportModal() {
try {
const response = await fetch('/settings/export_scrappers');
const data = await response.json();
this.textareaTarget.value = JSON.stringify(data, null, 2);
this.submitButtonTarget.textContent = 'Copy to Clipboard';
this.submitButtonTarget.dataset.action = 'scrapper-import#copyToClipboard';
this.openModal('Export Scrapper Configurations');
} catch (error) {
console.error('Error:', error);
}
}
prepareImportModal() {
this.textareaTarget.value = '';
this.submitButtonTarget.textContent = 'Import';
this.submitButtonTarget.dataset.action = 'scrapper-import#submitImport';
this.openModal('Import Scrapper Configurations');
}
openModal(title) {
const event = new CustomEvent('openScrapperModal', { detail: { title: title } });
document.dispatchEvent(event);
}
async submitImport() {
const jsonData = this.textareaTarget.value;
try {
const response = await fetch('/settings/import_scrappers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: jsonData
});
const result = await response.json();
if (response.ok) {
console.log(result.message);
document.dispatchEvent(new CustomEvent('closeScrapperModal'));
window.location.reload();
} else {
console.error(result.error);
}
} catch (error) {
console.error('Error:', error);
}
}
copyToClipboard() {
navigator.clipboard.writeText(this.textareaTarget.value).then(() => {
console.log('Copied to clipboard');
document.dispatchEvent(new CustomEvent('closeScrapperModal'));
}, (err) => {
console.error('Could not copy text: ', err);
});
}
}

View File

@@ -1,15 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['input']
clearSearch() {
this.inputTarget.value = '';
this.inputTarget.focus();
}
}

View File

@@ -1,35 +0,0 @@
import {Controller} from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ["body", "toggleIcon"]
static values = { open: Boolean }
connect() {
if (!this.openValue) {
this.close()
}
}
toggle() {
if (this.bodyTarget.style.display === "none") {
this.open()
} else {
this.close()
}
}
open() {
this.bodyTarget.style.display = "block"
this.toggleIconTarget.classList.replace("fa-chevron-down", "fa-chevron-up")
}
close() {
this.bodyTarget.style.display = "none"
this.toggleIconTarget.classList.replace("fa-chevron-up", "fa-chevron-down")
}
}

View File

@@ -1,198 +0,0 @@
// assets/controllers/toolbar_controller.js
import { Controller } from "@hotwired/stimulus"
import { visit } from "@hotwired/turbo"
export default class extends Controller {
static targets = ["dropdown", "icon", "text"]
static values = {
currentSort: String,
currentOrder: String,
currentStatus: String,
mangaId: Number
}
connect() {
window.addEventListener('alert:show', this.stopLoading.bind(this));
}
stopLoading(event) {
if(event.currentTarget.dataset !== undefined){
this.iconTarget.classList.remove('fa-spin');
}
}
refreshMetadata(event) {
const mangaId = event.currentTarget.dataset.mangaid;
const url = `/refresh_metadata`;
this.iconTarget.classList.add('fa-spin');
fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json',
},
body: JSON.stringify({ mangaId: mangaId })
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
}
searchLastChapter() {
console.log("Searching last chapter...");
}
import() {
console.log("Importing...");
}
monitoring(event){
const mangaId = event.currentTarget.dataset.mangaid;
const currentTarget = event.currentTarget;
const url = `/toggle_monitored`;
fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json',
},
body: JSON.stringify({ mangaId: mangaId })
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
}).then(data => {
if(data.isMonitored === true){
currentTarget.classList.remove('text-white');
currentTarget.classList.add('text-green-500');
this.textTarget.innerHTML = "Monitored";
}else if(data.isMonitored === false){
currentTarget.classList.remove('text-green-500');
currentTarget.classList.add('text-white');
this.textTarget.innerHTML = "Monitoring";
}
// console.log(data.isMonitored);
});
}
editMangas() {
console.log("Editing mangas...");
}
editManga() {
const event = new CustomEvent('openEditModal');
document.dispatchEvent(event);
}
editPreferredSources() {
const event = new CustomEvent('openPreferredSourcesModal');
document.dispatchEvent(event);
}
openImportModal() {
const importEvent = new CustomEvent('openImportModal');
document.dispatchEvent(importEvent);
}
openExportModal() {
const exportEvent = new CustomEvent('openExportModal');
document.dispatchEvent(exportEvent);
}
deleteMangas() {
console.log("Deleting mangas...");
}
deleteManga() {
const event = new CustomEvent('openDeleteModal');
document.dispatchEvent(event);
}
confirmDelete(event) {
event.preventDefault();
const url = `/manga/delete/${this.mangaIdValue}`;
fetch(url, {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json',
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
if (data.success) {
visit('/', {});
} else {
throw new Error(data.error);
}
})
.catch(error => {
console.error('Error:', error);
// Show error message to user
});
}
showOptions() {
console.log("Showing options...");
}
expandAll() {
console.log("Expanding all...");
}
changeView(event) {
event.preventDefault();
const viewOption = event.currentTarget.dataset.view;
const url = new URL(window.location);
url.searchParams.set('view', viewOption);
window.location = url.toString();
}
sort(event) {
event.preventDefault()
const sortOption = event.currentTarget.dataset.sort;
let order = 'asc';
if (sortOption === this.currentSortValue && this.currentOrderValue === 'asc') {
order = 'desc';
}
const url = new URL(window.location);
url.searchParams.set('sort', sortOption);
url.searchParams.set('order', order);
window.location = url.toString();
}
filter(event) {
event.preventDefault();
const filterOption = event.currentTarget.dataset.filter;
const url = new URL(window.location);
url.searchParams.set('status', filterOption);
// Réinitialiser la page à 1 si on utilise la pagination
// url.searchParams.set('page', '1');
window.location = url.toString();
}
}

View File

@@ -3,6 +3,11 @@
@import "tailwindcss/components"; @import "tailwindcss/components";
@import "tailwindcss/utilities"; @import "tailwindcss/utilities";
html, body {
overflow: hidden;
height: 100%;
}
body { body {
background-color: white; background-color: white;
} }
@@ -82,6 +87,33 @@ body {
@apply bg-gray-700; @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 */ ///* Custom styles for the scrollbar buttons */
//::-webkit-scrollbar-button { //::-webkit-scrollbar-button {
// @apply bg-gray-700; // @apply bg-gray-700;

23
assets/vue/app/App.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<router-view></router-view>
<NotificationToast />
</template>
<script setup>
import NotificationToast from './shared/components/ui/NotificationToast.vue';
import { useMercureNotifications } from './shared/composables/useMercureNotifications';
useMercureNotifications();
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,184 @@
import { defineStore } from 'pinia';
import { Job } from '../../domain/entities/job';
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
const jobRepository = new ApiJobRepository();
const ACTIVE_STATUSES = ['pending', 'in_progress'];
export const useActivityStore = defineStore('activity', {
state: () => ({
jobs: [],
loading: false,
error: null,
mercureEventSource: null,
// Pagination
currentPage: 1,
totalPages: 0,
total: 0,
limit: 20,
hasNextPage: false,
hasPreviousPage: false,
// Tri
sortBy: 'createdAt',
sortOrder: 'DESC',
}),
getters: {
activeJobs: state => state.jobs.filter(job => job.isActive()),
isLoading: state => state.loading,
hasError: state => !!state.error,
paginationInfo: state => ({
currentPage: state.currentPage,
totalPages: state.totalPages,
total: state.total,
limit: state.limit,
hasNextPage: state.hasNextPage,
hasPreviousPage: state.hasPreviousPage
})
},
actions: {
async loadJobs(page = null) {
this.loading = true;
this.error = null;
try {
const jobCollection = await jobRepository.getJobs({
page: page || this.currentPage,
limit: this.limit,
sortBy: this.sortBy,
sortOrder: this.sortOrder,
status: ACTIVE_STATUSES,
});
this.jobs = jobCollection.items;
this.currentPage = jobCollection.page;
this.total = jobCollection.total;
this.hasNextPage = jobCollection.hasNextPage;
this.hasPreviousPage = jobCollection.hasPreviousPage;
this.totalPages = Math.ceil(this.total / this.limit);
} catch (error) {
this.error = error.message;
console.error('Error loading jobs:', error);
} finally {
this.loading = false;
}
},
async goToPage(page) {
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
this.currentPage = page;
await this.loadJobs(page);
}
},
async updateSort(sortBy, sortOrder) {
this.sortBy = sortBy;
this.sortOrder = sortOrder;
this.currentPage = 1;
await this.loadJobs(1);
},
async updateLimit(limit) {
this.limit = limit;
this.currentPage = 1;
await this.loadJobs(1);
},
async deleteJob(id) {
this.loading = true;
this.error = null;
try {
await jobRepository.deleteJob(id);
this.jobs = this.jobs.filter(job => job.id !== id);
await this.loadJobs(this.currentPage);
} catch (error) {
this.error = error.message;
console.error('Error deleting job:', error);
} finally {
this.loading = false;
}
},
updateJobProgress(jobId, progress) {
const job = this.jobs.find(j => j.id === jobId);
if (job) job.progress = progress;
},
handleJobCreated(data) {
const alreadyExists = this.jobs.some(j => j.id === data.id);
if (alreadyExists) return;
const job = Job.create({
id: data.id,
type: data.type_job,
status: data.status,
createdAt: data.createdAt,
context: data.context,
attempts: data.attempts,
maxAttempts: data.maxAttempts,
});
this.jobs.unshift(job);
this.total += 1;
this.totalPages = Math.ceil(this.total / this.limit);
},
handleJobStatusChange(jobId, newStatus) {
const job = this.jobs.find(j => j.id === jobId);
if (!job) return;
if (newStatus === 'in_progress') {
job.status = 'in_progress';
} else {
setTimeout(() => {
this.jobs = this.jobs.filter(j => j.id !== jobId);
this.total = Math.max(0, this.total - 1);
this.totalPages = Math.ceil(this.total / this.limit);
}, 1500);
}
},
subscribeMercure() {
if (this.mercureEventSource) return;
const url = new URL('/.well-known/mercure', window.location.origin);
url.searchParams.append('topic', 'jobs/activity');
this.mercureEventSource = new EventSource(url.toString());
this.mercureEventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'job.created') {
this.handleJobCreated(data);
} else if (data.type === 'job.progress_updated') {
this.updateJobProgress(data.jobId, data.progress);
} else if (data.type === 'job.status_changed') {
this.handleJobStatusChange(data.jobId, data.status);
}
};
},
unsubscribeMercure() {
if (this.mercureEventSource) {
this.mercureEventSource.close();
this.mercureEventSource = null;
}
},
async deleteJobs(criteria = {}) {
this.loading = true;
this.error = null;
try {
const deleted = await jobRepository.deleteJobs(criteria);
await this.loadJobs(this.currentPage);
return deleted;
} catch (error) {
this.error = error.message;
console.error('Error deleting jobs:', error);
} finally {
this.loading = false;
}
},
}
});

View File

@@ -0,0 +1,61 @@
export class Job {
constructor({
id,
type,
status,
progress = 0,
payload = {},
result = null,
error = null,
failureReason = null,
createdAt = new Date().toISOString(),
updatedAt = new Date().toISOString(),
startedAt = null,
completedAt = null,
attempts = 0,
maxAttempts = 1,
context = {}
}) {
this.id = id;
this.type = type;
this.status = status;
this.progress = progress;
this.payload = payload;
this.result = result;
this.error = failureReason ?? error;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.startedAt = startedAt;
this.completedAt = completedAt;
this.attempts = attempts;
this.maxAttempts = maxAttempts;
this.context = context;
}
static create(data) {
return new Job(data);
}
isActive() {
return ['pending', 'in_progress'].includes(this.status);
}
hasError() {
return this.status === 'failed';
}
isCompleted() {
return this.status === 'completed';
}
}
export class JobCollection {
constructor(items, total, page, limit, hasNextPage, hasPreviousPage) {
this.items = items.map(item => Job.create(item));
this.total = total;
this.page = page;
this.limit = limit;
this.hasNextPage = hasNextPage;
this.hasPreviousPage = hasPreviousPage;
}
}

View File

@@ -0,0 +1,42 @@
export class JobRepositoryInterface {
/**
* Récupère la liste des jobs
* @param {Object} options Les options de filtrage et pagination
* @param {number} options.page Numéro de la page
* @param {number} options.limit Nombre d'éléments par page
* @param {string} options.sortBy Champ pour le tri
* @param {string} options.sortOrder Direction du tri ('ASC' ou 'DESC')
* @param {Array<string>} options.status Liste des statuts à filtrer
* @returns {Promise<JobCollection>} Collection de jobs
*/
async getJobs(options) {
throw new Error('Not implemented');
}
/**
* Récupère un job par son ID
* @param {string} id Identifiant du job
* @returns {Promise<Job>} Job
*/
async getJobById(id) {
throw new Error('Not implemented');
}
/**
* Supprime un job
* @param {string} id Identifiant du job
* @returns {Promise<boolean>} Succès de l'opération
*/
async deleteJob(id) {
throw new Error('Not implemented');
}
/**
* Supprime tous les jobs correspondant aux critères
* @param {Object} criteria Critères de suppression
* @returns {Promise<number>} Nombre de jobs supprimés
*/
async deleteJobs(criteria) {
throw new Error('Not implemented');
}
}

View File

@@ -0,0 +1,154 @@
import { Job, JobCollection } from '../../domain/entities/job';
import { JobRepositoryInterface } from '../../domain/repository/JobRepositoryInterface';
export class ApiJobRepository extends JobRepositoryInterface {
/**
* Récupère la liste des jobs
* @param {Object} options Les options de filtrage et pagination
* @param {number} options.page Numéro de la page
* @param {number} options.limit Nombre d'éléments par page
* @param {string} options.sortBy Champ pour le tri
* @param {string} options.sortOrder Direction du tri ('ASC' ou 'DESC')
* @param {Array<string>} options.status Liste des statuts à filtrer
* @returns {Promise<JobCollection>} Collection de jobs
*/
async getJobs(options = {}) {
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [], type = null } = options;
try {
let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
// Ajouter les filtres de statut s'ils sont fournis
if (status && status.length > 0) {
url += `&status=${status.join(',')}`;
}
// Ajouter le filtre de type si fourni
if (type) {
url += `&type=${type}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch jobs');
}
const data = await response.json();
// Gérer différents formats de réponse API
let jobs, total, currentPage, limit_returned, hasNext, hasPrev;
if (Array.isArray(data)) {
// Si l'API retourne directement un tableau
jobs = data;
total = data.length;
currentPage = page;
limit_returned = limit;
hasNext = false;
hasPrev = false;
} else if (data.items || data.data) {
// Si l'API retourne un objet avec les données dans items ou data
jobs = data.items || data.data || [];
total = data.total || data.totalCount || jobs.length;
currentPage = data.page || data.currentPage || page;
limit_returned = data.limit || data.perPage || limit;
hasNext = data.hasNextPage || data.hasNext || (currentPage * limit_returned < total);
hasPrev = data.hasPreviousPage || data.hasPrev || currentPage > 1;
} else {
// Format par défaut
jobs = data || [];
total = data.total || 0;
currentPage = data.page || 1;
limit_returned = data.limit || limit;
hasNext = !!data.hasNextPage;
hasPrev = !!data.hasPreviousPage;
}
return new JobCollection(
jobs,
total,
currentPage,
limit_returned,
hasNext,
hasPrev
);
} catch (error) {
throw error;
}
}
/**
* Récupère un job par son ID
* @param {string} id Identifiant du job
* @returns {Promise<Job>} Job
*/
async getJobById(id) {
try {
const response = await fetch(`/api/jobs/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch job');
}
const data = await response.json();
return Job.create(data);
} catch (error) {
throw error;
}
}
/**
* Supprime un job
* @param {string} id Identifiant du job
* @returns {Promise<boolean>} Succès de l'opération
*/
async deleteJob(id) {
try {
const response = await fetch(`/api/jobs/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete job');
}
return true;
} catch (error) {
throw error;
}
}
/**
* Supprime tous les jobs correspondant aux critères
* @param {Object} criteria Critères de suppression
* @returns {Promise<number>} Nombre de jobs supprimés
*/
async deleteJobs(criteria = {}) {
try {
const queryParams = new URLSearchParams();
// Ajouter les critères à l'URL
Object.entries(criteria).forEach(([key, value]) => {
if (Array.isArray(value)) {
queryParams.append(key, value.join(','));
} else {
queryParams.append(key, value);
}
});
const response = await fetch(`/api/jobs?${queryParams.toString()}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete jobs');
}
const data = await response.json();
return data.deleted || 0;
} catch (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,133 @@
<template>
<tr
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 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">
<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 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 dark:text-red-400">
{{ job.error }}
</div>
<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 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>
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
{{ job.progress }}%
</div>
</div>
</div>
<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>
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
100%
</div>
</div>
<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>
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
Erreur
</div>
</div>
<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 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
@click="onDelete"
class="text-red-500 hover:text-red-700 transition duration-150 ease-in-out"
title="Supprimer">
<TrashIcon class="h-5 w-5" />
</button>
</td>
</tr>
</template>
<script setup>
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']);
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

@@ -0,0 +1,153 @@
<template>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<div class="animate-spin h-10 w-10 border-b-2 border-indigo-500 rounded-full"></div>
</div>
<!-- Error -->
<div v-else-if="activityStore.error" class="px-6 py-8">
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4">
<p class="text-red-800 dark:text-red-200">{{ activityStore.error }}</p>
</div>
</div>
<!-- Content -->
<section v-else class="border-t border-gray-200 dark:border-gray-700">
<!-- Empty -->
<div v-if="activityStore.jobs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
<ClockIcon class="w-12 h-12 mb-3" />
<p class="text-base">Aucun job en cours ou en attente.</p>
</div>
<!-- Table -->
<div v-else class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
<th class="w-2/11 py-3 px-6 text-left">Type</th>
<th class="w-2/11 py-3 px-4 text-left">Statut</th>
<th class="w-3/11 py-3 px-4 text-left">Informations</th>
<th class="w-3/11 py-3 px-4 text-left">Progression</th>
<th class="w-1/11 py-3 px-4 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50 text-gray-700 dark:text-gray-300">
<JobItem
v-for="job in activityStore.jobs"
:key="job.id"
:job="job"
@delete="deleteJob" />
</tbody>
</table>
</div>
<!-- Pagination -->
<Pagination
v-if="total > activityStore.limit"
:current-page="activityStore.currentPage"
:total-pages="activityStore.totalPages"
:total="total"
:limit="activityStore.limit"
:has-next-page="activityStore.hasNextPage"
:has-previous-page="activityStore.hasPreviousPage"
@page-change="changePage" />
</section>
</div>
</div>
</template>
<script setup>
import { ArrowPathIcon, BarsArrowDownIcon, ClockIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted, onUnmounted } from 'vue';
import Pagination from '../../../../shared/components/ui/Pagination.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useActivityStore } from '../../application/store/activityStore';
import JobItem from '../components/JobItem.vue';
const activityStore = useActivityStore();
const { sortBy, sortOrder, total, loading } = storeToRefs(activityStore);
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Activité', class: 'text-sm font-medium' },
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
],
rightSection: [
{
type: 'dropdown',
icon: BarsArrowDownIcon,
label: 'Trier',
items: [
{
label: 'Plus récent',
isSelected: isSortSelected('createdAt', 'DESC'),
onClick: () => activityStore.updateSort('createdAt', 'DESC'),
},
{
label: 'Plus ancien',
isSelected: isSortSelected('createdAt', 'ASC'),
onClick: () => activityStore.updateSort('createdAt', 'ASC'),
},
{
label: 'Par type',
isSelected: isSortSelected('type', 'ASC'),
onClick: () => activityStore.updateSort('type', 'ASC'),
},
{
label: 'Par statut',
isSelected: isSortSelected('status', 'ASC'),
onClick: () => activityStore.updateSort('status', 'ASC'),
},
],
},
{
type: 'button',
icon: ArrowPathIcon,
label: 'Rafraîchir',
disabled: loading.value,
onClick: () => activityStore.loadJobs(),
},
{
type: 'button',
icon: TrashIcon,
label: 'Supprimer visibles',
disabled: loading.value || total.value === 0,
onClick: deleteVisibleJobs,
},
],
}));
onMounted(() => {
activityStore.loadJobs();
activityStore.subscribeMercure();
});
onUnmounted(() => {
activityStore.unsubscribeMercure();
});
function changePage(page) {
activityStore.goToPage(page);
}
function deleteJob(id) {
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
activityStore.deleteJob(id);
}
}
function deleteVisibleJobs() {
if (activityStore.jobs.length === 0) return;
if (confirm('Voulez-vous vraiment supprimer tous les jobs visibles ?')) {
activityStore.deleteJobs({ status: ['pending', 'in_progress'] });
}
}
</script>

View File

@@ -0,0 +1,228 @@
import { defineStore } from 'pinia';
import { ApiConversionRepository } from '../../infrastructure/api/apiConversionRepository';
const conversionRepository = new ApiConversionRepository();
export const useConversionStore = defineStore('conversion', {
state: () => ({
// État de conversion
isConverting: false,
conversionProgress: 0,
conversionError: null,
conversionSuccess: false,
// Fichier en cours de traitement
currentFile: null,
convertedFile: null,
// Historique des conversions (optionnel)
conversionHistory: [],
// État de l'interface
isDragOver: false,
}),
getters: {
/**
* Indique si une conversion est en cours
*/
isProcessing: (state) => state.isConverting,
/**
* Indique si un fichier est sélectionné
*/
hasSelectedFile: (state) => state.currentFile !== null,
/**
* Indique si une conversion a réussi
*/
hasSucceeded: (state) => state.conversionSuccess && state.convertedFile !== null,
/**
* Indique si une erreur est présente
*/
hasError: (state) => state.conversionError !== null,
/**
* Obtient le nom du fichier actuel
*/
currentFileName: (state) => state.currentFile?.name || '',
/**
* Obtient la taille formatée du fichier actuel
*/
currentFileSize: (state) => {
if (!state.currentFile) return '';
const bytes = state.currentFile.size;
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
},
/**
* Obtient le nombre de conversions réussies
*/
conversionCount: (state) => state.conversionHistory.length,
},
actions: {
/**
* Sélectionne un fichier pour la conversion
* @param {File} file - Le fichier sélectionné
*/
selectFile(file) {
// Validation du fichier
const validation = conversionRepository.validateFile(file);
if (!validation.isValid) {
this.setError(validation.error);
return false;
}
// Réinitialisation de l'état
this.clearError();
this.conversionSuccess = false;
this.convertedFile = null;
// Stockage du fichier
this.currentFile = file;
return true;
},
/**
* Lance la conversion du fichier sélectionné
*/
async convertCurrentFile() {
if (!this.currentFile) {
this.setError('Aucun fichier sélectionné');
return false;
}
try {
this.isConverting = true;
this.conversionProgress = 0;
this.clearError();
// Simulation du progrès (l'API ne fournit pas de progrès en temps réel)
const progressInterval = setInterval(() => {
if (this.conversionProgress < 90) {
this.conversionProgress += Math.random() * 10;
}
}, 100);
// Appel à l'API de conversion
const convertedFileBlob = await conversionRepository.convertFile(this.currentFile);
// Nettoyage de l'interval de progrès
clearInterval(progressInterval);
this.conversionProgress = 100;
// Stockage du fichier converti
this.convertedFile = convertedFileBlob;
this.conversionSuccess = true;
// Ajout à l'historique
this.addToHistory({
originalName: this.currentFile.name,
originalSize: this.currentFile.size,
convertedSize: convertedFileBlob.size,
timestamp: new Date().toISOString(),
});
return true;
} catch (error) {
this.setError(error.message || 'Erreur lors de la conversion');
return false;
} finally {
this.isConverting = false;
this.conversionProgress = 0;
}
},
/**
* Télécharge le fichier converti
*/
downloadConvertedFile() {
if (!this.convertedFile || !this.currentFile) {
this.setError('Aucun fichier converti disponible');
return;
}
try {
conversionRepository.downloadConvertedFile(
this.convertedFile,
this.currentFile.name
);
} catch (error) {
this.setError(error.message || 'Erreur lors du téléchargement');
}
},
/**
* Réinitialise l'état de conversion
*/
resetConversion() {
this.currentFile = null;
this.convertedFile = null;
this.conversionSuccess = false;
this.conversionProgress = 0;
this.clearError();
},
/**
* Définit une erreur
* @param {string} message - Message d'erreur
*/
setError(message) {
this.conversionError = message;
this.conversionSuccess = false;
},
/**
* Efface l'erreur actuelle
*/
clearError() {
this.conversionError = null;
},
/**
* Gère l'état du drag and drop
* @param {boolean} isDragOver - Indique si un fichier est survolé
*/
setDragOver(isDragOver) {
this.isDragOver = isDragOver;
},
/**
* Ajoute une conversion à l'historique
* @param {Object} conversionData - Données de la conversion
*/
addToHistory(conversionData) {
this.conversionHistory.unshift(conversionData);
// Limiter l'historique à 10 éléments
if (this.conversionHistory.length > 10) {
this.conversionHistory = this.conversionHistory.slice(0, 10);
}
},
/**
* Efface l'historique des conversions
*/
clearHistory() {
this.conversionHistory = [];
},
/**
* Valide un fichier sans le sélectionner
* @param {File} file - Le fichier à valider
* @returns {Object} - Résultat de la validation
*/
validateFile(file) {
return conversionRepository.validateFile(file);
}
}
});

View File

@@ -0,0 +1,133 @@
export class ApiConversionRepository {
/**
* Convertit un fichier CBR/CBZ en CBZ
* @param {File} file - Le fichier à convertir
* @returns {Promise<Blob>} - Le fichier converti
*/
async convertFile(file) {
try {
// Validation du fichier
if (!file) {
throw new Error('Aucun fichier fourni');
}
// Validation de la taille (150MB max selon l'API)
const maxSize = 150 * 1024 * 1024; // 150MB en bytes
if (file.size > maxSize) {
throw new Error('Le fichier est trop volumineux (max 150MB)');
}
// Validation du type de fichier
const allowedTypes = ['.cbr', '.cbz'];
const fileName = file.name.toLowerCase();
const isValidType = allowedTypes.some(type => fileName.endsWith(type));
if (!isValidType) {
throw new Error('Type de fichier non supporté. Seuls les fichiers .cbr et .cbz sont acceptés');
}
// Création du FormData pour l'envoi multipart
const formData = new FormData();
formData.append('file', file);
// Appel à l'API
const response = await fetch('/api/conversions/convert', {
method: 'POST',
body: formData,
// On ne définit pas Content-Type pour laisser le navigateur gérer multipart/form-data
});
if (!response.ok) {
// Gestion des erreurs HTTP
let errorMessage = 'Erreur lors de la conversion';
try {
const errorData = await response.json();
errorMessage = errorData.message || errorData.detail || errorMessage;
} catch {
// Si la réponse n'est pas du JSON, on utilise le status text
errorMessage = response.statusText || errorMessage;
}
throw new Error(errorMessage);
}
// Récupération du fichier converti
const convertedFile = await response.blob();
// Vérification que le fichier n'est pas vide
if (convertedFile.size === 0) {
throw new Error('Le fichier converti est vide');
}
return convertedFile;
} catch (error) {
console.error('Erreur lors de la conversion:', error);
throw error;
}
}
/**
* Télécharge le fichier converti
* @param {Blob} fileBlob - Le fichier à télécharger
* @param {string} originalFileName - Nom original du fichier
*/
downloadConvertedFile(fileBlob, originalFileName) {
try {
// Génération du nom de fichier de sortie
const baseName = originalFileName.replace(/\.(cbr|cbz)$/i, '');
const outputFileName = `${baseName}.cbz`;
// Création d'un lien de téléchargement
const url = URL.createObjectURL(fileBlob);
const link = document.createElement('a');
link.href = url;
link.download = outputFileName;
// Déclenchement du téléchargement
document.body.appendChild(link);
link.click();
// Nettoyage
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Erreur lors du téléchargement:', error);
throw new Error('Impossible de télécharger le fichier converti');
}
}
/**
* Valide si un fichier peut être converti
* @param {File} file - Le fichier à valider
* @returns {Object} - Résultat de la validation {isValid: boolean, error?: string}
*/
validateFile(file) {
if (!file) {
return { isValid: false, error: 'Aucun fichier sélectionné' };
}
// Vérification de la taille
const maxSize = 150 * 1024 * 1024; // 150MB
if (file.size > maxSize) {
return {
isValid: false,
error: 'Le fichier est trop volumineux (maximum 150MB)'
};
}
// Vérification du type
const allowedTypes = ['.cbr', '.cbz'];
const fileName = file.name.toLowerCase();
const isValidType = allowedTypes.some(type => fileName.endsWith(type));
if (!isValidType) {
return {
isValid: false,
error: 'Type de fichier non supporté. Seuls les fichiers .cbr et .cbz sont acceptés'
};
}
return { isValid: true };
}
}

View File

@@ -0,0 +1,247 @@
<template>
<div class="space-y-4">
<!-- Statut de la conversion -->
<div class="flex items-center space-x-3">
<!-- Icône de statut -->
<div class="flex-shrink-0">
<ArrowPathIcon
v-if="isConverting"
class="w-6 h-6 text-blue-500 animate-spin"
/>
<CheckCircleIcon
v-else-if="isSuccess"
class="w-6 h-6 text-green-500"
/>
<ExclamationTriangleIcon
v-else-if="hasError"
class="w-6 h-6 text-red-500"
/>
<ClockIcon
v-else
class="w-6 h-6 text-gray-400"
/>
</div>
<!-- Message de statut -->
<div class="flex-1">
<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 dark:text-gray-400">
{{ fileName }}
</p>
</div>
</div>
<!-- Barre de progression -->
<div v-if="showProgress" class="space-y-2">
<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 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}%` }"
/>
</div>
</div>
<!-- Détails de la conversion -->
<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>
</div>
<div v-if="convertedSize" class="flex justify-between">
<span>Taille convertie:</span>
<span>{{ formatFileSize(convertedSize) }}</span>
</div>
<div v-if="originalSize && convertedSize" class="flex justify-between font-medium">
<span>Gain d'espace:</span>
<span :class="spaceSavingClass">{{ spaceSavingText }}</span>
</div>
</div>
<!-- Actions -->
<div v-if="showActions" class="flex space-x-3">
<button
v-if="canDownload"
@click="$emit('download')"
class="flex items-center space-x-2 px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors"
>
<ArrowDownTrayIcon class="w-4 h-4" />
<span>Télécharger CBZ</span>
</button>
<button
v-if="canReset"
@click="$emit('reset')"
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>
</button>
</div>
<!-- Message d'erreur détaillé -->
<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 dark:text-red-300">
Erreur de conversion
</h3>
<p class="mt-1 text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</div>
</div>
</template>
<script>
import {
ArrowDownTrayIcon,
ArrowPathIcon,
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
} from '@heroicons/vue/24/outline';
import { computed } from 'vue';
export default {
name: 'ConversionProgress',
components: {
ArrowPathIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon,
ArrowDownTrayIcon,
},
props: {
isConverting: {
type: Boolean,
default: false,
},
progress: {
type: Number,
default: 0,
},
isSuccess: {
type: Boolean,
default: false,
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
fileName: {
type: String,
default: '',
},
originalSize: {
type: Number,
default: 0,
},
convertedSize: {
type: Number,
default: 0,
},
showActions: {
type: Boolean,
default: true,
},
showDetails: {
type: Boolean,
default: true,
},
},
emits: ['download', 'reset'],
setup(props) {
// Message de statut calculé
const statusMessage = computed(() => {
if (props.isConverting) {
return 'Conversion en cours...';
}
if (props.isSuccess) {
return 'Conversion terminée avec succès !';
}
if (props.hasError) {
return 'Erreur lors de la conversion';
}
return 'En attente de fichier';
});
// Affichage de la barre de progression
const showProgress = computed(() => {
return props.isConverting && props.progress > 0;
});
// Actions disponibles
const canDownload = computed(() => {
return props.isSuccess && !props.isConverting;
});
const canReset = computed(() => {
return (props.isSuccess || props.hasError) && !props.isConverting;
});
// Calcul du gain d'espace
const spaceSaving = computed(() => {
if (!props.originalSize || !props.convertedSize) return 0;
return ((props.originalSize - props.convertedSize) / props.originalSize) * 100;
});
const spaceSavingText = computed(() => {
const saving = spaceSaving.value;
if (saving > 0) {
return `-${saving.toFixed(1)}%`;
} else if (saving < 0) {
return `+${Math.abs(saving).toFixed(1)}%`;
}
return '0%';
});
const spaceSavingClass = computed(() => {
const saving = spaceSaving.value;
if (saving > 0) {
return 'text-green-600';
} else if (saving < 0) {
return 'text-red-600';
}
return 'text-gray-600';
});
// Formatage de la taille de fichier
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]}`;
};
return {
statusMessage,
showProgress,
canDownload,
canReset,
spaceSavingText,
spaceSavingClass,
formatFileSize,
};
},
};
</script>

View File

@@ -0,0 +1,214 @@
<template>
<div
class="relative"
@dragover.prevent="handleDragOver"
@dragenter.prevent="handleDragEnter"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<div
:class="[
'border-2 border-dashed rounded-lg p-8 text-center transition-all duration-200',
isDragOver
? '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 -->
<div class="space-y-4">
<!-- Icône -->
<div class="flex justify-center">
<ArchiveBoxIcon
:class="[
'w-16 h-16 transition-colors duration-200',
isDragOver ? 'text-green-500' : 'text-gray-400'
]"
/>
</div>
<!-- Message principal -->
<div class="space-y-2">
<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 dark:text-gray-400">
Glissez-déposez votre fichier ou cliquez pour le sélectionner
</p>
<p class="text-xs text-gray-400 dark:text-gray-500">
Fichiers supportés: .cbr, .cbz (max. 150MB)
</p>
</div>
<!-- Bouton de sélection -->
<div class="flex justify-center">
<label
for="file-upload"
:class="[
'relative cursor-pointer rounded-md px-4 py-2 font-medium text-white transition-colors duration-200',
isDragOver
? 'bg-green-500 hover:bg-green-600'
: 'bg-green-600 hover:bg-green-700'
]"
>
<span>Sélectionner un fichier</span>
<input
id="file-upload"
name="file-upload"
type="file"
class="sr-only"
accept=".cbr,.cbz"
@change="handleFileSelect"
/>
</label>
</div>
<!-- Informations du fichier sélectionné -->
<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 dark:text-gray-400" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ selectedFile.name }}
</p>
<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 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" />
</button>
</div>
</div>
</div>
</div>
<!-- Overlay pendant le drag -->
<div
v-if="isDragOver"
class="absolute inset-0 bg-green-100 bg-opacity-50 rounded-lg flex items-center justify-center"
style="pointer-events: none;"
>
<div class="text-green-600 font-medium text-lg">
Déposez le fichier ici
</div>
</div>
</div>
</template>
<script>
import { ArchiveBoxIcon, DocumentIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { ref } from 'vue';
export default {
name: 'FileUploadArea',
components: {
ArchiveBoxIcon,
DocumentIcon,
XMarkIcon,
},
props: {
selectedFile: {
type: File,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ['file-selected', 'file-cleared'],
setup(props, { emit }) {
const isDragOver = ref(false);
const dragCounter = ref(0);
// Handlers pour le drag & drop
const handleDragEnter = (event) => {
if (props.disabled) return;
event.preventDefault();
dragCounter.value++;
isDragOver.value = true;
};
const handleDragOver = (event) => {
if (props.disabled) return;
event.preventDefault();
};
const handleDragLeave = (event) => {
if (props.disabled) return;
event.preventDefault();
dragCounter.value--;
if (dragCounter.value === 0) {
isDragOver.value = false;
}
};
const handleDrop = (event) => {
if (props.disabled) return;
event.preventDefault();
isDragOver.value = false;
dragCounter.value = 0;
const files = event.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
emit('file-selected', file);
}
};
// Handler pour la sélection de fichier via input
const handleFileSelect = (event) => {
if (props.disabled) return;
const files = event.target.files;
if (files.length > 0) {
const file = files[0];
emit('file-selected', file);
}
// Réinitialiser l'input pour permettre la sélection du même fichier
event.target.value = '';
};
// Supprimer le fichier sélectionné
const clearFile = () => {
emit('file-cleared');
};
// Formater la taille du fichier
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]}`;
};
return {
isDragOver,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
handleFileSelect,
clearFile,
formatFileSize,
};
},
};
</script>

View File

@@ -0,0 +1,149 @@
<template>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<!-- 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>
<!-- 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>
<!-- 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>
</div>
</div>
</div>
</template>
<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';
const conversionStore = useConversionStore();
const { showSuccess, showError } = useNotifications();
const showProgress = computed(() =>
conversionStore.hasSelectedFile &&
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
);
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,
}] : []),
],
}));
const handleFileSelected = (file) => {
conversionStore.selectFile(file);
};
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 handleDownload = () => conversionStore.downloadConvertedFile();
const handleReset = () => conversionStore.resetConversion();
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 octets';
const k = 1024;
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const formatDate = (isoString) =>
new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(isoString));
const calculateSaving = (originalSize, convertedSize) => {
if (!originalSize || !convertedSize) return '';
const saving = ((originalSize - convertedSize) / originalSize) * 100;
if (saving > 0) return `-${saving.toFixed(1)}%`;
if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`;
return '0%';
};
onMounted(() => conversionStore.resetConversion());
</script>

View File

@@ -0,0 +1,217 @@
# Domaine Import - Analyse et Import de Fichiers CBZ/CBR
## Vue d'ensemble
Ce domaine permet l'import de fichiers CBZ/CBR dans Mangarr en utilisant l'analyse intelligente de noms de fichiers pour trouver automatiquement les correspondances avec les mangas de la bibliothèque.
## Architecture
### Structure des Dossiers
```
domain/import/
├── domain/
│ └── entities/
│ └── FileImport.js # Entité représentant un fichier à importer
├── infrastructure/
│ └── api/
│ └── apiImportRepository.js # Client API
├── application/
│ └── store/
│ └── newImportStore.js # Store Pinia principal
└── presentation/
├── pages/
│ └── NewImportPage.vue # Page principale d'import
└── components/
├── FileImportCard.vue # Carte de fichier à importer
├── ImportResults.vue # Résumé des résultats
└── StatusBadge.vue # Badge de statut
```
## Fonctionnalités
### 1. Upload de Fichiers
- **Drag & Drop** : Support du glisser-déposer pour les fichiers CBZ/CBR
- **Sélection multiple** : Import de plusieurs fichiers simultanément
- **Validation** : Vérification automatique des formats acceptés
### 2. Analyse Intelligente
- **Extraction automatique** : Le système analyse le nom de fichier pour extraire :
- Le titre du manga
- Le numéro de chapitre (si présent)
- Le numéro de volume (si présent)
- **Correspondance automatique** :
- Recherche des mangas correspondants dans la bibliothèque
- Score de correspondance pour chaque résultat
- Sélection automatique du meilleur match
### 3. Sélection et Validation
- **Sélection de manga** : Dropdown avec tous les mangas correspondants et leur score
- **Prévisualisation** : Affichage de la couverture et des informations du manga sélectionné
- **Édition des numéros** : Possibilité de modifier les numéros de chapitre/volume extraits
- **Exclusivité** : Un fichier ne peut être importé que comme chapitre OU volume (pas les deux)
### 4. Import
- **Import unitaire** : Import fichier par fichier
- **Import groupé** : Import de tous les fichiers prêts en une seule fois
- **Retry** : Possibilité de réessayer en cas d'erreur
- **Suivi en temps réel** : Indicateurs de progression et statuts
### 5. Résultats
- **Statistiques** : Nombre de fichiers importés, erreurs, total
- **Détails** : Liste des fichiers importés avec leurs associations
- **Erreurs** : Affichage détaillé des erreurs pour débogage
## API Endpoints Utilisés
### Analyse de fichiers
```
GET /api/manga-matches?filename={filename}
```
Retourne :
```json
{
"matches": [
{
"id": "string",
"title": "string",
"slug": "string",
"alternativeSlugs": ["string"],
"thumbnailUrl": "string",
"matchScore": 100
}
],
"chapterNumber": 1.5,
"volumeNumber": 2.0,
"possibleTitles": ["string"]
}
```
### Import de fichier
```
POST /api/chapters/import
```
FormData :
- `file`: Le fichier CBZ à importer
- `mangaId`: ID du manga
- `chapterNumber`: Numéro de chapitre (float, optionnel)
Réponse (200) :
```json
{
"message": "Chapter imported successfully",
"mangaId": "uuid",
"chapterNumber": 1.5
}
```
Erreurs :
- `404`: Manga ou Chapitre non trouvé
- `422`: Paramètres invalides ou fichier absent
- `400`: Fichier CBZ invalide
### Import de volume (À venir)
```
POST /api/volumes/import
```
FormData :
- `file`: Le fichier CBZ à importer
- `mangaId`: ID du manga
- `volumeNumber`: Numéro de volume (int)
## Store Pinia
Le store `newImportStore` gère tout l'état de l'application :
### État
- `files`: Liste des fichiers en cours de traitement
- `analyzingFiles`: Set des IDs de fichiers en analyse
- `importingFiles`: Set des IDs de fichiers en import
- `isLoading`: État de chargement global
- `globalError`: Erreur globale éventuelle
### Getters
- `pendingFiles`: Fichiers en attente d'analyse
- `analyzedFiles`: Fichiers analysés
- `readyFiles`: Fichiers prêts pour l'import
- `importedFiles`: Fichiers importés avec succès
- `errorFiles`: Fichiers en erreur
- `hasReadyFiles`: Au moins un fichier prêt
- `allFilesProcessed`: Tous les fichiers traités
- `progressPercentage`: Pourcentage de progression
### Actions Principales
- `addFiles(fileList)`: Ajoute des fichiers et lance l'analyse automatique
- `analyzeFile(fileId)`: Analyse un fichier spécifique
- `setFileManga(fileId, manga)`: Définit le manga sélectionné
- `setFileChapterNumber(fileId, number)`: Définit le numéro de chapitre
- `setFileVolumeNumber(fileId, number)`: Définit le numéro de volume
- `importFile(fileId)`: Importe un fichier
- `importAllReadyFiles()`: Importe tous les fichiers prêts
- `autoSelectBestMatches()`: Sélection automatique des meilleurs matchs
- `retryFile(fileId)`: Réessaye l'analyse ou l'import d'un fichier
## Entité FileImport
Représente un fichier dans le processus d'import :
### Propriétés
- `file`: Objet File du navigateur
- `filename`: Nom du fichier original
- `analysis`: Résultat de l'analyse (matches, chapterNumber, volumeNumber)
- `selectedManga`: Manga sélectionné par l'utilisateur
- `selectedChapterNumber`: Numéro de chapitre (auto ou manuel)
- `selectedVolumeNumber`: Numéro de volume (auto ou manuel)
- `status`: pending | analyzed | importing | imported | error
- `errorMessage`: Message d'erreur le cas échéant
### Méthodes Utiles
- `hasMatches()`: Vérifie si des correspondances ont été trouvées
- `getMatches()`: Retourne la liste des correspondances
- `getBestMatch()`: Retourne la meilleure correspondance
- `isReadyForImport()`: Vérifie si le fichier est prêt à être importé
- `getImportData()`: Prépare les données pour l'API d'import
## Workflow Utilisateur
1. **Upload**: L'utilisateur glisse-dépose ou sélectionne des fichiers CBZ/CBR
2. **Analyse automatique**: Chaque fichier est analysé pour extraire les informations
3. **Sélection auto**: Le meilleur match est automatiquement sélectionné
4. **Validation**: L'utilisateur peut modifier le manga ou les numéros si nécessaire
5. **Import**: Import unitaire ou groupé des fichiers prêts
6. **Résultats**: Affichage du résumé avec succès et erreurs
## Gestion des Erreurs
### Erreurs d'analyse
- Aucun manga trouvé → Message informatif, possibilité de réessayer
- Erreur réseau → Message d'erreur, bouton retry disponible
### Erreurs d'import
- Échec d'upload → Fichier marqué en erreur avec message détaillé
- Erreur serveur → Fichier en erreur, possibilité de retry
## Améliorations Futures
1. **Recherche manuelle** : Permettre la recherche manuelle si aucun match
2. **Multi-sélection** : Sélectionner plusieurs fichiers pour actions groupées
3. **Historique** : Garder un historique des imports récents
4. **Validation avancée** : Vérifier si le chapitre/volume existe déjà
5. **Métadonnées** : Extraire et afficher plus de métadonnées des fichiers CBZ
## Composants Réutilisables
### Depuis Shared
- `FileUpload.vue`: Zone d'upload avec drag & drop
- `LoadingSpinner.vue`: Indicateur de chargement
### Spécifiques au Domaine
- `FileImportCard.vue`: Carte complète de gestion d'un fichier
- `StatusBadge.vue`: Badge de statut avec couleurs
- `ImportResults.vue`: Résumé des résultats d'import

View File

@@ -0,0 +1,316 @@
import { defineStore } from 'pinia';
import { useNotifications } from '../../../../shared/composables/useNotifications';
import { FileImport } from '../../domain/entities/FileImport';
import { ApiImportRepository } from '../../infrastructure/api/apiImportRepository';
const importRepository = new ApiImportRepository();
const { showSuccess, showError, showInfo } = useNotifications();
export const useNewImportStore = defineStore('newImport', {
state: () => ({
// Files being processed
files: [], // Array of FileImport entities
// Loading states
analyzingFiles: new Set(), // File IDs being analyzed
importingFiles: new Set(), // File IDs being imported
// Global states
isLoading: false,
globalError: null,
}),
getters: {
// File status getters
pendingFiles: (state) => state.files.filter(f => f.isPending()),
analyzedFiles: (state) => state.files.filter(f => f.isAnalyzed()),
readyFiles: (state) => state.files.filter(f => f.isReadyForImport()),
importedFiles: (state) => state.files.filter(f => f.isImported()),
errorFiles: (state) => state.files.filter(f => f.hasError()),
// Counts
totalFiles: (state) => state.files.length,
readyCount: (state) => state.files.filter(f => f.isReadyForImport()).length,
importedCount: (state) => state.files.filter(f => f.isImported()).length,
errorCount: (state) => state.files.filter(f => f.hasError()).length,
// Status helpers
hasFiles: (state) => state.files.length > 0,
hasReadyFiles: (state) => state.files.some(f => f.isReadyForImport()),
allFilesProcessed: (state) => {
return state.files.length > 0 &&
state.files.every(f => f.isImported() || f.hasError());
},
// Progress
progressPercentage: (state) => {
if (state.files.length === 0) return 0;
const processed = state.files.filter(f => f.isImported() || f.hasError()).length;
return Math.round((processed / state.files.length) * 100);
},
// Specific file finders
getFileById: (state) => (id) => {
return state.files.find(f => f.id === id);
}
},
actions: {
// === FILE MANAGEMENT ===
/**
* Add files to the import queue
*/
addFiles(fileList) {
const validFiles = Array.from(fileList).filter(file => {
const extension = file.name.split('.').pop().toLowerCase();
return ['cbz', 'cbr'].includes(extension);
});
if (validFiles.length === 0) {
showError('Aucun fichier CBZ/CBR valide sélectionné');
return;
}
const newFiles = validFiles.map(file => FileImport.create(file));
this.files.push(...newFiles);
showInfo(`${newFiles.length} fichier(s) ajouté(s) à la queue d'import`);
// Auto-analyze all new files
this.analyzeAllPendingFiles();
},
/**
* Remove a file from the queue
*/
removeFile(fileId) {
const index = this.files.findIndex(f => f.id === fileId);
if (index !== -1) {
this.files.splice(index, 1);
}
},
/**
* Clear all files
*/
clearFiles() {
this.files = [];
this.analyzingFiles.clear();
this.importingFiles.clear();
this.globalError = null;
},
// === ANALYSIS ACTIONS ===
/**
* Analyze all pending files
*/
async analyzeAllPendingFiles() {
const pendingFiles = this.pendingFiles;
if (pendingFiles.length === 0) return;
this.isLoading = true;
try {
await Promise.all(
pendingFiles.map(file => this.analyzeFile(file.id))
);
showSuccess(`${pendingFiles.length} fichier(s) analysé(s) avec succès`);
} catch (error) {
console.error('Error analyzing files:', error);
this.globalError = 'Erreur lors de l\'analyse des fichiers';
} finally {
this.isLoading = false;
}
},
/**
* Analyze a specific file
*/
async analyzeFile(fileId) {
const fileIndex = this.files.findIndex(f => f.id === fileId);
if (fileIndex === -1) return;
const file = this.files[fileIndex];
if (!file.isPending()) return;
this.analyzingFiles.add(fileId);
try {
const analysis = await importRepository.analyzeFilename(file.filename);
file.setAnalysis(analysis);
// Force reactivity by replacing the object in the array
this.files[fileIndex] = file;
if (!file.hasMatches()) {
showError(`Aucun manga trouvé pour le fichier: ${file.filename}`);
}
} catch (error) {
console.error(`Error analyzing file ${file.filename}:`, error);
file.setError(`Erreur d'analyse: ${error.message}`);
this.files[fileIndex] = file;
showError(`Erreur lors de l'analyse de ${file.filename}`);
} finally {
this.analyzingFiles.delete(fileId);
}
},
// === SELECTION ACTIONS ===
/**
* Update manga selection for a file
*/
setFileManga(fileId, manga) {
const fileIndex = this.files.findIndex(f => f.id === fileId);
if (fileIndex !== -1) {
this.files[fileIndex].setSelectedManga(manga);
// Force reactivity
this.files[fileIndex] = this.files[fileIndex];
}
},
/**
* Update chapter number for a file
*/
setFileChapterNumber(fileId, chapterNumber) {
const fileIndex = this.files.findIndex(f => f.id === fileId);
if (fileIndex !== -1) {
this.files[fileIndex].setSelectedChapterNumber(chapterNumber);
// Force reactivity
this.files[fileIndex] = this.files[fileIndex];
}
},
/**
* Update volume number for a file
*/
setFileVolumeNumber(fileId, volumeNumber) {
const fileIndex = this.files.findIndex(f => f.id === fileId);
if (fileIndex !== -1) {
this.files[fileIndex].setSelectedVolumeNumber(volumeNumber);
// Force reactivity
this.files[fileIndex] = this.files[fileIndex];
}
},
// === IMPORT ACTIONS ===
/**
* Import all ready files
*/
async importAllReadyFiles() {
const readyFiles = this.readyFiles;
if (readyFiles.length === 0) {
showError('Aucun fichier prêt pour l\'import');
return;
}
this.isLoading = true;
let successCount = 0;
let errorCount = 0;
try {
for (const file of readyFiles) {
try {
await this.importFile(file.id);
successCount++;
} catch (error) {
errorCount++;
console.error(`Failed to import file ${file.filename}:`, error);
}
}
if (successCount > 0) {
showSuccess(`${successCount} fichier(s) importé(s) avec succès`);
}
if (errorCount > 0) {
showError(`${errorCount} fichier(s) ont échoué lors de l'import`);
}
} finally {
this.isLoading = false;
}
},
/**
* Import a specific file
*/
async importFile(fileId) {
const file = this.getFileById(fileId);
if (!file || !file.isReadyForImport()) {
throw new Error('File is not ready for import');
}
this.importingFiles.add(fileId);
file.setImporting();
try {
const importData = file.getImportData();
await importRepository.importFile(
file.file,
importData.mangaId,
importData.chapterNumber,
importData.volumeNumber
);
file.setImported();
showSuccess(`Fichier ${file.filename} importé avec succès`);
} catch (error) {
console.error(`Error importing file ${file.filename}:`, error);
file.setError(`Erreur d'import: ${error.message}`);
throw error;
} finally {
this.importingFiles.delete(fileId);
}
},
/**
* Retry import for a failed file
*/
async retryFile(fileId) {
const file = this.getFileById(fileId);
if (!file) return;
if (file.hasError() && file.selectedManga) {
// If the file had an import error but has selections, retry import
await this.importFile(fileId);
} else {
// If the file had an analysis error, retry analysis
file.status = 'pending';
file.errorMessage = null;
await this.analyzeFile(fileId);
}
},
// === UTILITY ACTIONS ===
/**
* Auto-select best matches for all files
*/
autoSelectBestMatches() {
let selectedCount = 0;
this.analyzedFiles.forEach(file => {
const bestMatch = file.getBestMatch();
if (bestMatch) {
file.setSelectedManga(bestMatch);
selectedCount++;
}
});
if (selectedCount > 0) {
showInfo(`${selectedCount} correspondance(s) automatique(s) effectuée(s)`);
}
},
/**
* Reset global state
*/
resetGlobalState() {
this.globalError = null;
this.isLoading = false;
this.analyzingFiles.clear();
this.importingFiles.clear();
}
}
});

View File

@@ -0,0 +1,200 @@
/**
* Entité représentant un fichier en cours d'import avec ses correspondances possibles
*/
export class FileImport {
constructor({
file, // File object from browser
filename, // Original filename
analysis = null, // Result from /api/manga-matches endpoint
selectedManga = null, // Selected manga match
selectedChapterNumber = null, // Selected chapter number (extracted from filename)
selectedVolumeNumber = null, // Selected volume number (extracted from filename)
status = 'pending', // 'pending', 'analyzed', 'importing', 'imported', 'error'
errorMessage = null,
importedAt = null
}) {
this.file = file;
this.filename = filename;
this.analysis = analysis;
this.selectedManga = selectedManga;
this.selectedChapterNumber = selectedChapterNumber;
this.selectedVolumeNumber = selectedVolumeNumber;
this.status = status;
this.errorMessage = errorMessage;
this.importedAt = importedAt;
this.id = this._generateId();
}
static create(file) {
return new FileImport({
file,
filename: file.name
});
}
_generateId() {
return `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// Status helpers
isPending() {
return this.status === 'pending';
}
isAnalyzed() {
return this.status === 'analyzed';
}
isImporting() {
return this.status === 'importing';
}
isImported() {
return this.status === 'imported';
}
hasError() {
return this.status === 'error';
}
// Analysis helpers
hasMatches() {
return this.analysis && this.analysis.matches && this.analysis.matches.length > 0;
}
getMatches() {
return this.analysis?.matches || [];
}
getBestMatch() {
const matches = this.getMatches();
// Sort by matchScore (highest first) and return the best one
return matches.length > 0 ? matches.sort((a, b) => b.matchScore - a.matchScore)[0] : null;
}
// Analysis extracted data
getExtractedChapterNumber() {
return this.analysis?.chapterNumber || null;
}
getExtractedVolumeNumber() {
return this.analysis?.volumeNumber || null;
}
// Selection helpers
isReadyForImport() {
// Ready if a manga is selected and at least chapter or volume number is set
return this.selectedManga && (this.selectedChapterNumber !== null || this.selectedVolumeNumber !== null);
}
getImportType() {
if (this.selectedChapterNumber !== null) return 'chapter';
if (this.selectedVolumeNumber !== null) return 'volume';
return null;
}
// File helpers
getFormattedSize() {
if (!this.file || !this.file.size) return 'Unknown';
const bytes = this.file.size;
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
getFileExtension() {
const extension = this.filename.split('.').pop().toLowerCase();
return extension;
}
isValidFormat() {
const validExtensions = ['cbz', 'cbr'];
return validExtensions.includes(this.getFileExtension());
}
// Update methods
setAnalysis(analysis) {
this.analysis = analysis;
this.status = 'analyzed';
// Auto-set extracted chapter/volume numbers from analysis
if (analysis.chapterNumber !== null && analysis.chapterNumber !== undefined) {
this.selectedChapterNumber = analysis.chapterNumber;
}
if (analysis.volumeNumber !== null && analysis.volumeNumber !== undefined) {
this.selectedVolumeNumber = analysis.volumeNumber;
}
// Auto-select best match if available
const bestMatch = this.getBestMatch();
if (bestMatch) {
this.selectedManga = bestMatch;
}
}
setSelectedManga(manga) {
this.selectedManga = manga;
// Keep the chapter/volume numbers from analysis
}
setSelectedChapterNumber(chapterNumber) {
this.selectedChapterNumber = chapterNumber;
// If setting chapter, clear volume
if (chapterNumber !== null) {
this.selectedVolumeNumber = null;
}
}
setSelectedVolumeNumber(volumeNumber) {
this.selectedVolumeNumber = volumeNumber;
// If setting volume, clear chapter
if (volumeNumber !== null) {
this.selectedChapterNumber = null;
}
}
setImporting() {
this.status = 'importing';
this.errorMessage = null;
}
setImported() {
this.status = 'imported';
this.importedAt = new Date().toISOString();
this.errorMessage = null;
}
setError(message) {
this.status = 'error';
this.errorMessage = message;
}
// Export selection for API
getImportData() {
if (!this.isReadyForImport()) {
throw new Error('File is not ready for import');
}
const data = {
mangaId: this.selectedManga.id
};
if (this.selectedChapterNumber !== null) {
data.chapterNumber = this.selectedChapterNumber;
}
if (this.selectedVolumeNumber !== null) {
data.volumeNumber = this.selectedVolumeNumber;
}
return data;
}
}

View File

@@ -0,0 +1,239 @@
export class ImportFile {
constructor({
id,
originalName,
fileSize,
extension,
status = 'pending',
createdAt,
metadata = null,
mangaMatches = [],
selectedMangaSlug = null,
selectedVolume = null,
selectedChapter = null,
errorMessage = null,
processedAt = null,
// New properties for simplified workflow
file = null, // Browser File object
analysis = null, // Analysis result from API
selectedManga = null, // Selected manga object
selectedChapterId = null // Selected chapter ID
}) {
this.id = id;
this.originalName = originalName;
this.fileSize = fileSize;
this.extension = extension;
this.status = status;
this.createdAt = createdAt;
this.metadata = metadata;
this.mangaMatches = mangaMatches;
this.selectedMangaSlug = selectedMangaSlug;
this.selectedVolume = selectedVolume;
this.selectedChapter = selectedChapter;
this.errorMessage = errorMessage;
this.processedAt = processedAt;
// New properties
this.file = file;
this.analysis = analysis;
this.selectedManga = selectedManga;
this.selectedChapterId = selectedChapterId;
this.mangaMatches = mangaMatches; // Store found manga matches
}
static create(data) {
return new ImportFile({
...data,
createdAt: data.createdAt || new Date().toISOString()
});
}
// Create from browser File object
static createFromFile(file) {
return new ImportFile({
id: `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
originalName: file.name,
fileSize: file.size,
extension: file.name.split('.').pop().toLowerCase(),
file: file,
createdAt: new Date().toISOString()
});
}
isProcessed() {
return this.status === 'processed';
}
hasError() {
return this.status === 'error';
}
isPending() {
return this.status === 'pending';
}
needsConversion() {
return this.extension === 'cbr';
}
isReadyForImport() {
return this.isProcessed() && this.selectedMangaSlug && (this.selectedVolume || this.selectedChapter);
}
getFormattedSize() {
const bytes = parseInt(this.fileSize);
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
getContentType() {
if (this.metadata?.chapter) {
return `Chapter ${this.metadata.chapter}`;
}
if (this.metadata?.volume) {
return `Volume ${this.metadata.volume}`;
}
return 'Unknown';
}
// === NEW METHODS FOR SIMPLIFIED WORKFLOW ===
// Status helpers for new workflow
isAnalyzed() {
return this.status === 'analyzed';
}
isImporting() {
return this.status === 'importing';
}
isImported() {
return this.status === 'imported';
}
// Analysis helpers
hasAnalysis() {
return this.analysis && this.analysis.possibleTitles && this.analysis.possibleTitles.length > 0;
}
getPossibleTitles() {
return this.analysis?.possibleTitles || [];
}
getAnalyzedChapter() {
return this.analysis?.chapterNumber || null;
}
getAnalyzedVolume() {
return this.analysis?.volumeNumber || null;
}
// For backward compatibility with existing code
hasMatches() {
return this.mangaMatches && this.mangaMatches.length > 0;
}
getMatches() {
return this.mangaMatches || [];
}
getBestMatch() {
const matches = this.getMatches();
return matches.length > 0 ? matches[0] : null;
}
// Selection helpers
isReadyForNewImport() {
return this.selectedManga && (this.selectedChapterId || this.selectedVolume !== null);
}
getImportType() {
if (this.selectedChapterId) return 'chapter';
if (this.selectedVolume !== null) return 'volume';
return null;
}
// File validation
isValidFormat() {
const validExtensions = ['cbz', 'cbr'];
return validExtensions.includes(this.extension);
}
// Update methods for new workflow
setAnalysis(analysis) {
this.analysis = analysis;
this.status = 'analyzed';
}
setMangaMatches(matches) {
this.mangaMatches = matches;
// Auto-select best match if available
const bestMatch = this.getBestMatch();
if (bestMatch) {
this.selectedManga = bestMatch;
}
}
setSelectedManga(manga) {
this.selectedManga = manga;
// Reset chapter/volume selection when manga changes
this.selectedChapterId = null;
this.selectedVolume = null;
}
setSelectedChapterById(chapterId) {
this.selectedChapterId = chapterId;
this.selectedVolume = null; // Can't have both
}
setSelectedVolumeNumber(volumeNumber) {
this.selectedVolume = volumeNumber;
this.selectedChapterId = null; // Can't have both
}
setImporting() {
this.status = 'importing';
this.errorMessage = null;
}
setImported() {
this.status = 'imported';
this.processedAt = new Date().toISOString();
this.errorMessage = null;
}
setError(message) {
this.status = 'error';
this.errorMessage = message;
}
// Export selection for API
getImportData() {
if (!this.isReadyForNewImport()) {
throw new Error('File is not ready for import');
}
const data = {
mangaId: this.selectedManga.id
};
if (this.selectedChapterId) {
data.chapterId = this.selectedChapterId;
}
if (this.selectedVolume !== null) {
data.volumeNumber = this.selectedVolume;
}
return data;
}
}

View File

@@ -0,0 +1,174 @@
export class ApiImportRepository {
/**
* Analyse le nom d'un fichier et trouve les mangas correspondants
* @param {string} filename - Nom du fichier à analyser
* @returns {Promise<Object>} - Résultat de l'analyse avec les correspondances
*/
async analyzeFilename(filename) {
try {
console.log('Analyzing filename:', filename);
const response = await fetch(`/api/manga-matches?filename=${encodeURIComponent(filename)}`);
if (!response.ok) {
const errorText = await response.text();
console.error('Analyze filename failed:', response.status, errorText);
throw new Error(`Failed to analyze filename: ${response.status}`);
}
const result = await response.json();
console.log('Analyze result:', result);
// Extract chapter and volume numbers from the first match if available
const firstMatch = result.matches && result.matches.length > 0 ? result.matches[0] : null;
const chapterNumber = firstMatch?.chapterNumber ?? null;
const volumeNumber = firstMatch?.volumeNumber ?? null;
return {
matches: result.matches || [],
chapterNumber,
volumeNumber,
possibleTitles: result.possibleTitles || []
};
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Récupère les détails d'un manga par son slug
* @param {string} slug - Slug du manga
* @returns {Promise<Object>} - Détails du manga avec chapitres et volumes
*/
async getMangaDetails(slug) {
try {
console.log('Fetching manga details for:', slug);
const response = await fetch(`/api/mangas/${slug}`);
if (!response.ok) {
const errorText = await response.text();
console.error('Get manga details failed:', response.status, errorText);
throw new Error(`Failed to get manga details: ${response.status}`);
}
const result = await response.json();
return result;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Upload et import d'un fichier avec les informations du manga
* @param {File} file - Fichier à uploader
* @param {string} mangaId - ID du manga
* @param {number|null} chapterNumber - Numéro du chapitre (optionnel)
* @param {number|null} volumeNumber - Numéro du volume (optionnel)
* @returns {Promise<Object>} - Résultat de l'import
*/
async importFile(file, mangaId, chapterNumber = null, volumeNumber = null) {
try {
// Déterminer s'il s'agit d'un import de chapitre ou volume
if (chapterNumber !== null && chapterNumber !== undefined) {
return await this.importChapter(file, mangaId, chapterNumber);
} else if (volumeNumber !== null && volumeNumber !== undefined) {
return await this.importVolume(file, mangaId, volumeNumber);
} else {
throw new Error('Either chapterNumber or volumeNumber must be provided');
}
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Import d'un chapitre
* @param {File} file - Fichier CBZ à uploader
* @param {string} mangaId - ID du manga
* @param {number} chapterNumber - Numéro du chapitre
* @returns {Promise<Object>} - Résultat de l'import
*/
async importChapter(file, mangaId, chapterNumber) {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('mangaId', mangaId);
formData.append('chapterNumber', chapterNumber.toString());
console.log('Importing chapter:', chapterNumber, 'for manga:', mangaId);
const response = await fetch('/api/chapters/import', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorText = await response.text();
console.error('Import failed:', response.status, errorText);
// Parse the error response if it's JSON
let errorMessage = `Failed to import chapter: ${response.status}`;
try {
const errorJson = JSON.parse(errorText);
errorMessage = errorJson.error || errorJson.details || errorMessage;
} catch (e) {
// Not JSON, use the status message
}
throw new Error(errorMessage);
}
const result = await response.json();
console.log('Import result:', result);
return result;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Import d'un volume (TODO: À implémenter)
* @param {File} file - Fichier CBZ à uploader
* @param {string} mangaId - ID du manga
* @param {number} volumeNumber - Numéro du volume
* @returns {Promise<Object>} - Résultat de l'import
*/
async importVolume(file, mangaId, volumeNumber) {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('mangaId', mangaId);
formData.append('volumeNumber', volumeNumber.toString());
console.log('Importing volume:', volumeNumber, 'for manga:', mangaId);
const response = await fetch('/api/volumes/import', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorText = await response.text();
console.error('Import failed:', response.status, errorText);
// Parse the error response if it's JSON
let errorMessage = `Failed to import volume: ${response.status}`;
try {
const errorJson = JSON.parse(errorText);
errorMessage = errorJson.error || errorJson.details || errorMessage;
} catch (e) {
// Not JSON, use the status message
}
throw new Error(errorMessage);
}
const result = await response.json();
console.log('Import result:', result);
return result;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,150 @@
<template>
<div class="py-3">
<!-- 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>
<!-- 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>
<!-- 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>
<!-- Numéros de chapitre / volume -->
<div v-if="file.selectedManga" class="mt-3 grid grid-cols-2 gap-3">
<div>
<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>
<div>
<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>
</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 },
});
const emit = defineEmits([
'manga-selected',
'chapter-number-selected',
'volume-number-selected',
'import-file',
'retry-file',
'remove-file',
]);
const sortedMatches = computed(() =>
[...props.file.getMatches()].sort((a, b) => b.matchScore - a.matchScore)
);
const handleMangaSelection = (manga) => emit('manga-selected', manga);
const handleChapterNumberInput = (event) => {
const value = event.target.value;
emit('chapter-number-selected', value ? parseFloat(value) : null);
};
const handleVolumeNumberInput = (event) => {
const value = event.target.value;
emit('volume-number-selected', value ? parseFloat(value) : null);
};
</script>

View File

@@ -0,0 +1,112 @@
<template>
<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>
</section>
<!-- 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 gap-2 py-2.5 text-sm"
>
<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>
<!-- 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 gap-2 py-2.5 text-sm"
>
<XCircleIcon class="flex-shrink-0 h-4 w-4 text-red-400 mt-0.5" />
<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>
</div>
</div>
</section>
<!-- Actions -->
<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';
const router = useRouter();
const store = useNewImportStore();
const importedFiles = computed(() => store.importedFiles);
const errorFiles = computed(() => store.errorFiles);
const importedCount = computed(() => store.importedCount);
const errorCount = computed(() => store.errorCount);
const totalCount = computed(() => store.totalFiles);
const startNewImport = () => {
store.clearFiles();
};
const goToLibrary = () => {
router.push({ name: 'manga-collection' });
};
</script>

View File

@@ -0,0 +1,47 @@
<template>
<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"
>
<PhotoIcon class="w-6 h-6 text-gray-400" />
</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>
</template>
<script setup>
import { CheckCircleIcon, PhotoIcon } from '@heroicons/vue/24/outline';
const props = defineProps({
match: { type: Object, required: true },
isSelected: { type: Boolean, default: false },
});
const emit = defineEmits(['select-match']);
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="manga-option">
<div class="flex items-center space-x-3">
<div v-if="manga.coverUrl" class="flex-shrink-0">
<img
:src="manga.coverUrl"
:alt="manga.title"
class="w-12 h-16 object-cover rounded"
/>
</div>
<div v-else class="flex-shrink-0 w-12 h-16 bg-gray-200 rounded flex items-center justify-center">
<svg class="w-6 h-6 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 class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate">
{{ manga.title }}
</h4>
<div class="text-xs text-gray-500 space-y-1">
<p v-if="manga.author" class="truncate">
{{ manga.author }}
</p>
<p v-if="manga.publicationYear" class="truncate">
{{ manga.publicationYear }}
</p>
<div v-if="manga.genres && manga.genres.length > 0" class="flex flex-wrap gap-1">
<span
v-for="genre in manga.genres.slice(0, 3)"
:key="genre"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
>
{{ genre }}
</span>
<span v-if="manga.genres.length > 3" class="text-xs text-gray-400">
+{{ manga.genres.length - 3 }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
manga: {
type: Object,
required: true
}
});
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div class="inline-flex items-center">
<!-- Loading Spinner for analyzing/importing -->
<svg v-if="isAnalyzing || isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4" 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>
<!-- Status Badge -->
<span :class="badgeClasses">
{{ badgeText }}
</span>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
status: {
type: String,
required: true
},
isAnalyzing: {
type: Boolean,
default: false
},
isImporting: {
type: Boolean,
default: false
}
});
const badgeText = computed(() => {
if (props.isImporting) return 'Import en cours...';
if (props.isAnalyzing) return 'Analyse en cours...';
switch (props.status) {
case 'pending': return 'En attente';
case 'analyzed': return 'Analysé';
case 'importing': return 'Import en cours';
case 'imported': return 'Importé';
case 'error': return 'Erreur';
default: return 'Inconnu';
}
});
const badgeClasses = computed(() => {
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 text-xs font-medium';
if (props.isImporting || props.isAnalyzing) {
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 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
case 'analyzed':
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
case 'importing':
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
case 'imported':
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
case 'error':
return `${baseClasses} bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300`;
default:
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
}
});
</script>

View File

@@ -0,0 +1,129 @@
<template>
<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" />
</div>
</div>
</div>
</template>
<script setup>
import { ArrowUpTrayIcon, SparklesIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { computed, onUnmounted } from 'vue';
import FileUpload from '../../../../shared/components/ui/FileUpload.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();
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 {
await store.importAllReadyFiles();
} catch (error) {
console.error('Error importing files:', error);
}
};
const importSingleFile = async (fileId) => {
try {
await store.importFile(fileId);
} catch (error) {
console.error('Error importing file:', error);
}
};
const retryFile = async (fileId) => {
try {
await store.retryFile(fileId);
} catch (error) {
console.error('Error retrying file:', error);
}
};
onUnmounted(() => {
store.resetGlobalState();
});
</script>

View File

@@ -0,0 +1,18 @@
export class SearchMangas {
constructor(mangaRepository) {
this.mangaRepository = mangaRepository;
}
async execute(query) {
if (!query || query.trim().length === 0) {
return [];
}
try {
return await this.mangaRepository.searchMangas(query);
} catch (error) {
console.error('Search error:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,312 @@
import { defineStore } from 'pinia';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
const mangaRepository = new ApiMangaRepository();
// Helper pour comparer la collection (peut être supprimé si non utilisé ailleurs)
const deepCompare = (obj1, obj2) => {
try {
if (obj1 == null && obj2 == null) return true;
if (obj1 == null || obj2 == null) return false;
return JSON.stringify(obj1) === JSON.stringify(obj2);
} catch (e) {
console.error("Erreur lors de la comparaison d'objets:", e);
return false;
}
};
export const useMangaStore = defineStore('manga', {
state: () => ({
// --- Collection State ---
collection: null,
loadingCollection: false,
errorCollection: null,
isBackgroundLoadingCollection: false,
// --- Selected Manga State ---
// Gardé pour savoir quel manga est sélectionné dans l'UI,
// mais les données détaillées ne sont plus stockées ici.
currentMangaId: null,
// --- Manga Chapters State ---
mangaChapters: {},
loadingChapters: false,
chaptersError: null,
// --- Search State ---
searchResults: [],
loadingSearch: false,
searchError: null,
// --- Add Manga State ---
addingManga: false,
addMangaError: null,
// --- Discover State ---
discoverResults: [],
loadingDiscover: false,
discoverError: null
}),
getters: {
// Plus de getters spécifiques aux détails/chapitres ici
},
actions: {
// --- Collection Actions ---
async loadCollection() {
if (this.loadingCollection) return;
this.loadingCollection = true;
this.errorCollection = null;
try {
const newCollection = await mangaRepository.getCollection();
// On garde la comparaison pour éviter màj inutile de la collection
if (!deepCompare(this.collection, newCollection)) {
this.collection = newCollection;
}
} catch (err) {
this.errorCollection = err.message;
} finally {
this.loadingCollection = false;
}
},
async refreshCollectionInBackground() {
if (this.isBackgroundLoadingCollection) return;
this.isBackgroundLoadingCollection = true;
try {
const newCollection = await mangaRepository.getCollection();
if (!deepCompare(this.collection, newCollection)) {
this.collection = newCollection;
}
} catch (err) {
console.error('Failed to refresh collection:', err);
} finally {
this.isBackgroundLoadingCollection = false;
}
},
// --- Selected Manga Actions ---
setCurrentMangaId(mangaId) {
// Met simplement à jour l'ID sélectionné
this.currentMangaId = mangaId;
},
clearCurrentMangaFocus() {
this.currentMangaId = null;
},
// --- Chapters Actions ---
async loadChapters(mangaId) {
if (this.loadingChapters) return;
this.loadingChapters = true;
this.chaptersError = null;
try {
const chaptersData = await mangaRepository.getChapters(mangaId);
this.mangaChapters[mangaId] = chaptersData;
} catch (err) {
this.chaptersError = err.message;
} finally {
this.loadingChapters = false;
}
},
updateChapterAvailability(chapterId, isAvailable = true) {
console.log(`Mise à jour du chapitre ${chapterId}, disponible: ${isAvailable}`);
// Pour chaque manga dans notre store
Object.keys(this.mangaChapters).forEach(mangaId => {
const chaptersObj = this.mangaChapters[mangaId];
if (!chaptersObj || !chaptersObj.items) return;
const chapters = chaptersObj.items;
// Chercher le chapitre correspondant
const chapterIndex = chapters.findIndex(chapter => chapter.id === chapterId);
// Si on trouve le chapitre, mettre à jour son état
if (chapterIndex !== -1) {
console.log(`Chapitre trouvé dans le manga ${mangaId}, index: ${chapterIndex}`);
// Important: créer une nouvelle référence pour que Vue détecte le changement
const updatedChapter = {
...chapters[chapterIndex],
isAvailable: isAvailable
};
// Créer un nouveau tableau pour garantir la réactivité
const updatedChapters = [...chapters];
updatedChapters[chapterIndex] = updatedChapter;
// Mise à jour reactive du store
this.mangaChapters[mangaId] = {
...chaptersObj,
items: updatedChapters
};
console.log('Chapitre mis à jour avec succès');
}
});
},
// --- Search Actions ---
async searchMangaDex(query) {
if (this.loadingSearch) return;
this.loadingSearch = true;
this.searchError = null;
this.searchResults = [];
try {
const data = await mangaRepository.searchMangaDex(query);
this.searchResults = data.items || [];
} catch (error) {
this.searchError = error.message;
throw error;
} finally {
this.loadingSearch = false;
}
},
clearSearchResults() {
this.searchResults = [];
this.searchError = null;
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;
this.addingManga = true;
this.addMangaError = null;
try {
await mangaRepository.createFromMangaDex(externalId);
// Rafraîchir la collection après l'ajout
await this.loadCollection();
} catch (error) {
this.addMangaError = error.message;
throw error;
} finally {
this.addingManga = false;
}
},
async fetchMangaChapters(mangaId) {
if (this.loadingChapters) return;
this.loadingChapters = true;
this.chaptersError = null;
try {
// Déclenche la récupération initiale des chapitres depuis la source externe
await mangaRepository.fetchMangaChapters(mangaId);
console.log('Récupération initiale des chapitres déclenchée avec succès');
// Note: Les nouveaux chapitres seront disponibles après traitement asynchrone
// Le MercureListener se chargera de mettre à jour l'interface
} catch (err) {
this.chaptersError = err.message;
console.error('Erreur lors de la récupération des chapitres:', err);
throw err;
} finally {
this.loadingChapters = false;
}
},
async refreshMangaChapters(mangaId) {
if (this.loadingChapters) return;
this.loadingChapters = true;
this.chaptersError = null;
try {
// Déclenche la synchronisation incrémentale avec scraping automatique
await mangaRepository.refreshMangaChapters(mangaId);
console.log('Synchronisation incrémentale déclenchée avec succès');
// Note: Les chapitres mis à jour seront disponibles après traitement asynchrone
// Le MercureListener se chargera de mettre à jour l'interface
} catch (err) {
this.chaptersError = err.message;
console.error('Erreur lors de la synchronisation des chapitres:', err);
throw err;
} finally {
this.loadingChapters = false;
}
},
// --- Scrape Chapter Action ---
async searchChapter(chapterId) {
try {
await mangaRepository.searchChapter(chapterId);
} catch (error) {
console.error('Erreur lors de la recherche du chapitre:', error);
throw error;
}
},
// --- Delete Chapter Action ---
async deleteChapter(chapterId) {
try {
await mangaRepository.deleteChapter(chapterId);
// Mettre à jour l'état du chapitre pour refléter qu'il n'est plus disponible
this.updateChapterAvailability(chapterId, false);
} catch (error) {
console.error('Erreur lors de la suppression du chapitre:', error);
throw error;
}
},
// --- Download Chapter Action ---
async downloadChapter(chapterId) {
try {
await mangaRepository.downloadChapter(chapterId);
} catch (error) {
console.error('Erreur lors du téléchargement du chapitre:', error);
throw error;
}
},
// --- Hide Chapter Action ---
async hideChapter(chapterId, mangaId) {
try {
await mangaRepository.hideChapter(chapterId);
// Recharger la liste des chapitres depuis l'API
await this.loadChapters(mangaId);
} catch (error) {
console.error('Erreur lors du masquage du chapitre:', error);
throw error;
}
},
// --- Download Volume Action ---
async downloadVolume(mangaId, volumeNumber) {
try {
await mangaRepository.downloadVolume(mangaId, volumeNumber);
} catch (error) {
console.error('Erreur lors du téléchargement du volume:', error);
throw error;
}
}
}
});

View File

@@ -0,0 +1,50 @@
export class Manga {
constructor({
id,
slug,
title,
description = null,
authors = [],
imageUrl = null,
thumbnailUrl = null,
publicationYear = null,
status = null,
rating = null,
genres = [],
createdAt = new Date().toISOString(),
monitored = false,
chaptersTotal = 0,
chaptersScraped = 0,
}) {
this.id = id;
this.slug = slug;
this.title = title;
this.description = description;
this.authors = authors;
this.imageUrl = imageUrl;
this.thumbnailUrl = thumbnailUrl;
this.publicationYear = publicationYear;
this.status = status;
this.rating = rating;
this.genres = genres;
this.createdAt = createdAt;
this.monitored = monitored;
this.chaptersTotal = chaptersTotal;
this.chaptersScraped = chaptersScraped;
}
static create(data) {
return new Manga(data);
}
}
export class MangaCollection {
constructor(items, total, page, limit, hasNextPage, hasPreviousPage) {
this.items = items.map(item => Manga.create(item));
this.total = total;
this.page = page;
this.limit = limit;
this.hasNextPage = hasNextPage;
this.hasPreviousPage = hasPreviousPage;
}
}

View File

@@ -0,0 +1,411 @@
import { MangaCollection } from '../../domain/entities/manga';
export class ApiMangaRepository {
async getCollection() {
try {
const response = await fetch('/api/mangas');
if (!response.ok) {
throw new Error('Failed to fetch manga collection');
}
const data = await response.json();
return new MangaCollection(
data.items,
data.total,
data.page,
data.limit,
data.hasNextPage,
data.hasPreviousPage
);
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async getMangaById(id) {
try {
const response = await fetch(`/api/mangas/by-id/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch manga details');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async getChapters(mangaId) {
try {
let allChapters = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(`/api/mangas/${mangaId}/chapters?limit=500&page=${page}`);
if (!response.ok) {
throw new Error('Failed to fetch manga chapters');
}
const data = await response.json();
allChapters = allChapters.concat(data.items);
hasMore = data.hasNextPage;
page++;
}
// Filtrer pour ne garder que les chapitres visibles
const visibleChapters = allChapters.filter(chapter => chapter.isVisible === true);
return {
items: visibleChapters,
total: visibleChapters.length
};
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async getMangaBySlug(slug) {
try {
const response = await fetch(`/api/mangas/by-slug/${slug}`);
if (!response.ok) {
throw new Error('Failed to fetch manga details');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async searchMangas(query) {
try {
const response = await fetch(`/api/manga-search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Failed to search mangas');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async searchMangaDex(query) {
try {
const response = await fetch(`/api/mangadex-search?title=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Failed to search MangaDex');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
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', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ externalId })
});
if (!response.ok) {
throw new Error('Failed to create manga from MangaDex');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async fetchMangaChapters(mangaId) {
try {
const response = await fetch(`/api/manga/chapters/fetch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ mangaId })
});
if (!response.ok) {
throw new Error('Failed to fetch manga chapters');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async refreshMangaChapters(mangaId) {
try {
const response = await fetch(`/api/manga/${mangaId}/chapters/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!response.ok) {
throw new Error('Failed to refresh manga chapters');
}
// L'endpoint retourne 202 (Accepted), pas de contenu JSON à parser
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async searchChapter(chapterId) {
try {
const response = await fetch('/api/scraping/chapters', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ chapterId })
});
if (!response.ok) {
throw new Error('Échec de la recherche du chapitre');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async getPreferredSources(mangaId) {
try {
const response = await fetch(`/api/mangas/${mangaId}/preferred-sources`);
if (!response.ok) {
throw new Error('Failed to fetch preferred sources');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async setPreferredSources(mangaId, sourceIds) {
try {
const response = await fetch(`/api/mangas/${mangaId}/preferred-sources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ sourceIds })
});
if (!response.ok) {
throw new Error('Failed to set preferred sources');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async editManga(mangaId, updateData) {
try {
const response = await fetch(`/api/mangas/${mangaId}/edit`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updateData)
});
if (!response.ok) {
throw new Error('Failed to edit manga');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async deleteChapter(chapterId) {
try {
const response = await fetch(`/api/manga/chapters/${chapterId}/cbz`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete chapter');
}
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async downloadChapter(chapterId) {
try {
const response = await fetch(`/api/manga/chapters/${chapterId}/download`);
if (!response.ok) {
throw new Error('Failed to download chapter');
}
// Récupérer le nom du fichier depuis les headers
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `chapter-${chapterId}.cbz`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Créer un blob à partir de la réponse
const blob = await response.blob();
// Créer un lien de téléchargement temporaire
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Nettoyer
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async hideChapter(chapterId) {
try {
const response = await fetch(`/api/manga/chapters/${chapterId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to hide chapter');
}
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async downloadVolume(mangaId, volumeNumber) {
try {
const response = await fetch(`/api/mangas/${mangaId}/volumes/${volumeNumber}/download`);
if (!response.ok) {
throw new Error('Failed to download volume');
}
// Récupérer le nom du fichier depuis les headers
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `volume-${volumeNumber}.zip`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Créer un blob à partir de la réponse
const blob = await response.blob();
// Créer un lien de téléchargement temporaire
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Nettoyer
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async toggleMonitoring(mangaId, enabled) {
try {
const response = await fetch(`/api/manga/${mangaId}/monitoring/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled })
});
if (!response.ok) {
// Tenter de récupérer le message d'erreur détaillé de l'API
let errorMessage = 'Failed to toggle monitoring';
try {
const errorData = await response.json();
if (errorData.detail) {
errorMessage = errorData.detail;
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.violations && errorData.violations.length > 0) {
errorMessage = errorData.violations.map(v => v.message).join(', ');
}
} catch (parseError) {
console.warn('Could not parse error response:', parseError);
}
throw new Error(errorMessage);
}
// L'endpoint retourne un statut 204 (No Content), donc pas de données à retourner
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async deleteManga(mangaId) {
try {
const response = await fetch(`/api/mangas/${mangaId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error('Failed to delete manga');
}
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,906 @@
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 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">
<!-- Overlay avec effet de flou Material Design -->
<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 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 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 dark:text-gray-100 leading-6">
Gérer les chapitres
</h3>
<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 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 dark:text-gray-300" />
</button>
</div>
</div>
<!-- Content avec style Material Design -->
<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>
<div class="absolute top-0 left-0 w-8 h-8 border-4 border-green-600 rounded-full border-t-transparent animate-spin"></div>
</div>
</div>
<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>
<span>{{ error }}</span>
</div>
<div v-else class="space-y-6">
<!-- Actions avec style Material Design -->
<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"
class="bg-green-600 text-white px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-green-700 shadow-md hover:shadow-lg transition-all duration-200 flex items-center space-x-2"
>
<PlusIcon class="h-4 w-4" />
<span>Créer un volume</span>
</button>
<button
@click="showUnassignedChapters = !showUnassignedChapters"
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>
<!-- Bouton de séparation automatique du volume fourre-tout -->
<button
v-if="hasVolumeZero && canSplitVolumeZero"
@click="showSplitVolumeZeroModal = true"
class="bg-green-600 text-white px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-green-700 shadow-md hover:shadow-lg transition-all duration-200 flex items-center space-x-2"
>
<ArrowPathIcon class="h-4 w-4" />
<span>Séparer le volume 00</span>
</button>
<!-- Actions de sélection multiple -->
<div v-if="selectedChapters.length > 0" class="flex items-center space-x-3 bg-green-50 px-4 py-2 rounded-xl border border-green-200">
<span class="text-sm font-medium text-green-700">{{ selectedChapters.length }} chapitre(s) sélectionné(s)</span>
<button
@click="showMoveToVolumeModal = true"
class="bg-green-600 text-white px-3 py-1.5 rounded-lg text-xs font-medium hover:bg-green-700 shadow-sm transition-colors duration-200"
>
Déplacer vers un volume
</button>
<button
@click="clearSelection"
class="text-green-600 hover:text-green-800 text-xs font-medium hover:bg-green-100 px-2 py-1 rounded transition-colors duration-200"
>
Annuler
</button>
</div>
</div>
<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 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 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 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>
<div class="space-y-2">
<div
v-for="chapter in unassignedChapters"
:key="chapter.id"
class="flex items-center space-x-3 p-3 hover:bg-white rounded-lg transition-colors duration-200 border border-transparent hover:border-gray-200"
:class="{ 'bg-green-50 border-green-200 shadow-sm': isChapterSelected(chapter) }"
>
<!-- Checkbox de sélection Material Design -->
<div class="relative">
<input
type="checkbox"
:checked="isChapterSelected(chapter)"
@change="toggleChapterSelection(chapter)"
class="h-5 w-5 text-green-600 border-gray-300 rounded focus:ring-green-500 focus:ring-2 transition-colors duration-200"
/>
</div>
<DocumentIcon class="h-5 w-5 text-gray-400" />
<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 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' }}
</span>
<button
@click="startEditingTitle(chapter)"
class="ml-2 text-gray-400 hover:text-gray-600 p-1 rounded-full hover:bg-gray-100 transition-colors duration-200"
>
<PencilIcon class="h-3 w-3" />
</button>
</div>
<div v-else class="flex items-center space-x-2">
<input
v-model="chapter.editingTitle"
type="text"
class="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
@keyup.enter="saveTitle(chapter)"
@keyup.esc="cancelEditingTitle(chapter)"
ref="titleInput"
/>
<button
@click="saveTitle(chapter)"
class="text-green-600 hover:text-green-800 p-1 rounded-full hover:bg-green-100 transition-colors duration-200"
>
<CheckIcon class="h-4 w-4" />
</button>
<button
@click="cancelEditingTitle(chapter)"
class="text-red-600 hover:text-red-800 p-1 rounded-full hover:bg-red-100 transition-colors duration-200"
>
<XMarkIcon class="h-4 w-4" />
</button>
</div>
</div>
<div class="flex items-center space-x-2">
<button
@click="assignToVolume(chapter)"
class="bg-green-600 text-white px-3 py-1.5 rounded-lg text-xs font-medium hover:bg-green-700 shadow-sm transition-colors duration-200"
>
Assigner
</button>
</div>
<div v-if="chapter.isModified" class="w-3 h-3 bg-yellow-400 rounded-full shadow-sm"></div>
</div>
</div>
</div>
</div>
<!-- Volumes avec style Material Design -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<div
v-for="volume in volumes"
:key="volume.number"
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 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 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">
<button
@click="toggleVolumeExpanded(volume)"
class="w-8 h-8 rounded-full bg-green-100 hover:bg-green-200 flex items-center justify-center transition-colors duration-200"
>
<ChevronDownIcon v-if="volume.isExpanded" class="h-4 w-4 text-green-600" />
<ChevronRightIcon v-else class="h-4 w-4 text-green-600" />
</button>
<button
@click="deleteVolume(volume.number)"
class="text-red-600 hover:text-red-800 text-sm font-medium hover:bg-red-100 px-3 py-1.5 rounded-lg transition-colors duration-200"
>
Supprimer
</button>
</div>
</div>
</div>
<!-- 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 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 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
v-for="chapter in volume.chapters"
:key="chapter.id"
class="flex items-center space-x-3 p-3 hover:bg-gray-50 rounded-lg transition-colors duration-200 border border-transparent hover:border-gray-200"
:class="{ 'bg-green-50 border-green-200 shadow-sm': isChapterSelected(chapter) }"
>
<!-- Checkbox de sélection -->
<div class="relative">
<input
type="checkbox"
:checked="isChapterSelected(chapter)"
@change="toggleChapterSelection(chapter)"
class="h-5 w-5 text-green-600 border-gray-300 rounded focus:ring-green-500 focus:ring-2 transition-colors duration-200"
/>
</div>
<DocumentIcon class="h-5 w-5 text-gray-400" />
<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 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' }}
</span>
<button
@click="startEditingTitle(chapter)"
class="ml-2 text-gray-400 hover:text-gray-600 p-1 rounded-full hover:bg-gray-100 transition-colors duration-200"
>
<PencilIcon class="h-3 w-3" />
</button>
</div>
<div v-else class="flex items-center space-x-2">
<input
v-model="chapter.editingTitle"
type="text"
class="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
@keyup.enter="saveTitle(chapter)"
@keyup.esc="cancelEditingTitle(chapter)"
ref="titleInput"
/>
<button
@click="saveTitle(chapter)"
class="text-green-600 hover:text-green-800 p-1 rounded-full hover:bg-green-100 transition-colors duration-200"
>
<CheckIcon class="h-4 w-4" />
</button>
<button
@click="cancelEditingTitle(chapter)"
class="text-red-600 hover:text-red-800 p-1 rounded-full hover:bg-red-100 transition-colors duration-200"
>
<XMarkIcon class="h-4 w-4" />
</button>
</div>
</div>
<div class="flex items-center space-x-2">
<button
@click="removeFromVolume(chapter)"
class="text-red-600 hover:text-red-800 text-xs font-medium hover:bg-red-100 px-3 py-1.5 rounded-lg transition-colors duration-200"
>
Retirer
</button>
</div>
<div v-if="chapter.isModified" class="w-3 h-3 bg-yellow-400 rounded-full shadow-sm"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer Material Design -->
<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 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>
<button
@click="handleSave"
:disabled="isSaving || !hasChanges"
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
>
<span v-if="isSaving" class="flex items-center space-x-2">
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Sauvegarde...</span>
</span>
<span v-else>Sauvegarder</span>
</button>
</div>
</div>
</div>
</div>
<!-- Modal de création de volume Material Design -->
<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 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 dark:text-gray-100">Créer un nouveau volume</h3>
</div>
</div>
<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 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 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>
<div v-if="volumeExists" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center space-x-2">
<XMarkIcon class="h-4 w-4 text-red-600" />
<span class="text-sm">Ce volume existe déjà.</span>
</div>
<div v-if="newVolumeNumber && !isValidVolumeNumber" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center space-x-2">
<XMarkIcon class="h-4 w-4 text-red-600" />
<span class="text-sm">Le numéro de volume doit être entre 1 et 999.</span>
</div>
</div>
</div>
<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"
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 transition-all duration-200 shadow-sm hover:shadow-md"
>
Annuler
</button>
<button
@click="createVolume"
:disabled="!isValidVolumeNumber || volumeExists"
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
>
Créer
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal d'assignation Material Design -->
<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 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" />
</div>
<h3 class="text-lg font-medium text-gray-900">Assigner le chapitre {{ selectedChapter?.number }}</h3>
</div>
</div>
<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>
<select
v-model="selectedVolumeForAssignment"
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"
>
<option value="">Sélectionner un volume</option>
<option v-for="volume in volumes" :key="volume.number" :value="volume.number">
Volume {{ volume.number }}
</option>
</select>
</div>
</div>
</div>
<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"
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 transition-all duration-200 shadow-sm hover:shadow-md"
>
Annuler
</button>
<button
@click="confirmAssignToVolume"
:disabled="!selectedVolumeForAssignment"
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
>
Assigner
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal de déplacement multiple Material Design -->
<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 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" />
</div>
<h3 class="text-lg font-medium text-gray-900">Déplacer {{ selectedChapters.length }} chapitre(s)</h3>
</div>
</div>
<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">
Chapitres sélectionnés : {{ selectedChapters.map(c => c.number).join(', ') }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Volume de destination</label>
<select
v-model="selectedVolumeForMove"
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"
>
<option value="">Sélectionner un volume</option>
<option v-for="volume in volumes" :key="volume.number" :value="volume.number">
Volume {{ volume.number }}
</option>
<option value="unassigned">Chapitres non assignés</option>
</select>
</div>
</div>
</div>
<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"
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 transition-all duration-200 shadow-sm hover:shadow-md"
>
Annuler
</button>
<button
@click="confirmMoveToVolume"
:disabled="!selectedVolumeForMove"
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
>
Déplacer
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal de séparation du volume fourre-tout Material Design -->
<div v-if="showSplitVolumeZeroModal" 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="showSplitVolumeZeroModal = 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-lg 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="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" />
</div>
<h3 class="text-lg font-medium text-gray-900">Séparer le volume 00</h3>
</div>
</div>
<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">
Le volume 00 contient {{ volumeZeroChapters.length }} chapitres et sera séparé en {{ numberOfNewVolumes }} nouveaux volumes.
</p>
</div>
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
<p class="text-sm text-green-800">
<strong>Moyenne des autres volumes :</strong> {{ averageChaptersPerVolume.toFixed(1) }} chapitres par volume
</p>
<p class="text-sm text-green-800 mt-1">
<strong>Chapitres par nouveau volume :</strong> {{ chaptersPerNewVolume }}
</p>
</div>
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-700">Répartition proposée :</h4>
<div class="space-y-2 max-h-32 overflow-y-auto">
<div v-for="(group, index) in proposedVolumeGroups" :key="index" class="text-sm text-gray-600 bg-gray-50 p-3 rounded-lg border border-gray-200">
<strong>Volume {{ group.volumeNumber }} :</strong>
Chapitres {{ group.chapters.map(c => c.number).join(', ') }}
</div>
</div>
</div>
</div>
</div>
<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"
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 transition-all duration-200 shadow-sm hover:shadow-md"
>
Annuler
</button>
<button
@click="confirmSplitVolumeZero"
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 shadow-md hover:shadow-lg"
>
Confirmer la séparation
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
ArrowPathIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
DocumentIcon,
FolderIcon,
PencilIcon,
PlusIcon,
XMarkIcon
} from '@heroicons/vue/24/outline';
import { computed, nextTick, ref, watch } from 'vue';
const props = defineProps({
isOpen: {
type: Boolean,
required: true
},
manga: {
type: Object,
default: null
},
chapters: {
type: Array,
default: () => []
},
isLoading: {
type: Boolean,
default: false
},
isSaving: {
type: Boolean,
default: false
},
error: {
type: String,
default: null
}
});
const emit = defineEmits(['close', 'save']);
// État local
const localChapters = ref([]);
const showCreateVolumeModal = ref(false);
const showAssignModal = ref(false);
const showMoveToVolumeModal = ref(false);
const showSplitVolumeZeroModal = ref(false);
const showUnassignedChapters = ref(true);
const newVolumeNumber = ref('');
const selectedChapter = ref(null);
const selectedVolumeForAssignment = ref('');
const selectedVolumeForMove = ref('');
const titleInput = ref(null);
const expandedVolumes = ref(new Set());
const selectedChapters = ref([]);
// Computed properties
const volumes = computed(() => {
const volumeMap = new Map();
// Ajouter les volumes existants avec leurs chapitres
localChapters.value.forEach(chapter => {
if (chapter.volume) {
if (!volumeMap.has(chapter.volume)) {
volumeMap.set(chapter.volume, {
number: chapter.volume,
chapters: []
});
}
volumeMap.get(chapter.volume).chapters.push(chapter);
}
});
// Ajouter les volumes vides qui sont dans expandedVolumes
expandedVolumes.value.forEach(volumeNumber => {
if (!volumeMap.has(volumeNumber)) {
volumeMap.set(volumeNumber, {
number: volumeNumber,
chapters: []
});
}
});
const volumesArray = Array.from(volumeMap.values())
.sort((a, b) => b.number - a.number) // Tri décroissant (derniers volumes en premier)
.map(volume => ({
...volume,
chapters: volume.chapters.sort((a, b) => b.number - a.number), // Chapitres décroissants
isExpanded: expandedVolumes.value.has(volume.number)
}));
return volumesArray;
});
const unassignedChapters = computed(() => {
return localChapters.value
.filter(chapter => !chapter.volume)
.sort((a, b) => b.number - a.number); // Tri décroissant (derniers chapitres en premier)
});
const totalChapters = computed(() => localChapters.value.length);
const volumeExists = computed(() => {
if (!newVolumeNumber.value) return false;
const volumeNumber = parseInt(newVolumeNumber.value);
return volumes.value.some(volume => volume.number === volumeNumber);
});
const isValidVolumeNumber = computed(() => {
if (!newVolumeNumber.value) return false;
const volumeNumber = parseInt(newVolumeNumber.value);
return volumeNumber > 0 && volumeNumber <= 999;
});
const hasChanges = computed(() => {
return localChapters.value.some(chapter => chapter.isModified);
});
// Computed properties pour la séparation du volume fourre-tout
const volumeZeroChapters = computed(() => {
return localChapters.value.filter(chapter => chapter.volume === 0);
});
const hasVolumeZero = computed(() => {
return volumeZeroChapters.value.length > 0;
});
const otherVolumes = computed(() => {
return volumes.value.filter(volume => volume.number !== 0 && volume.chapters.length > 0);
});
const averageChaptersPerVolume = computed(() => {
if (otherVolumes.value.length === 0) return 10; // Valeur par défaut si aucun autre volume
const totalChapters = otherVolumes.value.reduce((sum, volume) => sum + volume.chapters.length, 0);
return totalChapters / otherVolumes.value.length;
});
const canSplitVolumeZero = computed(() => {
return hasVolumeZero.value && volumeZeroChapters.value.length > averageChaptersPerVolume.value;
});
const numberOfNewVolumes = computed(() => {
if (!canSplitVolumeZero.value) return 0;
return Math.ceil(volumeZeroChapters.value.length / averageChaptersPerVolume.value);
});
const chaptersPerNewVolume = computed(() => {
if (!canSplitVolumeZero.value) return 0;
return Math.ceil(volumeZeroChapters.value.length / numberOfNewVolumes.value);
});
const proposedVolumeGroups = computed(() => {
if (!canSplitVolumeZero.value) return [];
const chapters = [...volumeZeroChapters.value].sort((a, b) => a.number - b.number); // Tri croissant par numéro
const groups = [];
const chaptersPerGroup = chaptersPerNewVolume.value;
// Trouver le prochain numéro de volume disponible
const existingVolumeNumbers = volumes.value.map(v => v.number).filter(n => n !== 0);
const maxVolumeNumber = existingVolumeNumbers.length > 0 ? Math.max(...existingVolumeNumbers) : 0;
for (let i = 0; i < numberOfNewVolumes.value; i++) {
const startIndex = i * chaptersPerGroup;
const endIndex = Math.min(startIndex + chaptersPerGroup, chapters.length);
const groupChapters = chapters.slice(startIndex, endIndex);
if (groupChapters.length > 0) {
groups.push({
volumeNumber: maxVolumeNumber + i + 1,
chapters: groupChapters
});
}
}
return groups;
});
// Méthodes
const handleClose = () => {
if (!props.isSaving) {
emit('close');
}
};
const handleSave = () => {
const modifiedChapters = localChapters.value
.filter(chapter => chapter.isModified)
.map(chapter => ({
id: chapter.id,
title: chapter.title,
volume: chapter.volume || null
}));
if (modifiedChapters.length > 0) {
emit('save', modifiedChapters);
}
};
const toggleVolumeExpanded = (volume) => {
if (expandedVolumes.value.has(volume.number)) {
expandedVolumes.value.delete(volume.number);
} else {
expandedVolumes.value.add(volume.number);
}
};
const createVolume = () => {
if (newVolumeNumber.value && !volumeExists.value && isValidVolumeNumber.value) {
const volumeNumber = parseInt(newVolumeNumber.value);
// Ajouter le nouveau volume à la liste des volumes dépliés
expandedVolumes.value.add(volumeNumber);
// Fermer la modale et réinitialiser
showCreateVolumeModal.value = false;
newVolumeNumber.value = '';
}
};
const deleteVolume = (volumeNumber) => {
localChapters.value.forEach(chapter => {
if (chapter.volume === volumeNumber) {
chapter.volume = null;
chapter.isModified = true;
}
});
};
const assignToVolume = (chapter) => {
selectedChapter.value = chapter;
selectedVolumeForAssignment.value = '';
showAssignModal.value = true;
};
const confirmAssignToVolume = () => {
if (selectedChapter.value && selectedVolumeForAssignment.value) {
selectedChapter.value.volume = parseInt(selectedVolumeForAssignment.value);
selectedChapter.value.isModified = true;
showAssignModal.value = false;
selectedChapter.value = null;
selectedVolumeForAssignment.value = '';
}
};
const removeFromVolume = (chapter) => {
chapter.volume = null;
chapter.isModified = true;
};
const startEditingTitle = async (chapter) => {
chapter.isEditing = true;
chapter.editingTitle = chapter.title || '';
await nextTick();
if (titleInput.value) {
titleInput.value.focus();
}
};
const saveTitle = (chapter) => {
if (chapter.editingTitle !== chapter.title) {
chapter.title = chapter.editingTitle;
chapter.isModified = true;
}
chapter.isEditing = false;
chapter.editingTitle = '';
};
const cancelEditingTitle = (chapter) => {
chapter.isEditing = false;
chapter.editingTitle = '';
};
// Méthodes de sélection multiple
const isChapterSelected = (chapter) => {
return selectedChapters.value.some(selected => selected.id === chapter.id);
};
const toggleChapterSelection = (chapter) => {
const index = selectedChapters.value.findIndex(selected => selected.id === chapter.id);
if (index > -1) {
selectedChapters.value.splice(index, 1);
} else {
selectedChapters.value.push(chapter);
}
};
const clearSelection = () => {
selectedChapters.value = [];
};
const confirmMoveToVolume = () => {
if (selectedVolumeForMove.value && selectedChapters.value.length > 0) {
const targetVolume = selectedVolumeForMove.value === 'unassigned' ? null : parseInt(selectedVolumeForMove.value);
selectedChapters.value.forEach(chapter => {
chapter.volume = targetVolume;
chapter.isModified = true;
});
// Vider la sélection et fermer la modale
selectedChapters.value = [];
showMoveToVolumeModal.value = false;
selectedVolumeForMove.value = '';
}
};
const confirmSplitVolumeZero = () => {
if (!canSplitVolumeZero.value) return;
// Appliquer la répartition proposée
proposedVolumeGroups.value.forEach(group => {
group.chapters.forEach(chapter => {
chapter.volume = group.volumeNumber;
chapter.isModified = true;
});
});
// Fermer la modale
showSplitVolumeZeroModal.value = false;
};
// Initialiser les chapitres locaux quand les props changent
watch(() => props.chapters, (newChapters) => {
localChapters.value = newChapters.map(chapter => ({
...chapter,
isModified: false,
isEditing: false,
editingTitle: ''
}));
}, { immediate: true });
// Réinitialiser quand la modale s'ouvre
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
localChapters.value = props.chapters.map(chapter => ({
...chapter,
isModified: false,
isEditing: false,
editingTitle: ''
}));
showCreateVolumeModal.value = false;
showAssignModal.value = false;
showMoveToVolumeModal.value = false;
showSplitVolumeZeroModal.value = false;
showUnassignedChapters.value = true;
newVolumeNumber.value = '';
selectedChapter.value = null;
selectedVolumeForAssignment.value = '';
selectedVolumeForMove.value = '';
expandedVolumes.value.clear();
selectedChapters.value = [];
// S'assurer que le dernier volume est déplié après l'initialisation
nextTick(() => {
if (volumes.value.length > 0) {
expandedVolumes.value.add(volumes.value[0].number);
}
});
}
});
</script>

View File

@@ -0,0 +1,60 @@
<template>
<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>
<!-- 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>
import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
import { RouterLink } from 'vue-router';
defineProps({
manga: {
type: Object,
required: true
}
});
defineEmits(['edit', 'sources', 'refresh']);
</script>

View File

@@ -0,0 +1,157 @@
<template>
<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">{{ chapter.volumeChaptersRange }}</template>
<template v-else>{{ String(chapter.number).padStart(2, '0') }}</template>
</td>
<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
}
}">
<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 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">
<MagnifyingGlassIcon class="h-5 w-5" />
</button>
<button v-else @click="handleDelete" class="text-gray-500 hover:text-green-500">
<XMarkIcon class="h-5 w-5" />
</button>
<button @click="handleDownload" :class="downloadButtonClass" :disabled="isDownloading || !chapter.isAvailable">
<ArrowDownTrayIcon class="h-5 w-5" />
</button>
<button @click="handleHide" :class="hideButtonClass" :disabled="isHiding">
<TrashIcon class="h-5 w-5" />
</button>
</td>
</tr>
</template>
<script setup>
import { ArrowDownTrayIcon, MagnifyingGlassIcon, TrashIcon, XMarkIcon } from '@heroicons/vue/24/solid';
import { computed, ref, watch } from 'vue';
import { useMangaStore } from '../../application/store/mangaStore';
const props = defineProps({
chapter: {
type: Object,
required: true
},
mangaSlug: {
type: String,
required: true
},
mangaId: {
type: Number,
required: true
}
});
const store = useMangaStore();
const isLoading = ref(false);
const isDownloading = ref(false);
const isHiding = ref(false);
const buttonClass = computed(() => {
return isLoading.value ? 'text-yellow-500 cursor-wait' : 'text-gray-500 hover:text-green-500';
});
const downloadButtonClass = computed(() => {
if (isDownloading.value) {
return 'text-yellow-500 cursor-wait';
}
if (!props.chapter.isAvailable) {
return 'text-gray-300 cursor-not-allowed';
}
return 'text-gray-500 hover:text-green-500';
});
const hideButtonClass = computed(() => {
return isHiding.value ? 'text-yellow-500 cursor-wait' : 'text-gray-500 hover:text-green-500';
});
// Surveiller les changements d'état du chapitre
watch(
() => props.chapter.isAvailable,
(newValue, oldValue) => {
console.log(
`MangaChapter: État du chapitre ${props.chapter.number} (ID: ${props.chapter.id}) modifié - ${oldValue} => ${newValue}`
);
// Si le chapitre devient disponible, on arrête le chargement
if (newValue === true) {
isLoading.value = false;
}
}
);
const handleSearch = async () => {
try {
console.log(`MangaChapter: Recherche du chapitre ${props.chapter.number} (ID: ${props.chapter.id})`);
// Montrer l'indicateur de chargement
isLoading.value = true;
// Lancer la recherche du chapitre - L'UI sera mise à jour par l'événement Mercure
await store.searchChapter(props.chapter.id);
} catch (error) {
// En cas d'erreur, on arrête le chargement
isLoading.value = false;
console.error('Erreur lors de la recherche du chapitre:', error);
}
};
const handleDelete = async () => {
try {
console.log(`MangaChapter: Suppression du chapitre ${props.chapter.number} (ID: ${props.chapter.id})`);
await store.deleteChapter(props.chapter.id);
} catch (error) {
console.error('Erreur lors de la suppression du chapitre:', error);
}
};
const handleDownload = async () => {
try {
console.log(`MangaChapter: Téléchargement du chapitre ${props.chapter.number} (ID: ${props.chapter.id})`);
// Montrer l'indicateur de chargement
isDownloading.value = true;
await store.downloadChapter(props.chapter.id);
} catch (error) {
console.error('Erreur lors du téléchargement du chapitre:', error);
} finally {
// Arrêter l'indicateur de chargement
isDownloading.value = false;
}
};
const handleHide = async () => {
try {
console.log(`MangaChapter: Masquage du chapitre ${props.chapter.number} (ID: ${props.chapter.id})`);
// Montrer l'indicateur de chargement
isHiding.value = true;
await store.hideChapter(props.chapter.id, props.mangaId);
} catch (error) {
console.error('Erreur lors du masquage du chapitre:', error);
} finally {
// Arrêter l'indicateur de chargement
isHiding.value = false;
}
};
</script>

View File

@@ -0,0 +1,40 @@
<template>
<div class="p-2 border-t dark:border-gray-700">
<table class="min-w-full table-auto">
<thead>
<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>
</tr>
</thead>
<tbody>
<MangaChapter
v-for="chapter in chapters"
:key="chapter.id"
:chapter="chapter"
:manga-slug="mangaSlug"
:manga-id="mangaId" />
</tbody>
</table>
</div>
</template>
<script setup>
import MangaChapter from './MangaChapter.vue';
defineProps({
chapters: {
type: Array,
required: true
},
mangaSlug: {
type: String,
required: true
},
mangaId: {
type: Number,
required: true
}
});
</script>

View File

@@ -0,0 +1,128 @@
<template>
<TransitionRoot as="template" :show="isOpen">
<Dialog as="div" class="relative z-50" @close="closeModal">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<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">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
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 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 dark:text-gray-100">
Supprimer le manga
</DialogTitle>
</div>
<!-- Error state -->
<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>
<!-- Warning message -->
<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 dark:text-gray-100">Action irréversible</span>
</div>
<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 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 dark:text-yellow-300">
Attention
</h3>
<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>
<li>Tous les chapitres associés</li>
<li>Tous les fichiers CBZ téléchargés</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="mt-6 flex justify-end space-x-3">
<button
type="button"
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"
>
Annuler
</button>
<button
type="button"
class="inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
@click="confirmDelete"
:disabled="isLoading"
>
<ArrowPathIcon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
{{ isLoading ? 'Suppression...' : 'Supprimer définitivement' }}
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
import { ArrowPathIcon, ExclamationTriangleIcon } from '@heroicons/vue/24/outline';
const props = defineProps({
isOpen: {
type: Boolean,
required: true
},
manga: {
type: Object,
default: null
},
isLoading: {
type: Boolean,
default: false
},
error: {
type: Object,
default: null
}
});
const emit = defineEmits(['close', 'confirm']);
const closeModal = () => {
emit('close');
};
const confirmDelete = () => {
emit('confirm');
};
</script>

View File

@@ -0,0 +1,371 @@
<template>
<TransitionRoot as="template" :show="isOpen">
<Dialog as="div" class="relative z-50" @close="closeModal">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<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">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
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 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 dark:text-gray-100">
Edit Manga
</DialogTitle>
</div>
<!-- Error state -->
<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>
<!-- Form -->
<form @submit.prevent="saveChanges" class="space-y-6">
<!-- 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 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 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 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 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 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 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 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 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>
<!-- 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 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 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 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 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>
</div>
<!-- Note -->
<div>
<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"
type="number"
min="0"
max="10"
step="0.001"
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 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 dark:bg-green-900/40 text-green-800 dark:text-green-300"
>
{{ slug }}
<button
type="button"
@click="removeAlternativeSlug(index)"
class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full text-green-400 hover:text-green-600"
>
<XMarkIcon class="w-3 h-3" />
</button>
</span>
</div>
<button
type="button"
@click="showAlternativeSlugInput = !showAlternativeSlugInput"
class="text-green-600 hover:text-green-700 text-sm font-medium"
>
+ Ajouter un slug alternatif
</button>
<div v-if="showAlternativeSlugInput" class="flex gap-2">
<input
v-model="newAlternativeSlug"
type="text"
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"
/>
<button
type="button"
@click="addAlternativeSlug"
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
>
Ajouter
</button>
</div>
</div>
</div>
<!-- Genres -->
<div>
<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 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 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
>
<XMarkIcon class="w-3 h-3" />
</button>
</span>
</div>
<button
type="button"
@click="showGenreInput = !showGenreInput"
class="text-green-600 hover:text-green-700 text-sm font-medium"
>
+ Ajouter un genre
</button>
<div v-if="showGenreInput" class="flex gap-2">
<input
v-model="newGenre"
type="text"
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"
/>
<button
type="button"
@click="addGenre"
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
>
Ajouter
</button>
</div>
</div>
</div>
</form>
<!-- Boutons -->
<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 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"
>
Cancel
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving"
@click="saveChanges"
>
<div v-if="isSaving" class="flex items-center">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</div>
<span v-else>Save</span>
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
import { XMarkIcon } from '@heroicons/vue/24/outline';
import { ref, watch } from 'vue';
const props = defineProps({
isOpen: {
type: Boolean,
required: true
},
manga: {
type: Object,
default: null
},
isSaving: {
type: Boolean,
default: false
},
error: {
type: Object,
default: null
}
});
const emit = defineEmits(['close', 'save']);
// Données du formulaire
const formData = ref({
title: '',
description: '',
author: '',
publicationYear: null,
status: '',
rating: null,
genres: [],
alternativeSlugs: []
});
// Contrôle de l'affichage des inputs
const showGenreInput = ref(false);
const showAlternativeSlugInput = ref(false);
// Champs temporaires pour ajouter des genres et slugs
const newGenre = ref('');
const newAlternativeSlug = ref('');
// Initialiser le formulaire avec les données du manga
watch(() => props.manga, (newManga) => {
if (newManga) {
formData.value = {
title: newManga.title || '',
description: newManga.description || '',
author: newManga.author || '',
publicationYear: newManga.publicationYear || null,
status: newManga.status || '',
rating: newManga.rating || null,
genres: Array.isArray(newManga.genres) ? [...newManga.genres] : [],
alternativeSlugs: Array.isArray(newManga.alternativeSlugs) ? [...newManga.alternativeSlugs] : []
};
}
}, { immediate: true });
const closeModal = () => {
// Réinitialiser les états d'affichage
showGenreInput.value = false;
showAlternativeSlugInput.value = false;
newGenre.value = '';
newAlternativeSlug.value = '';
emit('close');
};
const saveChanges = () => {
// Nettoyer les données avant de les envoyer
const dataToSave = {
title: formData.value.title || undefined,
description: formData.value.description || undefined,
author: formData.value.author || undefined,
publicationYear: formData.value.publicationYear || undefined,
status: formData.value.status || undefined,
rating: formData.value.rating || undefined,
genres: formData.value.genres.length > 0 ? formData.value.genres : undefined,
alternativeSlugs: formData.value.alternativeSlugs.length > 0 ? formData.value.alternativeSlugs : undefined
};
// Supprimer les valeurs undefined
Object.keys(dataToSave).forEach(key => {
if (dataToSave[key] === undefined) {
delete dataToSave[key];
}
});
emit('save', dataToSave);
};
const addGenre = () => {
if (newGenre.value.trim() && !formData.value.genres.includes(newGenre.value.trim())) {
formData.value.genres.push(newGenre.value.trim());
newGenre.value = '';
showGenreInput.value = false;
}
};
const removeGenre = (index) => {
formData.value.genres.splice(index, 1);
};
const addAlternativeSlug = () => {
if (newAlternativeSlug.value.trim() && !formData.value.alternativeSlugs.includes(newAlternativeSlug.value.trim())) {
formData.value.alternativeSlugs.push(newAlternativeSlug.value.trim());
newAlternativeSlug.value = '';
showAlternativeSlugInput.value = false;
}
};
const removeAlternativeSlug = (index) => {
formData.value.alternativeSlugs.splice(index, 1);
};
</script>

View File

@@ -0,0 +1,96 @@
<template>
<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 { 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
}
});
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,102 @@
<template>
<div class="shadow-lg text-white">
<div class="relative h-64 sm:h-80 lg:h-96 bg-cover bg-center" :style="{ backgroundImage: `url('${manga.imageUrl}')` }">
<div class="absolute inset-0 bg-black opacity-50"></div>
<div class="absolute inset-0 flex flex-col lg:flex-row justify-center p-4 lg:p-6">
<!-- Image de couverture - cachée sur mobile, visible sur desktop -->
<div class="hidden lg:block mr-12">
<img :src="manga.thumbnailUrl" :alt="manga.title" class="max-w-48 lg:max-w-72 max-h-48 lg:max-h-72" />
</div>
<!-- Informations du manga -->
<div class="flex flex-col space-y-3 lg:space-y-4 flex-1 min-w-0">
<div class="flex items-start lg:items-center space-x-3">
<BookmarkIcon class="h-6 w-6 lg:h-8 lg:w-8 text-white flex-shrink-0 mt-1 lg:mt-0" />
<h1 class="text-xl sm:text-2xl lg:text-3xl font-bold leading-tight">{{ manga.title }}</h1>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm lg:text-base">
<span>{{ manga.year }}</span>
<span>Chapitres: {{ manga.totalChapters }}</span>
</div>
<div class="flex items-start lg:items-center space-x-2">
<FolderIcon class="h-5 w-5 lg:h-6 lg:w-6 text-gray-400 flex-shrink-0 mt-0.5 lg:mt-0" />
<div class="flex flex-col lg:flex-row lg:items-center lg:space-x-4 min-w-0 flex-1">
<span class="truncate text-sm lg:text-base">/media/mangas/{{ manga.title }} ({{ manga.year }})</span>
<span class="bg-green-600 py-1 px-2 rounded text-xs lg:text-sm self-start lg:self-auto">{{ manga.status || 'Terminé' }}</span>
</div>
</div>
<div class="flex flex-wrap gap-2" v-if="manga.tags?.length">
<template v-for="(tag, index) in manga.tags.slice(0, isMobile ? 3 : 5)" :key="index">
<span class="bg-gray-700 py-1 px-2 rounded-sm text-xs lg:text-sm">{{ tag }}</span>
</template>
<span v-if="manga.tags.length > (isMobile ? 3 : 5)" class="bg-gray-700 py-1 px-2 rounded-sm text-xs lg:text-sm">...</span>
</div>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<HeartIcon class="h-5 w-5 lg:h-6 lg:w-6 text-red-500" />
<span class="text-sm lg:text-base">{{ manga.rating }}</span>
</div>
<p class="text-sm lg:text-base line-clamp-3 lg:line-clamp-5">{{ manga.description }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { BookmarkIcon, FolderIcon, HeartIcon } from '@heroicons/vue/24/outline';
import { computed, onMounted, onUnmounted, ref } from 'vue';
defineProps({
manga: {
type: Object,
required: true
}
});
// Détection du mobile
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 1024);
const updateWindowWidth = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => {
window.addEventListener('resize', updateWindowWidth);
});
onUnmounted(() => {
window.removeEventListener('resize', updateWindowWidth);
});
</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;
}
}
@supports (-webkit-line-clamp: 5) {
.line-clamp-5 {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 5;
line-clamp: 5;
-webkit-box-orient: vertical;
}
}
</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

@@ -0,0 +1,277 @@
<template>
<TransitionRoot as="template" :show="isOpen">
<Dialog as="div" class="relative z-50" @close="closeModal">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<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">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
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 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 dark:text-gray-100">
Sources préférées
</DialogTitle>
<div class="mt-2">
<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>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoading" class="mt-5 flex justify-center items-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
<!-- Error state -->
<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 dark:text-gray-400">
Aucune source disponible
</div>
<div v-else class="space-y-3">
<div
v-for="(source, index) in localSources"
:key="source.id"
: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 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
}
]"
draggable="true"
@dragstart="handleDragStart(index, $event)"
@dragover="handleDragOver(index, $event)"
@dragleave="handleDragLeave"
@drop="handleDrop(index, $event)"
@dragend="handleDragEnd"
@mousedown="handleMouseDown(index)"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
>
<!-- Badge de priorité -->
<div class="absolute -top-2 -left-2 z-10">
<div :class="[
'inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold text-white shadow-lg',
{
'bg-gradient-to-r from-blue-500 to-indigo-600': index === 0,
'bg-gradient-to-r from-green-500 to-emerald-600': index === 1,
'bg-gradient-to-r from-yellow-500 to-amber-600': index === 2,
'bg-gradient-to-r from-gray-500 to-slate-600': index > 2
}
]">
{{ index + 1 }}
</div>
</div>
<!-- Indicateur de priorité -->
<div class="mr-4">
<div :class="[
'flex items-center space-x-1 px-3 py-1 rounded-full text-xs font-semibold',
{
'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>
<span v-else-if="index === 1">🥈 Priorité moyenne</span>
<span v-else-if="index === 2">🥉 Priorité basse</span>
<span v-else>Priorité {{ index + 1 }}</span>
</div>
</div>
<!-- Informations de la source -->
<div class="flex-1 min-w-0">
<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 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>
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="button"
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 sm:col-start-2 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || isLoading"
@click="saveChanges"
>
<div v-if="isSaving" class="flex items-center">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Sauvegarde...
</div>
<span v-else>Sauvegarder</span>
</button>
<button
type="button"
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"
>
Annuler
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
import { Cog6ToothIcon } from '@heroicons/vue/24/outline';
import { ref, watch } from 'vue';
const props = defineProps({
isOpen: {
type: Boolean,
required: true
},
sources: {
type: Array,
default: () => []
},
isLoading: {
type: Boolean,
default: false
},
error: {
type: Object,
default: null
},
isSaving: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['close', 'save']);
// Copie locale des sources pour le drag & drop
const localSources = ref([]);
// États pour le drag & drop
const draggedIndex = ref(null);
const dragOverIndex = ref(null);
// État pour l'effet de clic
const isPressed = ref(null);
// Watcher pour mettre à jour la copie locale quand les props changent
watch(
() => props.sources,
(newSources) => {
localSources.value = [...newSources];
},
{ immediate: true, deep: true }
);
const closeModal = () => {
emit('close');
};
// Fonctions pour l'effet de clic
const handleMouseDown = (index) => {
isPressed.value = index;
};
const handleMouseUp = () => {
isPressed.value = null;
};
// Fonctions pour le drag & drop
const handleDragStart = (index, event) => {
draggedIndex.value = index;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/html', event.target);
};
const handleDragOver = (index, event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
dragOverIndex.value = index;
};
const handleDragLeave = () => {
dragOverIndex.value = null;
};
const handleDrop = (dropIndex, event) => {
event.preventDefault();
if (draggedIndex.value !== null && draggedIndex.value !== dropIndex) {
const sources = [...localSources.value];
const draggedItem = sources[draggedIndex.value];
// Supprimer l'élément de sa position actuelle
sources.splice(draggedIndex.value, 1);
// L'insérer à la nouvelle position
// Si on drop après l'élément original, on doit ajuster l'index
const insertIndex = draggedIndex.value < dropIndex ? dropIndex - 1 : dropIndex;
sources.splice(insertIndex, 0, draggedItem);
localSources.value = sources;
}
draggedIndex.value = null;
dragOverIndex.value = null;
};
const handleDragEnd = () => {
draggedIndex.value = null;
dragOverIndex.value = null;
};
const saveChanges = () => {
// Extraire seulement les IDs dans l'ordre actuel
const sourceIds = localSources.value.map(source => source.id);
emit('save', sourceIds);
};
</script>

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

@@ -0,0 +1,178 @@
<template>
<div class="bg-white dark:bg-gray-800 rounded-sm shadow mb-2">
<!-- En-tête du volume -->
<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 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="[
'px-2 py-1 text-xs sm:text-sm rounded text-center text-white min-w-[3rem] sm:min-w-[4rem]',
{
'bg-red-500': volume.downloadedChapter === 0,
'bg-yellow-500':
volume.downloadedChapter < volume.totalChapter && volume.downloadedChapter > 0,
'bg-green-500': volume.downloadedChapter === volume.totalChapter
}
]">
{{ volume.downloadedChapter }}/{{ volume.totalChapter }}
</span>
</div>
</div>
<!-- Actions du volume -->
<div class="flex items-center space-x-1 sm:space-x-2">
<button
class="w-8 sm:w-10 h-8 sm:h-10 flex items-center justify-center"
@click="handleSearch"
:class="{
'text-yellow-500 cursor-wait': isSearching,
'text-gray-500 hover:text-green-500': !isSearching
}">
<MagnifyingGlassIcon class="h-5 w-5 sm:h-6 sm:w-6" />
</button>
<button
class="w-8 sm:w-10 h-8 sm:h-10 flex items-center justify-center"
@click="handleDownload"
:class="{
'text-yellow-500 cursor-wait': isDownloading,
'text-gray-500 hover:text-green-500': !isDownloading
}"
:disabled="isDownloading">
<ArrowDownTrayIcon class="h-5 w-5 sm:h-6 sm:w-6" />
</button>
</div>
</div>
<!-- Bouton toggle centré -->
<div class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
<button
@click="toggleVolume"
class="w-8 sm:w-10 h-8 sm:h-10 flex items-center justify-center">
<component
:is="isOpen ? ChevronUpIcon : ChevronDownIcon"
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"
/>
</button>
</div>
</div>
<!-- Liste des chapitres -->
<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 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"
/>
</button>
</div>
</div>
</template>
<script setup>
import {
ArrowDownTrayIcon,
BookmarkIcon,
ChevronDownIcon,
ChevronUpIcon,
MagnifyingGlassIcon
} from '@heroicons/vue/24/outline';
import { ref, watch } from 'vue';
import { useMangaStore } from '../../application/store/mangaStore';
import MangaChapterList from './MangaChapterList.vue';
const props = defineProps({
volume: {
type: Object,
required: true
},
mangaSlug: {
type: String,
required: true
},
mangaId: {
type: Number,
required: true
},
isOpen: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['toggle']);
const store = useMangaStore();
const isOpen = ref(props.isOpen);
const isSearching = ref(false);
const isDownloading = ref(false);
// Synchroniser l'état local avec la prop
watch(() => props.isOpen, (newValue) => {
isOpen.value = newValue;
});
const toggleVolume = () => {
isOpen.value = !isOpen.value;
emit('toggle', props.volume.number);
};
const handleSearch = async () => {
if (isSearching.value) return; // Éviter les clicks multiples
try {
isSearching.value = true;
console.log(
`Recherche du volume ${props.volume.number} - Lancement du scraping de ${props.volume.chapters.length} chapitres`
);
// Récupérer tous les chapitres non disponibles du volume
const chaptersToSearch = props.volume.chapters
.filter(chapter => !chapter.isAvailable)
.map(chapter => chapter.id);
if (chaptersToSearch.length === 0) {
console.log('Tous les chapitres sont déjà disponibles !');
isSearching.value = false;
return;
}
console.log(`Chapitres à scraper: ${chaptersToSearch.length}`);
// Lancer le scraping de chaque chapitre non disponible en séquentiel
for (const chapterId of chaptersToSearch) {
console.log(`Scraping du chapitre ${chapterId}...`);
await store.searchChapter(chapterId);
// Petite pause entre chaque requête pour éviter de surcharger le serveur
await new Promise(resolve => setTimeout(resolve, 500));
}
console.log(`Scraping des chapitres du volume ${props.volume.number} terminé`);
} catch (error) {
console.error(`Erreur lors du scraping du volume ${props.volume.number}:`, error);
} finally {
isSearching.value = false;
}
};
const handleDownload = async () => {
try {
console.log(`MangaVolume: Téléchargement du volume ${props.volume.number} (Manga ID: ${props.mangaId})`);
// Montrer l'indicateur de chargement
isDownloading.value = true;
await store.downloadVolume(props.mangaId, props.volume.number);
} catch (error) {
console.error('Erreur lors du téléchargement du volume:', error);
} finally {
// Arrêter l'indicateur de chargement
isDownloading.value = false;
}
};
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div>
<MangaVolume
v-for="(volume, index) in volumes"
:key="volume.number"
:volume="volume"
:mangaSlug="mangaSlug"
:mangaId="mangaId"
:isOpen="expandedVolumes.has(volume.number)"
@toggle="handleVolumeToggle" />
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import MangaVolume from './MangaVolume.vue';
const props = defineProps({
volumes: {
type: Array,
required: true
},
mangaSlug: {
type: String,
required: true
},
mangaId: {
type: Number,
required: true
},
expandAll: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:expandAll']);
// Set pour stocker les numéros de volumes ouverts
const expandedVolumes = ref(new Set());
// Initialiser avec le premier volume ouvert par défaut
watch(() => props.volumes, (newVolumes) => {
if (newVolumes.length > 0 && expandedVolumes.value.size === 0) {
// Ouvrir le premier volume (volume 00) par défaut
expandedVolumes.value.add(newVolumes[0].number);
}
}, { immediate: true });
// Gérer l'expansion de tous les volumes
watch(() => props.expandAll, (shouldExpand) => {
if (shouldExpand) {
// Ouvrir tous les volumes
props.volumes.forEach(volume => {
expandedVolumes.value.add(volume.number);
});
} else {
// Fermer tous les volumes (y compris le volume 00)
expandedVolumes.value.clear();
}
});
const handleVolumeToggle = (volumeNumber) => {
if (expandedVolumes.value.has(volumeNumber)) {
expandedVolumes.value.delete(volumeNumber);
} else {
expandedVolumes.value.add(volumeNumber);
}
// Émettre l'état d'expansion
const allExpanded = props.volumes.length > 0 &&
props.volumes.every(volume => expandedVolumes.value.has(volume.number));
emit('update:expandAll', allExpanded);
};
// Méthode publique pour contrôler l'expansion
const expandAllVolumes = () => {
props.volumes.forEach(volume => {
expandedVolumes.value.add(volume.number);
});
emit('update:expandAll', true);
};
const collapseAllVolumes = () => {
expandedVolumes.value.clear();
// Ne plus garder le premier volume ouvert, tous les volumes sont fermés
emit('update:expandAll', false);
};
// Exposer les méthodes pour le composant parent
defineExpose({
expandAllVolumes,
collapseAllVolumes
});
</script>

View File

@@ -0,0 +1,134 @@
<template>
<!-- Composant invisible qui écoute les événements Mercure -->
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, inject } from 'vue';
import { useMangaStore } from '../../application/store/mangaStore';
import { useQueryClient } from '@tanstack/vue-query';
const props = defineProps({
mangaId: {
type: String,
required: true
}
});
const store = useMangaStore();
const eventSource = ref(null);
// On récupère le client de requête pour invalider le cache
const queryClient = useQueryClient();
const setupMercureEventSource = () => {
if (eventSource.value) {
eventSource.value.close();
}
// Créer les topics à écouter
const topics = [`manga/${props.mangaId}/chapters`, 'scraping/status'];
// Construire l'URL du hub Mercure avec les topics
const mercureHubUrl = new URL('/.well-known/mercure', window.location.origin);
topics.forEach(topic => {
mercureHubUrl.searchParams.append('topic', topic);
});
console.log(`MercureListener: Abonnement aux topics pour manga ${props.mangaId}`);
console.log(`MercureListener: URL Mercure - ${mercureHubUrl.toString()}`);
// Créer la source d'événements
eventSource.value = new EventSource(mercureHubUrl, { withCredentials: true });
// Définir les gestionnaires d'événements
eventSource.value.onmessage = event => {
try {
console.log('MercureListener: Événement reçu', event.data);
const data = JSON.parse(event.data);
handleMercureEvent(data);
} catch (error) {
console.error("Erreur lors du traitement de l'événement Mercure:", error);
}
};
eventSource.value.onerror = error => {
console.error('Erreur de connexion à Mercure:', error);
// Tenter de reconnecter après un délai
setTimeout(() => {
if (eventSource.value) {
setupMercureEventSource();
}
}, 5000);
};
};
const handleMercureEvent = data => {
if (!data || !data.type) {
console.warn('MercureListener: Événement sans type reçu', data);
return;
}
switch (data.type) {
case 'chapter.scraped':
console.log(`MercureListener: Chapitre ${data.chapterNumber} scrappé avec succès!`, data);
// Vérifier que l'ID du chapitre est présent et au bon format
if (!data.chapterId) {
console.error("MercureListener: ID du chapitre manquant dans l'événement", data);
return;
}
// Mettre à jour l'état du chapitre dans le store
try {
// Mettre à jour le store Pinia
store.updateChapterAvailability(data.chapterId, true);
console.log(
`MercureListener: Chapitre ${data.chapterNumber} (ID: ${data.chapterId}) marqué comme disponible`
);
// Invalider le cache des requêtes pour forcer un rechargement frais
console.log(`MercureListener: Invalidation du cache pour les chapitres du manga ${props.mangaId}`);
queryClient.invalidateQueries(['manga', ref(props.mangaId), 'chapters']);
// Force le rechargement des chapitres via le store
setTimeout(() => {
console.log('MercureListener: Rechargement forcé des chapitres via le store');
store.loadChapters(props.mangaId);
}, 100);
} catch (error) {
console.error('MercureListener: Erreur lors de la mise à jour du chapitre', error);
}
break;
case 'chapter.scraping.failed':
console.error(`MercureListener: Échec du scraping du chapitre ${data.chapterNumber}:`, data.reason);
break;
default:
console.log('MercureListener: Événement Mercure non géré:', data);
}
};
watch(
() => props.mangaId,
(newMangaId, oldMangaId) => {
console.log(`MercureListener: MangaId changé de ${oldMangaId} à ${newMangaId}`);
if (newMangaId) {
setupMercureEventSource();
} else if (eventSource.value) {
eventSource.value.close();
eventSource.value = null;
}
}
);
onMounted(() => {
setupMercureEventSource();
});
onBeforeUnmount(() => {
if (eventSource.value) {
eventSource.value.close();
eventSource.value = null;
}
});
</script>

View File

@@ -0,0 +1,37 @@
import { computed } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
export function useMangaChapters(mangaId) {
const mangaRepository = new ApiMangaRepository();
const query = useQuery({
queryKey: ['manga', mangaId, 'chapters'],
queryFn: async () => {
if (!mangaId.value) {
return Promise.resolve([]); // Retourne un tableau vide si pas d'ID
}
console.log(`useMangaChapters: Chargement des chapitres pour le manga ${mangaId.value}`);
const response = await mangaRepository.getChapters(mangaId.value);
// Log pour déboguer
console.log(`useMangaChapters: ${response.items?.length || 0} chapitres chargés`);
// Assure de toujours retourner un tableau
return Array.isArray(response) ? response : response?.items ?? [];
},
// Refresh toutes les 30 secondes en arrière-plan
refetchInterval: 30000,
// S'assurer que si le composant est visible à nouveau, on récupère les données fraîches
refetchOnWindowFocus: true,
// Query activée uniquement si mangaId est défini
enabled: computed(() => !!mangaId.value),
// Options pour conserver les données entre les requêtes
staleTime: 60000, // Considère les données comme "périmées" après 1 minute
cacheTime: 5 * 60 * 1000 // Garde les données en cache pendant 5 minutes
});
// Retourne le résultat de useQuery (contenant data, isLoading, etc.)
return query;
}

View File

@@ -0,0 +1,49 @@
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { ref } from 'vue';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
export function useMangaDelete() {
const mangaRepository = new ApiMangaRepository();
const queryClient = useQueryClient();
const isDeleteModalOpen = ref(false);
const deleteMutation = useMutation({
mutationFn: ({ mangaId }) => {
return mangaRepository.deleteManga(mangaId);
},
onSuccess: (data, variables) => {
// Invalider et refetch les listes de mangas
queryClient.invalidateQueries({ queryKey: ['mangas'] });
queryClient.invalidateQueries({ queryKey: ['manga-search'] });
}
});
const openDeleteModal = () => {
isDeleteModalOpen.value = true;
};
const closeDeleteModal = () => {
isDeleteModalOpen.value = false;
};
const deleteManga = async (mangaId) => {
try {
await deleteMutation.mutateAsync({ mangaId });
closeDeleteModal();
return true;
} catch (error) {
console.error('Erreur lors de la suppression du manga:', error);
throw error;
}
};
return {
isDeleteModalOpen,
openDeleteModal,
closeDeleteModal,
deleteManga,
isLoading: deleteMutation.isPending,
error: deleteMutation.error,
isSuccess: deleteMutation.isSuccess
};
}

View File

@@ -0,0 +1,31 @@
import { computed } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
// Accepte un ID de manga (peut être une ref, un computed ref, ou une valeur simple)
export function useMangaDetails(mangaId) {
const mangaRepository = new ApiMangaRepository();
// Assure que mangaId est une ref ou une computed pour la réactivité de la queryKey
// Si ce n'est pas déjà le cas, mais généralement on passera une computed( () => route.params.id )
// const mangaIdRef = computed(() => unref(mangaId)); // unref est utile si on accepte des valeurs simples aussi
const query = useQuery({
// La queryKey doit être réactive à mangaId
queryKey: ['manga', mangaId], // mangaId est déjà une computed ref, donc c'est bon
queryFn: () => {
// Vérifier que l'ID a une valeur avant d'appeler l'API
if (!mangaId.value) {
// Retourner null ou undefined si pas d'ID, pour éviter un appel API invalide
// TanStack Query gère aussi l'option 'enabled' pour cela
return Promise.resolve(null); // ou throw new Error("ID manquant");
}
return mangaRepository.getMangaById(mangaId.value);
},
// Activer la requête seulement si mangaId a une valeur truthy
enabled: computed(() => !!mangaId.value)
});
// Retourne tous les états et données fournis par useQuery
return query;
}

View File

@@ -0,0 +1,48 @@
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { ref } from 'vue';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
export function useMangaEdit() {
const mangaRepository = new ApiMangaRepository();
const queryClient = useQueryClient();
const isEditModalOpen = ref(false);
const editMutation = useMutation({
mutationFn: ({ mangaId, updateData }) => {
return mangaRepository.editManga(mangaId, updateData);
},
onSuccess: (data, variables) => {
// Invalider et refetch les données du manga
queryClient.invalidateQueries({ queryKey: ['manga', variables.mangaId] });
queryClient.invalidateQueries({ queryKey: ['mangas'] });
}
});
const openEditModal = () => {
isEditModalOpen.value = true;
};
const closeEditModal = () => {
isEditModalOpen.value = false;
};
const editManga = async (mangaId, updateData) => {
try {
await editMutation.mutateAsync({ mangaId, updateData });
closeEditModal();
} catch (error) {
console.error('Erreur lors de l\'édition du manga:', error);
throw error;
}
};
return {
isEditModalOpen,
openEditModal,
closeEditModal,
editManga,
isLoading: editMutation.isPending,
error: editMutation.error,
isSuccess: editMutation.isSuccess
};
}

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