77 Commits

Author SHA1 Message Date
810e18c26c Merge pull request 'fix(mercure): utiliser la nouvelle syntaxe transport bolt pour Caddy' (#49) from fix/mercure-transport-directive into main
All checks were successful
Deploy / deploy (push) Successful in 1m11s
Reviewed-on: #49
2026-04-10 15:24:55 +02:00
Jérémy Guillot
1905581214 fix(mercure): utiliser la nouvelle syntaxe transport bolt pour Caddy
La directive transport_url a été supprimée dans les versions récentes
de Mercure, remplacée par un sous-bloc transport bolt { url ... }.
2026-04-10 15:23:42 +02:00
c0ab40eacd Merge pull request 'fix(manga): conserver le padding du numéro de chapitre après scraping' (#48) from fix/chapter-number-padding-after-scraping into main
All checks were successful
Deploy / deploy (push) Successful in 1m5s
Reviewed-on: #48
2026-04-09 15:11:56 +02:00
Jérémy Guillot
e214e1ea46 fix(manga): conserver le padding du numéro de chapitre après scraping 2026-04-09 15:11:23 +02:00
1f1efd1b16 Merge pull request 'fix(manga): générer le CBZ de téléchargement depuis les dossiers de pages' (#47) from fix/download-cbz-from-pages-directory into main
All checks were successful
Deploy / deploy (push) Successful in 1m11s
Reviewed-on: #47
2026-04-09 14:49:41 +02:00
Jérémy Guillot
41c1fc5e2e fix(manga): générer le CBZ de téléchargement depuis les dossiers de pages
Les endpoints de téléchargement chapitre/volume plantaient (500 "file does
not exist") car le FileService traitait `pagesDirectory` comme un CBZ. Le
service reconstruit maintenant l'archive à la volée à partir des images du
dossier, et le nom du fichier chapitre inclut le titre du manga et le numéro.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:48:17 +02:00
848efd3327 Merge pull request 'feat(home): toolbar filtre/affichage et modale options d'affichage' (#46) from feat/home-toolbar-display-settings into main
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Reviewed-on: #46
2026-03-27 16:26:33 +01:00
65eef59999 Merge branch 'main' into feat/home-toolbar-display-settings 2026-03-27 16:26:20 +01:00
ext.jeremy.guillot@maxicoffee.domains
e525c9b7bd feat(home): toolbar filtre/affichage et modale options d'affichage
- Correction du dropdown toolbar : prop align (left/right) pour éviter le débordement hors écran côté droit
- Filtre de collection par statut (all/completed/ongoing) persisté dans userPreferencesStore
- toolbarConfig rendu réactif (computed) avec isSelected sur Filter, Sort et View
- Modale Options d'affichage par vue (Grille, Overview, Table) avec toggles persistés
- Composant ToggleRow réutilisable
- Normalisation author → authors dans l'entité Manga (l'API renvoie author string)
2026-03-27 16:25:45 +01:00
8d8389377d Merge pull request 'fix(monitoring): corriger la résolution de l'ID chapitre après synchronisation MangaDex' (#45) from fix/monitoring-chapter-id-mismatch into main
All checks were successful
Deploy / deploy (push) Successful in 1m5s
Reviewed-on: #45
2026-03-27 15:04:20 +01:00
a9c5769c8e Merge branch 'main' into fix/monitoring-chapter-id-mismatch 2026-03-27 15:04:12 +01:00
ext.jeremy.guillot@maxicoffee.domains
969f4569f5 fix(monitoring): corriger la résolution de l'ID chapitre après synchronisation MangaDex
synchronizeChapters() retournait des UUID temporaires générés en mémoire. Ces UUID
n'étant jamais persistés, le Scraping domain ne pouvait pas retrouver le chapitre
(SQLSTATE 22P02 : invalid input syntax for type integer).

- ChapterSynchronizationServiceInterface : retourne float[] (numéros) au lieu de string[] (UUID)
- MangadxChapterSynchronizationService : retourne getNumber() au lieu de getId()
- RefreshMangaChaptersHandler : après save(), retrouve chaque chapitre par manga+numéro
  via findChapterByMangaIdAndNumber() pour obtenir le vrai PK integer avant de dispatcher
  ChapterReadyForScraping
2026-03-27 15:03:05 +01:00
13eac6954d Merge pull request 'fix(monitoring): ajouter le handler Symfony manquant pour CheckMonitoredMangas' (#44) from fix/monitoring-missing-symfony-handler into main
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Reviewed-on: #44
2026-03-27 14:36:25 +01:00
ext.jeremy.guillot@maxicoffee.domains
7e6bacd934 fix(monitoring): ajouter le handler Symfony manquant pour CheckMonitoredMangas
Sans ce wrapper #[AsMessageHandler], Messenger ne trouvait aucun handler pour
le message CheckMonitoredMangas — le scheduler et la commande console échouaient
silencieusement avec NoHandlerForMessageException.
2026-03-27 14:35:47 +01:00
d1279c90cc Merge pull request 'fix(deploy): corriger la race condition sur le cache prod au déploiement' (#43) from fix/deploy-cache-race-condition into main
All checks were successful
Deploy / deploy (push) Successful in 1m6s
Reviewed-on: #43
2026-03-27 14:29:06 +01:00
a0729d2e6e Merge branch 'main' into fix/deploy-cache-race-condition 2026-03-27 14:28:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
f47d1a245f fix(deploy): corriger la race condition sur le cache prod au déploiement
L'entrypoint faisait rm -rf var/cache/prod puis lançait FrankenPHP.
FrankenPHP compilait partiellement le container DI pendant que le script
Deployer lançait aussi cache:clear → fichiers manquants → crash.

- entrypoint.sh : ajouter cache:warmup après rm -rf, avant exec FrankenPHP
  (l'entrypoint est séquentiel, FrankenPHP ne démarre qu'une fois le cache prêt)
- deploy.php : supprimer le docker exec cache:clear devenu inutile et dangereux
2026-03-27 14:28:30 +01:00
78cc83d465 Merge pull request 'feat(monitoring): ajouter une commande console pour déclencher le monitoring manuellement' (#42) from feat/monitoring-run-command into main
Some checks failed
Deploy / deploy (push) Failing after 1m7s
Reviewed-on: #42
2026-03-27 14:23:30 +01:00
7204ea7754 Merge branch 'main' into feat/monitoring-run-command 2026-03-27 14:23:21 +01:00
ext.jeremy.guillot@maxicoffee.domains
f42b5a9cf5 feat(monitoring): ajouter une commande console pour déclencher le monitoring manuellement
Permet de tester le scheduler en prod sans attendre le cycle de 2h :
  make sf c="app:monitoring:run"
2026-03-27 14:21:05 +01:00
5edd28309f Merge pull request 'fix(monitoring): corriger le scheduler qui ne détectait plus les nouveaux chapitres' (#41) from fix/monitoring-scheduler-since-frozen into main
All checks were successful
Deploy / deploy (push) Successful in 1m8s
Reviewed-on: #41
2026-03-27 12:08:33 +01:00
ext.jeremy.guillot@maxicoffee.domains
3f08e1c899 fix(monitoring): corriger le scheduler qui ne détectait plus les nouveaux chapitres
- MonitoringSchedule : supprimer la date passée au message (était évaluée une
  seule fois au démarrage du container, rendant la requête caduque après le
  premier cycle)
- CheckMonitoredMangasHandler : calculer `since` dynamiquement à l'exécution
  (`new \DateTimeImmutable('-2 hours')`) plutôt que de dépendre du message
- AutoScrapingListener : corriger le TypeError silencieux — créer un ScrapingJob
  avant d'appeler ScrapeChapterHandler (paramètre jobId manquant)

Ajoute les tests unitaires CheckMonitoredMangasHandlerTest et AutoScrapingListenerTest.
2026-03-27 12:08:06 +01:00
214f470e77 Merge pull request 'fix(manga): afficher le titre du chapitre téléchargé individuellement' (#40) from fix/chapter-title-downloaded into main
All checks were successful
Deploy / deploy (push) Successful in 1m9s
Reviewed-on: #40
2026-03-27 11:30:16 +01:00
ext.jeremy.guillot@maxicoffee.domains
345434c25d fix(manga): afficher le titre du chapitre téléchargé individuellement
Quand un chapitre téléchargé est seul dans son groupe (volumeChapterCount === 1),
on affichait "Chapitre 42" au lieu du titre réel. La condition isVolumeGroup
s'appliquait même pour les groupes à un seul élément.

Fix : la mise en forme "Chapitres X-Y" n'est désormais appliquée que lorsque
volumeChapterCount > 1, sinon on affiche chapter.title comme pour les chapitres
non téléchargés.
2026-03-27 11:29:13 +01:00
2868772f5c Merge pull request 'fix(deploy): vider le cache prod au démarrage du conteneur' (#39) from fix/entrypoint-clear-cache into main
All checks were successful
Deploy / deploy (push) Successful in 1m9s
Reviewed-on: #39
2026-03-26 18:50:15 +01:00
a2469b6c07 Merge branch 'main' into fix/entrypoint-clear-cache 2026-03-26 18:50:08 +01:00
ext.jeremy.guillot@maxicoffee.domains
926f938c45 fix(deploy): vider le cache prod au démarrage du conteneur
Sans ce fix, les workers FrankenPHP démarrent avec l'ancien cache persisté
dans le volume Docker. Si les classes référencées (ex. EntityManagerGhost,
LazyGhostTrait) ne correspondent plus à la version déployée, les workers
crashent en boucle, rendant le conteneur instable et faisant échouer le
cache:clear lancé ensuite par Deployer (exit 137).

La suppression de var/cache/prod à l'entrypoint garantit que les workers
démarrent toujours sur un cache vierge, généré à chaud à la première requête.
2026-03-26 18:49:30 +01:00
5551d73962 Merge pull request 'fix: limiter les workers FrankenPHP et nettoyer le Dockerfile' (#38) from fix/cache-clear-oom into main
Some checks failed
Deploy / deploy (push) Failing after 35s
Reviewed-on: #38
2026-03-26 18:44:26 +01:00
395a0a16cb Merge branch 'main' into fix/cache-clear-oom 2026-03-26 18:44:17 +01:00
ext.jeremy.guillot@maxicoffee.domains
8e2e608ad9 fix: limiter les workers FrankenPHP et nettoyer le Dockerfile
- worker.Caddyfile : limiter à 2 workers FrankenPHP pour éviter l'OOM
  lors du cache:clear en prod (chaque worker charge le kernel Symfony
  complet, la valeur par défaut = nb de CPUs était trop élevée)
- Dockerfile : supprimer les COPY des assets UX (ux-live-component,
  ux-react, ux-turbo) supprimés de composer.json
2026-03-26 18:43:51 +01:00
0f80cb9fec Merge pull request 'fix(doctrine): supprimer auto_generate_proxy_classes et proxy_dir' (#37) from fix/doctrine-orm3-config into main
Some checks failed
Deploy / deploy (push) Failing after 36s
Reviewed-on: #37
2026-03-26 18:39:56 +01:00
a3477629fb Merge branch 'main' into fix/doctrine-orm3-config 2026-03-26 18:39:51 +01:00
ext.jeremy.guillot@maxicoffee.domains
cde701986e fix(doctrine): supprimer auto_generate_proxy_classes et proxy_dir
Ces options ont été supprimées de Doctrine Bundle 3.x / ORM 3.x.
Elles causaient une erreur "Unrecognized options" au cache:clear en prod.
2026-03-26 18:39:22 +01:00
b921768aef Merge pull request 'chore: supprimer les dépendances Twig/Stimulus/React/Turbo inutilisées' (#36) from chore/cleanup-unused-dependencies into main
Some checks failed
Deploy / deploy (push) Failing after 1m10s
Reviewed-on: #36
2026-03-26 18:36:11 +01:00
ext.jeremy.guillot@maxicoffee.domains
5f0178f784 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:35:40 +01:00
c610d22bd2 Merge pull request 'feature/upgrade-symfony-8' (#35) from feature/upgrade-symfony-8 into main
Some checks failed
Deploy / deploy (push) Failing after 21s
Reviewed-on: #35
2026-03-26 18:23:13 +01:00
ab2cf319ac Merge branch 'main' into feature/upgrade-symfony-8 2026-03-26 18:23:07 +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
4e30af6a16 Merge pull request 'refactor: supprimer tout le code legacy MVC/Twig/Stimulus' (#34) from refactor/remove-legacy-code into main
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Reviewed-on: #34
2026-03-26 17:01:34 +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
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
588 changed files with 10237 additions and 17419 deletions

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
@@ -108,9 +108,6 @@ RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scri
FROM node:22-alpine AS node_build FROM node:22-alpine AS node_build
WORKDIR /app WORKDIR /app
COPY --link package.json package-lock.json ./ 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 RUN npm install
COPY --link assets ./assets COPY --link assets ./assets
COPY --link webpack.config.js ./ COPY --link webpack.config.js ./

View File

@@ -1,17 +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';
// La ligne registerReactControllerComponents a déjà été commentée

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,30 +0,0 @@
{
"controllers": {
"@symfony/ux-live-component": {
"live": {
"enabled": true,
"fetch": "eager",
"autoimport": {
"@symfony/ux-live-component/dist/live.min.css": true
}
}
},
"@symfony/ux-react": {
"react": {
"enabled": true,
"fetch": "eager"
}
},
"@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://mangarr.test.nestor-server.fr/.well-known/mercure';
const eventSource = new EventSource(`${mercureHubUrl}?topic=activity`, {withCredentials: true});
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://mangarr.test.nestor-server.fr/.well-known/mercure';
this.eventSource = new EventSource(`${mercureHubUrl}?topic=activity`, {withCredentials: true});
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://mangarr.test.nestor-server.fr/.well-known/mercure';
const eventSource = new EventSource(`${mercureHubUrl}?topic=${topic}`, {withCredentials: true});
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

@@ -1,13 +1,17 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { Job } from '../../domain/entities/job';
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository'; import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
const jobRepository = new ApiJobRepository(); const jobRepository = new ApiJobRepository();
const ACTIVE_STATUSES = ['pending', 'in_progress'];
export const useActivityStore = defineStore('activity', { export const useActivityStore = defineStore('activity', {
state: () => ({ state: () => ({
jobs: [], jobs: [],
loading: false, loading: false,
error: null, error: null,
mercureEventSource: null,
// Pagination // Pagination
currentPage: 1, currentPage: 1,
totalPages: 0, totalPages: 0,
@@ -15,21 +19,15 @@ export const useActivityStore = defineStore('activity', {
limit: 20, limit: 20,
hasNextPage: false, hasNextPage: false,
hasPreviousPage: false, hasPreviousPage: false,
// Filtres // Tri
filter: { sortBy: 'createdAt',
status: ['pending', 'in_progress'], // Par défaut, ne montrer que les actifs sortOrder: 'DESC',
sortBy: 'createdAt',
sortOrder: 'DESC'
}
}), }),
getters: { getters: {
activeJobs: state => state.jobs.filter(job => job.isActive()), activeJobs: state => state.jobs.filter(job => job.isActive()),
completedJobs: state => state.jobs.filter(job => job.isCompleted()),
failedJobs: state => state.jobs.filter(job => job.hasError()),
isLoading: state => state.loading, isLoading: state => state.loading,
hasError: state => !!state.error, hasError: state => !!state.error,
// Getters pour la pagination
paginationInfo: state => ({ paginationInfo: state => ({
currentPage: state.currentPage, currentPage: state.currentPage,
totalPages: state.totalPages, totalPages: state.totalPages,
@@ -41,44 +39,25 @@ export const useActivityStore = defineStore('activity', {
}, },
actions: { actions: {
/**
* Charge la liste des jobs selon les filtres actuels
* @param {number} page - Numéro de page optionnel
*/
async loadJobs(page = null) { async loadJobs(page = null) {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
try { try {
const options = { const jobCollection = await jobRepository.getJobs({
page: page || this.currentPage, page: page || this.currentPage,
limit: this.limit, limit: this.limit,
sortBy: this.filter.sortBy, sortBy: this.sortBy,
sortOrder: this.filter.sortOrder, sortOrder: this.sortOrder,
status: this.filter.status status: ACTIVE_STATUSES,
}; });
const jobCollection = await jobRepository.getJobs(options);
// Mettre à jour les données
this.jobs = jobCollection.items; this.jobs = jobCollection.items;
this.currentPage = jobCollection.page; this.currentPage = jobCollection.page;
this.total = jobCollection.total; this.total = jobCollection.total;
this.hasNextPage = jobCollection.hasNextPage; this.hasNextPage = jobCollection.hasNextPage;
this.hasPreviousPage = jobCollection.hasPreviousPage; this.hasPreviousPage = jobCollection.hasPreviousPage;
// Calculer le nombre total de pages
this.totalPages = Math.ceil(this.total / this.limit); this.totalPages = Math.ceil(this.total / this.limit);
console.log('Store updated with:', {
jobs: this.jobs.length,
currentPage: this.currentPage,
total: this.total,
limit: this.limit,
totalPages: this.totalPages,
hasNextPage: this.hasNextPage,
hasPreviousPage: this.hasPreviousPage
});
} catch (error) { } catch (error) {
this.error = error.message; this.error = error.message;
console.error('Error loading jobs:', error); console.error('Error loading jobs:', error);
@@ -87,10 +66,6 @@ export const useActivityStore = defineStore('activity', {
} }
}, },
/**
* Va à une page spécifique
* @param {number} page
*/
async goToPage(page) { async goToPage(page) {
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) { if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
this.currentPage = page; this.currentPage = page;
@@ -98,39 +73,26 @@ export const useActivityStore = defineStore('activity', {
} }
}, },
/** async updateSort(sortBy, sortOrder) {
* Met à jour les filtres et recharge la liste this.sortBy = sortBy;
* @param {Object} filter this.sortOrder = sortOrder;
*/ this.currentPage = 1;
async updateFilter(filter) {
this.filter = { ...this.filter, ...filter };
this.currentPage = 1; // Retourner à la première page lors du changement de filtre
await this.loadJobs(1); await this.loadJobs(1);
}, },
/**
* Met à jour la limite par page
* @param {number} limit
*/
async updateLimit(limit) { async updateLimit(limit) {
this.limit = limit; this.limit = limit;
this.currentPage = 1; // Retourner à la première page this.currentPage = 1;
await this.loadJobs(1); await this.loadJobs(1);
}, },
/**
* Supprime un job par son ID
* @param {string} id
*/
async deleteJob(id) { async deleteJob(id) {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
try { try {
await jobRepository.deleteJob(id); await jobRepository.deleteJob(id);
// Supprimer le job de la liste locale
this.jobs = this.jobs.filter(job => job.id !== id); this.jobs = this.jobs.filter(job => job.id !== id);
// Recharger la page courante pour avoir les bons totaux
await this.loadJobs(this.currentPage); await this.loadJobs(this.currentPage);
} catch (error) { } catch (error) {
this.error = error.message; this.error = error.message;
@@ -140,17 +102,75 @@ export const useActivityStore = defineStore('activity', {
} }
}, },
/** updateJobProgress(jobId, progress) {
* Supprime tous les jobs correspondant aux critères const job = this.jobs.find(j => j.id === jobId);
* @param {Object} criteria 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 = {}) { async deleteJobs(criteria = {}) {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
try { try {
const deleted = await jobRepository.deleteJobs(criteria); const deleted = await jobRepository.deleteJobs(criteria);
// Recharger la liste après suppression
await this.loadJobs(this.currentPage); await this.loadJobs(this.currentPage);
return deleted; return deleted;
} catch (error) { } catch (error) {
@@ -160,26 +180,5 @@ export const useActivityStore = defineStore('activity', {
this.loading = false; this.loading = false;
} }
}, },
/**
* Supprime tous les jobs terminés
*/
async deleteCompletedJobs() {
return this.deleteJobs({ status: ['COMPLETED'] });
},
/**
* Supprime tous les jobs en erreur
*/
async deleteFailedJobs() {
return this.deleteJobs({ status: ['ERROR'] });
},
/**
* Supprime tous les jobs
*/
async deleteAllJobs() {
return this.deleteJobs({});
}
} }
}); });

View File

@@ -10,6 +10,8 @@ export class Job {
failureReason = null, failureReason = null,
createdAt = new Date().toISOString(), createdAt = new Date().toISOString(),
updatedAt = new Date().toISOString(), updatedAt = new Date().toISOString(),
startedAt = null,
completedAt = null,
attempts = 0, attempts = 0,
maxAttempts = 1, maxAttempts = 1,
context = {} context = {}
@@ -23,6 +25,8 @@ export class Job {
this.error = failureReason ?? error; this.error = failureReason ?? error;
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
this.startedAt = startedAt;
this.completedAt = completedAt;
this.attempts = attempts; this.attempts = attempts;
this.maxAttempts = maxAttempts; this.maxAttempts = maxAttempts;
this.context = context; this.context = context;

View File

@@ -13,7 +13,7 @@ export class ApiJobRepository extends JobRepositoryInterface {
* @returns {Promise<JobCollection>} Collection de jobs * @returns {Promise<JobCollection>} Collection de jobs
*/ */
async getJobs(options = {}) { async getJobs(options = {}) {
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [] } = options; const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [], type = null } = options;
try { try {
let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`; let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
@@ -23,6 +23,11 @@ export class ApiJobRepository extends JobRepositoryInterface {
url += `&status=${status.join(',')}`; url += `&status=${status.join(',')}`;
} }
// Ajouter le filtre de type si fourni
if (type) {
url += `&type=${type}`;
}
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {

View File

@@ -1,169 +1,153 @@
<template> <template>
<div class="overflow-y-auto h-full"> <div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" class="mb-6" /> <Toolbar :config="toolbarConfig" />
<div v-if="activityStore.loading" class="flex justify-center py-8"> <div class="overflow-y-auto flex-1">
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div> <!-- Loading -->
</div> <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>
<div v-else-if="activityStore.error" class="bg-red-100 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-400 p-4 mb-6"> <!-- Error -->
<p>{{ activityStore.error }}</p> <div v-else-if="activityStore.error" class="px-6 py-8">
</div> <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>
<div v-else class="container mx-auto p-2"> <!-- Content -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg"> <section v-else class="border-t border-gray-200 dark:border-gray-700">
<div class="overflow-x-auto"> <!-- Empty -->
<table class="min-w-full bg-white dark:bg-gray-800"> <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> <thead>
<tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200"> <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-1/12 py-3 px-4 text-left"> <th class="w-2/11 py-3 px-6 text-left">Type</th>
<input <th class="w-2/11 py-3 px-4 text-left">Statut</th>
type="checkbox" <th class="w-3/11 py-3 px-4 text-left">Informations</th>
class="form-checkbox h-5 w-5 text-green-600" <th class="w-3/11 py-3 px-4 text-left">Progression</th>
@change="toggleSelectAll" /> <th class="w-1/11 py-3 px-4 text-left">Actions</th>
</th>
<th class="w-2/12 py-3 px-4 text-left">Type</th>
<th class="w-2/12 py-3 px-4 text-left">Statut</th>
<th class="w-3/12 py-3 px-4 text-left">Informations</th>
<th class="w-3/12 py-3 px-4 text-left">Progression</th>
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-gray-700 dark:text-gray-300"> <tbody class="divide-y divide-gray-100 dark:divide-gray-700/50 text-gray-700 dark:text-gray-300">
<template v-if="activityStore.jobs.length === 0"> <JobItem
<tr> v-for="job in activityStore.jobs"
<td colspan="6" class="py-8 px-4 text-center text-gray-500"> :key="job.id"
<div class="flex flex-col items-center"> :job="job"
<ClockIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mb-4" /> @delete="deleteJob" />
<p class="text-lg font-medium dark:text-gray-300">Aucune activité trouvée</p>
<p class="text-sm dark:text-gray-400">Aucune activité ne correspond aux filtres actuels.</p>
</div>
</td>
</tr>
</template>
<template v-else>
<JobItem
v-for="job in activityStore.jobs"
:key="job.id"
:job="job"
@delete="deleteJob" />
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<Pagination <Pagination
v-if="activityStore.total > activityStore.limit" v-if="total > activityStore.limit"
:current-page="activityStore.currentPage" :current-page="activityStore.currentPage"
:total-pages="activityStore.totalPages" :total-pages="activityStore.totalPages"
:total="activityStore.total" :total="total"
:limit="activityStore.limit" :limit="activityStore.limit"
:has-next-page="activityStore.hasNextPage" :has-next-page="activityStore.hasNextPage"
:has-previous-page="activityStore.hasPreviousPage" :has-previous-page="activityStore.hasPreviousPage"
@page-change="changePage" /> @page-change="changePage" />
</div> </section>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ArrowPathIcon, ClockIcon, FunnelIcon, TrashIcon } from '@heroicons/vue/24/outline'; import { ArrowPathIcon, BarsArrowDownIcon, ClockIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { computed, onMounted, ref } from 'vue'; import { storeToRefs } from 'pinia';
import { computed, onMounted, onUnmounted } from 'vue';
import Pagination from '../../../../shared/components/ui/Pagination.vue'; import Pagination from '../../../../shared/components/ui/Pagination.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue'; import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useActivityStore } from '../../application/store/activityStore'; import { useActivityStore } from '../../application/store/activityStore';
import JobItem from '../components/JobItem.vue'; import JobItem from '../components/JobItem.vue';
const activityStore = useActivityStore(); const activityStore = useActivityStore();
const selectedAll = ref(false);
// Statuts disponibles pour le filtre const { sortBy, sortOrder, total, loading } = storeToRefs(activityStore);
const statusOptions = [
{ value: ['pending', 'in_progress'], label: 'Actifs' },
{ value: ['pending', 'in_progress', 'completed', 'failed'], label: 'Tous' },
{ value: ['completed'], label: 'Terminés' },
{ value: ['failed'], label: 'En erreur' },
{ value: ['pending'], label: 'En attente' },
{ value: ['in_progress'], label: 'En cours' }
];
// Index du statut actif (par défaut "Actifs") const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
const activeStatusIndex = ref(0);
// Configuration de la toolbar réactive const toolbarConfig = computed(() => ({
const toolbarConfig = computed(() => ({ leftSection: [
leftSection: [ { type: 'label', text: 'Activité', class: 'text-sm font-medium' },
{ { type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
icon: FunnelIcon, ],
type: 'dropdown', rightSection: [
label: statusOptions[activeStatusIndex.value].label, {
active: false, type: 'dropdown',
items: statusOptions.map((option, index) => ({ icon: BarsArrowDownIcon,
label: option.label, label: 'Trier',
isSelected: index === activeStatusIndex.value, items: [
onClick: () => setStatusFilter(index) {
})) label: 'Plus récent',
} isSelected: isSortSelected('createdAt', 'DESC'),
], onClick: () => activityStore.updateSort('createdAt', 'DESC'),
rightSection: [ },
{ {
icon: ArrowPathIcon, label: 'Plus ancien',
type: 'button', isSelected: isSortSelected('createdAt', 'ASC'),
label: 'Rafraîchir', onClick: () => activityStore.updateSort('createdAt', 'ASC'),
onClick: refreshJobs },
}, {
{ label: 'Par type',
icon: TrashIcon, isSelected: isSortSelected('type', 'ASC'),
type: 'button', onClick: () => activityStore.updateSort('type', 'ASC'),
label: 'Supprimer visibles', },
onClick: deleteVisibleJobs {
} 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(() => { onMounted(() => {
loadJobs(); activityStore.loadJobs();
}); activityStore.subscribeMercure();
});
function loadJobs() { onUnmounted(() => {
activityStore.loadJobs(); activityStore.unsubscribeMercure();
});
function changePage(page) {
activityStore.goToPage(page);
}
function deleteJob(id) {
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
activityStore.deleteJob(id);
} }
}
function refreshJobs() { function deleteVisibleJobs() {
loadJobs(); if (activityStore.jobs.length === 0) return;
} if (confirm('Voulez-vous vraiment supprimer tous les jobs visibles ?')) {
activityStore.deleteJobs({ status: ['pending', 'in_progress'] });
function changePage(page) {
activityStore.goToPage(page);
}
function toggleSelectAll() {
selectedAll.value = !selectedAll.value;
// La logique pour sélectionner tous les jobs serait ajoutée ici
}
function setStatusFilter(index) {
if (index >= 0 && index < statusOptions.length) {
activeStatusIndex.value = index;
activityStore.updateFilter({ status: statusOptions[index].value });
}
}
function deleteJob(id) {
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
activityStore.deleteJob(id);
}
}
function deleteVisibleJobs() {
if (activityStore.jobs.length === 0) {
return;
}
const statusLabel = statusOptions[activeStatusIndex.value].label.toLowerCase();
if (confirm(`Voulez-vous vraiment supprimer tous les jobs ${statusLabel} ?`)) {
activityStore.deleteJobs({ status: activityStore.filter.status });
}
} }
}
</script> </script>

View File

@@ -4,6 +4,7 @@ export class Manga {
slug, slug,
title, title,
description = null, description = null,
author = null,
authors = [], authors = [],
imageUrl = null, imageUrl = null,
thumbnailUrl = null, thumbnailUrl = null,
@@ -20,7 +21,7 @@ export class Manga {
this.slug = slug; this.slug = slug;
this.title = title; this.title = title;
this.description = description; this.description = description;
this.authors = authors; this.authors = authors.length ? authors : (author ? [author] : []);
this.imageUrl = imageUrl; this.imageUrl = imageUrl;
this.thumbnailUrl = thumbnailUrl; this.thumbnailUrl = thumbnailUrl;
this.publicationYear = publicationYear; this.publicationYear = publicationYear;

View File

@@ -0,0 +1,161 @@
<template>
<TransitionRoot as="template" :show="isOpen">
<Dialog as="div" class="relative z-50" @close="$emit('close')">
<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">
Options d'affichage
</DialogTitle>
</div>
<div class="space-y-6">
<!-- Vue Grid -->
<section>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Vue Grille
</h4>
<div class="space-y-3">
<ToggleRow
label="Titre"
:value="options.grid.showTitle"
@update="setOption('grid', 'showTitle', $event)" />
<ToggleRow
label="Année de publication"
:value="options.grid.showYear"
@update="setOption('grid', 'showYear', $event)" />
<ToggleRow
label="Auteur(s)"
:value="options.grid.showAuthor"
@update="setOption('grid', 'showAuthor', $event)" />
</div>
</section>
<div class="border-t border-gray-200 dark:border-gray-700" />
<!-- Vue Overview -->
<section>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Vue Overview
</h4>
<div class="space-y-3">
<ToggleRow
label="Couverture"
:value="options.overview.showCover"
@update="setOption('overview', 'showCover', $event)" />
<ToggleRow
label="Statut"
:value="options.overview.showStatus"
@update="setOption('overview', 'showStatus', $event)" />
<ToggleRow
label="Description"
:value="options.overview.showDescription"
@update="setOption('overview', 'showDescription', $event)" />
<ToggleRow
label="Auteur(s)"
:value="options.overview.showAuthor"
@update="setOption('overview', 'showAuthor', $event)" />
<ToggleRow
label="Année de publication"
:value="options.overview.showYear"
@update="setOption('overview', 'showYear', $event)" />
</div>
</section>
<div class="border-t border-gray-200 dark:border-gray-700" />
<!-- Vue Table -->
<section>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Vue Table
</h4>
<div class="space-y-3">
<ToggleRow
label="Monitoring"
:value="options.table.showMonitoring"
@update="setOption('table', 'showMonitoring', $event)" />
<ToggleRow
label="Source préférée"
:value="options.table.showPreferredSource"
@update="setOption('table', 'showPreferredSource', $event)" />
<ToggleRow
label="Progression chapitres"
:value="options.table.showChapters"
@update="setOption('table', 'showChapters', $event)" />
<ToggleRow
label="Statut"
:value="options.table.showStatus"
@update="setOption('table', 'showStatus', $event)" />
<ToggleRow
label="Auteur(s)"
:value="options.table.showAuthor"
@update="setOption('table', 'showAuthor', $event)" />
<ToggleRow
label="Année de publication"
:value="options.table.showYear"
@update="setOption('table', 'showYear', $event)" />
</div>
</section>
</div>
<div class="mt-6 flex justify-end">
<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="$emit('close')"
>
Fermer
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
import ToggleRow from '../../../../shared/components/ui/ToggleRow.vue';
defineProps({
isOpen: {
type: Boolean,
required: true
},
options: {
type: Object,
required: true
}
});
const emit = defineEmits(['close', 'update']);
function setOption(view, key, value) {
emit('update', { view, key, value });
}
</script>

View File

@@ -35,12 +35,13 @@
</div> </div>
</div> </div>
<!-- Titre + année --> <!-- Titre + méta -->
<RouterLink <RouterLink
:to="{ name: 'manga-details', params: { id: manga.id } }" :to="{ name: 'manga-details', params: { id: manga.id } }"
class="block p-2"> class="block p-2">
<h3 class="text-xs font-medium text-gray-800 dark:text-gray-100 truncate">{{ manga.title }}</h3> <h3 v-if="options.showTitle" 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> <span v-if="options.showYear && manga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
<span v-if="options.showAuthor && manga.authors?.length" class="text-xs text-gray-500 dark:text-gray-400 truncate block">{{ manga.authors[0] }}</span>
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
@@ -53,6 +54,10 @@ defineProps({
manga: { manga: {
type: Object, type: Object,
required: true required: true
},
options: {
type: Object,
default: () => ({ showTitle: true, showYear: true, showAuthor: false })
} }
}); });

View File

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

View File

@@ -4,6 +4,7 @@
v-for="manga in mangas" v-for="manga in mangas"
:key="manga.id" :key="manga.id"
:manga="manga" :manga="manga"
:options="options"
@edit="openEdit" @edit="openEdit"
@sources="openSources" @sources="openSources"
@refresh="doRefresh" /> @refresh="doRefresh" />
@@ -41,6 +42,10 @@ defineProps({
mangas: { mangas: {
type: Array, type: Array,
required: true required: true
},
options: {
type: Object,
default: () => ({ showTitle: true, showYear: true, showAuthor: false })
} }
}); });

View File

@@ -8,6 +8,7 @@
<!-- Cover --> <!-- Cover -->
<img <img
v-if="options.showCover"
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'" :src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
alt="" alt=""
class="h-36 w-24 object-cover flex-shrink-0 self-start" class="h-36 w-24 object-cover flex-shrink-0 self-start"
@@ -23,13 +24,21 @@
{{ manga.title }} {{ manga.title }}
</RouterLink> </RouterLink>
<span <span
v-if="manga.status" v-if="options.showStatus && manga.status"
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0" class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
:class="statusClass(manga.status)"> :class="statusClass(manga.status)">
{{ manga.status }} {{ manga.status }}
</span> </span>
</div> </div>
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4"> <div class="flex items-center gap-3 mt-1 flex-wrap">
<span v-if="options.showAuthor && manga.authors?.length" class="text-xs text-gray-500 dark:text-gray-400">
{{ manga.authors.join(', ') }}
</span>
<span v-if="options.showYear && manga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
{{ manga.publicationYear }}
</span>
</div>
<p v-if="options.showDescription && manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">
{{ manga.description }} {{ manga.description }}
</p> </p>
</div> </div>
@@ -100,6 +109,10 @@ const props = defineProps({
mangas: { mangas: {
type: Array, type: Array,
required: true required: true
},
options: {
type: Object,
default: () => ({ showCover: true, showStatus: true, showDescription: true, showAuthor: false, showYear: false })
} }
}); });

View File

@@ -4,10 +4,13 @@
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <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"> <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 v-if="options.showMonitoring" 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">Titre</th>
<th class="py-3 pr-4 text-left font-medium w-44">Source préférée</th> <th v-if="options.showAuthor" class="py-3 pr-4 text-left font-medium w-36">Auteur</th>
<th class="py-3 pr-4 text-left font-medium w-44">Chapitres</th> <th v-if="options.showYear" class="py-3 pr-4 text-left font-medium w-20">Année</th>
<th v-if="options.showStatus" class="py-3 pr-4 text-left font-medium w-28">Statut</th>
<th v-if="options.showPreferredSource" class="py-3 pr-4 text-left font-medium w-44">Source préférée</th>
<th v-if="options.showChapters" 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> <th class="py-3 px-4 text-right font-medium w-28">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -18,7 +21,7 @@
class="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"> class="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
<!-- Monitoring --> <!-- Monitoring -->
<td class="px-4 py-3 text-center"> <td v-if="options.showMonitoring" class="px-4 py-3 text-center">
<button <button
:title="manga.monitored ? 'Monitoring actif — cliquer pour désactiver' : 'Monitoring inactif — cliquer pour activer'" :title="manga.monitored ? 'Monitoring actif — cliquer pour désactiver' : 'Monitoring inactif — cliquer pour activer'"
:class="manga.monitored :class="manga.monitored
@@ -41,13 +44,34 @@
</RouterLink> </RouterLink>
</td> </td>
<!-- Auteur -->
<td v-if="options.showAuthor" class="py-3 pr-4">
<span class="text-sm text-gray-600 dark:text-gray-300">{{ manga.authors?.join(', ') || '—' }}</span>
</td>
<!-- Année -->
<td v-if="options.showYear" class="py-3 pr-4">
<span class="text-sm text-gray-600 dark:text-gray-300">{{ manga.publicationYear || '—' }}</span>
</td>
<!-- Statut -->
<td v-if="options.showStatus" class="py-3 pr-4">
<span
v-if="manga.status"
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="statusClass(manga.status)">
{{ manga.status }}
</span>
<span v-else class="text-gray-400 dark:text-gray-600 text-xs"></span>
</td>
<!-- Source préférée --> <!-- Source préférée -->
<td class="py-3 pr-4"> <td v-if="options.showPreferredSource" class="py-3 pr-4">
<MangaPreferredSourceCell :manga-id="manga.id" /> <MangaPreferredSourceCell :manga-id="manga.id" />
</td> </td>
<!-- Chapitres barre de progression --> <!-- Chapitres barre de progression -->
<td class="py-3 pr-4"> <td v-if="options.showChapters" class="py-3 pr-4">
<div v-if="manga.chaptersTotal > 0"> <div v-if="manga.chaptersTotal > 0">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400"> <span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
@@ -139,9 +163,19 @@ const props = defineProps({
mangas: { mangas: {
type: Array, type: Array,
required: true required: true
},
options: {
type: Object,
default: () => ({ showMonitoring: true, showPreferredSource: true, showChapters: true, showStatus: false, showAuthor: false, showYear: false })
} }
}); });
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';
}
function progressPercent(manga) { function progressPercent(manga) {
if (!manga.chaptersTotal) return 0; if (!manga.chaptersTotal) return 0;
return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100); return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100);

View File

@@ -3,12 +3,13 @@
<Toolbar :config="toolbarConfig" /> <Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1"> <div class="overflow-y-auto flex-1">
<div class="w-full"> <div class="w-full">
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" /> <MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" :options="prefs.displayOptions.grid" />
<MangaOverview <MangaOverview
v-else-if="viewMode === 'list'" v-else-if="viewMode === 'list'"
:mangas="pagedItems" :mangas="pagedItems"
:options="prefs.displayOptions.overview"
@manga-click="handleMangaClick" /> @manga-click="handleMangaClick" />
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" /> <MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" :options="prefs.displayOptions.table" />
<Pagination <Pagination
v-if="totalPages > 1" v-if="totalPages > 1"
:current-page="currentPage" :current-page="currentPage"
@@ -25,6 +26,12 @@
</div> </div>
</div> </div>
</div> </div>
<HomeDisplaySettingsModal
:is-open="isDisplaySettingsOpen"
:options="prefs.displayOptions"
@close="isDisplaySettingsOpen = false"
@update="({ view, key, value }) => prefs.setDisplayOption(view, key, value)" />
</div> </div>
</template> </template>
@@ -44,6 +51,7 @@ import { useUserPreferencesStore } from '../../../../domain/setting/application/
import Pagination from '../../../../shared/components/ui/Pagination.vue'; import Pagination from '../../../../shared/components/ui/Pagination.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue'; import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore'; import { useMangaStore } from '../../application/store/mangaStore';
import HomeDisplaySettingsModal from '../components/HomeDisplaySettingsModal.vue';
import MangaGrid from '../components/MangaGrid.vue'; import MangaGrid from '../components/MangaGrid.vue';
import MangaOverview from '../components/MangaOverview.vue'; import MangaOverview from '../components/MangaOverview.vue';
import MangaTable from '../components/MangaTable.vue'; import MangaTable from '../components/MangaTable.vue';
@@ -61,6 +69,7 @@ import MangaTable from '../components/MangaTable.vue';
const viewMode = ref(prefs.defaultView); const viewMode = ref(prefs.defaultView);
const currentPage = ref(1); const currentPage = ref(1);
const isDisplaySettingsOpen = ref(false);
onMounted(() => { onMounted(() => {
mangaStore.loadCollection(); mangaStore.loadCollection();
@@ -71,7 +80,12 @@ import MangaTable from '../components/MangaTable.vue';
}; };
const sortedCollection = computed(() => { const sortedCollection = computed(() => {
const items = [...(collection.value?.items || [])]; let items = [...(collection.value?.items || [])];
if (prefs.filterBy === 'completed') {
items = items.filter(m => m.status?.toLowerCase() === 'completed');
} else if (prefs.filterBy === 'ongoing') {
items = items.filter(m => m.status?.toLowerCase() === 'ongoing');
}
if (prefs.sortBy === 'title') { if (prefs.sortBy === 'title') {
items.sort((a, b) => a.title.localeCompare(b.title)); items.sort((a, b) => a.title.localeCompare(b.title));
} else if (prefs.sortBy === 'addedAt') { } else if (prefs.sortBy === 'addedAt') {
@@ -91,7 +105,7 @@ import MangaTable from '../components/MangaTable.vue';
currentPage.value = 1; currentPage.value = 1;
}); });
const toolbarConfig = { const toolbarConfig = computed(() => ({
leftSection: [ leftSection: [
{ {
icon: ArrowPathIcon, icon: ArrowPathIcon,
@@ -103,15 +117,15 @@ import MangaTable from '../components/MangaTable.vue';
{ icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} } { icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} }
], ],
rightSection: [ rightSection: [
{ icon: Cog6ToothIcon, type: 'button', onClick: () => {} }, { icon: Cog6ToothIcon, label: 'Options', type: 'button', onClick: () => { isDisplaySettingsOpen.value = true; } },
{ {
icon: EyeIcon, icon: EyeIcon,
type: 'dropdown', type: 'dropdown',
label: 'View', label: 'View',
items: [ items: [
{ label: 'Overview', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } }, { label: 'Overview', isSelected: prefs.defaultView === 'list', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
{ label: 'Grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } }, { label: 'Grid', isSelected: prefs.defaultView === 'grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
{ label: 'Table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } } { label: 'Table', isSelected: prefs.defaultView === 'table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
] ]
}, },
{ {
@@ -119,9 +133,9 @@ import MangaTable from '../components/MangaTable.vue';
type: 'dropdown', type: 'dropdown',
label: 'Sort', label: 'Sort',
items: [ items: [
{ label: 'Title', onClick: () => prefs.setSortBy('title') }, { label: 'Title', isSelected: prefs.sortBy === 'title', onClick: () => prefs.setSortBy('title') },
{ label: "Date d'ajout", onClick: () => prefs.setSortBy('addedAt') }, { label: "Date d'ajout", isSelected: prefs.sortBy === 'addedAt', onClick: () => prefs.setSortBy('addedAt') },
{ label: 'Progression', onClick: () => prefs.setSortBy('progress') } { label: 'Progression', isSelected: prefs.sortBy === 'progress', onClick: () => prefs.setSortBy('progress') }
] ]
}, },
{ {
@@ -129,11 +143,11 @@ import MangaTable from '../components/MangaTable.vue';
type: 'dropdown', type: 'dropdown',
label: 'Filter', label: 'Filter',
items: [ items: [
{ label: 'All', onClick: () => {} }, { label: 'All', isSelected: prefs.filterBy === 'all', onClick: () => prefs.setFilterBy('all') },
{ label: 'Completed', onClick: () => {} }, { label: 'Completed', isSelected: prefs.filterBy === 'completed', onClick: () => prefs.setFilterBy('completed') },
{ label: 'In Progress', onClick: () => {} } { label: 'In Progress', isSelected: prefs.filterBy === 'ongoing', onClick: () => prefs.setFilterBy('ongoing') }
] ]
} }
] ]
}; }));
</script> </script>

View File

@@ -94,14 +94,14 @@ import ReaderPage from './ReaderPage.vue';
}); });
}; };
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage // Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage, zoom inclus
const getPlaceholderHeight = (page) => { const getPlaceholderHeight = (page) => {
const dims = page?.dimensions; const dims = page?.dimensions;
if (!dims?.width || !dims?.height) return 800; if (!dims?.width || !dims?.height) return Math.round(800 * props.zoom);
const displayWidth = windowWidth.value < 1200 const displayWidth = windowWidth.value < 1200
? Math.min(dims.width, windowWidth.value * 0.95) ? Math.min(dims.width, windowWidth.value * 0.95)
: Math.min(dims.width, 1200); : Math.min(dims.width, 1200);
return Math.round((dims.height / dims.width) * displayWidth); return Math.round((dims.height / dims.width) * displayWidth * props.zoom);
}; };
const setupObservers = () => { const setupObservers = () => {
@@ -109,7 +109,7 @@ import ReaderPage from './ReaderPage.vue';
visibilityObserver.value?.disconnect(); visibilityObserver.value?.disconnect();
observer.value = new IntersectionObserver(observeIntersection, { observer.value = new IntersectionObserver(observeIntersection, {
root: null, root: containerRef.value,
threshold: 0.5 threshold: 0.5
}); });
@@ -124,7 +124,7 @@ import ReaderPage from './ReaderPage.vue';
} }
}); });
}, },
{ root: null, rootMargin: '1000px 0px', threshold: 0 } { root: containerRef.value, rootMargin: '1000px 0px', threshold: 0 }
); );
nextTick(() => { nextTick(() => {
@@ -328,7 +328,6 @@ import ReaderPage from './ReaderPage.vue';
@apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0; @apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
/* Réduction du padding sur mobile */ /* Réduction du padding sur mobile */
@apply py-2 sm:py-8; @apply py-2 sm:py-8;
scroll-behavior: smooth;
} }
.page-wrapper { .page-wrapper {

View File

@@ -87,13 +87,9 @@ import { useReaderStore } from '../../application/store/readerStore';
const store = useReaderStore(); const store = useReaderStore();
// En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles) // zoom via la propriété CSS `zoom` dans les deux modes (affecte le layout → pas de chevauchement en mode scroll)
// En mode infinite : zoom via transform: scale (pas d'impact layout souhaité)
const containerStyle = computed(() => { const containerStyle = computed(() => {
if (store.readingMode === 'single') { return { zoom: props.zoom };
return { zoom: props.zoom };
}
return { transform: `scale(${props.zoom})` };
}); });
const imageRef = ref(null); const imageRef = ref(null);

View File

@@ -23,7 +23,15 @@ export const useContentSourceStore = defineStore('contentSource', {
importing: false, importing: false,
exporting: false, exporting: false,
importError: null, importError: null,
exportError: null exportError: null,
// Health check state
checkingHealth: false,
checkHealthError: null,
// Delete state
deleting: false,
deleteError: null,
}), }),
getters: { getters: {
@@ -168,12 +176,64 @@ export const useContentSourceStore = defineStore('contentSource', {
} }
}, },
// Delete a source
async deleteSource(id) {
if (this.deleting) return;
this.deleting = true;
this.deleteError = null;
try {
await contentSourceRepository.delete(id);
this.sources = this.sources.filter(source => source.id !== id);
if (this.currentSource && this.currentSource.id === id) {
this.currentSource = null;
}
} catch (error) {
this.deleteError = error.message;
console.error('Erreur lors de la suppression de la source:', error);
throw error;
} finally {
this.deleting = false;
}
},
// Clear current source // Clear current source
clearCurrentSource() { clearCurrentSource() {
this.currentSource = null; this.currentSource = null;
this.currentSourceError = null; this.currentSourceError = null;
}, },
// Check all scrapers health
async checkAllHealth() {
if (this.checkingHealth) return;
this.checkingHealth = true;
this.checkHealthError = null;
try {
await contentSourceRepository.checkAllHealth();
} catch (error) {
this.checkHealthError = error.message;
console.error('Erreur lors du health check:', error);
throw error;
} finally {
this.checkingHealth = false;
}
},
// Update health status of a single source (called from Mercure)
updateSourceHealth(sourceId, status, error = null) {
const index = this.sources.findIndex(s => s.id === sourceId);
if (index !== -1) {
this.sources[index] = {
...this.sources[index],
healthStatus: status,
healthLastError: error,
};
}
},
// Clear errors // Clear errors
clearErrors() { clearErrors() {
this.sourcesError = null; this.sourcesError = null;
@@ -181,6 +241,7 @@ export const useContentSourceStore = defineStore('contentSource', {
this.saveError = null; this.saveError = null;
this.importError = null; this.importError = null;
this.exportError = null; this.exportError = null;
this.checkHealthError = null;
} }
} }
}); });

View File

@@ -8,6 +8,12 @@ const defaultState = {
defaultView: 'grid', defaultView: 'grid',
itemsPerPage: 20, itemsPerPage: 20,
sortBy: 'title', sortBy: 'title',
filterBy: 'all',
displayOptions: {
grid: { showTitle: true, showYear: true, showAuthor: false },
overview: { showCover: true, showStatus: true, showDescription: true, showAuthor: false, showYear: false },
table: { showMonitoring: true, showPreferredSource: true, showChapters: true, showStatus: false, showAuthor: false, showYear: false }
},
readingDirection: 'ltr', readingDirection: 'ltr',
readingMode: 'scroll', readingMode: 'scroll',
autoFullscreen: false, autoFullscreen: false,
@@ -88,6 +94,16 @@ export const useUserPreferencesStore = defineStore('userPreferences', {
this.persist(); this.persist();
}, },
setFilterBy(filter) {
this.filterBy = filter;
this.persist();
},
setDisplayOption(view, key, value) {
this.displayOptions[view][key] = value;
this.persist();
},
setReadingDirection(direction) { setReadingDirection(direction) {
this.readingDirection = direction; this.readingDirection = direction;
this.persist(); this.persist();
@@ -127,6 +143,8 @@ export const useUserPreferencesStore = defineStore('userPreferences', {
defaultView: this.defaultView, defaultView: this.defaultView,
itemsPerPage: this.itemsPerPage, itemsPerPage: this.itemsPerPage,
sortBy: this.sortBy, sortBy: this.sortBy,
filterBy: this.filterBy,
displayOptions: this.displayOptions,
readingDirection: this.readingDirection, readingDirection: this.readingDirection,
readingMode: this.readingMode, readingMode: this.readingMode,
autoFullscreen: this.autoFullscreen, autoFullscreen: this.autoFullscreen,

View File

@@ -0,0 +1,6 @@
export const ScraperHealthStatus = {
UNKNOWN: 'unknown',
OK: 'ok',
KO: 'ko',
TESTING: 'testing',
};

View File

@@ -82,6 +82,28 @@ export class ApiContentSourceRepository {
} }
} }
/**
* Déclenche le test de santé de tous les scrapers
*/
async checkAllHealth() {
try {
await this.apiClient.post('/scraping/check-all-health', {});
} catch (error) {
throw new Error(error.response?.data?.message || 'Erreur lors du lancement du health check');
}
}
/**
* Supprime une source de contenu
*/
async delete(id) {
try {
await this.apiClient.delete(`/content-sources/${id}`);
} catch (error) {
throw new Error(error.response?.data?.message || 'Erreur lors de la suppression de la source');
}
}
/** /**
* Teste une configuration de scraper * Teste une configuration de scraper
*/ */

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
@click="$emit('edit', source)" @click="$emit('edit', source)"
class="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow duration-200 cursor-pointer"> class="bg-white dark:bg-gray-800 shadow-md border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow duration-200 cursor-pointer">
<!-- Header avec URL et icône externe --> <!-- Header avec URL et icône externe -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate" :title="source.cleanBaseUrl"> <h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate" :title="source.cleanBaseUrl">
@@ -20,16 +20,24 @@
<!-- Badge type de scraping --> <!-- Badge type de scraping -->
<span <span
:class="getScrapingTypeBadgeClass(source.scrapingType)" :class="getScrapingTypeBadgeClass(source.scrapingType)"
class="px-2 py-1 text-xs font-medium rounded-md"> class="px-2 py-1 text-xs font-medium">
{{ source.scrapingType?.toLowerCase() || 'N/A' }} {{ source.scrapingType?.toLowerCase() || 'N/A' }}
</span> </span>
<!-- Badge orientation basé sur les sélecteurs --> <!-- Badge orientation basé sur les sélecteurs -->
<span <span
:class="getOrientationBadgeClass(source)" :class="getOrientationBadgeClass(source)"
class="px-2 py-1 text-xs font-medium rounded-md"> class="px-2 py-1 text-xs font-medium">
{{ getOrientation(source) }} {{ getOrientation(source) }}
</span> </span>
<!-- Badge health status -->
<span
:class="getHealthBadgeClass(source.healthStatus)"
class="px-2 py-1 text-xs font-medium"
:title="source.healthLastError || ''">
{{ getHealthLabel(source.healthStatus) }}
</span>
</div> </div>
@@ -39,6 +47,7 @@
<script setup> <script setup>
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'; import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
import { ScraperHealthStatus } from '../../domain/model/ScraperHealthStatus';
defineProps({ defineProps({
source: { source: {
@@ -86,4 +95,26 @@ const getOrientationBadgeClass = (source) => {
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
} }
}; };
const getHealthLabel = (status) => {
switch (status) {
case ScraperHealthStatus.OK: return '✓ ok';
case ScraperHealthStatus.KO: return '✗ ko';
case ScraperHealthStatus.TESTING: return '⟳ test';
default: return '? unknown';
}
};
const getHealthBadgeClass = (status) => {
switch (status) {
case ScraperHealthStatus.OK:
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
case ScraperHealthStatus.KO:
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
case ScraperHealthStatus.TESTING:
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
default:
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
}
};
</script> </script>

View File

@@ -0,0 +1,123 @@
<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 la source de contenu
</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 }}
</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 la source <strong>{{ source?.baseUrl }}</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 source ne pourra plus être utilisée pour le scraping des chapitres.</p>
</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
},
source: {
type: Object,
default: null
},
isLoading: {
type: Boolean,
default: false
},
error: {
type: String,
default: null
}
});
const emit = defineEmits(['close', 'confirm']);
const closeModal = () => {
emit('close');
};
const confirmDelete = () => {
emit('confirm');
};
</script>

View File

@@ -1,17 +1,7 @@
<template> <template>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"> <div>
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600 rounded-t-lg">
<div class="flex items-center space-x-2">
<Cog6ToothIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ isEditing ? 'Edit Scrapper Configuration' : 'New Scrapper Configuration' }}
</h2>
</div>
</div>
<!-- Form --> <!-- Form -->
<form @submit.prevent="handleSubmit" class="p-6 space-y-6"> <form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Base URL --> <!-- Base URL -->
<div> <div>
<label for="baseUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label for="baseUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -22,25 +12,12 @@
v-model="form.baseUrl" v-model="form.baseUrl"
type="url" type="url"
required required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="https://example.com" /> placeholder="https://example.com" />
</div> </div>
<!-- Image Selector -->
<div>
<label for="imageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Image Selector
</label>
<input
id="imageSelector"
v-model="form.imageSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".reading-content .page-break img" />
</div>
<!-- Chapter URL Format --> <!-- Chapter URL Format -->
<div> <div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<label for="chapterUrlFormat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label for="chapterUrlFormat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Chapter URL Format <span class="text-gray-500">({slug}, {chapterNumber})</span> Chapter URL Format <span class="text-gray-500">({slug}, {chapterNumber})</span>
</label> </label>
@@ -49,132 +26,132 @@
v-model="form.chapterUrlFormat" v-model="form.chapterUrlFormat"
type="text" type="text"
required required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="https://example.com/manga/{slug}-{chapterNumber}/" /> placeholder="https://example.com/manga/{slug}-{chapterNumber}/" />
</div> </div>
<!-- Next Page Selector --> <!-- Selectors -->
<div> <div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <div>
Next Page Selector <span class="text-gray-500">(let empty if vertical reader)</span> <label for="imageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label> Image Selector
<input </label>
id="nextPageSelector" <input
v-model="form.nextPageSelector" id="imageSelector"
type="text" v-model="form.imageSelector"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" type="text"
placeholder=".next-page" /> class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".reading-content .page-break img" />
</div>
<div>
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Next Page Selector <span class="text-gray-500">(laisser vide si lecteur vertical)</span>
</label>
<input
id="nextPageSelector"
v-model="form.nextPageSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".next-page" />
</div>
<div>
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Chapter Selector <span class="text-gray-500">(requis pour le scraping Javascript)</span>
</label>
<input
id="chapterSelector"
v-model="form.chapterSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".chapter-selector" />
</div>
</div> </div>
<!-- Chapter Selector --> <!-- Scraping Type + Token -->
<div> <div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <div>
Chapter Selector <span class="text-gray-500">(required for Javascript scraping)</span> <label for="scrapingType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label> Scraping Type
<input </label>
id="chapterSelector" <select
v-model="form.chapterSelector" id="scrapingType"
type="text" v-model="form.scrapingType"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" required
placeholder=".chapter-selector" /> class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white">
</div> <option value="html">HTML</option>
<option value="javascript">Javascript</option>
</select>
</div>
<!-- Scraping Type --> <div>
<div> <label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label for="scrapingType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> Token
Scraping Type </label>
</label> <input
<select id="token"
id="scrapingType" v-model="form.token"
v-model="form.scrapingType" type="text"
required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"> placeholder="Optional authentication token" />
<option value="html">HTML</option> </div>
<option value="javascript">Javascript</option>
</select>
</div>
<!-- Token (optionnel) -->
<div>
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Token
</label>
<input
id="token"
v-model="form.token"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Optional authentication token" />
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button
type="submit"
:disabled="saving"
class="px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center space-x-2">
<ArrowPathIcon v-if="saving" class="w-4 h-4 animate-spin" />
<span>{{ isEditing ? 'Update Configuration' : 'Create Configuration' }}</span>
<PencilSquareIcon v-if="!saving" class="w-4 h-4" />
</button>
</div> </div>
<!-- Error message --> <!-- Error message -->
<div v-if="error" class="text-red-600 dark:text-red-400 text-sm"> <div v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 text-red-600 dark:text-red-400 text-sm">
{{ error }} {{ error }}
</div> </div>
</form> </form>
<!-- Test Configuration Section --> <!-- Test Configuration Section -->
<div class="border-t border-gray-200 dark:border-gray-600 p-6 bg-gray-50 dark:bg-gray-700 rounded-b-lg"> <div class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="flex items-center space-x-2 mb-4"> <div class="flex items-center space-x-2 mb-6">
<WrenchScrewdriverIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" /> <WrenchScrewdriverIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Test Configuration</h3> <h3 class="text-sm font-medium text-gray-900 dark:text-white">Configuration de test (health check)</h3>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div> <div>
<label for="testMangaSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label for="testSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Manga Slug Manga Slug <span class="text-gray-500">(enregistré)</span>
</label> </label>
<input <input
id="testMangaSlug" id="testSlug"
v-model="testData.mangaSlug" v-model="form.testSlug"
type="text" type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="manga-slug" /> placeholder="manga-slug" />
</div> </div>
<div> <div>
<label for="testChapterNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label for="testChapterNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Chapter Number Numéro de chapitre <span class="text-gray-500">(enregistré)</span>
</label> </label>
<input <input
id="testChapterNumber" id="testChapterNumber"
v-model="testData.chapterNumber" v-model="form.testChapterNumber"
type="number" type="number"
step="0.1" step="0.1"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="1" /> placeholder="1" />
</div> </div>
</div> </div>
<!-- Preview de l'URL qui sera testée --> <!-- Preview URL -->
<div v-if="generatedTestUrl" class="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md"> <div v-if="generatedTestUrl" class="mb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="text-sm text-blue-800 dark:text-blue-200"> <p class="text-xs text-gray-500 dark:text-gray-400 mb-1">URL qui sera testée</p>
<strong>URL qui sera testée :</strong> <code class="text-xs text-gray-700 dark:text-gray-300 break-all">{{ generatedTestUrl }}</code>
<div class="mt-1 font-mono text-xs break-all">{{ generatedTestUrl }}</div>
</div>
</div> </div>
<button <button
type="button" type="button"
@click="testConfiguration" @click="testConfiguration"
:disabled="testing || !canTest" :disabled="testing || !canTest"
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center justify-center space-x-2"> class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200 flex items-center justify-center space-x-2">
<ArrowPathIcon v-if="testing" class="w-4 h-4 animate-spin" /> <ArrowPathIcon v-if="testing" class="w-4 h-4 animate-spin" />
<PlayIcon v-else class="w-4 h-4" /> <PlayIcon v-else class="w-4 h-4" />
<span>Test Configuration</span> <span>Tester maintenant</span>
</button> </button>
</div> </div>
</div> </div>
@@ -183,8 +160,6 @@
<script setup> <script setup>
import { import {
ArrowPathIcon, ArrowPathIcon,
Cog6ToothIcon,
PencilSquareIcon,
PlayIcon, PlayIcon,
WrenchScrewdriverIcon WrenchScrewdriverIcon
} from '@heroicons/vue/24/outline'; } from '@heroicons/vue/24/outline';
@@ -216,12 +191,9 @@ const form = ref({
nextPageSelector: '', nextPageSelector: '',
chapterSelector: '', chapterSelector: '',
scrapingType: 'html', scrapingType: 'html',
token: '' token: '',
}); testSlug: '',
testChapterNumber: '',
const testData = ref({
mangaSlug: '',
chapterNumber: ''
}); });
const testing = ref(false); const testing = ref(false);
@@ -229,20 +201,19 @@ const testing = ref(false);
const canTest = computed(() => { const canTest = computed(() => {
return form.value.baseUrl && return form.value.baseUrl &&
form.value.chapterUrlFormat && form.value.chapterUrlFormat &&
testData.value.mangaSlug && form.value.testSlug &&
testData.value.chapterNumber; form.value.testChapterNumber;
}); });
const generatedTestUrl = computed(() => { const generatedTestUrl = computed(() => {
if (!form.value.chapterUrlFormat || !testData.value.mangaSlug || !testData.value.chapterNumber) { if (!form.value.chapterUrlFormat || !form.value.testSlug || !form.value.testChapterNumber) {
return ''; return '';
} }
return form.value.chapterUrlFormat return form.value.chapterUrlFormat
.replace('{slug}', testData.value.mangaSlug) .replace('{slug}', form.value.testSlug)
.replace('{chapterNumber}', testData.value.chapterNumber); .replace('{chapterNumber}', form.value.testChapterNumber);
}); });
// Initialize form with source data if editing, clear if creating new
watch(() => props.source, (newSource) => { watch(() => props.source, (newSource) => {
if (newSource) { if (newSource) {
form.value = { form.value = {
@@ -252,10 +223,11 @@ watch(() => props.source, (newSource) => {
nextPageSelector: newSource.nextPageSelector || '', nextPageSelector: newSource.nextPageSelector || '',
chapterSelector: newSource.chapterSelector || '', chapterSelector: newSource.chapterSelector || '',
scrapingType: (newSource.scrapingType || 'html').toLowerCase(), scrapingType: (newSource.scrapingType || 'html').toLowerCase(),
token: newSource.token || '' token: newSource.token || '',
testSlug: newSource.testSlug || '',
testChapterNumber: newSource.testChapterNumber ?? '',
}; };
} else { } else {
// Reset form when no source (creating new)
form.value = { form.value = {
baseUrl: '', baseUrl: '',
imageSelector: '', imageSelector: '',
@@ -263,23 +235,37 @@ watch(() => props.source, (newSource) => {
nextPageSelector: '', nextPageSelector: '',
chapterSelector: '', chapterSelector: '',
scrapingType: 'html', scrapingType: 'html',
token: '' token: '',
testSlug: '',
testChapterNumber: '',
}; };
} }
}, { immediate: true }); }, { immediate: true });
const handleSubmit = () => { const buildPayload = (formData) => {
emit('submit', { ...form.value }); const data = { ...formData };
const raw = data.testChapterNumber;
data.testChapterNumber = (raw === '' || raw === null || raw === undefined)
? null
: parseFloat(raw);
return data;
}; };
const handleSubmit = () => {
emit('submit', buildPayload(form.value));
};
defineExpose({ submitForm: handleSubmit });
const testConfiguration = async () => { const testConfiguration = async () => {
testing.value = true; testing.value = true;
try { try {
await emit('test', { await emit('test', {
configuration: { ...form.value }, configuration: buildPayload(form.value),
testData: { testData: {
...testData.value, mangaSlug: form.value.testSlug,
testUrl: generatedTestUrl.value chapterNumber: form.value.testChapterNumber,
testUrl: generatedTestUrl.value,
} }
}); });
} finally { } finally {

View File

@@ -3,72 +3,54 @@
<Toolbar :config="toolbarConfig" /> <Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1"> <div class="overflow-y-auto flex-1">
<div class="container mx-auto px-4 py-6"> <div class="px-6 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Scrapper Configurations
</h1>
<p class="text-gray-600 dark:text-gray-400">
Gérez les configurations de scraping pour les différentes sources de manga
</p>
</div>
<!-- Loading State --> <!-- Loading State -->
<div v-if="loadingSources" class="flex justify-center py-12"> <div v-if="loadingSources" class="flex justify-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div> <div class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
</div> </div>
<!-- Error State --> <!-- Error State -->
<div v-else-if="sourcesError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6"> <div v-else-if="sourcesError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-6">
<div class="flex items-center"> <div class="flex items-center">
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" /> <ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
<p class="text-red-800 dark:text-red-200">{{ sourcesError }}</p> <p class="text-red-800 dark:text-red-200">{{ sourcesError }}</p>
</div> </div>
<button <button
@click="contentSourceStore.loadSources()" @click="contentSourceStore.loadSources()"
class="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"> class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700">
Réessayer Réessayer
</button> </button>
</div> </div>
<!-- Debug Info (temporary) -->
<div v-if="!loadingSources && !sourcesError && sources.length === 0" class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6">
<p class="text-blue-800 dark:text-blue-200">Aucune source trouvée. Rechargement en cours...</p>
<button
@click="contentSourceStore.loadSources()"
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Actualiser
</button>
</div>
<!-- Sources Grid --> <!-- Sources Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <section v-else class="border-t border-gray-200 dark:border-gray-700 pt-6">
<!-- Existing Sources --> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<ContentSourceCard <!-- Existing Sources -->
v-for="source in sources" <ContentSourceCard
:key="source.id" v-for="source in sources"
:source="source" :key="source.id"
@edit="editSource" :source="source"
@open-link="openSourceLink" /> @edit="editSource"
@open-link="openSourceLink" />
<!-- Add New Configuration Card --> <!-- Add New Configuration Card -->
<div <div
@click="addNewSource" @click="addNewSource"
class="bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer flex flex-col items-center justify-center h-full"> class="bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 p-6 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer flex flex-col items-center justify-center h-full">
<PlusIcon class="w-8 h-8 text-gray-400 dark:text-gray-500 mb-3" /> <PlusIcon class="w-8 h-8 text-gray-400 dark:text-gray-500 mb-3" />
<span class="text-lg font-medium text-gray-600 dark:text-gray-400 mb-2"> <span class="text-lg font-medium text-gray-600 dark:text-gray-400 mb-2">
Add New Configuration Add New Configuration
</span> </span>
</div>
</div> </div>
</div> </section>
<!-- Import/Export Success Messages --> <!-- Import/Export Success Messages -->
<div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg"> <div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
Configuration importée avec succès ! Configuration importée avec succès !
</div> </div>
<div v-if="showExportSuccess" class="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg"> <div v-if="showExportSuccess" class="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 shadow-lg">
Configuration exportée ! Configuration exportée !
</div> </div>
</div> </div>
@@ -76,12 +58,12 @@
<!-- Import Modal --> <!-- Import Modal -->
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md"> <div class="bg-white dark:bg-gray-800 shadow-xl w-full max-w-md">
<div class="p-6"> <div class="p-6">
<h3 class="text-lg font-semibold mb-4">Importer des configurations</h3> <h3 class="text-lg font-semibold mb-4">Importer des configurations</h3>
<textarea <textarea
v-model="importData" v-model="importData"
class="w-full h-40 p-3 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white" class="w-full h-40 p-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Collez ici le JSON des configurations à importer..."></textarea> placeholder="Collez ici le JSON des configurations à importer..."></textarea>
<div class="flex justify-end space-x-3 mt-4"> <div class="flex justify-end space-x-3 mt-4">
@@ -93,7 +75,7 @@
<button <button
@click="handleImport" @click="handleImport"
:disabled="importing || !importData.trim()" :disabled="importing || !importData.trim()"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md"> class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white">
{{ importing ? 'Import...' : 'Importer' }} {{ importing ? 'Import...' : 'Importer' }}
</button> </button>
</div> </div>
@@ -109,10 +91,11 @@ import {
ArrowPathIcon, ArrowPathIcon,
ArrowUpTrayIcon, ArrowUpTrayIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
HeartIcon,
PlusIcon PlusIcon
} from '@heroicons/vue/24/outline'; } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue'; import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useContentSourceStore } from '../../application/store/contentSourceStore'; import { useContentSourceStore } from '../../application/store/contentSourceStore';
@@ -126,9 +109,13 @@ const {
loadingSources, loadingSources,
sourcesError, sourcesError,
importing, importing,
exporting exporting,
checkingHealth,
} = storeToRefs(contentSourceStore); } = storeToRefs(contentSourceStore);
// Mercure — écoute des mises à jour health
let mercureEventSource = null;
// Local state // Local state
const showImportModal = ref(false); const showImportModal = ref(false);
const showExportSuccess = ref(false); const showExportSuccess = ref(false);
@@ -138,40 +125,45 @@ const importData = ref('');
// Load sources on mount and clear current source // Load sources on mount and clear current source
onMounted(async () => { onMounted(async () => {
try { try {
contentSourceStore.clearCurrentSource(); // Clear any previously loaded source contentSourceStore.clearCurrentSource();
contentSourceStore.clearErrors(); // Clear any previous errors contentSourceStore.clearErrors();
await contentSourceStore.loadSources(); await contentSourceStore.loadSources();
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des sources:', error); console.error('Erreur lors du chargement des sources:', error);
} }
// Écoute Mercure pour les mises à jour de health status
const url = new URL('/.well-known/mercure', window.location.href);
sources.value.forEach(source => {
url.searchParams.append('topic', `scrapers/health/${source.id}`);
});
mercureEventSource = new EventSource(url.toString());
mercureEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
contentSourceStore.updateSourceHealth(data.sourceId, data.status, data.error);
} catch (e) {
console.error('Erreur parsing Mercure event:', e);
}
};
});
onUnmounted(() => {
mercureEventSource?.close();
}); });
// Toolbar configuration // Toolbar configuration
const toolbarConfig = computed(() => ({ const toolbarConfig = computed(() => ({
leftSection: [ leftSection: [
{ { type: 'label', text: 'Scrapers', class: 'text-sm font-medium' },
icon: ArrowPathIcon,
label: 'Actualiser',
type: 'button',
onClick: () => contentSourceStore.loadSources(),
active: loadingSources.value
}
], ],
rightSection: [ rightSection: [
{ { type: 'button', icon: ArrowPathIcon, label: 'Actualiser', onClick: () => contentSourceStore.loadSources(), disabled: loadingSources.value },
icon: ArrowDownTrayIcon, { type: 'button', icon: HeartIcon, label: 'Tester tous', onClick: handleCheckAllHealth, disabled: checkingHealth.value },
label: 'Exporter', { type: 'button', icon: ArrowDownTrayIcon, label: 'Exporter', onClick: handleExport, disabled: exporting.value },
type: 'button', { type: 'button', icon: ArrowUpTrayIcon, label: 'Importer', onClick: () => showImportModal.value = true },
onClick: handleExport, ],
disabled: exporting.value
},
{
icon: ArrowUpTrayIcon,
label: 'Importer',
type: 'button',
onClick: () => showImportModal.value = true
}
]
})); }));
// Actions // Actions
@@ -190,6 +182,14 @@ const openSourceLink = (url) => {
window.open(url, '_blank'); window.open(url, '_blank');
}; };
async function handleCheckAllHealth() {
try {
await contentSourceStore.checkAllHealth();
} catch (error) {
console.error('Erreur lors du health check:', error);
}
}
async function handleExport() { async function handleExport() {
try { try {
const exportData = await contentSourceStore.exportSources(); const exportData = await contentSourceStore.exportSources();

View File

@@ -3,43 +3,36 @@
<Toolbar :config="toolbarConfig" /> <Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1"> <div class="overflow-y-auto flex-1">
<div class="container mx-auto px-4 py-6"> <div class="px-6 py-8">
<!-- Back Navigation --> <section class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="mb-6"> <!-- Loading State -->
<button <div v-if="loadingCurrentSource" class="flex justify-center py-12">
@click="goBack" <div class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
<ArrowLeftIcon class="w-5 h-5" />
<span>Retour aux configurations</span>
</button>
</div>
<!-- Loading State -->
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
<!-- Error State -->
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
<div class="flex items-center">
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
</div> </div>
</div>
<!-- Form --> <!-- Error State -->
<div v-else class="max-w-4xl mx-auto"> <div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-6">
<ContentSourceForm <div class="flex items-center">
:source="currentSource" <ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
:saving="saving" <p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
:error="saveError" </div>
@submit="handleSubmit" </div>
@test="handleTest" />
</div> <!-- Form -->
<div v-else>
<ContentSourceForm
ref="formRef"
:source="currentSource"
:saving="saving"
:error="saveError"
@submit="handleSubmit"
@test="handleTest" />
</div>
</section>
<!-- Test Results Modal --> <!-- Test Results Modal -->
<div v-if="showTestResults" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div v-if="showTestResults" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden"> <div class="bg-white dark:bg-gray-800 shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden">
<div class="p-6 border-b border-gray-200 dark:border-gray-600"> <div class="p-6 border-b border-gray-200 dark:border-gray-600">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Résultats du test</h3> <h3 class="text-lg font-semibold">Résultats du test</h3>
@@ -54,7 +47,7 @@
<div class="p-6 overflow-y-auto"> <div class="p-6 overflow-y-auto">
<!-- Loading state during test --> <!-- Loading state during test -->
<div v-if="testingConfiguration" class="flex items-center justify-center py-8"> <div v-if="testingConfiguration" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div> <div class="animate-spin h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
<span class="text-gray-600">Test en cours...</span> <span class="text-gray-600">Test en cours...</span>
</div> </div>
@@ -65,7 +58,7 @@
<span class="font-medium">Test réussi !</span> <span class="font-medium">Test réussi !</span>
</div> </div>
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4"> <div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 p-4">
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span class="font-medium text-green-800 dark:text-green-200">URL testée:</span> <span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
@@ -92,10 +85,11 @@
<img <img
:src="imageUrl" :src="imageUrl"
:alt="`Image ${index + 1}`" :alt="`Image ${index + 1}`"
class="w-full h-32 object-cover rounded border border-gray-200 dark:border-gray-600" class="w-full h-32 object-cover border border-gray-200 dark:border-gray-600"
referrerpolicy="no-referrer"
@error="handleImageError" @error="handleImageError"
@load="handleImageLoad" /> @load="handleImageLoad" />
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity rounded flex items-center justify-center"> <div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity flex items-center justify-center">
<span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium"> <span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium">
Page {{ index + 1 }} Page {{ index + 1 }}
</span> </span>
@@ -107,7 +101,7 @@
</p> </p>
</div> </div>
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4"> <div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 p-4">
<div class="flex items-center"> <div class="flex items-center">
<ExclamationTriangleIcon class="w-5 h-5 text-yellow-400 mr-2" /> <ExclamationTriangleIcon class="w-5 h-5 text-yellow-400 mr-2" />
<p class="text-yellow-800 dark:text-yellow-200"> <p class="text-yellow-800 dark:text-yellow-200">
@@ -125,7 +119,7 @@
<span class="font-medium">Test échoué</span> <span class="font-medium">Test échoué</span>
</div> </div>
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-4"> <div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-4">
<div class="text-sm text-red-800 dark:text-red-200"> <div class="text-sm text-red-800 dark:text-red-200">
<div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div> <div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div>
<div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div> <div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div>
@@ -138,14 +132,14 @@
<div <div
v-for="(error, index) in testResults.errors" v-for="(error, index) in testResults.errors"
:key="index" :key="index"
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4 rounded"> class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4">
<div class="flex items-start"> <div class="flex items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<ExclamationTriangleIcon class="w-5 h-5 text-red-400" /> <ExclamationTriangleIcon class="w-5 h-5 text-red-400" />
</div> </div>
<div class="ml-3 flex-1"> <div class="ml-3 flex-1">
<div class="flex items-center mb-1"> <div class="flex items-center mb-1">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2"> <span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
{{ formatErrorType(error.type) }} {{ formatErrorType(error.type) }}
</span> </span>
<span class="text-sm font-medium text-red-800 dark:text-red-200"> <span class="text-sm font-medium text-red-800 dark:text-red-200">
@@ -155,7 +149,7 @@
<p class="text-sm text-red-700 dark:text-red-300 mb-2"> <p class="text-sm text-red-700 dark:text-red-300 mb-2">
{{ error.message }} {{ error.message }}
</p> </p>
<div class="bg-red-50 dark:bg-red-900 rounded p-2"> <div class="bg-red-50 dark:bg-red-900 p-2">
<p class="text-xs text-red-600 dark:text-red-400"> <p class="text-xs text-red-600 dark:text-red-400">
<strong>Suggestion :</strong> {{ error.suggestion }} <strong>Suggestion :</strong> {{ error.suggestion }}
</p> </p>
@@ -166,7 +160,7 @@
</div> </div>
<!-- Generic Error --> <!-- Generic Error -->
<div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded p-3"> <div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-3">
<code class="text-sm text-red-800 dark:text-red-200"> <code class="text-sm text-red-800 dark:text-red-200">
{{ testResults.error }} {{ testResults.error }}
</code> </code>
@@ -177,11 +171,20 @@
</div> </div>
<!-- Success Message --> <!-- Success Message -->
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg"> <div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès ! Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Modal -->
<ContentSourceDeleteModal
:is-open="isDeleteModalOpen"
:source="currentSource"
:is-loading="isDeleting"
:error="deleteError"
@close="isDeleteModalOpen = false"
@confirm="confirmDeleteSource" />
</div> </div>
</template> </template>
@@ -190,6 +193,8 @@ import {
ArrowLeftIcon, ArrowLeftIcon,
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
PencilSquareIcon,
TrashIcon,
XCircleIcon, XCircleIcon,
XMarkIcon XMarkIcon
} from '@heroicons/vue/24/outline'; } from '@heroicons/vue/24/outline';
@@ -199,6 +204,7 @@ import { useRoute, useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue'; import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useContentSourceStore } from '../../application/store/contentSourceStore'; import { useContentSourceStore } from '../../application/store/contentSourceStore';
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository'; import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
import ContentSourceDeleteModal from '../components/ContentSourceDeleteModal.vue';
import ContentSourceForm from '../components/ContentSourceForm.vue'; import ContentSourceForm from '../components/ContentSourceForm.vue';
const route = useRoute(); const route = useRoute();
@@ -214,11 +220,17 @@ const {
saveError saveError
} = storeToRefs(contentSourceStore); } = storeToRefs(contentSourceStore);
// Form ref
const formRef = ref(null);
// Local state // Local state
const showTestResults = ref(false); const showTestResults = ref(false);
const showSuccessMessage = ref(false); const showSuccessMessage = ref(false);
const testResults = ref({}); const testResults = ref({});
const testingConfiguration = ref(false); const testingConfiguration = ref(false);
const isDeleteModalOpen = ref(false);
const isDeleting = ref(false);
const deleteError = ref(null);
const isEditing = computed(() => !!route.params.id); const isEditing = computed(() => !!route.params.id);
@@ -233,16 +245,19 @@ onMounted(async () => {
}); });
// Toolbar configuration // Toolbar configuration
const toolbarConfig = { const toolbarConfig = computed(() => ({
leftSection: [], leftSection: [
rightSection: [] { type: 'button', icon: ArrowLeftIcon, label: 'Retour', onClick: () => router.push({ name: 'scrapper-configurations' }) },
}; { type: 'divider' },
{ type: 'label', text: isEditing.value ? 'Modifier la configuration' : 'Nouvelle configuration', class: 'text-sm font-medium' },
],
rightSection: [
...(isEditing.value ? [{ type: 'button', icon: TrashIcon, label: 'Supprimer', onClick: () => { isDeleteModalOpen.value = true; }, class: 'text-red-600 hover:text-red-700' }, { type: 'divider' }] : []),
{ type: 'button', icon: PencilSquareIcon, label: isEditing.value ? 'Mettre à jour' : 'Créer', onClick: () => formRef.value?.submitForm(), disabled: saving.value },
],
}));
// Actions // Actions
const goBack = () => {
router.push({ name: 'scrapper-configurations' });
};
const handleSubmit = async (formData) => { const handleSubmit = async (formData) => {
try { try {
if (isEditing.value) { if (isEditing.value) {
@@ -279,6 +294,11 @@ const handleTest = async ({ configuration, testData }) => {
testResults.value = {}; testResults.value = {};
try { try {
// Persister testSlug + testChapterNumber avant de lancer le test
if (isEditing.value) {
await contentSourceStore.updateSource(route.params.id, configuration);
}
// Préparer les données selon le format de l'API // Préparer les données selon le format de l'API
const testConfiguration = { const testConfiguration = {
baseUrl: configuration.baseUrl, baseUrl: configuration.baseUrl,
@@ -323,6 +343,21 @@ const handleImageLoad = (event) => {
event.target.style.display = 'block'; event.target.style.display = 'block';
}; };
const confirmDeleteSource = async () => {
isDeleting.value = true;
deleteError.value = null;
try {
await contentSourceStore.deleteSource(route.params.id);
isDeleteModalOpen.value = false;
await router.push({ name: 'scrapper-configurations' });
} catch (error) {
deleteError.value = error.message;
} finally {
isDeleting.value = false;
}
};
const formatErrorType = (type) => { const formatErrorType = (type) => {
const typeMap = { const typeMap = {
'selector_error': 'Erreur sélecteur', 'selector_error': 'Erreur sélecteur',

View File

@@ -0,0 +1,110 @@
import { defineStore } from 'pinia';
import { ApiJobRepository } from '../../../activity/infrastructure/api/ApiJobRepository';
const jobRepository = new ApiJobRepository();
// Statuts disponibles par filtre
const STATUS_MAP = {
failed: ['failed'],
completed: ['completed'],
all: ['failed', 'completed'],
};
export const useLogsStore = defineStore('logs', {
state: () => ({
logs: [],
loading: false,
error: null,
currentPage: 1,
totalPages: 0,
total: 0,
limit: 50,
hasNextPage: false,
hasPreviousPage: false,
sortBy: 'createdAt',
sortOrder: 'DESC',
statusFilter: 'failed', // 'failed' | 'completed' | 'all'
}),
getters: {
isLoading: state => state.loading,
hasError: state => !!state.error,
},
actions: {
async loadLogs(page = null) {
this.loading = true;
this.error = null;
try {
const collection = await jobRepository.getJobs({
page: page || this.currentPage,
limit: this.limit,
sortBy: this.sortBy,
sortOrder: this.sortOrder,
status: STATUS_MAP[this.statusFilter],
type: 'scraping_job',
});
this.logs = collection.items;
this.currentPage = collection.page;
this.total = collection.total;
this.hasNextPage = collection.hasNextPage;
this.hasPreviousPage = collection.hasPreviousPage;
this.totalPages = Math.ceil(this.total / this.limit);
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
async goToPage(page) {
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
this.currentPage = page;
await this.loadLogs(page);
}
},
async updateSort(sortBy, sortOrder) {
this.sortBy = sortBy;
this.sortOrder = sortOrder;
this.currentPage = 1;
await this.loadLogs(1);
},
async setStatusFilter(filter) {
this.statusFilter = filter;
this.currentPage = 1;
await this.loadLogs(1);
},
async deleteLog(id) {
try {
await jobRepository.deleteJob(id);
this.logs = this.logs.filter(log => log.id !== id);
this.total = Math.max(0, this.total - 1);
this.totalPages = Math.ceil(this.total / this.limit);
} catch (error) {
this.error = error.message;
}
},
async deleteAllLogs() {
this.loading = true;
this.error = null;
try {
await jobRepository.deleteJobs({
status: STATUS_MAP[this.statusFilter].join(','),
type: 'scraping_job',
});
await this.loadLogs(1);
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineStore } from 'pinia';
import { ApiStatusRepository } from '../../infrastructure/api/ApiStatusRepository';
const statusRepository = new ApiStatusRepository();
export const useStatusStore = defineStore('system-status', {
state: () => ({
status: null,
loading: false,
error: null,
}),
actions: {
async loadStatus() {
this.loading = true;
this.error = null;
try {
this.status = await statusRepository.getStatus();
} catch (e) {
this.error = e.message ?? 'Erreur lors du chargement du statut système';
} finally {
this.loading = false;
}
},
},
});

View File

@@ -0,0 +1,13 @@
export class ApiStatusRepository {
async getStatus() {
const response = await fetch('/api/system/status', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Erreur HTTP ${response.status}`);
}
return response.json();
}
}

View File

@@ -0,0 +1,36 @@
<template>
<StatusCard title="Chapitres" :icon="DocumentTextIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalChapters }}</span>
<span class="text-sm text-gray-500">total</span>
</div>
<div class="mb-1 flex justify-between text-xs text-gray-500">
<span>{{ status.downloadedChapters }} téléchargés</span>
<span>{{ downloadedPercent }}%</span>
</div>
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-green-500 rounded-full transition-all"
:style="{ width: downloadedPercent + '%' }" />
</div>
<p class="mt-1 text-xs text-gray-400">{{ status.pendingChapters }} en attente</p>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { DocumentTextIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const downloadedPercent = computed(() => {
if (!props.status.totalChapters) return 0;
return Math.round((props.status.downloadedChapters / props.status.totalChapters) * 100);
});
</script>

View File

@@ -0,0 +1,104 @@
<template>
<StatusCard title="Jobs" :icon="CpuChipIcon">
<!-- Onglets -->
<div class="flex gap-1 mb-3 border-b border-gray-200 dark:border-gray-700">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="px-3 py-1.5 text-xs font-medium transition-colors"
:class="activeTab === tab.key
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'">
{{ tab.label }}
</button>
</div>
<!-- Contenu -->
<template v-if="activeTab === 'global'">
<div class="grid grid-cols-2 gap-2">
<Metric label="Total" :value="status.totalJobs" />
<Metric label="En cours" :value="status.inProgressJobs" color="blue" />
<Metric label="Terminés" :value="status.completedJobs" color="green" />
<Metric label="En attente" :value="status.pendingJobs" color="yellow" />
<Metric label="Échoués" :value="status.failedJobs" color="red" />
</div>
</template>
<template v-else-if="activeTab === '24h'">
<div class="grid grid-cols-2 gap-2">
<Metric label="Total" :value="status.totalJobsLast24h" />
<Metric label="Terminés" :value="status.completedJobsLast24h" color="green" />
<Metric label="Échoués" :value="status.failedJobsLast24h" color="red" />
<div class="col-span-2">
<p class="text-xs text-gray-500 mb-1">Taux de succès</p>
<span class="text-xl font-bold" :class="rateColor(status.successRateLast24h)">
{{ status.successRateLast24h }}%
</span>
</div>
</div>
</template>
<template v-else>
<div class="grid grid-cols-2 gap-2">
<Metric label="Total" :value="status.totalJobsLast7d" />
<Metric label="Terminés" :value="status.completedJobsLast7d" color="green" />
<Metric label="Échoués" :value="status.failedJobsLast7d" color="red" />
<div class="col-span-2">
<p class="text-xs text-gray-500 mb-1">Taux de succès</p>
<span class="text-xl font-bold" :class="rateColor(status.successRateLast7d)">
{{ status.successRateLast7d }}%
</span>
</div>
</div>
</template>
</StatusCard>
</template>
<script setup>
import { ref } from 'vue';
import { CpuChipIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
defineProps({
status: {
type: Object,
required: true,
},
});
const activeTab = ref('global');
const tabs = [
{ key: 'global', label: 'Global' },
{ key: '24h', label: '24h' },
{ key: '7j', label: '7 jours' },
];
function rateColor(rate) {
if (rate >= 80) return 'text-green-600 dark:text-green-400';
if (rate >= 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
}
const Metric = {
props: {
label: String,
value: Number,
color: { type: String, default: 'gray' },
},
template: `
<div>
<p class="text-xs text-gray-500">{{ label }}</p>
<p class="text-lg font-semibold"
:class="{
'text-gray-900 dark:text-white': color === 'gray',
'text-green-600 dark:text-green-400': color === 'green',
'text-red-600 dark:text-red-400': color === 'red',
'text-yellow-600 dark:text-yellow-400': color === 'yellow',
'text-blue-600 dark:text-blue-400': color === 'blue',
}">{{ value }}</p>
</div>
`,
};
</script>

View File

@@ -0,0 +1,131 @@
<template>
<div class="border-t border-gray-200 dark:border-gray-700 py-4 px-6">
<!-- Ligne 1 : Titre manga + chapitre + badge statut + date + bouton supprimer -->
<div class="flex items-start justify-between gap-4">
<div class="flex items-baseline gap-2 min-w-0">
<span class="font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ log.context?.mangaTitle ?? 'Manga inconnu' }}
</span>
<span class="text-gray-400 dark:text-gray-500 text-sm shrink-0"></span>
<span class="text-sm text-gray-600 dark:text-gray-400 shrink-0">
Chapitre {{ log.context?.chapterNumber ?? '?' }}
</span>
<span
:class="[
'px-1.5 py-0.5 text-xs font-medium shrink-0',
log.status === 'completed'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
]">
{{ log.status === 'completed' ? 'Terminé' : 'Échec' }}
</span>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ formattedDate }}</span>
<button
@click="$emit('delete', log.id)"
class="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors"
title="Supprimer ce log">
<TrashIcon class="w-4 h-4" />
</button>
</div>
</div>
<!-- Ligne 2 : Source + slug + durée -->
<div class="flex items-center justify-between mt-1 gap-4">
<div class="flex items-center gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400">
<!-- Domaine de la source (lien vers la page d'édition) -->
<RouterLink
v-if="source"
:to="{ name: 'scrapper-edit', params: { id: source.id } }"
class="flex items-center gap-1 hover:text-blue-500 dark:hover:text-blue-400 transition-colors shrink-0">
<GlobeAltIcon class="w-3.5 h-3.5" />
<span class="font-mono">{{ cleanDomain }}</span>
</RouterLink>
<span v-else class="font-mono shrink-0">
ID {{ log.context?.sourceId ?? '-' }}
</span>
<!-- Badge type de scraping -->
<span
v-if="source?.scrapingType"
:class="[
'px-1.5 py-0.5 text-xs font-medium shrink-0',
source.scrapingType === 'Javascript'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
]">
{{ source.scrapingType }}
</span>
<!-- Slug utilisé -->
<span v-if="log.context?.slug" class="truncate text-gray-400 dark:text-gray-500">
slug : <span class="font-mono">{{ log.context.slug }}</span>
</span>
</div>
<span v-if="duration !== null" class="text-xs text-gray-400 dark:text-gray-500 shrink-0">
{{ duration }}
</span>
</div>
<!-- Ligne 3 : Message d'erreur -->
<div v-if="log.error" class="mt-2">
<p
:class="[
'text-sm font-mono text-red-600 dark:text-red-400',
!expanded && isLong ? 'line-clamp-1' : ''
]">
{{ log.error }}
</p>
<button
v-if="isLong"
@click="expanded = !expanded"
class="mt-1 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
{{ expanded ? 'voir moins' : 'voir plus' }}
</button>
</div>
</div>
</template>
<script setup>
import { GlobeAltIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { computed, ref } from 'vue';
import { RouterLink } from 'vue-router';
const props = defineProps({
log: {
type: Object,
required: true,
},
source: {
type: Object,
default: null,
},
});
defineEmits(['delete']);
const expanded = ref(false);
const isLong = computed(() => props.log.error && props.log.error.length > 120);
const cleanDomain = computed(() => {
if (!props.source?.baseUrl) return null;
return props.source.baseUrl.replace(/^(https?:\/\/)?(www\.)?/, '').replace(/\/+$/, '');
});
const formattedDate = computed(() => {
if (!props.log.createdAt) return '';
const d = new Date(props.log.createdAt);
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
});
const duration = computed(() => {
if (!props.log.startedAt || !props.log.completedAt) return null;
const ms = new Date(props.log.completedAt) - new Date(props.log.startedAt);
if (ms < 0) return null;
return `${(ms / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 })}s`;
});
</script>

View File

@@ -0,0 +1,33 @@
<template>
<StatusCard title="Mangas" :icon="BookOpenIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalMangas }}</span>
<span class="text-sm text-gray-500">total</span>
<span class="ml-auto text-sm text-blue-600 dark:text-blue-400">{{ status.monitoredMangas }} suivis</span>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(count, label) in status.mangasByStatus"
:key="label"
class="px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400">
{{ label }}: {{ count }}
</span>
<span v-if="!hasStatuses" class="text-xs text-gray-400">Aucun statut disponible</span>
</div>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { BookOpenIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const hasStatuses = computed(() => Object.keys(props.status.mangasByStatus ?? {}).length > 0);
</script>

View File

@@ -0,0 +1,41 @@
<template>
<StatusCard title="Sources" :icon="GlobeAltIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalSources }}</span>
<span class="text-sm text-gray-500">sources configurées</span>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(count, health) in status.sourcesByHealth"
:key="health"
class="px-2 py-0.5 text-xs rounded-full"
:class="healthBadgeClass(health)">
{{ health }}: {{ count }}
</span>
<span v-if="!hasSources" class="text-xs text-gray-400">Aucune source</span>
</div>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { GlobeAltIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const hasSources = computed(() => Object.keys(props.status.sourcesByHealth ?? {}).length > 0);
function healthBadgeClass(health) {
switch (health) {
case 'healthy': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'unhealthy': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
}
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<component :is="icon" v-if="icon" class="w-5 h-5 text-blue-500 shrink-0" />
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ title }}</h3>
</div>
<slot />
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true,
},
icon: {
type: [Object, Function],
default: null,
},
});
</script>

View File

@@ -0,0 +1,40 @@
<template>
<StatusCard title="Stockage" :icon="CircleStackIcon">
<div class="flex items-baseline gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.storageUsedHuman }}</span>
<span class="text-sm text-gray-500">utilisés</span>
</div>
<div class="mb-1 flex justify-between text-xs text-gray-500">
<span>{{ status.storageFreeHuman }} libres</span>
<span>{{ usedPercent }}%</span>
</div>
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="usedPercent > 90 ? 'bg-red-500' : 'bg-blue-500'"
:style="{ width: usedPercent + '%' }" />
</div>
<p class="mt-1 text-xs text-gray-400">Total : {{ status.storageTotalHuman }}</p>
<p class="mt-1 text-xs text-gray-400 truncate" :title="status.storagePath">{{ status.storagePath }}</p>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { CircleStackIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const diskUsedBytes = computed(() => props.status.storageTotalBytes - props.status.storageFreeBytes);
const usedPercent = computed(() => {
if (!props.status.storageTotalBytes) return 0;
return Math.round((diskUsedBytes.value / props.status.storageTotalBytes) * 100);
});
</script>

View File

@@ -0,0 +1,32 @@
<template>
<StatusCard title="Informations système" :icon="ServerIcon">
<dl class="space-y-2">
<div class="flex justify-between text-sm">
<dt class="text-gray-500">Version PHP</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ status.phpVersion }}</dd>
</div>
<div class="flex justify-between text-sm">
<dt class="text-gray-500">Généré le</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ formattedDate }}</dd>
</div>
</dl>
</StatusCard>
</template>
<script setup>
import { computed } from 'vue';
import { ServerIcon } from '@heroicons/vue/24/outline';
import StatusCard from './StatusCard.vue';
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const formattedDate = computed(() => {
if (!props.status.generatedAt) return '';
return new Date(props.status.generatedAt).toLocaleString('fr-FR');
});
</script>

View File

@@ -0,0 +1,165 @@
<template>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<section class="border-t border-gray-200 dark:border-gray-700">
<!-- Loading -->
<div v-if="isLoading" class="flex justify-center py-12">
<div class="animate-spin h-10 w-10 border-b-2 border-blue-500 rounded-full"></div>
</div>
<!-- Error -->
<div v-else-if="hasError" class="px-6 py-8">
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4">
<div class="flex items-center">
<ExclamationCircleIcon class="w-5 h-5 text-red-400 mr-2 shrink-0" />
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
</div>
<button
@click="logsStore.loadLogs()"
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700">
Réessayer
</button>
</div>
</div>
<!-- Empty -->
<div v-else-if="!isLoading && logs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
<ExclamationCircleIcon class="w-12 h-12 mb-3" />
<p class="text-base">Aucune erreur de scraping</p>
</div>
<!-- List -->
<template v-else>
<LogItem
v-for="log in logs"
:key="log.id"
:log="log"
:source="getSource(log)"
@delete="handleDelete" />
</template>
</section>
<!-- Pagination -->
<Pagination
v-if="totalPages > 1"
:current-page="currentPage"
:total-pages="totalPages"
:total="total"
:limit="limit"
:has-next-page="hasNextPage"
:has-previous-page="hasPreviousPage"
@page-change="logsStore.goToPage" />
</div>
</div>
</template>
<script setup>
import { ArrowPathIcon, ExclamationCircleIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { BarsArrowDownIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted } from 'vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import Pagination from '../../../../shared/components/ui/Pagination.vue';
import { useContentSourceStore } from '../../../setting/application/store/contentSourceStore';
import { useLogsStore } from '../../application/store/logsStore';
import LogItem from '../components/LogItem.vue';
const logsStore = useLogsStore();
const contentSourceStore = useContentSourceStore();
const { sources } = storeToRefs(contentSourceStore);
const {
logs,
loading: isLoading,
error,
currentPage,
totalPages,
total,
limit,
hasNextPage,
hasPreviousPage,
sortBy,
sortOrder,
statusFilter,
} = storeToRefs(logsStore);
const hasError = computed(() => !!error.value);
onMounted(() => {
logsStore.loadLogs();
contentSourceStore.loadSources();
});
function getSource(log) {
const sourceId = log.context?.sourceId;
if (!sourceId) return null;
// eslint-disable-next-line eqeqeq
return sources.value.find(s => s.id == sourceId) ?? null;
}
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
const STATUS_FILTERS = [
{ key: 'failed', label: 'Échecs' },
{ key: 'completed', label: 'Terminés' },
{ key: 'all', label: 'Tous' },
];
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Logs', class: 'text-sm font-medium' },
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
],
rightSection: [
...STATUS_FILTERS.map(f => ({
type: 'button',
label: f.label,
active: statusFilter.value === f.key,
onClick: () => logsStore.setStatusFilter(f.key),
})),
{ type: 'divider' },
{
type: 'dropdown',
icon: BarsArrowDownIcon,
label: 'Trier',
items: [
{
label: 'Plus récent',
isSelected: isSortSelected('createdAt', 'DESC'),
onClick: () => logsStore.updateSort('createdAt', 'DESC'),
},
{
label: 'Plus ancien',
isSelected: isSortSelected('createdAt', 'ASC'),
onClick: () => logsStore.updateSort('createdAt', 'ASC'),
},
],
},
{
type: 'button',
icon: ArrowPathIcon,
label: 'Rafraîchir',
disabled: isLoading.value,
onClick: () => logsStore.loadLogs(),
},
{
type: 'button',
icon: TrashIcon,
label: 'Tout supprimer',
disabled: isLoading.value || total.value === 0,
onClick: handleDeleteAll,
},
],
}));
async function handleDelete(id) {
await logsStore.deleteLog(id);
}
async function handleDeleteAll() {
if (!confirm('Supprimer tous les logs d\'erreur ? Cette action est irréversible.')) return;
await logsStore.deleteAllLogs();
}
</script>

View File

@@ -0,0 +1,71 @@
<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-blue-500 rounded-full"></div>
</div>
<!-- Error -->
<div v-else-if="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 rounded">
<div class="flex items-center">
<ExclamationCircleIcon class="w-5 h-5 text-red-400 mr-2 shrink-0" />
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
</div>
<button
@click="statusStore.loadStatus()"
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded">
Réessayer
</button>
</div>
</div>
<!-- Données -->
<div v-else-if="status" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4">
<MangasStatusCard :status="status" />
<ChaptersStatusCard :status="status" />
<JobsStatusCard :status="status" />
<StorageStatusCard :status="status" />
<SourcesStatusCard :status="status" />
<SystemInfoCard :status="status" />
</div>
</div>
</div>
</template>
<script setup>
import { ArrowPathIcon, ExclamationCircleIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted } from 'vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useStatusStore } from '../../application/store/statusStore';
import ChaptersStatusCard from '../components/ChaptersStatusCard.vue';
import JobsStatusCard from '../components/JobsStatusCard.vue';
import MangasStatusCard from '../components/MangasStatusCard.vue';
import SourcesStatusCard from '../components/SourcesStatusCard.vue';
import StorageStatusCard from '../components/StorageStatusCard.vue';
import SystemInfoCard from '../components/SystemInfoCard.vue';
const statusStore = useStatusStore();
const { status, loading, error } = storeToRefs(statusStore);
onMounted(() => statusStore.loadStatus());
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Statut système', class: 'text-sm font-medium' },
],
rightSection: [
{
type: 'button',
icon: ArrowPathIcon,
label: 'Rafraîchir',
disabled: loading.value,
onClick: () => statusStore.loadStatus(),
},
],
}));
</script>

View File

@@ -10,24 +10,10 @@ import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue'; import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue'; import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue'; import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
import LogsPage from '../domain/system/presentation/pages/LogsPage.vue';
import StatusPage from '../domain/system/presentation/pages/StatusPage.vue';
import Layout from '../shared/components/layout/Layout.vue'; import Layout from '../shared/components/layout/Layout.vue';
// Placeholder component for new routes
const PlaceholderComponent = {
props: {
title: {
type: String,
required: true
}
},
template: `
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">{{ title }}</h1>
<p class="text-gray-600">Cette fonctionnalité sera bientôt disponible.</p>
</div>
`
};
const routes = [ const routes = [
{ {
path: '/', path: '/',
@@ -65,13 +51,6 @@ const routes = [
name: 'import', name: 'import',
component: NewImportPage component: NewImportPage
}, },
// Pages placeholder avec chargement différé
{
path: '/manga/import',
name: 'manga-import',
component: PlaceholderComponent,
props: { title: 'Import de bibliothèque' }
},
{ {
path: '/manga/discover', path: '/manga/discover',
name: 'discover', name: 'discover',
@@ -90,21 +69,7 @@ const routes = [
// Paramètres // Paramètres
{ {
path: '/settings', path: '/settings',
name: 'settings', redirect: '/settings/scrappers',
component: PlaceholderComponent,
props: { title: 'Paramètres' }
},
{
path: '/settings/general',
name: 'settings-general',
component: PlaceholderComponent,
props: { title: 'Paramètres généraux' }
},
{
path: '/settings/folders',
name: 'settings-folders',
component: PlaceholderComponent,
props: { title: 'Gestion des dossiers' }
}, },
{ {
path: '/settings/scrappers', path: '/settings/scrappers',
@@ -129,34 +94,18 @@ const routes = [
// Système // Système
{ {
path: '/system', path: '/system',
name: 'system', redirect: '/system/status',
component: PlaceholderComponent,
props: { title: 'Système' }
}, },
{ {
path: '/system/status', path: '/system/status',
name: 'system-status', name: 'system-status',
component: PlaceholderComponent, component: StatusPage,
props: { title: 'Status du système' }
},
{
path: '/system/backup',
name: 'system-backup',
component: PlaceholderComponent,
props: { title: 'Sauvegarde' }
}, },
{ {
path: '/system/logs', path: '/system/logs',
name: 'system-logs', name: 'system-logs',
component: PlaceholderComponent, component: LogsPage,
props: { title: 'Journaux système' }
}, },
{
path: '/system/updates',
name: 'system-updates',
component: PlaceholderComponent,
props: { title: 'Mises à jour' }
}
] ]
} }
]; ];

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="h-screen overflow-hidden bg-gray-50 dark:bg-gray-900 flex"> <div class="h-[100dvh] overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
<Header <Header
:show-menu-button="isReaderMode" :show-menu-button="isReaderMode"
@menu-click="toggleSidebar" @menu-click="toggleSidebar"
@@ -16,7 +16,7 @@
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0', headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
isReaderMode ? '' : 'md:ml-60' isReaderMode ? '' : 'md:ml-60'
]" style="transition: margin-top 300ms ease-in-out;"> ]" style="transition: margin-top 300ms ease-in-out;">
<RouterView></RouterView> <RouterView class="flex-1 min-h-0"></RouterView>
</main> </main>
</div> </div>
</template> </template>

View File

@@ -78,11 +78,9 @@ import MenuGroup from './sidebar/MenuGroup.vue';
{ {
icon: Cog6ToothIcon, icon: Cog6ToothIcon,
text: 'Paramètres', text: 'Paramètres',
to: '/settings', to: '/settings/scrappers',
id: 'settings', id: 'settings',
subItems: [ subItems: [
{ icon: null, text: 'Général', to: '/settings/general' },
{ icon: null, text: 'Dossiers', to: '/settings/folders' },
{ icon: null, text: 'Scrappers', to: '/settings/scrappers' }, { icon: null, text: 'Scrappers', to: '/settings/scrappers' },
{ icon: null, text: 'UI', to: '/settings/ui' } { icon: null, text: 'UI', to: '/settings/ui' }
] ]
@@ -90,13 +88,11 @@ import MenuGroup from './sidebar/MenuGroup.vue';
{ {
icon: ComputerDesktopIcon, icon: ComputerDesktopIcon,
text: 'Système', text: 'Système',
to: '/system', to: '/system/status',
id: 'system', id: 'system',
subItems: [ subItems: [
{ icon: null, text: 'Status', to: '/system/status' }, { icon: null, text: 'Status', to: '/system/status' },
{ icon: null, text: 'Backup', to: '/system/backup' },
{ icon: null, text: 'Logs', to: '/system/logs' }, { icon: null, text: 'Logs', to: '/system/logs' },
{ icon: null, text: 'Updates', to: '/system/updates' }
] ]
} }
]; ];

View File

@@ -0,0 +1,37 @@
<template>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ label }}</span>
<button
type="button"
role="switch"
:aria-checked="value"
:class="[
value ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600',
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2'
]"
@click="$emit('update', !value)"
>
<span
:class="[
value ? 'translate-x-4' : 'translate-x-0',
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
]"
/>
</button>
</div>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true
},
value: {
type: Boolean,
required: true
}
});
defineEmits(['update']);
</script>

View File

@@ -8,7 +8,7 @@
<slot name="center" /> <slot name="center" />
<!-- Right section --> <!-- Right section -->
<ToolbarSection :items="config.rightSection" /> <ToolbarSection :items="config.rightSection" align="right" />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -13,7 +13,10 @@
</div> </div>
<MenuItems <MenuItems
class="absolute left-0 mt-2 w-max origin-top-left rounded-sm bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-10"> :class="[
'absolute mt-2 w-max rounded-sm bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-10',
align === 'right' ? 'right-0 origin-top-right' : 'left-0 origin-top-left'
]">
<div class="px-1 py-1"> <div class="px-1 py-1">
<MenuItem v-for="(item, index) in items" :key="index" v-slot="{ active }" :disabled="item.disabled"> <MenuItem v-for="(item, index) in items" :key="index" v-slot="{ active }" :disabled="item.disabled">
<button <button
@@ -50,6 +53,11 @@ import ToolbarLabel from './ToolbarLabel.vue';
type: Boolean, type: Boolean,
default: false default: false
}, },
align: {
type: String,
default: 'left',
validator: v => ['left', 'right'].includes(v)
},
items: { items: {
type: Array, type: Array,
required: true, required: true,

View File

@@ -13,7 +13,8 @@
:icon="item.icon" :icon="item.icon"
:label="item.label" :label="item.label"
:active="item.active" :active="item.active"
:items="item.items" /> :items="item.items"
:align="align" />
<Divider v-else-if="item.type === 'divider'" /> <Divider v-else-if="item.type === 'divider'" />
<span <span
v-else-if="item.type === 'label'" v-else-if="item.type === 'label'"
@@ -43,6 +44,10 @@
(item.type === 'dropdown' && Array.isArray(item.items))) (item.type === 'dropdown' && Array.isArray(item.items)))
); );
} }
},
align: {
type: String,
default: 'left'
} }
}); });
</script> </script>

