86 Commits

Author SHA1 Message Date
ext.jeremy.guillot@maxicoffee.domains
cc27fc4564 style(homepage): supprimer px-4 pour tableau pleine largeur sans marges 2026-03-14 00:22:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
e1909b9804 style(homepage): remplacer container par w-full pour pleine largeur en vue table 2026-03-14 00:21:20 +01:00
ext.jeremy.guillot@maxicoffee.domains
07d3b56d1b style(manga-table): supprimer le padding du wrapper pour pleine largeur 2026-03-14 00:19:40 +01:00
ext.jeremy.guillot@maxicoffee.domains
ac19cc53ca style(manga-table): supprimer wrapper card + hover vert + icônes Bookmark 2026-03-14 00:18:23 +01:00
ext.jeremy.guillot@maxicoffee.domains
15cb59e420 style: scrollbar isolée dans la zone de contenu + suppression des flèches
All checks were successful
Deploy / deploy (push) Successful in 2m38s
- Layout: h-screen overflow-hidden, <main> flex-col avec mt-16
- Pages avec toolbar: toolbar hors du conteneur scrollable (flex-col + overflow-y-auto flex-1)
- Pages sans toolbar: wrapper overflow-y-auto h-full
- app.scss: scrollbar-width/color limité à Firefox via @supports (-moz-appearance: none) pour éviter le conflit avec les pseudo-éléments webkit sur Chrome 121+
- Suppression des flèches de scrollbar via ::-webkit-scrollbar-button
- html/body overflow:hidden pour éviter la double scrollbar
2026-03-13 19:32:45 +01:00
ext.jeremy.guillot@maxicoffee.domains
d4e456961a fix: volume gap filling for chapter transitions between different volumes
All checks were successful
Deploy / deploy (push) Successful in 3m3s
`fillVolumeGaps` incorrectly left chapters null when surrounded by two
different non-null volumes (e.g. Vol10 → null → Vol11). Simplify the
condition to always prefer the previous volume, covering all cases.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 20:52:16 +01:00
ext.jeremy.guillot@maxicoffee.domains
d444f86315 Merge branch 'main' of ssh://git.homelab.nestor-server.fr:2222/colgora/Mangarr
All checks were successful
Build and Deploy / deploy (push) Successful in 1m46s
# Conflicts:
#	src/Domain/Manga/Application/CommandHandler/DeleteChapterHandler.php
#	src/Domain/Manga/Application/CommandHandler/EditMultipleChaptersHandler.php
#	src/Domain/Manga/Application/EventListener/ChapterImportedEventListener.php
#	src/Domain/Manga/Application/EventListener/VolumeImportedEventListener.php
#	src/Domain/Manga/Application/Response/ChapterResponse.php
#	src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteCbzProvider.php
#	src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteChapterProvider.php
#	src/Domain/Manga/Infrastructure/Persistence/Repository/LegacyChapterRepository.php
2026-03-09 20:47:43 +01:00
ext.jeremy.guillot@maxicoffee.domains
7506a7a3c1 style: apply php-cs-fixer formatting (PSR-12)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 20:46:59 +01:00
4cd277aec7 Merge pull request 'fix(migration): DROP INDEX IF EXISTS pour messenger_messages' (#2) from feat/chapter-entity-image-storage into main
All checks were successful
Build and Deploy / deploy (push) Successful in 1m36s
Reviewed-on: #2
2026-03-09 19:36:19 +01:00
ext.jeremy.guillot@maxicoffee.domains
640d1cec82 fix(migration): DROP INDEX IF EXISTS pour messenger_messages
Les index idx_available_at/idx_delivered_at/idx_queue_available/idx_queue_name
n'existent pas sur tous les environnements. IF EXISTS évite l'erreur 42704.

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:54:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
dae215dd3d feat: ajout de claude + correction des tests
All checks were successful
Build and Deploy / deploy (push) Successful in 9m36s
2026-03-09 17:09:31 +01:00
ext.jeremy.guillot@maxicoffee.domains
b5a832fbbc fix: delete manga
All checks were successful
Build and Deploy / deploy (push) Successful in 1m2s
2026-02-11 16:27:11 +01:00
ext.jeremy.guillot@maxicoffee.domains
f75b535426 fix: .env.example MESSENGER_DSN
All checks were successful
Build and Deploy / deploy (push) Successful in 52s
2026-02-11 16:13:01 +01:00
ext.jeremy.guillot@maxicoffee.domains
74e321bc50 fix: .env.example CORS_ALLOW_ORIGIN
All checks were successful
Build and Deploy / deploy (push) Successful in 50s
2026-02-11 16:06:01 +01:00
ext.jeremy.guillot@maxicoffee.domains
20f1211d5b fix: .env.example placeholders
All checks were successful
Build and Deploy / deploy (push) Successful in 1m38s
2026-02-11 16:00:38 +01:00
ext.jeremy.guillot@maxicoffee.domains
eafcc58d84 feat: cp du env.example
Some checks failed
Build and Deploy / deploy (push) Failing after 2m54s
2026-02-11 15:53:17 +01:00
ext.jeremy.guillot@maxicoffee.domains
c18f3653b8 feat: ignore .env
Some checks failed
Build and Deploy / deploy (push) Failing after 25s
2026-02-08 23:28:35 +01:00
ext.jeremy.guillot@maxicoffee.domains
ec8a45a500 fix: test deploy images
All checks were successful
Build and Deploy / deploy (push) Successful in 54s
2026-02-08 23:11:17 +01:00
ext.jeremy.guillot@maxicoffee.domains
889646afda fix: test deploy images
All checks were successful
Build and Deploy / deploy (push) Successful in 1m36s
2026-02-08 23:02:02 +01:00
ext.jeremy.guillot@maxicoffee.domains
af84deadd2 feat: test success
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
2026-02-08 22:53:56 +01:00
ext.jeremy.guillot@maxicoffee.domains
4d18c45af1 fix: test deploy
All checks were successful
Build and Deploy / deploy (push) Successful in 1m28s
2026-02-08 22:50:36 +01:00
ext.jeremy.guillot@maxicoffee.domains
8d261a9de3 feat: deploy
All checks were successful
Build and Deploy / deploy (push) Successful in 2m35s
2026-02-08 22:45:57 +01:00
ext.jeremy.guillot@maxicoffee.domains
8bebde2f58 feat: deploy
Some checks failed
Build and Deploy / deploy (push) Failing after 1s
2026-02-08 22:43:22 +01:00
ext.jeremy.guillot@maxicoffee.domains
5a3e68fa2a fix: assets
Some checks failed
Build and Deploy / deploy (push) Failing after 0s
2026-02-08 22:21:33 +01:00
ext.jeremy.guillot@maxicoffee.domains
c03cad6028 fix: Dockerfile DATABASE_URL
Some checks failed
Build and Deploy / deploy (push) Failing after 0s
2026-02-08 22:04:54 +01:00
ext.jeremy.guillot@maxicoffee.domains
03b0e5a34f fix: Dockerfile DATABASE_URL
Some checks failed
Build and Deploy / deploy (push) Failing after 1s
2026-02-08 22:04:00 +01:00
ext.jeremy.guillot@maxicoffee.domains
d8f8984192 fix: Dockerfile npm install
Some checks failed
Build and Deploy / deploy (push) Failing after 1s
2026-02-08 21:59:55 +01:00
ext.jeremy.guillot@maxicoffee.domains
58f68541f4 fix: composer.lock sync
Some checks failed
Build and Deploy / deploy (push) Failing after 1s
2026-02-08 21:56:38 +01:00
ext.jeremy.guillot@maxicoffee.domains
f472e250eb fix: composer.lock sync
Some checks failed
Build and Deploy / deploy (push) Failing after 0s
2026-02-08 21:54:59 +01:00
ext.jeremy.guillot@maxicoffee.domains
89b074113c fix: build
Some checks failed
Build and Deploy / deploy (push) Failing after 0s
2026-02-08 21:52:56 +01:00
ext.jeremy.guillot@maxicoffee.domains
134b4679ae fix: package-lock sync
Some checks failed
Build and Deploy / deploy (push) Failing after 0s
2026-02-08 21:47:30 +01:00
ext.jeremy.guillot@maxicoffee.domains
fb6a61d5b6 feat: deploy
Some checks failed
Build and Deploy / deploy (push) Failing after 21s
2026-02-08 21:35:13 +01:00
365 changed files with 9813 additions and 3896 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
- name: Deploy via Deployer
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
# Créer le container sans le démarrer (évite le problème DinD avec les volumes)
CONTAINER=$(docker create \
-e DEPLOY_HOST \
-e GITEA_TOKEN \
-w /app \
deployphp/deployer:v7 \
-f /app/deploy.php deploy production -vvv)
# Copier les sources et les clés SSH dans le container
docker cp "$PWD/." "$CONTAINER:/app/"
docker cp "$HOME/.ssh/." "$CONTAINER:/root/.ssh/"
# Démarrer et attendre la fin
docker start -a "$CONTAINER"
EXIT_CODE=$?
docker rm "$CONTAINER" || true
exit $EXIT_CODE

1
.gitignore vendored
View File

@@ -38,3 +38,4 @@ yarn-error.log
/public/images/
src/Controller/TestController.php
.phpunit.cache/test-results
/tests/Fixtures/pages/

110
CLAUDE.md Normal file
View File

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

View File

@@ -68,6 +68,19 @@ ENTRYPOINT ["docker-entrypoint"]
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
# Runtime FrankenPHP image (sans code baked-in)
# Le code vient du bind mount /srv/mangarr/current:/app (géré par Deployer)
# Builder une seule fois : docker build --target frankenphp_runtime -t mangarr:runtime .
FROM frankenphp_base AS frankenphp_runtime
ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="import worker.Caddyfile"
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --link frankenphp/conf.d/app.prod.ini $PHP_INI_DIR/conf.d/
COPY --link frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile
# Dev FrankenPHP image
FROM frankenphp_base AS frankenphp_dev
@@ -85,6 +98,26 @@ COPY --link frankenphp/conf.d/app.dev.ini $PHP_INI_DIR/conf.d/
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ]
# Composer dependencies (needed for Symfony UX assets referenced in package.json)
FROM composer:2 AS composer_deps
WORKDIR /app
COPY --link composer.* symfony.* ./
RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress --ignore-platform-reqs
# Stage Node.js pour compiler les assets (Webpack Encore)
FROM node:22-alpine AS node_build
WORKDIR /app
COPY --link package.json package-lock.json ./
COPY --from=composer_deps /app/vendor/symfony/ux-live-component/assets ./vendor/symfony/ux-live-component/assets
COPY --from=composer_deps /app/vendor/symfony/ux-react/assets ./vendor/symfony/ux-react/assets
COPY --from=composer_deps /app/vendor/symfony/ux-turbo/assets ./vendor/symfony/ux-turbo/assets
RUN npm install
COPY --link assets ./assets
COPY --link webpack.config.js ./
COPY --link tailwind.config.js postcss.config.js ./
COPY --link templates ./templates
RUN npm run build
# Prod FrankenPHP image
FROM frankenphp_base AS frankenphp_prod
@@ -103,11 +136,15 @@ RUN set -eux; \
# copy sources
COPY --link . ./
RUN rm -Rf frankenphp/
RUN rm -Rf frankenphp/ && \
test -f .env || cp .env.example .env
# Copier les assets compilés depuis le stage Node.js
COPY --from=node_build /app/public/build ./public/build
RUN set -eux; \
mkdir -p var/cache var/log; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
DATABASE_URL="postgresql://dummy:dummy@dummy:5432/dummy?serverVersion=15&charset=utf8" composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
<template>
<div class="container mx-auto px-4 py-8 max-w-4xl">
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8 max-w-4xl">
<!-- En-tête -->
<div class="mb-8">
<div class="flex items-center space-x-3 mb-4">
<ArrowPathIcon class="w-8 h-8 text-green-600" />
<h1 class="text-3xl font-bold text-gray-900">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">
Convertir CBR en CBZ
</h1>
</div>
<p class="text-lg text-gray-600">
<p class="text-lg text-gray-600 dark:text-gray-400">
Convertissez vos fichiers CBR (Comic Book RAR) en CBZ (Comic Book ZIP) pour une meilleure compatibilité.
</p>
</div>
<!-- Zone principale -->
<div class="bg-white shadow-lg rounded-lg overflow-hidden">
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden">
<!-- En-tête de la carte -->
<div class="bg-gray-800 text-white p-6">
<div class="flex items-center space-x-3">
@@ -75,14 +75,14 @@
/>
<!-- Message d'information -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex">
<InformationCircleIcon class="w-5 h-5 text-blue-500 flex-shrink-0" />
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300">
À propos de la conversion
</h3>
<div class="mt-2 text-sm text-blue-700 space-y-1">
<div class="mt-2 text-sm text-blue-700 dark:text-blue-400 space-y-1">
<p> Les fichiers CBZ sont plus largement supportés par les lecteurs de bandes dessinées</p>
<p> La compression ZIP permet généralement une meilleure accessibilité</p>
<p> Aucune perte de qualité lors de la conversion</p>
@@ -95,34 +95,34 @@
<!-- Historique des conversions -->
<div v-if="conversionStore.conversionCount > 0" class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Historique des conversions
</h3>
<button
@click="handleClearHistory"
class="text-sm text-gray-500 hover:text-gray-700 transition-colors"
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
Effacer l'historique
</button>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<div class="space-y-3">
<div
v-for="(conversion, index) in conversionStore.conversionHistory"
:key="index"
class="flex items-center justify-between py-2 border-b border-gray-200 last:border-b-0"
class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-600 last:border-b-0"
>
<div class="flex-1">
<p class="text-sm font-medium text-gray-900">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ conversion.originalName }}
</p>
<p class="text-xs text-gray-500">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ formatDate(conversion.timestamp) }}
</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600">
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
</p>
<p class="text-xs text-green-600">
@@ -150,7 +150,7 @@
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</div>
</div></div>
</template>
<script>

View File

@@ -1,10 +1,10 @@
<template>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6">
<div class="flex items-start space-x-4">
<!-- File Icon and Info -->
<div class="flex-shrink-0">
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
@@ -13,7 +13,7 @@
<!-- File Details -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900 truncate">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 truncate">
{{ file.filename }}
</h3>
@@ -23,29 +23,29 @@
</div>
</div>
<p class="text-sm text-gray-500 mt-1">
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ file.getFormattedSize() }} {{ file.getFileExtension().toUpperCase() }}
</p>
<!-- Extracted Info -->
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 text-blue-700">
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
Chapitre {{ file.getExtractedChapterNumber() }}
</span>
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 text-purple-700">
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
Volume {{ file.getExtractedVolumeNumber() }}
</span>
</div>
<!-- Error Display -->
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div class="flex">
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Erreur</h3>
<div class="mt-2 text-sm text-red-700">{{ file.errorMessage }}</div>
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">Erreur</h3>
<div class="mt-2 text-sm text-red-700 dark:text-red-400">{{ file.errorMessage }}</div>
</div>
</div>
</div>
@@ -53,7 +53,7 @@
<!-- Manga Selection -->
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
</label>
@@ -70,7 +70,7 @@
</div>
<!-- Selected Manga Preview -->
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
<img
v-if="file.selectedManga.thumbnailUrl"
:src="file.selectedManga.thumbnailUrl"
@@ -78,9 +78,9 @@
class="w-12 h-16 object-cover rounded"
/>
<div class="flex-1">
<p class="font-medium text-gray-900">{{ file.selectedManga.title }}</p>
<p class="text-sm text-gray-500">{{ file.selectedManga.slug }}</p>
<p class="text-xs text-blue-600 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
<p class="font-medium text-gray-900 dark:text-gray-100">{{ file.selectedManga.title }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ file.selectedManga.slug }}</p>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
</div>
</div>
@@ -88,7 +88,7 @@
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
<!-- Chapter Number -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Numéro de chapitre
</label>
<input
@@ -97,14 +97,14 @@
:value="file.selectedChapterNumber ?? ''"
@input="handleChapterNumberInput"
:disabled="file.selectedVolumeNumber !== null"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600"
placeholder="Ex: 1, 1.5, 2..."
/>
</div>
<!-- Volume Number -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Numéro de volume
</label>
<input
@@ -113,7 +113,7 @@
:value="file.selectedVolumeNumber ?? ''"
@input="handleVolumeNumberInput"
:disabled="file.selectedChapterNumber !== null"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600"
placeholder="Ex: 1, 1.5, 2..."
/>
</div>
@@ -121,14 +121,14 @@
</div>
<!-- No Matches Message -->
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
<div class="flex">
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Aucun manga trouvé</h3>
<div class="mt-2 text-sm text-yellow-700">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Aucun manga trouvé</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
</div>
</div>
@@ -138,7 +138,7 @@
</div>
<!-- Actions -->
<div class="mt-6 flex justify-between items-center">
<div class="mt-6 flex justify-between items-center border-t dark:border-gray-700 pt-4">
<div class="flex space-x-3">
<!-- Import Button -->
<button