View File

@@ -3,57 +3,50 @@
"type": "project", "type": "project",
"license": "MIT", "license": "MIT",
"description": "A minimal Symfony project recommended to create bare bones applications", "description": "A minimal Symfony project recommended to create bare bones applications",
"minimum-stability": "stable", "minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.3.1", "php": ">=8.4.0",
"ext-ctype": "*", "ext-ctype": "*",
"ext-curl": "*", "ext-curl": "*",
"ext-gd": "*", "ext-gd": "*",
"ext-iconv": "*", "ext-iconv": "*",
"ext-zip": "*", "ext-zip": "*",
"api-platform/core": "^3.2", "api-platform/core": "^4.0",
"doctrine/dbal": "^3", "doctrine/dbal": "^4",
"doctrine/doctrine-bundle": "^2.11", "doctrine/doctrine-bundle": "^3.0",
"doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^2.17", "doctrine/orm": "^3.0",
"guzzlehttp/guzzle": "^7.8", "guzzlehttp/guzzle": "^7.8",
"intervention/image": "^3.7", "intervention/image": "^3.7",
"nelmio/cors-bundle": "^2.4", "nelmio/cors-bundle": "^2.4",
"phpdocumentor/reflection-docblock": "^5.3", "phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.25", "phpstan/phpdoc-parser": "^1.25",
"ramsey/uuid": "^4.7", "ramsey/uuid": "^4.7",
"runtime/frankenphp-symfony": "^0.2.0", "symfony/asset": "8.0.*",
"symfony/asset": "7.0.*", "symfony/console": "8.0.*",
"symfony/console": "7.0.*", "symfony/css-selector": "8.0.*",
"symfony/css-selector": "7.0.*", "symfony/doctrine-messenger": "8.0.*",
"symfony/doctrine-messenger": "7.0.*", "symfony/dotenv": "8.0.*",
"symfony/dotenv": "7.0.*", "symfony/expression-language": "8.0.*",
"symfony/expression-language": "7.0.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.0.*", "symfony/framework-bundle": "8.0.*",
"symfony/framework-bundle": "7.0.*", "symfony/http-client": "8.0.*",
"symfony/http-client": "7.0.*", "symfony/mercure-bundle": "^0.4",
"symfony/mercure-bundle": "^0.3.9", "symfony/messenger": "8.0.*",
"symfony/messenger": "7.0.*", "symfony/mime": "8.0.*",
"symfony/mime": "7.0.*", "symfony/monolog-bundle": "^4.0",
"symfony/monolog-bundle": "^3.10",
"symfony/panther": "^2.1", "symfony/panther": "^2.1",
"symfony/property-access": "7.0.*", "symfony/property-access": "8.0.*",
"symfony/property-info": "7.0.*", "symfony/property-info": "8.0.*",
"symfony/runtime": "7.0.*", "symfony/runtime": "8.0.*",
"symfony/scheduler": "7.0.*", "symfony/scheduler": "8.0.*",
"symfony/security-bundle": "7.0.*", "symfony/security-bundle": "8.0.*",
"symfony/serializer": "7.0.*", "symfony/serializer": "8.0.*",
"symfony/stimulus-bundle": "^2.17", "symfony/twig-bundle": "8.0.*",
"symfony/twig-bundle": "7.0.*", "symfony/validator": "8.0.*",
"symfony/ux-live-component": "^2.17",
"symfony/ux-react": "^2.23",
"symfony/ux-turbo": "^2.18",
"symfony/validator": "7.0.*",
"symfony/webpack-encore-bundle": "^2.1", "symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.0.*", "symfony/yaml": "8.0.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0", "twig/twig": "^2.12|^3.0",
"vich/uploader-bundle": "^2.7" "vich/uploader-bundle": "^2.7"
}, },
@@ -103,7 +96,7 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "7.0.*", "require": "8.0.*",
"docker": true "docker": true
} }
}, },
@@ -111,18 +104,18 @@
"dama/doctrine-test-bundle": "^8.2", "dama/doctrine-test-bundle": "^8.2",
"dbrekelmans/bdi": "^1.3", "dbrekelmans/bdi": "^1.3",
"deployer/deployer": "^7.5", "deployer/deployer": "^7.5",
"doctrine/doctrine-fixtures-bundle": "^3.5", "doctrine/doctrine-fixtures-bundle": "^4.0",
"friendsofphp/php-cs-fixer": "^3.48", "friendsofphp/php-cs-fixer": "^3.48",
"mtdowling/jmespath.php": "^2.7", "mtdowling/jmespath.php": "^2.7",
"phparkitect/phparkitect": "^0.3.33", "phparkitect/phparkitect": "^0.8",
"phpmd/phpmd": "^2.15", "phpmd/phpmd": "3.x-dev",
"phpunit/phpunit": "^10.5", "phpunit/phpunit": "^10.5",
"symfony/browser-kit": "7.0.*", "symfony/browser-kit": "8.0.*",
"symfony/maker-bundle": "^1.52", "symfony/maker-bundle": "^1.52",
"symfony/phpunit-bridge": "^7.0", "symfony/phpunit-bridge": "^8.0",
"symfony/stopwatch": "7.0.*", "symfony/stopwatch": "8.0.*",
"symfony/web-profiler-bundle": "7.0.*", "symfony/web-profiler-bundle": "8.0.*",
"zenstruck/browser": "^1.8", "zenstruck/browser": "^1.8",
"zenstruck/foundry": "^1.36" "zenstruck/foundry": "^2.0"
} }
} }