View File

@@ -1,13 +1,13 @@
<template>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6">
<div class="text-center mb-6">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/40 mb-4">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Import terminé</h3>
<p class="text-sm text-gray-500">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Import terminé</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Voici le résumé de votre session d'import
</p>
</div>
@@ -16,7 +16,7 @@
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
<div class="text-sm text-gray-500">Importés</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Importés</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
@@ -30,7 +30,7 @@
<!-- Success Files List -->
<div v-if="importedFiles.length > 0" class="mb-6">
<h4 class="text-sm font-medium text-gray-900 mb-3">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
Fichiers importés avec succès ({{ importedFiles.length }})
</h4>
<ul class="space-y-2">
@@ -42,8 +42,8 @@
<svg class="flex-shrink-0 h-4 w-4 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span class="text-gray-900">{{ file.filename }}</span>
<span v-if="file.selectedManga" class="ml-2 text-gray-500">
<span class="text-gray-900 dark:text-gray-100">{{ file.filename }}</span>
<span v-if="file.selectedManga" class="ml-2 text-gray-500 dark:text-gray-400">
→ {{ file.selectedManga.title }}
</span>
</li>
@@ -52,7 +52,7 @@
<!-- Error Files List -->
<div v-if="errorFiles.length > 0" class="mb-6">
<h4 class="text-sm font-medium text-gray-900 mb-3">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
Fichiers en erreur ({{ errorFiles.length }})
</h4>
<ul class="space-y-2">
@@ -65,15 +65,15 @@
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<div>
<div class="text-gray-900">{{ file.filename }}</div>
<div class="text-red-600 text-xs mt-1">{{ file.errorMessage }}</div>
<div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div>
<div class="text-red-600 dark:text-red-400 text-xs mt-1">{{ file.errorMessage }}</div>
</div>
</li>
</ul>
</div>
<!-- Actions -->
<div class="flex justify-center space-x-4 pt-6 border-t">
<div class="flex justify-center space-x-4 pt-6 border-t dark:border-gray-700">
<button
@click="startNewImport"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"

View File

@@ -2,8 +2,8 @@
<div
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md"
:class="{
'border-blue-500 bg-blue-50': isSelected,
'border-gray-200 hover:border-gray-300': !isSelected
'border-blue-500 bg-blue-50 dark:bg-blue-900/20': isSelected,
'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-500': !isSelected
}"
@click="$emit('select-match', match)"
>
@@ -17,7 +17,7 @@
'bg-gray-300': !isSelected
}"
></div>
<span class="text-sm font-medium text-gray-700">Score: {{ match.matchScore }}</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Score: {{ match.matchScore }}</span>
</div>
<div v-if="isSelected" class="text-blue-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
@@ -37,9 +37,9 @@
/>
<div
v-else
class="w-16 h-20 bg-gray-200 rounded border flex items-center justify-center"
class="w-16 h-20 bg-gray-200 dark:bg-gray-700 rounded border dark:border-gray-600 flex items-center justify-center"
>
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
@@ -47,27 +47,27 @@
<!-- Manga Info -->
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate" :title="match.title">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" :title="match.title">
{{ match.title }}
</h4>
<p class="text-xs text-gray-500 mt-1 truncate" :title="match.slug">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate" :title="match.slug">
{{ match.slug }}
</p>
<!-- Alternative Slugs -->
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
<p class="text-xs text-gray-400">Autres titres:</p>
<p class="text-xs text-gray-400 dark:text-gray-500">Autres titres:</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
:key="altSlug"
class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"
class="text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded"
>
{{ altSlug }}
</span>
<span
v-if="match.alternativeSlugs.length > 2"
class="text-xs text-gray-400"
class="text-xs text-gray-400 dark:text-gray-500"
>
+{{ match.alternativeSlugs.length - 2 }} autres
</span>
@@ -78,11 +78,11 @@
<!-- Score Bar -->
<div class="mt-3">
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span>Correspondance</span>
<span>{{ match.matchScore }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="{

View File

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

View File

@@ -1,27 +1,27 @@
<template>
<div class="container mx-auto px-4 py-8">
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Import de Bibliothèque</h1>
<p class="text-gray-600">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">Import de Bibliothèque</h1>
<p class="text-gray-600 dark:text-gray-400">
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
</p>
</div>
<!-- Progress Bar (if files are being processed) -->
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8">
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">Progression</span>
<span class="text-sm text-gray-500">{{ store.progressPercentage }}%</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Progression</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ store.progressPercentage }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: store.progressPercentage + '%' }"
></div>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-2">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-2">
<span>{{ store.importedCount }} importés</span>
<span>{{ store.errorCount }} erreurs</span>
<span>{{ store.totalFiles }} total</span>
@@ -92,7 +92,7 @@
<div v-if="store.allFilesProcessed" class="mt-8">
<ImportResults />
</div>
</div>
</div></div>
</template>
<script setup>