4313
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,13 +14,7 @@ return [
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
Symfony\UX\React\ReactBundle::class => ['all' => true],
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true], Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
]; ];

View File

@@ -23,8 +23,6 @@ api_platform:
extra_properties: extra_properties:
standard_put: true standard_put: true
rfc_7807_compliant_errors: true rfc_7807_compliant_errors: true
event_listeners_backward_compatibility_layer: false
keep_legacy_inflector: false
mapping: mapping:
paths: paths:
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto' - '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
@@ -34,5 +32,6 @@ api_platform:
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/System/Infrastructure/ApiPlatform/Resource'
patch_formats: patch_formats:
json: ['application/merge-patch+json'] json: ['application/merge-patch+json']

View File

@@ -3,7 +3,6 @@ doctrine:
connections: connections:
default: default:
url: '%env(resolve:DATABASE_URL)%' url: '%env(resolve:DATABASE_URL)%'
use_savepoints: true
profiling_collect_backtrace: '%kernel.debug%' profiling_collect_backtrace: '%kernel.debug%'
# IMPORTANT: You MUST configure your server version, # IMPORTANT: You MUST configure your server version,
@@ -11,9 +10,6 @@ doctrine:
#server_version: '16' #server_version: '16'
orm: orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true auto_mapping: true
@@ -40,15 +36,12 @@ when@test:
dbal: dbal:
connections: connections:
default: default:
use_savepoints: true
# "TEST_TOKEN" is typically set by ParaTest # "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%' dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod: when@prod:
doctrine: doctrine:
orm: orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver: query_cache_driver:
type: pool type: pool
pool: doctrine.system_cache_pool pool: doctrine.system_cache_pool