View File

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

View File

@@ -1,7 +1,7 @@
<template>
<RouterLink
:to="{ name: 'manga-details', params: { id: manga.id } }"
class="bg-white rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
<div class="relative pb-[150%]">
<img
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
@@ -9,11 +9,11 @@
class="absolute inset-0 w-full h-full object-cover bg-gray-100" />
</div>
<div class="p-2">
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ manga.title }}</h3>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">{{ manga.title }}</h3>
<div class="flex items-center">
<span class="text-sm text-gray-500">{{ manga.publicationYear }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
</div>
<div class="mt-1 text-sm text-gray-500"> Added: {{ formatDate(manga.createdAt) }} </div>
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400"> Added: {{ formatDate(manga.createdAt) }} </div>
</div>
</RouterLink>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
@click="$emit('manga-click', manga)">
<!-- Cover Image -->
<div class="flex-shrink-0">
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" />
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" referrerpolicy="no-referrer" />
<!-- TODO: Add placeholder image -->
</div>

View File

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

View File

@@ -0,0 +1,208 @@
<template>
<div>
<div class="border-t border-gray-200 dark:border-gray-700">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="w-10 px-4 py-3"></th>
<th class="py-3 pr-4 text-left font-medium">Titre</th>
<th class="py-3 pr-4 text-left font-medium w-44">Source préférée</th>
<th class="py-3 pr-4 text-left font-medium w-44">Chapitres</th>
<th class="py-3 px-4 text-right font-medium w-28">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<tr
v-for="manga in mangas"
:key="manga.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
<!-- Monitoring -->
<td class="px-4 py-3 text-center">
<button
:title="manga.monitored ? 'Monitoring actif — cliquer pour désactiver' : 'Monitoring inactif — cliquer pour activer'"
:class="manga.monitored
? 'text-green-500 hover:text-green-600'
: 'text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500'"
class="transition-colors"
@click="doToggleMonitoring(manga)">
<component
:is="manga.monitored ? BookmarkIcon : BookmarkSlashIcon"
class="w-4 h-4" />
</button>
</td>
<!-- Titre -->
<td class="py-3 pr-4">
<RouterLink
:to="{ name: 'manga-details', params: { id: manga.id } }"
class="font-medium text-gray-900 dark:text-gray-100 hover:text-green-500 dark:hover:text-green-400 transition-colors">
{{ manga.title }}
</RouterLink>
</td>
<!-- Source préférée -->
<td class="py-3 pr-4">
<MangaPreferredSourceCell :manga-id="manga.id" />
</td>
<!-- Chapitres barre de progression -->
<td class="py-3 pr-4">
<div v-if="manga.chaptersTotal > 0">
<div class="flex items-center justify-between mb-1">
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
{{ manga.chaptersScraped }} / {{ manga.chaptersTotal }}
</span>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ progressPercent(manga) }}%
</span>
</div>
<div class="w-full bg-gray-100 dark:bg-gray-600 rounded-full h-1.5">
<div
class="h-1.5 rounded-full transition-all"
:class="progressPercent(manga) >= 100
? 'bg-green-500'
: 'bg-blue-500'"
:style="{ width: progressPercent(manga) + '%' }" />
</div>
</div>
<span v-else class="text-gray-400 dark:text-gray-600 text-xs"></span>
</td>
<!-- Actions -->
<td class="py-3 px-4">
<div class="flex items-center justify-end gap-0.5">
<button
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="Éditer"
@click="openEdit(manga)">
<PencilIcon class="w-4 h-4" />
</button>
<button
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="Sources préférées"
@click="openSources(manga)">
<Cog6ToothIcon class="w-4 h-4" />
</button>
<button
class="p-1.5 rounded-md transition-colors"
:class="refreshingId === manga.id
? 'text-blue-400 cursor-not-allowed'
: 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600'"
title="Rafraîchir"
:disabled="refreshingId === manga.id"
@click="doRefresh(manga)">
<ArrowPathIcon
class="w-4 h-4"
:class="{ 'animate-spin': refreshingId === manga.id }" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Modales -->
<MangaEditModal
:is-open="isEditModalOpen"
:manga="selectedManga"
:is-saving="editIsLoading"
:error="editError"
@close="closeEditModal"
@save="handleSaveEdit" />
<MangaPreferredSourcesModal
:is-open="isSourcesModalOpen"
:sources="preferredSources"
:is-loading="sourcesIsLoading"
:error="sourcesError"
:is-saving="sourcesIsSaving"
@close="isSourcesModalOpen = false"
@save="handleSaveSources" />
</div>
</template>
<script setup>
import { ArrowPathIcon, BookmarkIcon, BookmarkSlashIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
import { computed, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { useMangaEdit } from '../composables/useMangaEdit';
import { useMangaMonitoring } from '../composables/useMangaMonitoring';
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
import { useMangaRefresh } from '../composables/useMangaRefresh';
import MangaEditModal from './MangaEditModal.vue';
import MangaPreferredSourceCell from './MangaPreferredSourceCell.vue';
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
const props = defineProps({
mangas: {
type: Array,
required: true
}
});
function progressPercent(manga) {
if (!manga.chaptersTotal) return 0;
return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100);
}
// ── Monitoring ────────────────────────────────────────────
const { toggleMonitoring } = useMangaMonitoring();
async function doToggleMonitoring(manga) {
await toggleMonitoring(manga.id, !manga.monitored);
manga.monitored = !manga.monitored;
}
// ── Selected manga ────────────────────────────────────────
const selectedManga = ref(null);
const isSourcesModalOpen = ref(false);
// ── Edit ──────────────────────────────────────────────────
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
function openEdit(manga) {
selectedManga.value = manga;
openEditModal();
}
async function handleSaveEdit(data) {
if (!selectedManga.value) return;
await editManga(selectedManga.value.id, data);
}
// ── Sources préférées ─────────────────────────────────────
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
const {
sources: preferredSources,
isLoading: sourcesIsLoading,
error: sourcesError,
isSaving: sourcesIsSaving,
savePreferredSources
} = useMangaPreferredSources(selectedMangaId);
function openSources(manga) {
selectedManga.value = manga;
isSourcesModalOpen.value = true;
}
function handleSaveSources(sourceIds) {
savePreferredSources(sourceIds);
isSourcesModalOpen.value = false;
}
// ── Refresh ───────────────────────────────────────────────
const { refreshMetadata } = useMangaRefresh();
const refreshingId = ref(null);
async function doRefresh(manga) {
if (refreshingId.value) return;
refreshingId.value = manga.id;
try {
await refreshMetadata(manga.id);
} finally {
refreshingId.value = null;
}
}
</script>

View File

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

View File

@@ -8,7 +8,7 @@
v-model="searchQuery"
@keyup.enter="performSearch"
placeholder="Rechercher un manga..."
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
<button
@click="performSearch"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
@@ -20,27 +20,27 @@
<!-- État de chargement -->
<div v-if="loading" class="text-center py-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-4 text-gray-600">Recherche en cours...</p>
<p class="mt-4 text-gray-600 dark:text-gray-400">Recherche en cours...</p>
</div>
<!-- Message d'erreur -->
<div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
<div v-if="error" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded relative mb-6">
{{ error }}
</div>
<!-- Résultats de recherche -->
<div class="max-w-full overflow-hidden">
<MangaList v-if="searchResults.length > 0" :mangas="searchResults" @manga-click="openMangaModal" />
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600">Aucun résultat trouvé</p>
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600 dark:text-gray-400">Aucun résultat trouvé</p>
</div>
<!-- Modal de confirmation -->
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" />
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" aria-hidden="true" />
<div class="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel class="w-full max-w-lg bg-white rounded-xl shadow-xl p-6">
<DialogTitle class="text-lg mb-4"> Ajouter à la bibliothèque </DialogTitle>
<DialogPanel class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6">
<DialogTitle class="text-lg mb-4 text-gray-900 dark:text-gray-100"> Ajouter à la bibliothèque </DialogTitle>
<div v-if="selectedManga">
<div class="flex gap-4">
@@ -49,8 +49,8 @@
:alt="selectedManga.title"
class="h-48 w-32 object-cover" />
<div class="flex-1 min-w-0">
<h4 class="text-lg">{{ selectedManga.title }}</h4>
<p class="mt-2">
<h4 class="text-lg text-gray-900 dark:text-gray-100">{{ selectedManga.title }}</h4>
<p class="mt-2 text-gray-700 dark:text-gray-300">
{{ truncatedDescription }}
</p>
</div>
@@ -61,7 +61,7 @@
<button
type="button"
@click="closeModal"
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50">
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 dark:bg-gray-800">
Annuler
</button>
<button

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,6 +65,7 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useHeaderStore } from '../../../../shared/stores/headerStore';
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
import { useReaderStore } from '../../application/store/readerStore';
import InfiniteReader from './InfiniteReader.vue';
import ReaderControls from './ReaderControls.vue';
@@ -84,6 +85,7 @@ import SingleModeReader from './SingleModeReader.vue';
const store = useReaderStore();
const headerStore = useHeaderStore();
const prefs = useUserPreferencesStore();
// Référence vers InfiniteReader pour accéder à ses méthodes
const infiniteReaderRef = ref(null);
@@ -97,6 +99,7 @@ import SingleModeReader from './SingleModeReader.vue';
const toggleReadingMode = () => {
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
store.setReadingMode(newMode);
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
// Gérer la visibilité selon le mode
if (newMode === 'single') {
@@ -111,7 +114,9 @@ import SingleModeReader from './SingleModeReader.vue';
};
const toggleReadingDirection = () => {
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
store.setReadingDirection(newDir);
prefs.setReadingDirection(newDir);
resetButtonsTimer();
};
@@ -222,6 +227,16 @@ import SingleModeReader from './SingleModeReader.vue';
window.addEventListener('keydown', handleKeyPress);
// Auto-hide header si activé dans les préférences
if (prefs.autoHideHeaderReader) {
headerStore.enableAutoHide();
}
// Auto-fullscreen si activé dans les préférences
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(() => {});
}
// Afficher les boutons au démarrage
showButtonsWithTimer();
});