View File

@@ -17,7 +17,6 @@ framework:
command.bus: command.bus:
middleware: middleware:
- validation - validation
- doctrine_transaction
event.bus: event.bus:
default_middleware: allow_no_handlers default_middleware: allow_no_handlers
@@ -38,10 +37,6 @@ framework:
'App\Domain\Shared\Domain\Event\VolumeImported': events 'App\Domain\Shared\Domain\Event\VolumeImported': events
'App\Domain\Shared\Domain\Event\ChapterScraped': events 'App\Domain\Shared\Domain\Event\ChapterScraped': events
# Legacy messages (à garder si nécessaire)
'App\Message\DownloadChapter': commands
'App\Message\RefreshMetadata': commands
'App\Message\RefreshAndDownloadChapters': commands
# when@test: # when@test:
# framework: # framework:

View File

@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View File

@@ -1,5 +0,0 @@
twig_component:
anonymous_template_directory: 'components/'
defaults:
# Namespace & directory for components
App\Twig\Components\: 'components/'

2001
config/reference.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,3 @@ vue_app:
requirements: requirements:
req: "^(?!api/|legacy).*" req: "^(?!api/|legacy).*"
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

View File

@@ -1,4 +1,4 @@
when@dev: when@dev:
_errors: _errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml' resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error prefix: /_error

View File

@@ -1,5 +0,0 @@
live_component:
resource: '@LiveComponentBundle/config/routes.php'
prefix: '/_components'
# adjust prefix to add localization to your components
#prefix: '/{_locale}/_components'

View File

@@ -1,8 +1,8 @@
when@dev: when@dev:
web_profiler_wdt: web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
prefix: /_wdt prefix: /_wdt
web_profiler_profiler: web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
prefix: /_profiler prefix: /_profiler

View File

@@ -26,10 +26,6 @@ services:
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\EventListener\ExceptionListener:
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }
GuzzleHttp\Client: GuzzleHttp\Client:
class: GuzzleHttp\Client class: GuzzleHttp\Client
arguments: arguments:
@@ -43,63 +39,11 @@ services:
protocols: [ 'http', 'https' ] protocols: [ 'http', 'https' ]
track_redirects: true track_redirects: true
App\Service\MangaScraperService:
arguments:
$projectDir: '%kernel.project_dir%'
App\Controller\TestController:
arguments:
$projectDir: '%kernel.project_dir%'
App\Domain\Conversion\Infrastructure\Service\ConversionService: App\Domain\Conversion\Infrastructure\Service\ConversionService:
arguments: arguments:
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
App\Service\CbrToCbzConverter: # Scrapers Factory for Domain Layer
arguments:
$projectDir: '%kernel.project_dir%'
App\Manager\FileSystemManager:
arguments:
$projectDir: '%kernel.project_dir%'
App\EventSubscriber\QueueStatusSubscriber:
tags:
- { name: kernel.event_subscriber }
App\Client\MangadexClient:
arguments:
$httpClient: '@GuzzleHttp\Client'
$clientId: '%env(MANGADEX_CLIENT_ID)%'
$clientSecret: '%env(MANGADEX_CLIENT_SECRET)%'
$username: '%env(MANGADEX_USERNAME)%'
$password: '%env(MANGADEX_PASSWORD)%'
App\Service\MangadexProvider:
arguments:
$client: '@App\Client\MangadexClient'
# Scraper Service
App\Service\Scraper\HtmlScraper:
tags: [ 'app.scraper' ]
App\Service\Scraper\JavascriptScraper:
tags: [ 'app.scraper' ]
App\Service\Scraper\MangadexScraper:
tags: [ 'app.scraper' ]
# Scraper Factory
App\Service\Scraper\ScraperFactory:
arguments:
$scrapers: !tagged_iterator app.scraper
# Manga Scraper Service
App\Service\Scraper\MangaScraperService:
arguments:
$scraperFactory: '@App\Service\Scraper\ScraperFactory'
# New Scrapers Factory for Domain Layer
App\Domain\Scraping\Infrastructure\Service\ScraperFactory: App\Domain\Scraping\Infrastructure\Service\ScraperFactory:
arguments: arguments:
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
@@ -180,16 +124,18 @@ services:
tags: tags:
- { name: messenger.message_handler, bus: command.bus } - { name: messenger.message_handler, bus: command.bus }
# Import Domain Services # Scraper Health Check
App\Domain\Import\Infrastructure\Service\FilenameAnalyzer: ~ App\Domain\Scraping\Domain\Contract\Repository\ContentSourceForHealthCheckInterface:
alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
App\Domain\Import\Domain\Service\FilenameAnalyzerInterface: App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface:
alias: App\Domain\Import\Infrastructure\Service\FilenameAnalyzer alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
# Import Domain Query/Command Handlers # System Domain
App\Domain\Import\Application\QueryHandler\AnalyzeFilenameQueryHandler: ~ App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface:
App\Domain\Import\Application\CommandHandler\ImportFileCommandHandler: ~ alias: App\Domain\System\Infrastructure\Persistence\Repository\DoctrineSystemStatusRepository
# Import Domain API Platform Services App\Domain\System\Application\QueryHandler\GetSystemStatusQueryHandler:
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~ arguments:
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~ $mangaDataPath: '%env(resolve:MANGA_DATA_PATH)%'
$imagesStoragePath: '%kernel.project_dir%/public/images'