View File

@@ -6,13 +6,10 @@
</div>
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
<div v-if="page?.loading" class="loading">
<div v-if="!page?.url" class="loading">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<div v-else-if="page?.error" class="error">
{{ page.error }}
</div>
<ReaderPage v-else-if="page?.base64Content" :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" />
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
</div>
<!-- Navigation en bas -->

View File

@@ -1,7 +1,7 @@
<template>
<div class="page-container" :style="{ transform: `scale(${zoom})` }">
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div>
<div v-else-if="!pageData.base64Content" class="error">Contenu de l'image manquant</div>
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
<!-- Affichage spécial pour les doubles pages sur mobile -->
<div v-else-if="isDoublePage && isMobile && doublePageMode !== 'normal'" class="double-page-mobile">
@@ -88,10 +88,7 @@ import { useReaderStore } from '../../application/store/readerStore';
const imageLoaded = ref(false);
const imageSource = computed(() => {
if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
return '';
}
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`;
return props.pageData?.url ?? '';
});
// Détection des doubles pages basée sur le ratio largeur/hauteur et les dimensions API

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
import Layout from '../shared/components/layout/Layout.vue';
// Placeholder component for new routes
@@ -129,8 +130,7 @@ const routes = [
{
path: '/settings/ui',
name: 'settings-ui',
component: PlaceholderComponent,
props: { title: "Paramètres de l'interface" }
component: UserPreferencesPage
},
// Système
{
@@ -168,6 +168,6 @@ const routes = [
];
export const router = createRouter({
history: createWebHistory('/vue/'),
history: createWebHistory('/'),
routes
});

View File

@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-gray-50 flex">
<div class="h-screen overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
<Header
:show-menu-button="isReaderMode"
@menu-click="toggleSidebar"
@@ -12,7 +12,7 @@
@add-manga-click="$emit('add-manga-click', $event)" />
<main :class="[
'flex-1 pt-16',
'flex-1 mt-16 flex flex-col overflow-hidden',
isReaderMode ? '' : 'md:ml-60'
]">
<RouterView></RouterView>

View File

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

View File

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

View File

@@ -18,7 +18,6 @@
type: Object,
required: true,
validator: value => {
// Vérifie que leftSection et rightSection sont des tableaux
return Array.isArray(value.leftSection) && Array.isArray(value.rightSection);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4032
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,10 +1,8 @@
vich_uploader:
db_driver: orm
mappings:
conversion_uploads:
uri_prefix: /uploads/conversions
upload_destination: '%kernel.project_dir%/public/tmp/conversions'
namer: Vich\UploaderBundle\Naming\UniqidNamer
delete_on_update: true
delete_on_remove: true
#mappings:
# products:
# uri_prefix: /images/products
# upload_destination: '%kernel.project_dir%/public/images/products'
# namer: Vich\UploaderBundle\Naming\SmartUniqueNamer

View File

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

View File

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

View File

@@ -126,7 +126,12 @@ services:
tags:
- { name: messenger.message_handler, bus: command.bus }
App\Domain\Scraping\Infrastructure\Service\CbzGenerator: ~
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface:
alias: App\Domain\Scraping\Infrastructure\Service\LocalImageStorage
App\Domain\Scraping\Infrastructure\Service\LocalImageStorage:
arguments:
$storagePath: '%kernel.project_dir%/public/images'
# Shared Manga Path/File Manager
App\Domain\Shared\Domain\Contract\MangaPathManagerInterface:
@@ -148,10 +153,6 @@ services:
$publicDir: '%kernel.project_dir%/public'
$httpClient: '@GuzzleHttp\Client'
# Chapter Repository
App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface:
alias: App\Domain\Manga\Infrastructure\Persistence\Repository\LegacyChapterRepository
# File Service
App\Domain\Manga\Domain\Contract\Service\FileServiceInterface:
alias: App\Domain\Manga\Infrastructure\Service\FileService

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
<?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 Version20260309165048 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 chapter ADD pages_directory VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE chapter ADD page_count INT DEFAULT 0 NOT NULL');
$this->addSql('DROP INDEX IF EXISTS idx_available_at');
$this->addSql('DROP INDEX IF EXISTS idx_delivered_at');
$this->addSql('DROP INDEX IF EXISTS idx_queue_available');
$this->addSql('DROP INDEX IF EXISTS idx_queue_name');
}
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('CREATE INDEX idx_available_at ON messenger_messages (available_at)');
$this->addSql('CREATE INDEX idx_delivered_at ON messenger_messages (delivered_at)');
$this->addSql('CREATE INDEX idx_queue_available ON messenger_messages (queue_name, available_at)');
$this->addSql('CREATE INDEX idx_queue_name ON messenger_messages (queue_name)');
$this->addSql('ALTER TABLE chapter DROP pages_directory');
$this->addSql('ALTER TABLE chapter DROP page_count');
}
}

2099
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,5 +15,6 @@ readonly class CreateManga
public ?string $externalId,
public ?string $imageUrl,
public ?float $rating
) {}
}
) {
}
}

View File

@@ -6,5 +6,6 @@ readonly class CreateMangaFromMangadex
{
public function __construct(
public string $externalId
) {}
}
) {
}
}

View File

@@ -8,5 +8,6 @@ readonly class DeleteCbz implements CommandInterface
{
public function __construct(
public string $chapterId
) {}
) {
}
}

View File

@@ -8,5 +8,6 @@ readonly class DeleteChapter implements CommandInterface
{
public function __construct(
public string $chapterId
) {}
) {
}
}

View File

@@ -8,5 +8,6 @@ readonly class DeleteManga implements CommandInterface
{
public function __construct(
public string $mangaId
) {}
) {
}
}

View File

@@ -14,5 +14,6 @@ readonly class EditManga
public ?string $status = null,
public ?float $rating = null,
public ?array $alternativeSlugs = null
) {}
) {
}
}

View File

@@ -9,5 +9,6 @@ readonly class EditMultipleChapters
*/
public function __construct(
public array $chapters
) {}
) {
}
}

View File

@@ -8,5 +8,6 @@ readonly class FetchMangaChapters
{
public function __construct(
public MangaId $mangaId
) {}
) {
}
}

View File

@@ -8,5 +8,6 @@ readonly class ImportChapter
public string $mangaId,
public float $chapterNumber,
public string $fileBinary
) {}
) {
}
}

View File

@@ -8,9 +8,6 @@ readonly class ImportVolume
public string $mangaId,
public int $volumeNumber,
public string $fileBinary
) {}
) {
}
}

View File

@@ -8,5 +8,6 @@ readonly class RefreshMangaChapters
{
public function __construct(
public MangaId $mangaId
) {}
) {
}
}

View File

@@ -9,5 +9,6 @@ readonly class ToggleMangaMonitoring
public function __construct(
public MangaId $mangaId,
public bool $enabled
) {}
) {
}
}

View File

@@ -14,7 +14,8 @@ readonly class CheckMonitoredMangasHandler
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private MessageBusInterface $commandBus
) {}
) {
}
public function handle(CheckMonitoredMangas $command): void
{

View File

@@ -20,7 +20,8 @@ readonly class CreateMangaFromMangadexHandler
private MangaRepositoryInterface $mangaRepository,
private ImageProcessorInterface $imageProcessor,
private EventDispatcherInterface $eventDispatcher
) {}
) {
}
public function handle(CreateMangaFromMangadex $command): void
{

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