View File

@@ -1,4 +1,5 @@
<?php <?php
namespace Deployer; namespace Deployer;
require 'recipe/symfony.php'; require 'recipe/symfony.php';
@@ -33,15 +34,16 @@ task('deploy:prepare_dirs', function () {
// --user assure que vendor/ appartient au user deploy et non root // --user assure que vendor/ appartient au user deploy et non root
// Skip si composer.lock inchangé et vendor/ déjà populé (hard-linké depuis la release précédente) // Skip si composer.lock inchangé et vendor/ déjà populé (hard-linké depuis la release précédente)
task('deploy:vendors', function () { task('deploy:vendors', function () {
$releaseDir = get('release_path'); $releaseDir = get('release_path');
$previousDir = get('previous_release'); $previousDir = get('previous_release');
if ($previousDir !== null) { if (null !== $previousDir) {
$lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1"); $lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1");
$vendorPopulated = test("[ -d $releaseDir/vendor/composer ]"); $vendorPopulated = test("[ -d $releaseDir/vendor/composer ]");
if ($lockUnchanged && $vendorPopulated) { if ($lockUnchanged && $vendorPopulated) {
writeln('<info>deploy:vendors skipped — composer.lock unchanged</info>'); writeln('<info>deploy:vendors skipped — composer.lock unchanged</info>');
return; return;
} }
} }
@@ -56,23 +58,23 @@ task('deploy:vendors', function () {
// 3. Cache npm et webpack persistants entre les releases // 3. Cache npm et webpack persistants entre les releases
desc('Build Webpack Encore assets'); desc('Build Webpack Encore assets');
task('webpack_encore:build', function () { task('webpack_encore:build', function () {
$sharedDir = '/srv/mangarr/shared'; $sharedDir = '/srv/mangarr/shared';
$sharedWebpackCache = "$sharedDir/webpack_cache"; $sharedWebpackCache = "$sharedDir/webpack_cache";
$sharedNodeModules = "$sharedDir/node_modules"; $sharedNodeModules = "$sharedDir/node_modules";
$sharedNpmCache = "$sharedDir/npm_cache"; $sharedNpmCache = "$sharedDir/npm_cache";
run("mkdir -p $sharedWebpackCache $sharedNodeModules $sharedNpmCache"); run("mkdir -p $sharedWebpackCache $sharedNodeModules $sharedNpmCache");
$releaseDir = get('release_path'); $releaseDir = get('release_path');
$previousDir = get('previous_release'); // null au 1er déploiement $previousDir = get('previous_release'); // null au 1er déploiement
// --- COUCHE 1 : skip total si aucun fichier front-end n'a changé --- // --- COUCHE 1 : skip total si aucun fichier front-end n'a changé ---
if ($previousDir !== null) { if (null !== $previousDir) {
$watchList = ['assets', 'templates', 'package.json', 'package-lock.json', $watchList = ['assets', 'templates', 'package.json', 'package-lock.json',
'webpack.config.js', 'postcss.config.js', 'tailwind.config.js']; 'webpack.config.js', 'postcss.config.js', 'tailwind.config.js'];
$diffChecks = implode(' && ', array_map( $diffChecks = implode(' && ', array_map(
fn($p) => "diff -rq --no-dereference $previousDir/$p $releaseDir/$p > /dev/null 2>&1", fn ($p) => "diff -rq --no-dereference $previousDir/$p $releaseDir/$p > /dev/null 2>&1",
$watchList $watchList
)); ));
@@ -81,15 +83,16 @@ task('webpack_encore:build', function () {
if ($hasPreviousBuild && test("($diffChecks)")) { if ($hasPreviousBuild && test("($diffChecks)")) {
run("cp -al $previousDir/public/build $releaseDir/public/build"); run("cp -al $previousDir/public/build $releaseDir/public/build");
writeln('<info>webpack_encore:build skipped — no front-end files changed</info>'); writeln('<info>webpack_encore:build skipped — no front-end files changed</info>');
return; return;
} }
} }
// --- COUCHE 2 : skip npm install si package-lock.json inchangé --- // --- COUCHE 2 : skip npm install si package-lock.json inchangé ---
$needsNpmInstall = true; $needsNpmInstall = true;
if ($previousDir !== null) { if (null !== $previousDir) {
$lockUnchanged = test("diff -q $previousDir/package-lock.json $releaseDir/package-lock.json > /dev/null 2>&1"); $lockUnchanged = test("diff -q $previousDir/package-lock.json $releaseDir/package-lock.json > /dev/null 2>&1");
$nmPopulated = test("[ -d $sharedNodeModules/.bin ]"); $nmPopulated = test("[ -d $sharedNodeModules/.bin ]");
if ($lockUnchanged && $nmPopulated) { if ($lockUnchanged && $nmPopulated) {
$needsNpmInstall = false; $needsNpmInstall = false;
} }
@@ -113,14 +116,13 @@ task('webpack_encore:build', function () {
sh -c '$installCmd'"); sh -c '$installCmd'");
}); });
// Restart Docker containers (entrypoint gère les migrations automatiquement) // Restart Docker containers (entrypoint gère migrations + cache:warmup automatiquement)
// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage // Le cache est regénéré par l'entrypoint AVANT que FrankenPHP ne démarre,
// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release. // ce qui évite la race condition entre FrankenPHP et un docker exec concurrent.
desc('Restart Docker containers'); desc('Restart Docker containers');
task('docker:restart', function () { task('docker:restart', function () {
run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler'); run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler');
run('docker restart mangarr'); run('docker restart mangarr');
run('docker exec mangarr php bin/console cache:clear --env=prod');
}); });
// Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin // Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin

View File

@@ -31,7 +31,9 @@
mercure { mercure {
# Transport to use (default to Bolt) # Transport to use (default to Bolt)
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db} transport bolt {
url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
}
# Publisher JWT key # Publisher JWT key
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
# Subscriber JWT key # Subscriber JWT key

View File

@@ -53,6 +53,14 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
fi fi
fi fi
# Vider le cache prod stale et le regénérer AVANT le démarrage de FrankenPHP.
# Sans ça, FrankenPHP et le deploy script compilent le container DI en parallèle
# → fichiers partiellement écrits → crash au démarrage des workers.
if [ "$APP_ENV" = "prod" ]; then
rm -rf var/cache/prod
php bin/console cache:warmup --env=prod
fi
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
fi fi

View File

@@ -1,4 +1,4 @@
worker { worker {
file ./public/index.php file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime num 2
} }

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260315221706 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE content_source ADD test_slug VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE content_source ADD test_chapter_number DOUBLE PRECISION DEFAULT NULL');
$this->addSql('ALTER TABLE content_source ADD health_status VARCHAR(20) DEFAULT \'unknown\' NOT NULL');
$this->addSql('ALTER TABLE content_source ADD health_last_tested_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE content_source ADD health_last_error TEXT DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE content_source DROP test_slug');
$this->addSql('ALTER TABLE content_source DROP test_chapter_number');
$this->addSql('ALTER TABLE content_source DROP health_status');
$this->addSql('ALTER TABLE content_source DROP health_last_tested_at');
$this->addSql('ALTER TABLE content_source DROP health_last_error');
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260326165659 extends AbstractMigration
{
public function getDescription(): string
{
return 'Migrate manga.genres column from PHP-serialized array to JSON';
}
public function preUp(Schema $schema): void
{
// Convert existing PHP-serialized data to JSON before changing the column type
$rows = $this->connection->fetchAllAssociative('SELECT id, genres FROM manga WHERE genres IS NOT NULL');
foreach ($rows as $row) {
$raw = $row['genres'];
// Skip if already valid JSON
json_decode($raw);
if (json_last_error() === JSON_ERROR_NONE) {
continue;
}
// Unserialize PHP format and re-encode as JSON
$value = @unserialize($raw);
if ($value === false && $raw !== 'b:0;') {
$value = [];
}
$this->connection->executeStatement(
'UPDATE manga SET genres = :json WHERE id = :id',
['json' => json_encode($value), 'id' => $row['id']]
);
}
}
public function up(Schema $schema): void
{
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'\'');
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'\'');
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'\'');
$this->addSql('COMMENT ON COLUMN job.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN job.started_at IS \'\'');
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'\'');
$this->addSql('ALTER TABLE manga ALTER genres TYPE JSON USING genres::json');
$this->addSql('COMMENT ON COLUMN manga.genres IS \'\'');
$this->addSql('COMMENT ON COLUMN manga.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'\'');
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.updated_at IS \'\'');
$this->addSql('COMMENT ON COLUMN source.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'\'');
$this->addSql('DROP INDEX idx_75ea56e0e3bd61ce');
$this->addSql('DROP INDEX idx_75ea56e0fb7336f0');
$this->addSql('DROP INDEX idx_75ea56e016ba31db');
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE messenger_messages ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'\'');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN job.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN job.started_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE manga ALTER genres TYPE TEXT');
$this->addSql('COMMENT ON COLUMN manga.genres IS \'(DC2Type:array)\'');
$this->addSql('COMMENT ON COLUMN manga.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750');
$this->addSql('ALTER TABLE messenger_messages ALTER id SET DEFAULT nextval(\'messenger_messages_id_seq\'::regclass)');
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE INDEX idx_75ea56e0e3bd61ce ON messenger_messages (available_at)');
$this->addSql('CREATE INDEX idx_75ea56e0fb7336f0 ON messenger_messages (queue_name)');
$this->addSql('CREATE INDEX idx_75ea56e016ba31db ON messenger_messages (delivered_at)');
$this->addSql('COMMENT ON COLUMN source.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'(DC2Type:datetime_immutable)\'');
}
}

3506
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,26 +2,15 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.17.0", "@babel/core": "^7.17.0",
"@babel/preset-env": "^7.16.0", "@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.26.3",
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@hotwired/stimulus": "^3.0.0",
"@hotwired/turbo": "^7.1.1 || ^8.0",
"@symfony/stimulus-bridge": "^3.2.0",
"@symfony/ux-live-component": "file:vendor/symfony/ux-live-component/assets",
"@symfony/ux-react": "file:vendor/symfony/ux-react/assets",
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
"@symfony/webpack-encore": "^4.0.0", "@symfony/webpack-encore": "^4.0.0",
"@vue/compiler-sfc": "^3.5.13", "@vue/compiler-sfc": "^3.5.13",
"core-js": "^3.23.0", "core-js": "^3.23.0",
"daisyui": "^4.4.2",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"react": "^18.0",
"react-dom": "^18.0",
"regenerator-runtime": "^0.13.9", "regenerator-runtime": "^0.13.9",
"sass": "^1.59.3", "sass": "^1.59.3",
"sass-loader": "^13.2.0", "sass-loader": "^13.2.0",
"stimulus-use": "^0.52.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-loader": "^17.4.2", "vue-loader": "^17.4.2",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
@@ -41,18 +30,12 @@
"@fortawesome/fontawesome-free": "^6.5.2", "@fortawesome/fontawesome-free": "^6.5.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@tanstack/vue-query": "^5.71.0", "@tanstack/vue-query": "^5.71.0",
"alpinejs": "^3.13.3",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"axios": "^1.7.9", "axios": "^1.7.9",
"bootstrap": "^5.3.3",
"postcss-loader": "^7.1.0", "postcss-loader": "^7.1.0",
"puppeteer": "^22.10.0", "puppeteer": "^22.10.0",
"react-router-dom": "^7.1.5",
"sortablejs": "^1.15.2",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"vue-i18n": "^11.3.0", "vue-i18n": "^11.3.0"
"vuedraggable": "^2.24.3"
} }
} }

View File

@@ -11,7 +11,7 @@ use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;
use Arkitect\Rules\Rule; use Arkitect\Rules\Rule;
return static function (Config $config): void { return static function (Config $config): void {
$domainClassSet = ClassSet::fromDir(__DIR__ . '/src/Domain'); $domainClassSet = ClassSet::fromDir(__DIR__.'/src/Domain');
$businessDomains = ['Manga', 'Reader', 'Scraping', 'Conversion']; $businessDomains = ['Manga', 'Reader', 'Scraping', 'Conversion'];
// Classes PHP standards et utilitaires // Classes PHP standards et utilitaires
@@ -29,7 +29,7 @@ return static function (Config $config): void {
// Dépendances externes autorisées // Dépendances externes autorisées
$externalDependencies = [ $externalDependencies = [
'Symfony\Component\Messenger', 'Symfony\Component\Messenger',
'Ramsey\Uuid' 'Ramsey\Uuid',
]; ];
// Règle pour le namespace cohérent // Règle pour le namespace cohérent
@@ -72,7 +72,7 @@ return static function (Config $config): void {
// Interdiction explicite pour l'Application d'accéder à l'Infrastructure // Interdiction explicite pour l'Application d'accéder à l'Infrastructure
$rules[] = Rule::allClasses() $rules[] = Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application")) ->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
->should(new NotDependsOnTheseNamespaces("App\Domain\\$domain\Infrastructure")) ->should(new NotDependsOnTheseNamespaces(["App\Domain\\$domain\Infrastructure"]))
->because("la couche Application de $domain ne doit jamais dépendre de l'Infrastructure, même au sein de son propre domaine"); ->because("la couche Application de $domain ne doit jamais dépendre de l'Infrastructure, même au sein de son propre domaine");
} }

View File

View File

@@ -1,86 +0,0 @@
<?php
namespace App\Client;
use App\Interface\ClientInterface;
use GuzzleHttp\ClientInterface as GuzzleInterface;
class MangadexClient implements ClientInterface
{
private const AUTHENTICATION_URL = 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token';
private const API_URL = 'https://api.mangadex.org';
private GuzzleInterface $httpClient;
private string $clientId;
private string $clientSecret;
private string $username;
private string $password;
private ?string $accessToken = null;
private ?string $refreshToken = null;
public function __construct(GuzzleInterface $httpClient, string $clientId, string $clientSecret, string $username, string $password)
{
$this->httpClient = $httpClient;
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
$this->username = $username;
$this->password = $password;
$this->authenticate();
}
public function authenticate(): void
{
$response = $this->httpClient->request('POST', self::AUTHENTICATION_URL, [
'form_params' => [
'grant_type' => 'password',
'username' => $this->username,
'password' => $this->password,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
$this->accessToken = $data['access_token'];
$this->refreshToken = $data['refresh_token'];
}
public function refresh(): void
{
$response = $this->httpClient->request('POST', self::AUTHENTICATION_URL, [
'form_params' => [
'grant_type' => 'refresh_token',
'refresh_token' => $this->refreshToken,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
$this->accessToken = $data['access_token'];
}
private function request(string $method, string $endpoint, array $options = []): array
{
$options['headers']['Authorization'] = 'Bearer ' . $this->accessToken;
$response = $this->httpClient->request($method, self::API_URL . $endpoint, $options);
if ($response->getStatusCode() === 429) {
$this->refresh();
$options['headers']['Authorization'] = 'Bearer ' . $this->accessToken;
$response = $this->httpClient->request($method, self::API_URL . $endpoint, $options);
}
return json_decode($response->getBody()->getContents(), true);
}
public function get(string $endpoint, array $params = []): array
{
return $this->request('GET', $endpoint, ['query' => $params]);
}
public function post(string $endpoint, array $data): array
{
return $this->request('POST', $endpoint, ['json' => $data]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand(
name: 'app:monitoring:run',
description: 'Déclenche immédiatement la vérification des mangas monitorés (sans attendre le scheduler)',
)]
class RunMonitoringCommand extends Command
{
public function __construct(
private readonly MessageBusInterface $commandBus,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Déclenchement du monitoring des mangas...');
$this->commandBus->dispatch(new CheckMonitoredMangas());
$output->writeln('<info>Vérification lancée. Les nouveaux chapitres détectés seront scrappés via le worker commands.</info>');
return Command::SUCCESS;
}
}

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
class SendTestNotificationCommand extends Command class SendTestNotificationCommand extends Command
{ {
public function __construct( public function __construct(
private readonly NotificationInterface $notification private readonly NotificationInterface $notification,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -38,14 +38,15 @@ class SendTestNotificationCommand extends Command
$allowed = ['info', 'success', 'error', 'warning']; $allowed = ['info', 'success', 'error', 'warning'];
if (!in_array($type, $allowed, true)) { if (!in_array($type, $allowed, true)) {
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed))); $output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
return Command::FAILURE; return Command::FAILURE;
} }
match ($type) { match ($type) {
'success' => $this->notification->sendSuccess($message), 'success' => $this->notification->sendSuccess($message),
'error' => $this->notification->sendError($message), 'error' => $this->notification->sendError($message),
'warning' => $this->notification->sendWarning($message), 'warning' => $this->notification->sendWarning($message),
default => $this->notification->sendInfo($message), default => $this->notification->sendInfo($message),
}; };
$output->writeln(sprintf('<info>[%s] Notification envoyée : %s</info>', strtoupper($type), $message)); $output->writeln(sprintf('<info>[%s] Notification envoyée : %s</info>', strtoupper($type), $message));

View File

@@ -1,120 +0,0 @@
<?php
namespace App\Controller;
use App\Manager\Toolbar\Factory\ToolbarFactory;
use App\Manager\ToolbarManager;
use App\Message\DownloadChapter;
use App\Repository\ChapterRepository;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Routing\Annotation\Route;
class ActivityController extends AbstractController
{
public function __construct(
private readonly Connection $connection,
private readonly ChapterRepository $chapterRepository,
private readonly ToolbarFactory $toolbarFactory
) {
}
#[Route('/activity', name: 'app_activity')]
public function index(): Response
{
$queueStatus = $this->getQueueStatus();
$decodedPending = $this->decodeMessages($queueStatus['pending']);
$decodedProcessing = $this->decodeMessages($queueStatus['processing']);
$status = array_merge(
$this->buildStatusActivity($decodedPending),
$this->buildStatusActivity($decodedProcessing)
);
return $this->render('activity/index.html.twig', [
'controller_name' => 'ActivityController',
'status' => $status,
'toolbar' => $this->toolbarFactory->createToolbar('activity')->getGroups(),
]);
}
#[Route('/activity/status', name: 'app_activity_status', methods: ['GET'])]
public function getStatus(): JsonResponse
{
$queueStatus = $this->getQueueStatus();
$decodedPending = $this->decodeMessages($queueStatus['pending']);
$decodedProcessing = $this->decodeMessages($queueStatus['processing']);
$status = array_merge(
$this->buildStatusActivity($decodedPending),
$this->buildStatusActivity($decodedProcessing)
);
return new JsonResponse($status);
}
// TODO refactorer ce code avec celui du QueueStatusSubscriber
private function getQueueStatus(): array
{
// Requête pour récupérer les messages en attente
$sqlPending = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NULL';
$pending = $this->connection->fetchAllAssociative($sqlPending, ['queue' => 'default']);
// Requête pour récupérer les messages en cours de traitement
$sqlProcessing = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NOT NULL';
$processing = $this->connection->fetchAllAssociative($sqlProcessing, ['queue' => 'default']);
return [
'pending' => $pending,
'processing' => $processing
];
}
private function buildStatusActivity(array $activity): array
{
$status = [];
foreach ($activity as $envelope) {
$envelope = $envelope['body'];
if ($envelope instanceof Envelope) {
if (!$envelope->getMessage() instanceof DownloadChapter) {
continue;
}
$chapter = $this->chapterRepository->find($envelope->getMessage()->getChapterId());
$manga = $chapter->getManga();
$status[] = [
'manga' => $manga->getTitle(),
'volume' => $chapter->getVolume(),
'chapter' => $chapter->getNumber(),
'chapterId' => $chapter->getId(),
'title' => $chapter->getTitle(),
];
}
}
return $status;
}
private function decodeMessages(array $messages): array
{
$decodedMessages = [];
foreach ($messages as $message) {
$decodedMessages[] = [
'id' => $message['id'],
'body' => $this->decodeMessageBody($message['body']),
'headers' => json_decode($message['headers'], true),
];
}
return $decodedMessages;
}
private function decodeMessageBody(string $body)
{
return unserialize(stripcslashes($body));
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class CalendarController extends AbstractController
{
#[Route('/calendar', name: 'app_calendar')]
public function index(): Response
{
return $this->render('calendar/index.html.twig', [
'controller_name' => 'CalendarController',
]);
}
}

View File

@@ -1,64 +0,0 @@
<?php
namespace App\Controller;
use App\Service\CbrToCbzConverter;
use App\Service\NotificationService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Annotation\Route;
class ConversionController extends AbstractController
{
public function __construct(
private readonly CbrToCbzConverter $cbrToCbzConverter,
private readonly NotificationService $notificationService
) {
}
#[Route('/convert', name: 'app_convert')]
public function convert(Request $request): Response
{
if ($request->isMethod('POST')) {
/** @var UploadedFile $file */
$file = $request->files->get('file');
if ($file && $file->getClientOriginalExtension() === 'cbr') {
$originalFileName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$tempFilePath = $file->getPathname();
try {
$cbzPath = $this->cbrToCbzConverter->convert($tempFilePath);
$response = new BinaryFileResponse($cbzPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$originalFileName . '.cbz'
);
$response->headers->set('Content-Type', 'application/x-cbz');
$response->headers->set('Turbo-Visit-Control', 'reload');
$response->deleteFileAfterSend(true);
return $response;
} catch (\Exception $e) {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Une erreur est survenue lors de la conversion : ' . $e->getMessage()
]);
}
} else {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Veuillez sélectionner un fichier CBR valide.'
]);
}
}
return $this->render('conversion/index.html.twig');
}
}

View File

@@ -1,220 +0,0 @@
<?php
namespace App\Controller;
use App\Manager\FileSystemManager;
use App\Repository\ChapterRepository;
use App\Repository\MangaRepository;
use App\Service\CbrToCbzConverter;
use App\Service\CbzService;
use App\Service\MangaImportService;
use App\Service\NotificationService;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\String\Slugger\SluggerInterface;
class ImportController extends AbstractController
{
public function __construct(
private readonly FileSystemManager $fileSystemManager,
private readonly CbzService $cbzService,
private readonly MangaImportService $mangaImportService,
private readonly NotificationService $notificationService,
private readonly MangaRepository $mangaRepository,
private readonly CbrToCbzConverter $cbrToCbzConverter
) {
}
#[Route('/manga/import', name: 'app_manga_import')]
public function index(Request $request, SessionInterface $session): Response
{
if ($request->isMethod('POST')) {
$files = $request->files->get('files');
if ($files) {
$importFiles = [];
foreach ($files as $file) {
if ($file && in_array($file->getClientOriginalExtension(), ['cbz', 'cbr'])) {
$originalFileName = $file->getClientOriginalName();
try {
$tmpPath = $this->fileSystemManager->moveUploadedFile(
$file->getPathname(),
$this->fileSystemManager->getUploadsDirectory(),
$file->getClientOriginalName()
);
$importFiles[] = [
'id' => uniqid(),
'path' => $tmpPath,
'original_name' => $originalFileName,
];
} catch (FileException $e) {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Une erreur est survenue lors de l\'import du fichier ' . $originalFileName,
]);
}
} else {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Le fichier ' . $file->getClientOriginalName() . ' doit être au format CBZ ou CBR.',
]);
}
}
if (!empty($importFiles)) {
$session->set('import_files', $importFiles);
return $this->redirectToRoute('import_match');
}
} else {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Aucun fichier n\'a été sélectionné.',
]);
}
}
return $this->render('import/index.html.twig');
}
/**
* @throws Exception
*/
#[Route('/import/match', name: 'import_match')]
public function match(SessionInterface $session): Response
{
$files = $session->get('import_files', []);
if (empty($files)) {
return $this->redirectToRoute('app_manga_import');
}
$processedFiles = [];
foreach ($files as $fileId => $fileInfo) {
$filePath = $fileInfo['path'];
$originalFileName = $fileInfo['original_name'];
$fileExtension = pathinfo($filePath, PATHINFO_EXTENSION);
if (strtolower($fileExtension) === 'cbr') {
$cbzPath = $this->cbrToCbzConverter->convert($filePath);
$filePath = $cbzPath;
$originalFileName = pathinfo($originalFileName, PATHINFO_FILENAME) . '.cbz';
$files[$fileId]['path'] = $filePath;
$files[$fileId]['original_name'] = $originalFileName;
}
$metadata = $this->cbzService->extractMetadata($filePath, $originalFileName);
$mangas = $this->mangaRepository->findBySlug($metadata['title']);
$mangaOptions = [];
foreach ($mangas as $manga) {
$mangaOptions[] = [
'slug' => $manga->getSlug(),
'title' => $manga->getTitle(),
'author' => $manga->getAuthor(),
'publicationYear' => $manga->getPublicationYear(),
'genres' => $manga->getGenres(),
'description' => $manga->getDescription()
];
}
$processedFiles[] = [
'id' => $fileId,
'originalFileName' => $originalFileName,
'fileSize' => $this->formatBytes(filesize($filePath)),
'metadata' => $metadata,
'mangaOptions' => $mangaOptions
];
}
$session->set('import_files', $files);
return $this->render('import/match.html.twig', [
'files' => $processedFiles
]);
}
private function formatBytes($bytes, $precision = 2)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
#[Route('/import/confirm', name: 'import_confirm', methods: ['POST'])]
public function confirm(Request $request, SessionInterface $session): Response
{
$files = $session->get('import_files', []);
$selectedFiles = $request->request->all('selected');
$mangaSlugs = $request->request->all('manga_slug');
$volumes = $request->request->all('volume');
$chapters = $request->request->all('chapter');
$importedFiles = [];
$errors = [];
foreach ($selectedFiles as $fileId) {
if (!isset($files[$fileId])) {
continue;
}
$file = $files[$fileId];
$mangaSlug = $mangaSlugs[$fileId] ?? null;
$volume = $volumes[$fileId] ?? null;
$chapter = $chapters[$fileId] ?? null;
try {
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw new \Exception('Manga non trouvé.');
}
if (!is_null($chapter)) {
$chapter = $manga->getChapterByNumber($chapter);
if (!$chapter) {
throw new \Exception('Chapitre non trouvé.');
}
}
$importedFiles[] = $file['original_name'];
$this->mangaImportService->importFile($manga, $volume, $chapter, $file['path']);
} catch (\Exception $e) {
$errors[] = "Erreur lors de l'import de {$file['original_name']} : " . $e->getMessage();
}
}
// Nettoyer les fichiers temporaires non importés
foreach ($files as $file) {
$this->fileSystemManager->deleteFile($file['path']);
}
// Nettoyer la session
$session->remove('import_files');
// Préparer le message de notification
if (!empty($importedFiles)) {
$successMessage = 'Fichiers importés avec succès : ' . implode(', ', $importedFiles);
$this->notificationService->sendUpdate([
'status' => 'success',
'message' => $successMessage
]);
}
if (!empty($errors)) {
$errorMessage = implode("\n", $errors);
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => $errorMessage
]);
}
return $this->redirectToRoute('app_manga');
}
}

View File

@@ -1,475 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\Chapter;
use App\Entity\Manga;
use App\Form\MangaEditType;
use App\Manager\FileSystemManager;
use App\Manager\Toolbar\Factory\ToolbarFactory;
use App\Message\DownloadChapter;
use App\Message\RefreshMetadata;
use App\Repository\ChapterRepository;
use App\Repository\ContentSourceRepository;
use App\Repository\MangaRepository;
use App\Service\CbzService;
use App\Service\MangadexProvider;
use App\Service\NotificationService;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\String\Slugger\SluggerInterface;
class MangaController extends AbstractController
{
private ImageManager $imageManager;
public function __construct(
private readonly FileSystemManager $fileSystemManager,
private readonly MangaRepository $mangaRepository,
private readonly ChapterRepository $chapterRepository,
private readonly MessageBusInterface $bus,
private readonly CbzService $cbzService,
private readonly ToolbarFactory $toolbarFactory,
private readonly MangadexProvider $mangadexProvider,
private readonly EntityManagerInterface $entityManager,
private readonly NotificationService $notificationService,
private readonly ContentSourceRepository $contentSourceRepository
) {
$this->imageManager = new ImageManager(new Driver());
}
#[Route('/legacy', name: 'app_legacy')]
public function index(Request $request): Response
{
$sort = $request->query->get('sort', 'title');
$order = $request->query->get('order', 'asc');
$status = $request->query->get('status', 'all');
$view = $request->query->get('view', 'poster');
$mangas = $this->mangaRepository->findAllSortedAndFiltered($sort, $order, $status);
return $this->render('manga/index.html.twig', [
'mangas' => $mangas,
'toolbar' => $this->toolbarFactory->createToolbar('manga_list')->getGroups(),
'currentStatus' => $status,
'currentView' => $view,
]);
}
#[Route('/manga/chapters/{mangaSlug}', name: 'app_manga_show')]
public function showChapters(string $mangaSlug, Request $request): Response
{
// $manga = $this->mangaRepository->findOneWithChapterBy(['slug' => $mangaSlug]);
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw new NotFoundHttpException("Le manga demandé n'existe pas.");
}
$form = $this->createForm(MangaEditType::class, $manga);
$contentSources = $this->contentSourceRepository->findAll();
return $this->render('manga/show_chapters.html.twig', [
'manga' => $manga,
'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId(), 'isMonitored' => (int)$manga->isMonitored()])->getGroups(),
'form' => $form->createView(),
'contentSources' => $contentSources,
]);
}
#[Route('/manga/delete/{id}', name: 'app_manga_delete', methods: ['DELETE'])]
public function deleteManga(Manga $manga): JsonResponse
{
try {
foreach ($manga->getChapters() as $chapter) {
file_exists($chapter->getCbzPath()) ?? unlink($chapter->getCbzPath());
$this->entityManager->remove($chapter);
}
$this->entityManager->remove($manga);
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
} catch (\Exception $e) {
return new JsonResponse(['success' => false, 'error' => 'Unable to delete manga.'], 500);
}
}
#[Route('/manga/{id}/edit', name: 'app_manga_edit', methods: ['POST'])]
public function edit(Request $request, Manga $manga, EntityManagerInterface $entityManager): JsonResponse|Response
{
$form = $this->createForm(MangaEditType::class, $manga);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
}
$errors = [];
foreach ($form->getErrors(true) as $error) {
$errors[] = $error->getMessage();
}
return new JsonResponse(['errors' => $errors], 400);
}
#[Route('/manga/{id}/preferred-sources', name: 'manga_preferred_sources', methods: ['POST'])]
public function updatePreferredSources(
Request $request,
Manga $manga,
ContentSourceRepository $contentSourceRepository
): JsonResponse {
$data = json_decode($request->getContent(), true);
$preferredSourceIds = $data['preferredSources'] ?? [];
$preferredSources = $contentSourceRepository->findBy(['id' => $preferredSourceIds]);
// This will maintain the order of the sources as they were sent in the request
$orderedPreferredSources = array_map(
fn ($id) => current(array_filter($preferredSources, fn ($s) => $s->getId() == $id)),
$preferredSourceIds
);
$manga->setPreferredSources(array_filter($orderedPreferredSources));
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
}
public function _chaptersByManga(int $id): Response
{
$manga = $this->mangaRepository->find($id);
$chaptersByVolume = [];
foreach ($manga->getChapters() as $chapter) {
$volume = $chapter->getVolume() ?? 'Not Found';
$chaptersByVolume[$volume][] = $chapter;
}
foreach ($chaptersByVolume as $volume => &$chapters) {
usort($chapters, function ($a, $b) {
return $b->getNumber() <=> $a->getNumber();
});
}
unset($chapters);
uksort($chaptersByVolume, function ($a, $b) {
if ($a == 0) {
return -1;
}
if ($b == 0) {
return 1;
}
return $b <=> $a;
});
return $this->render('manga/_chapter_list.html.twig', [
'manga' => $manga,
'chapters_by_volume' => $chaptersByVolume
]);
}
#[Route('/delete_cbz/{id}', name: 'app_delete_cbz')]
public function deleteChapterCbz(Chapter $chapter): JsonResponse
{
$cbzPath = $chapter->getCbzPath();
if (!$cbzPath) {
return new JsonResponse(['error' => 'No CBZ path for this chapter.'], 400);
}
file_exists($cbzPath) ?? unlink($cbzPath);
$chapter->setCbzPath(null);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
return new JsonResponse(['success' => 'CBZ file deleted.'], 200);
}
#[Route('/chapter/{id}/edit', name: 'app_chapter_edit', methods: ['POST'])]
public function editChapter(Request $request, Chapter $chapter): JsonResponse
{
$data = json_decode($request->getContent(), true);
$chapter->setNumber($data['number']);
$chapter->setTitle($data['title']);
$this->entityManager->flush();
return new JsonResponse(['success' => true, 'message' => 'Chapter updated successfully']);
}
#[Route('/hide_chapter/{id}', name: 'app_hide_chapter')]
public function hideChapter(Chapter $chapter): JsonResponse
{
$chapter->setVisible(false);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
return new JsonResponse(['success' => 'Chapter hidden.'], 200);
}
#[Route('/manga/search/{query}', name: 'app_manga_search')]
public function search(string $query = ''): Response
{
return $this->render('manga/add_new.html.twig', [
'query' => $query,
]);
}
/**
* @throws GuzzleException
*/
#[Route('/addManga', name: 'app_manga_add')]
public function addManga(Request $request): Response
{
$manga = $this->mangaRepository->findOneBy(['slug' => $request->request->get('slug')]);
if ($manga) {
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
}
$manga = new Manga();
$manga->setTitle($request->request->get('title'))
->setSlug($request->request->get('slug'))
->setDescription($request->request->get('description'))
->setStatus($request->request->get('status'))
->setGenres(explode(',', $request->request->get('genres')))
->setAuthor($request->request->get('author'))
->setPublicationYear($request->request->get('publicationYear'))
->setRating($request->request->get('rating'))
->setExternalId($request->request->get('externalId'))
->setMonitored(false);
// Traitement de l'image
$imageUrl = $request->request->get('imageUrl');
try {
$imageUrls = $this->processAndSaveImage($imageUrl);
$manga->setImageUrl($imageUrls['full']);
$manga->setThumbnailUrl($imageUrls['thumbnail']);
} catch (\Exception|GuzzleException $e) {
throw $e;
}
$mergedChapters = $this->mangadexProvider->addAllChaptersToManga($manga);
if (empty($mergedChapters)) {
return $this->redirectToRoute('app_manga_search', ['query' => $manga->getTitle()]);
}
try {
foreach ($manga->getChapters() as $chapter) {
$this->entityManager->persist($chapter);
}
$this->entityManager->persist($manga);
$this->entityManager->flush();
} catch (\Exception $e) {
if ($e instanceof UniqueConstraintViolationException) {
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
}
throw $e;
}
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
}
/**
* @throws GuzzleException
*/
private function processAndSaveImage(string $imageUrl): array
{
$client = new Client();
$response = $client->get($imageUrl);
$tempImage = tmpfile();
fwrite($tempImage, $response->getBody()->getContents());
$tempImagePath = stream_get_meta_data($tempImage)['uri'];
// Générer un nom de fichier unique
$originalFilename = pathinfo($imageUrl, PATHINFO_FILENAME);
$newFilename = $this->fileSystemManager->generateUniqueImageFilename($imageUrl);
try {
// Créer et sauvegarder la miniature
$thumbnail = $this->imageManager->read($tempImagePath);
$thumbnail->cover(300, 440);
$thumbnail->save($this->fileSystemManager->getImagePath('thumbnails') . '/' . $newFilename, quality: 85);
// Sauvegarder l'image en taille réelle
$fullImage = $this->imageManager->read($tempImagePath);
$fullImage->save($this->fileSystemManager->getImagePath('full') . '/' . $newFilename, quality: 90);
// Fermer et supprimer le fichier temporaire
fclose($tempImage);
return [
'full' => '/images/full/' . $newFilename,
'thumbnail' => '/images/thumbnails/' . $newFilename
];
} catch (FileException $e) {
// Fermer le fichier temporaire en cas d'erreur
fclose($tempImage);
throw $e;
}
}
#[Route('/searchChapter/{id}', name: 'search_chapter')]
public function addChapterMessenger(int $id): JsonResponse
{
$chapter = $this->chapterRepository->find($id);
if (!$chapter) {
return new JsonResponse(['error' => 'Chapter Not Found.'], 400);
} elseif ($chapter->getCbzPath() !== null) {
return new JsonResponse(['error' => 'Chapter already scraped.'], 400);
}
$this->bus->dispatch(new DownloadChapter($id));
return new JsonResponse(['success' => 'Scrapping started...'], 200);
}
#[Route('/searchVolume/{mangaSlug}/{volume}', name: 'search_volume')]
public function searchVolume(string $mangaSlug, int $volume): JsonResponse
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
}
$volumeChapters = $this->chapterRepository->findBy([
'manga' => $manga,
'volume' => $volume,
'visible' => true
]);
if (empty($volumeChapters)) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'No chapters found for this volume.']);
return new JsonResponse(['error' => 'No chapters found for this volume.'], 200);
}
foreach ($volumeChapters as $chapter) {
if ($chapter->getCbzPath() === null) {
$this->bus->dispatch(new DownloadChapter($chapter->getId()));
}
}
return new JsonResponse(['success' => 'Scrapping started...'], 200);
}
#[Route('/download-cbz/{chapterId}', name: 'download_cbz')]
public function downloadChapter(int $chapterId): BinaryFileResponse|JsonResponse
{
$chapter = $this->chapterRepository->find($chapterId);
if (!$chapter) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapitre non trouvé.']);
return new JsonResponse(['error' => 'Chapitre non trouvé.'], 200);
}
$cbzPath = $chapter->getCbzPath();
if (!$cbzPath || !file_exists($cbzPath)) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Le fichier CBZ n\'existe pas.']);
return new JsonResponse(['error' => 'Le fichier CBZ n\'existe pas.'], 200);
}
$isFullVolume = $this->isFullVolume($chapter);
$fileName = $isFullVolume
? $this->cbzService->generateFileName($chapter->getManga(), $chapter->getVolume())
: $this->cbzService->generateFileName($chapter->getManga(), null, $chapter->getNumber());
return $this->cbzService->createBinaryFileResponse($cbzPath, $fileName);
}
#[Route('/download-volume/{mangaSlug}/{volume}', name: 'download_volume')]
public function downloadVolume(string $mangaSlug, int $volume): BinaryFileResponse|JsonResponse
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
$volumeChapters = $this->chapterRepository->findBy([
'manga' => $manga,
'volume' => $volume,
'visible' => true
], ['number' => 'ASC']);
if (empty($volumeChapters)) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Aucun chapitre trouvé pour ce volume.']);
}
if (!$this->cbzService->doAllChaptersHaveCbz($volumeChapters)) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Tous les chapitres du volume ne sont pas scrapés.']);
return new JsonResponse(['error' => 'Tous les chapitres du volume ne sont pas scrapés.'], 200);
}
$fileName = $this->cbzService->generateFileName($manga, $volume);
if ($this->cbzService->areAllChaptersCbzIdentical($volumeChapters)) {
return $this->cbzService->createBinaryFileResponse($volumeChapters[0]->getCbzPath(), $fileName);
} else {
$tempFile = $this->cbzService->createVolumeArchive($volumeChapters);
$response = $this->cbzService->createBinaryFileResponse($tempFile, $fileName);
$response->deleteFileAfterSend(true);
return $response;
}
}
#[Route('/refresh_metadata', name: 'refresh_metadata')]
public function refreshMetadata(Request $request): JsonResponse
{
$mangaId = json_decode($request->getContent(), true)['mangaId'];
$manga = $this->mangaRepository->find($mangaId);
if (!$manga) {
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
}
$this->bus->dispatch(new RefreshMetadata($mangaId));
return new JsonResponse(['success' => 'Metadata refresh started...'], 200);
}
#[Route('/toggle_monitored', name: 'toggle_monitored')]
public function toogleMonitored(Request $request): JsonResponse
{
$id = json_decode($request->getContent(), true)['mangaId'];
$manga = $this->mangaRepository->find($id);
if (!$manga) {
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
}
$manga->setMonitored(!$manga->isMonitored());
$this->entityManager->persist($manga);
$this->entityManager->flush();
return new JsonResponse(['success' => 'Monitored status updated.', 'isMonitored' => $manga->isMonitored()], 200);
}
private function isFullVolume(Chapter $chapter): bool
{
$volumeChapters = $this->chapterRepository->findBy([
'manga' => $chapter->getManga(),
'volume' => $chapter->getVolume()
]);
$firstChapterPath = $volumeChapters[0]->getCbzPath();
foreach ($volumeChapters as $volumeChapter) {
if ($volumeChapter->getCbzPath() !== $firstChapterPath) {
return false;
}
}
return true;
}
}

View File

@@ -1,138 +0,0 @@
<?php
namespace App\Controller;
use App\Repository\MangaRepository;
use App\Service\CbzService;
use App\Service\NotificationService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ReaderController extends AbstractController
{
public function __construct(
private readonly MangaRepository $mangaRepository,
private readonly CbzService $cbzService,
private readonly NotificationService $notificationService,
) {
}
#[Route('/read/{mangaSlug}/{chapterNumber}', name: 'app_reader')]
public function read(string $mangaSlug, float $chapterNumber): Response
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
}
$chapter = $manga->getChapterByNumber($chapterNumber);
if (!$chapter) {
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
}
if (is_null($chapter->getCbzPath())) {
$this->notificationService->sendUpdate([
'status' => 'error',
'message' => 'Le chapitre demandé n\'est pas encore disponible.',
]);
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $mangaSlug]);
}
$totalPages = $this->cbzService->getPageCount($chapter->getCbzPath());
return $this->render('reader/index.html.twig', [
'manga' => $manga,
'chapter' => $chapter,
'totalPages' => $totalPages,
]);
}
#[Route('/api/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_reader_page')]
public function getPage(string $mangaSlug, float $chapterNumber, int $pageNumber): Response
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
}
$chapter = $manga->getChapterByNumber($chapterNumber);
if (!$chapter) {
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
}
$pageContent = $this->cbzService->getPageContent($chapter->getCbzPath(), $pageNumber);
if (!$pageContent) {
throw $this->createNotFoundException("La page demandée n'existe pas.");
}
return new Response(base64_encode($pageContent), 200, ['Content-Type' => 'text/plain']);
}
#[Route('/api/chapters/{mangaSlug}', name: 'app_reader_chapters')]
public function getChapters(string $mangaSlug): JsonResponse
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
}
$chapters = $manga->getChapters()
->filter(fn ($chapter) => $chapter->isVisible() && !is_null($chapter->getCbzPath()))
->toArray();
usort($chapters, fn ($a, $b) => $b->getNumber() <=> $a->getNumber());
$chapters = array_values(array_map(fn ($chapter) => [
'number' => $chapter->getNumber(),
'title' => $chapter->getTitle(),
], $chapters));
return $this->json($chapters);
}
#[Route('/api/previous-chapter/{mangaSlug}/{currentChapterNumber}', name: 'app_reader_previous_chapter')]
public function getPreviousChapter(string $mangaSlug, float $currentChapterNumber): JsonResponse
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
}
$chapters = $manga->getChapters()
->filter(fn ($chapter) => $chapter->isVisible() && $chapter->getNumber() < $currentChapterNumber)
->toArray();
usort($chapters, fn ($a, $b) => $b->getNumber() <=> $a->getNumber());
$previousChapter = reset($chapters) ?: null;
return $this->json($previousChapter ? [
'number' => $previousChapter->getNumber(),
'title' => $previousChapter->getTitle(),
] : null);
}
#[Route('/api/next-chapter/{mangaSlug}/{currentChapterNumber}', name: 'app_reader_next_chapter')]
public function getNextChapter(string $mangaSlug, float $currentChapterNumber): JsonResponse
{
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
if (!$manga) {
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
}
$nextChapter = $manga->getChapters()
->filter(fn ($chapter) => $chapter->isVisible() && $chapter->getNumber() > $currentChapterNumber)
->toArray();
usort($nextChapter, fn ($a, $b) => $a->getNumber() <=> $b->getNumber());
$nextChapter = reset($nextChapter) ?: null;
return $this->json($nextChapter ? [
'number' => $nextChapter->getNumber(),
'title' => $nextChapter->getTitle(),
] : null);
}
}

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