34 Commits

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

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

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

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

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

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

La suppression de var/cache/prod à l'entrypoint garantit que les workers
démarrent toujours sur un cache vierge, généré à chaud à la première requête.
2026-03-26 18:49:30 +01:00
5551d73962 Merge pull request 'fix: limiter les workers FrankenPHP et nettoyer le Dockerfile' (#38) from fix/cache-clear-oom into main
Some checks failed
Deploy / deploy (push) Failing after 35s
Reviewed-on: #38
2026-03-26 18:44:26 +01:00
395a0a16cb Merge branch 'main' into fix/cache-clear-oom 2026-03-26 18:44:17 +01:00
0f80cb9fec Merge pull request 'fix(doctrine): supprimer auto_generate_proxy_classes et proxy_dir' (#37) from fix/doctrine-orm3-config into main
Some checks failed
Deploy / deploy (push) Failing after 36s
Reviewed-on: #37
2026-03-26 18:39:56 +01:00
a3477629fb Merge branch 'main' into fix/doctrine-orm3-config 2026-03-26 18:39:51 +01:00
ext.jeremy.guillot@maxicoffee.domains
cde701986e fix(doctrine): supprimer auto_generate_proxy_classes et proxy_dir
Ces options ont été supprimées de Doctrine Bundle 3.x / ORM 3.x.
Elles causaient une erreur "Unrecognized options" au cache:clear en prod.
2026-03-26 18:39:22 +01:00
b921768aef Merge pull request 'chore: supprimer les dépendances Twig/Stimulus/React/Turbo inutilisées' (#36) from chore/cleanup-unused-dependencies into main
Some checks failed
Deploy / deploy (push) Failing after 1m10s
Reviewed-on: #36
2026-03-26 18:36:11 +01:00
ext.jeremy.guillot@maxicoffee.domains
5f0178f784 chore: supprimer les dépendances Twig/Stimulus/React/Turbo inutilisées
PHP : suppression de symfony/stimulus-bundle, ux-live-component, ux-react,
ux-turbo, twig/extra-bundle et symfony/form (plus utilisés depuis la
migration vers Vue.js SPA).

npm : suppression de @hotwired/stimulus, @hotwired/turbo, react, react-dom,
alpinejs, bootstrap, daisyui, sortablejs, vuedraggable et leurs types.
Corrige l'erreur de déploiement causée par vitest@^4.1.0 (introuvable)
requis par les anciens packages @symfony/ux-react et @symfony/ux-turbo v2.33.0.
2026-03-26 18:35:40 +01:00
45 changed files with 817 additions and 4607 deletions

View File

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

View File

@@ -0,0 +1,161 @@
<template>
<TransitionRoot as="template" :show="isOpen">
<Dialog as="div" class="relative z-50" @close="$emit('close')">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
</TransitionChild>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div class="mb-6">
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
Options d'affichage
</DialogTitle>
</div>
<div class="space-y-6">
<!-- Vue Grid -->
<section>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Vue Grille
</h4>
<div class="space-y-3">
<ToggleRow
label="Titre"
:value="options.grid.showTitle"
@update="setOption('grid', 'showTitle', $event)" />
<ToggleRow
label="Année de publication"
:value="options.grid.showYear"
@update="setOption('grid', 'showYear', $event)" />
<ToggleRow
label="Auteur(s)"
:value="options.grid.showAuthor"
@update="setOption('grid', 'showAuthor', $event)" />
</div>
</section>
<div class="border-t border-gray-200 dark:border-gray-700" />
<!-- Vue Overview -->
<section>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Vue Overview
</h4>
<div class="space-y-3">
<ToggleRow
label="Couverture"
:value="options.overview.showCover"
@update="setOption('overview', 'showCover', $event)" />
<ToggleRow
label="Statut"
:value="options.overview.showStatus"
@update="setOption('overview', 'showStatus', $event)" />
<ToggleRow
label="Description"
:value="options.overview.showDescription"
@update="setOption('overview', 'showDescription', $event)" />
<ToggleRow
label="Auteur(s)"
:value="options.overview.showAuthor"
@update="setOption('overview', 'showAuthor', $event)" />
<ToggleRow
label="Année de publication"
:value="options.overview.showYear"
@update="setOption('overview', 'showYear', $event)" />
</div>
</section>
<div class="border-t border-gray-200 dark:border-gray-700" />
<!-- Vue Table -->
<section>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Vue Table
</h4>
<div class="space-y-3">
<ToggleRow
label="Monitoring"
:value="options.table.showMonitoring"
@update="setOption('table', 'showMonitoring', $event)" />
<ToggleRow
label="Source préférée"
:value="options.table.showPreferredSource"
@update="setOption('table', 'showPreferredSource', $event)" />
<ToggleRow
label="Progression chapitres"
:value="options.table.showChapters"
@update="setOption('table', 'showChapters', $event)" />
<ToggleRow
label="Statut"
:value="options.table.showStatus"
@update="setOption('table', 'showStatus', $event)" />
<ToggleRow
label="Auteur(s)"
:value="options.table.showAuthor"
@update="setOption('table', 'showAuthor', $event)" />
<ToggleRow
label="Année de publication"
:value="options.table.showYear"
@update="setOption('table', 'showYear', $event)" />
</div>
</section>
</div>
<div class="mt-6 flex justify-end">
<button
type="button"
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="$emit('close')"
>
Fermer
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
import ToggleRow from '../../../../shared/components/ui/ToggleRow.vue';
defineProps({
isOpen: {
type: Boolean,
required: true
},
options: {
type: Object,
required: true
}
});
const emit = defineEmits(['close', 'update']);
function setOption(view, key, value) {
emit('update', { view, key, value });
}
</script>

View File

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

View File

@@ -14,14 +14,14 @@
chapterId: chapter.id chapterId: chapter.id
} }
}"> }">
<template v-if="chapter.isVolumeGroup"> <template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }} Chapitres {{ chapter.volumeChaptersRange }}
</template> </template>
<template v-else>{{ chapter.title || 'Sans titre' }}</template> <template v-else>{{ chapter.title || 'Sans titre' }}</template>
</router-link> </router-link>
<span v-else class="text-gray-500 dark:text-gray-400"> <span v-else class="text-gray-500 dark:text-gray-400">
<template v-if="chapter.isVolumeGroup"> <template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }} Chapitres {{ chapter.volumeChaptersRange }}
</template> </template>
<template v-else>{{ chapter.title || 'Sans titre' }}</template> <template v-else>{{ chapter.title || 'Sans titre' }}</template>
</span> </span>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,6 @@
"symfony/dotenv": "8.0.*", "symfony/dotenv": "8.0.*",
"symfony/expression-language": "8.0.*", "symfony/expression-language": "8.0.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "8.0.*",
"symfony/framework-bundle": "8.0.*", "symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*", "symfony/http-client": "8.0.*",
"symfony/mercure-bundle": "^0.4", "symfony/mercure-bundle": "^0.4",
@@ -44,15 +43,10 @@
"symfony/scheduler": "8.0.*", "symfony/scheduler": "8.0.*",
"symfony/security-bundle": "8.0.*", "symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*", "symfony/serializer": "8.0.*",
"symfony/stimulus-bundle": "^2.17",
"symfony/twig-bundle": "8.0.*", "symfony/twig-bundle": "8.0.*",
"symfony/ux-live-component": "^2.17",
"symfony/ux-react": "^2.23",
"symfony/ux-turbo": "^2.18",
"symfony/validator": "8.0.*", "symfony/validator": "8.0.*",
"symfony/webpack-encore-bundle": "^2.1", "symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "8.0.*", "symfony/yaml": "8.0.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0", "twig/twig": "^2.12|^3.0",
"vich/uploader-bundle": "^2.7" "vich/uploader-bundle": "^2.7"
}, },

849
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "281edff65ffa4e019c69d0ffbef5f223", "content-hash": "6c61952b2d792d9e9204594abd228d6f",
"packages": [ "packages": [
{ {
"name": "api-platform/core", "name": "api-platform/core",
@@ -4822,101 +4822,6 @@
], ],
"time": "2025-11-16T09:38:19+00:00" "time": "2025-11-16T09:38:19+00:00"
}, },
{
"name": "symfony/form",
"version": "v8.0.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/form.git",
"reference": "954e17b053dad9fb227ebd90260752e3a46bb06a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/form/zipball/954e17b053dad9fb227ebd90260752e3a46bb06a",
"reference": "954e17b053dad9fb227ebd90260752e3a46bb06a",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/options-resolver": "^7.4|^8.0",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-intl-icu": "^1.21",
"symfony/polyfill-mbstring": "^1.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"symfony/intl": "<7.4",
"symfony/translation-contracts": "<2.5",
"symfony/validator": "<7.4"
},
"require-dev": {
"doctrine/collections": "^1.0|^2.0",
"symfony/clock": "^7.4|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/console": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/expression-language": "^7.4|^8.0",
"symfony/html-sanitizer": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/intl": "^7.4|^8.0",
"symfony/security-core": "^7.4|^8.0",
"symfony/security-csrf": "^7.4|^8.0",
"symfony/translation": "^7.4|^8.0",
"symfony/uid": "^7.4|^8.0",
"symfony/validator": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Form\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows to easily create, process and reuse HTML forms",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/form/tree/v8.0.7"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-06T13:17:40+00:00"
},
{ {
"name": "symfony/framework-bundle", "name": "symfony/framework-bundle",
"version": "v8.0.7", "version": "v8.0.7",
@@ -5913,77 +5818,6 @@
], ],
"time": "2025-12-08T08:00:13+00:00" "time": "2025-12-08T08:00:13+00:00"
}, },
{
"name": "symfony/options-resolver",
"version": "v8.0.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v8.0.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-11-12T15:55:31+00:00"
},
{ {
"name": "symfony/panther", "name": "symfony/panther",
"version": "v2.4.0", "version": "v2.4.0",
@@ -6228,94 +6062,6 @@
], ],
"time": "2025-06-27T09:58:17+00:00" "time": "2025-06-27T09:58:17+00:00"
}, },
{
"name": "symfony/polyfill-intl-icu",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git",
"reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c",
"reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance and support of other locales than \"en\""
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Icu\\": ""
},
"classmap": [
"Resources/stubs"
],
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's ICU-related data and classes",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"icu",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-06-20T22:24:30+00:00"
},
{ {
"name": "symfony/polyfill-intl-idn", "name": "symfony/polyfill-intl-idn",
"version": "v1.33.0", "version": "v1.33.0",
@@ -7737,79 +7483,6 @@
], ],
"time": "2025-07-15T11:30:57+00:00" "time": "2025-07-15T11:30:57+00:00"
}, },
{
"name": "symfony/stimulus-bundle",
"version": "v2.34.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/stimulus-bundle.git",
"reference": "d610a2e021cf63f955838b4bfe40da7e4cafe850"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/d610a2e021cf63f955838b4bfe40da7e4cafe850",
"reference": "d610a2e021cf63f955838b4bfe40da7e4cafe850",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/config": "^5.4|^6.0|^7.0|^8.0",
"symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.0|^3.0",
"symfony/finder": "^5.4|^6.0|^7.0|^8.0",
"symfony/http-kernel": "^5.4|^6.0|^7.0|^8.0",
"twig/twig": "^2.15.3|^3.8"
},
"require-dev": {
"symfony/asset-mapper": "^6.3|^7.0|^8.0",
"symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0",
"zenstruck/browser": "^1.4"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\UX\\StimulusBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Integration with your Symfony app & Stimulus!",
"keywords": [
"symfony-ux"
],
"support": {
"source": "https://github.com/symfony/stimulus-bundle/tree/v2.34.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-21T22:29:11+00:00"
},
{ {
"name": "symfony/stopwatch", "name": "symfony/stopwatch",
"version": "v8.0.0", "version": "v8.0.0",
@@ -8321,379 +7994,6 @@
], ],
"time": "2026-03-04T13:55:34+00:00" "time": "2026-03-04T13:55:34+00:00"
}, },
{
"name": "symfony/ux-live-component",
"version": "v2.34.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-live-component.git",
"reference": "f246c189192121781c267e26a64ff6942ef61ab6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ux-live-component/zipball/f246c189192121781c267e26a64ff6942ef61ab6",
"reference": "f246c189192121781c267e26a64ff6942ef61ab6",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/property-access": "^5.4.5|^6.0|^7.0|^8.0",
"symfony/property-info": "^5.4|^6.0|^7.0|^8.0",
"symfony/stimulus-bundle": "^2.9",
"symfony/ux-twig-component": "^2.33.0",
"twig/twig": "^3.10.3"
},
"conflict": {
"symfony/config": "<5.4.0",
"symfony/property-info": "~7.0.0",
"symfony/type-info": "<7.2"
},
"require-dev": {
"doctrine/annotations": "^1.0|^2.0",
"doctrine/collections": "^1.6.8|^2.0",
"doctrine/doctrine-bundle": "^2.4.3|^3.0|^4.0",
"doctrine/orm": "^2.9.4|^3.0",
"doctrine/persistence": "^2.5.2|^3.0|^4.0",
"phpdocumentor/reflection-docblock": "^5.6.2",
"symfony/config": "^6.3|^7.0|^8.0",
"symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0",
"symfony/expression-language": "^5.4|^6.0|^7.0|^8.0",
"symfony/form": "^5.4|^6.0|^7.0|^8.0",
"symfony/framework-bundle": "^5.4|^6.1|^7.0|^8.0",
"symfony/http-kernel": "^6.1|^7.0|^8.0",
"symfony/options-resolver": "^5.4|^6.0|^7.0|^8.0",
"symfony/phpunit-bridge": "^6.1|^7.0|^8.0",
"symfony/security-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/serializer": "^5.4|^6.0|^7.0|^8.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/uid": "^5.4|^6.0|^7.0|^8.0",
"symfony/validator": "^5.4|^6.0|^7.0|^8.0",
"zenstruck/browser": "^1.2.0",
"zenstruck/foundry": "^2.0"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/ux",
"name": "symfony/ux"
}
},
"autoload": {
"psr-4": {
"Symfony\\UX\\LiveComponent\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Live components for Symfony",
"homepage": "https://symfony.com",
"keywords": [
"components",
"symfony-ux",
"twig"
],
"support": {
"source": "https://github.com/symfony/ux-live-component/tree/v2.34.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-21T22:29:11+00:00"
},
{
"name": "symfony/ux-react",
"version": "v2.34.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-react.git",
"reference": "42ee2b86e3af8493e4a008ebe2af166c2c3d4d05"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ux-react/zipball/42ee2b86e3af8493e4a008ebe2af166c2c3d4d05",
"reference": "42ee2b86e3af8493e4a008ebe2af166c2c3d4d05",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/stimulus-bundle": "^2.9.1"
},
"require-dev": {
"symfony/asset-mapper": "^6.3|^7.0|^8.0",
"symfony/finder": "^5.4|^6.0|^7.0|^8.0",
"symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/ux",
"name": "symfony/ux"
}
},
"autoload": {
"psr-4": {
"Symfony\\UX\\React\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Titouan Galopin",
"email": "galopintitouan@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Integration of React in Symfony",
"homepage": "https://symfony.com",
"keywords": [
"symfony-ux"
],
"support": {
"source": "https://github.com/symfony/ux-react/tree/v2.34.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-21T22:29:11+00:00"
},
{
"name": "symfony/ux-turbo",
"version": "v2.34.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-turbo.git",
"reference": "87511f621db238302a3bb819958a72feda27fc45"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ux-turbo/zipball/87511f621db238302a3bb819958a72feda27fc45",
"reference": "87511f621db238302a3bb819958a72feda27fc45",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/stimulus-bundle": "^2.9.1"
},
"conflict": {
"symfony/flex": "<1.13"
},
"require-dev": {
"dbrekelmans/bdi": "dev-main",
"doctrine/doctrine-bundle": "^2.4.3|^3.0|^4.0",
"doctrine/orm": "^2.8|^3.0",
"php-webdriver/webdriver": "^1.15",
"phpstan/phpstan": "^2.1.17",
"symfony/asset-mapper": "^6.4|^7.0|^8.0",
"symfony/debug-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/expression-language": "^5.4|^6.0|^7.0|^8.0",
"symfony/form": "^5.4|^6.0|^7.0|^8.0",
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
"symfony/mercure-bundle": "^0.3.7|^0.4.1",
"symfony/messenger": "^5.4|^6.0|^7.0|^8.0",
"symfony/panther": "^2.2",
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0",
"symfony/process": "^5.4|6.3.*|^7.0|^8.0",
"symfony/property-access": "^5.4|^6.0|^7.0|^8.0",
"symfony/security-core": "^5.4|^6.0|^7.0|^8.0",
"symfony/stopwatch": "^5.4|^6.0|^7.0|^8.0",
"symfony/twig-bundle": "^6.4|^7.0|^8.0",
"symfony/ux-twig-component": "^2.21",
"symfony/web-profiler-bundle": "^5.4|^6.0|^7.0|^8.0"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/ux",
"name": "symfony/ux"
}
},
"autoload": {
"psr-4": {
"Symfony\\UX\\Turbo\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kévin Dunglas",
"email": "kevin@dunglas.fr"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Hotwire Turbo integration for Symfony",
"homepage": "https://symfony.com",
"keywords": [
"hotwire",
"javascript",
"mercure",
"symfony-ux",
"turbo",
"turbo-stream"
],
"support": {
"source": "https://github.com/symfony/ux-turbo/tree/v2.34.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-21T22:29:11+00:00"
},
{
"name": "symfony/ux-twig-component",
"version": "v2.34.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-twig-component.git",
"reference": "f9942e32246fe3fa9d31f60cffc1ada4d274830a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/f9942e32246fe3fa9d31f60cffc1ada4d274830a",
"reference": "f9942e32246fe3fa9d31f60cffc1ada4d274830a",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.2|^3.0",
"symfony/event-dispatcher": "^5.4|^6.0|^7.0|^8.0",
"symfony/property-access": "^5.4|^6.0|^7.0|^8.0",
"twig/twig": "^3.10.3"
},
"conflict": {
"symfony/config": "<5.4.0"
},
"require-dev": {
"symfony/console": "^5.4|^6.0|^7.0|^8.0",
"symfony/css-selector": "^5.4|^6.0|^7.0|^8.0",
"symfony/dom-crawler": "^5.4|^6.0|^7.0|^8.0",
"symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/phpunit-bridge": "^6.0|^7.0|^8.0",
"symfony/stimulus-bundle": "^2.9.1",
"symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0",
"symfony/webpack-encore-bundle": "^1.15|^2.3.0"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/ux",
"name": "symfony/ux"
}
},
"autoload": {
"psr-4": {
"Symfony\\UX\\TwigComponent\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Twig components for Symfony",
"homepage": "https://symfony.com",
"keywords": [
"components",
"symfony-ux",
"twig"
],
"support": {
"source": "https://github.com/symfony/ux-twig-component/tree/v2.34.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-15T18:48:53+00:00"
},
{ {
"name": "symfony/validator", "name": "symfony/validator",
"version": "v8.0.7", "version": "v8.0.7",
@@ -9191,80 +8491,6 @@
], ],
"time": "2026-02-09T10:14:57+00:00" "time": "2026-02-09T10:14:57+00:00"
}, },
{
"name": "twig/extra-bundle",
"version": "v3.24.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/twig-extra-bundle.git",
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9",
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9",
"shasum": ""
},
"require": {
"php": ">=8.1.0",
"symfony/framework-bundle": "^5.4|^6.4|^7.0|^8.0",
"symfony/twig-bundle": "^5.4|^6.4|^7.0|^8.0",
"twig/twig": "^3.2|^4.0"
},
"require-dev": {
"league/commonmark": "^2.7",
"symfony/phpunit-bridge": "^6.4|^7.0",
"twig/cache-extra": "^3.0",
"twig/cssinliner-extra": "^3.0",
"twig/html-extra": "^3.0",
"twig/inky-extra": "^3.0",
"twig/intl-extra": "^3.0",
"twig/markdown-extra": "^3.0",
"twig/string-extra": "^3.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Twig\\Extra\\TwigExtraBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}
],
"description": "A Symfony bundle for extra Twig extensions",
"homepage": "https://twig.symfony.com",
"keywords": [
"bundle",
"extra",
"twig"
],
"support": {
"source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0"
},
"funding": [
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
"type": "tidelift"
}
],
"time": "2026-02-07T08:07:38+00:00"
},
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.24.0", "version": "v3.24.0",
@@ -13096,6 +12322,77 @@
], ],
"time": "2026-03-18T13:39:06+00:00" "time": "2026-03-18T13:39:06+00:00"
}, },
{
"name": "symfony/options-resolver",
"version": "v8.0.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v8.0.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-11-12T15:55:31+00:00"
},
{ {
"name": "symfony/phpunit-bridge", "name": "symfony/phpunit-bridge",
"version": "v8.0.7", "version": "v8.0.7",
@@ -13655,5 +12952,5 @@
"ext-zip": "*" "ext-zip": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.9.0"
} }

View File

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

View File

@@ -1,11 +0,0 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

View File

@@ -42,8 +42,6 @@ when@test:
when@prod: when@prod:
doctrine: doctrine:
orm: orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver: query_cache_driver:
type: pool type: pool
pool: doctrine.system_cache_pool pool: doctrine.system_cache_pool

View File

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

View File

@@ -148,7 +148,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" * cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token"
* }, * },
* form?: bool|array{ // Form configuration * form?: bool|array{ // Form configuration
* enabled?: bool|Param, // Default: true * enabled?: bool|Param, // Default: false
* csrf_protection?: bool|array{ * csrf_protection?: bool|array{
* enabled?: scalar|Param|null, // Default: null * enabled?: scalar|Param|null, // Default: null
* token_id?: scalar|Param|null, // Default: null * token_id?: scalar|Param|null, // Default: null
@@ -1765,73 +1765,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* script_attributes?: array<string, scalar|Param|null>, * script_attributes?: array<string, scalar|Param|null>,
* link_attributes?: array<string, scalar|Param|null>, * link_attributes?: array<string, scalar|Param|null>,
* } * }
* @psalm-type TwigComponentConfig = array{
* defaults?: array<string, string|array{ // Default: ["__deprecated__use_old_naming_behavior"]
* template_directory?: scalar|Param|null, // Default: "components"
* name_prefix?: scalar|Param|null, // Default: ""
* }>,
* anonymous_template_directory?: scalar|Param|null, // Defaults to `components`
* profiler?: bool|array{ // Enables the profiler for Twig Component
* enabled?: bool|Param, // Default: "%kernel.debug%"
* collect_components?: bool|Param, // Collect components instances // Default: true
* },
* controllers_json?: scalar|Param|null, // Deprecated: The "twig_component.controllers_json" config option is deprecated, and will be removed in 3.0. // Default: null
* }
* @psalm-type LiveComponentConfig = array{
* secret?: scalar|Param|null, // The secret used to compute fingerprints and checksums // Default: "%kernel.secret%"
* fetch_credentials?: "same-origin"|"include"|"omit"|Param, // The default fetch credentials mode for all Live Components ('same-origin', 'include', 'omit') // Default: "same-origin"
* }
* @psalm-type StimulusConfig = array{
* controller_paths?: list<scalar|Param|null>,
* controllers_json?: scalar|Param|null, // Default: "%kernel.project_dir%/assets/controllers.json"
* }
* @psalm-type TwigExtraConfig = array{
* cache?: bool|array{
* enabled?: bool|Param, // Default: false
* },
* html?: bool|array{
* enabled?: bool|Param, // Default: false
* },
* markdown?: bool|array{
* enabled?: bool|Param, // Default: false
* },
* intl?: bool|array{
* enabled?: bool|Param, // Default: false
* },
* cssinliner?: bool|array{
* enabled?: bool|Param, // Default: false
* },
* inky?: bool|array{
* enabled?: bool|Param, // Default: false
* },
* string?: bool|array{
* enabled?: bool|Param, // Default: false
* },
* commonmark?: array{
* renderer?: array{ // Array of options for rendering HTML.
* block_separator?: scalar|Param|null,
* inner_separator?: scalar|Param|null,
* soft_break?: scalar|Param|null,
* },
* html_input?: "strip"|"allow"|"escape"|Param, // How to handle HTML input.
* allow_unsafe_links?: bool|Param, // Remove risky link and image URLs by setting this to false. // Default: true
* max_nesting_level?: int|Param, // The maximum nesting level for blocks. // Default: 9223372036854775807
* max_delimiters_per_line?: int|Param, // The maximum number of strong/emphasis delimiters per line. // Default: 9223372036854775807
* slug_normalizer?: array{ // Array of options for configuring how URL-safe slugs are created.
* instance?: mixed,
* max_length?: int|Param, // Default: 255
* unique?: mixed,
* },
* commonmark?: array{ // Array of options for configuring the CommonMark core extension.
* enable_em?: bool|Param, // Default: true
* enable_strong?: bool|Param, // Default: true
* use_asterisk?: bool|Param, // Default: true
* use_underscore?: bool|Param, // Default: true
* unordered_list_markers?: list<scalar|Param|null>,
* },
* ...<mixed>
* },
* }
* @psalm-type MercureConfig = array{ * @psalm-type MercureConfig = array{
* hubs?: array<string, array{ // Default: [] * hubs?: array<string, array{ // Default: []
* url?: scalar|Param|null, // URL of the hub's publish endpoint // Default: null * url?: scalar|Param|null, // URL of the hub's publish endpoint // Default: null
@@ -1853,26 +1786,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* default_cookie_lifetime?: int|Param, // Default lifetime of the cookie containing the JWT, in seconds. Defaults to the value of "framework.session.cookie_lifetime". // Default: null * default_cookie_lifetime?: int|Param, // Default lifetime of the cookie containing the JWT, in seconds. Defaults to the value of "framework.session.cookie_lifetime". // Default: null
* enable_profiler?: bool|Param, // Deprecated: The child node "enable_profiler" at path "mercure.enable_profiler" is deprecated. // Enable Symfony Web Profiler integration. * enable_profiler?: bool|Param, // Deprecated: The child node "enable_profiler" at path "mercure.enable_profiler" is deprecated. // Enable Symfony Web Profiler integration.
* } * }
* @psalm-type TurboConfig = array{
* broadcast?: bool|array{
* enabled?: bool|Param, // Default: true
* entity_template_prefixes?: list<scalar|Param|null>,
* doctrine_orm?: bool|array{ // Enable the Doctrine ORM integration
* enabled?: bool|Param, // Default: true
* },
* },
* default_transport?: scalar|Param|null, // Default: "default"
* }
* @psalm-type DamaDoctrineTestConfig = array{ * @psalm-type DamaDoctrineTestConfig = array{
* enable_static_connection?: mixed, // Default: true * enable_static_connection?: mixed, // Default: true
* enable_static_meta_data_cache?: bool|Param, // Default: true * enable_static_meta_data_cache?: bool|Param, // Default: true
* enable_static_query_cache?: bool|Param, // Default: true * enable_static_query_cache?: bool|Param, // Default: true
* connection_keys?: list<mixed>, * connection_keys?: list<mixed>,
* } * }
* @psalm-type ReactConfig = array{
* controllers_path?: scalar|Param|null, // The path to the directory where React controller components are stored - relevant only when using symfony/asset-mapper. // Default: "%kernel.project_dir%/assets/react/controllers"
* name_glob?: list<scalar|Param|null>,
* }
* @psalm-type VichUploaderConfig = array{ * @psalm-type VichUploaderConfig = array{
* default_filename_attribute_suffix?: scalar|Param|null, // Default: "_name" * default_filename_attribute_suffix?: scalar|Param|null, // Default: "_name"
* db_driver?: scalar|Param|null, * db_driver?: scalar|Param|null,
@@ -1924,13 +1843,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* api_platform?: ApiPlatformConfig, * api_platform?: ApiPlatformConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* webpack_encore?: WebpackEncoreConfig, * webpack_encore?: WebpackEncoreConfig,
* twig_component?: TwigComponentConfig,
* live_component?: LiveComponentConfig,
* stimulus?: StimulusConfig,
* twig_extra?: TwigExtraConfig,
* mercure?: MercureConfig, * mercure?: MercureConfig,
* turbo?: TurboConfig,
* react?: ReactConfig,
* vich_uploader?: VichUploaderConfig, * vich_uploader?: VichUploaderConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1948,13 +1861,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* zenstruck_foundry?: ZenstruckFoundryConfig, * zenstruck_foundry?: ZenstruckFoundryConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* webpack_encore?: WebpackEncoreConfig, * webpack_encore?: WebpackEncoreConfig,
* twig_component?: TwigComponentConfig,
* live_component?: LiveComponentConfig,
* stimulus?: StimulusConfig,
* twig_extra?: TwigExtraConfig,
* mercure?: MercureConfig, * mercure?: MercureConfig,
* turbo?: TurboConfig,
* react?: ReactConfig,
* vich_uploader?: VichUploaderConfig, * vich_uploader?: VichUploaderConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
@@ -1970,13 +1877,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* api_platform?: ApiPlatformConfig, * api_platform?: ApiPlatformConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* webpack_encore?: WebpackEncoreConfig, * webpack_encore?: WebpackEncoreConfig,
* twig_component?: TwigComponentConfig,
* live_component?: LiveComponentConfig,
* stimulus?: StimulusConfig,
* twig_extra?: TwigExtraConfig,
* mercure?: MercureConfig, * mercure?: MercureConfig,
* turbo?: TurboConfig,
* react?: ReactConfig,
* vich_uploader?: VichUploaderConfig, * vich_uploader?: VichUploaderConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
@@ -1994,14 +1895,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* zenstruck_foundry?: ZenstruckFoundryConfig, * zenstruck_foundry?: ZenstruckFoundryConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* webpack_encore?: WebpackEncoreConfig, * webpack_encore?: WebpackEncoreConfig,
* twig_component?: TwigComponentConfig,
* live_component?: LiveComponentConfig,
* stimulus?: StimulusConfig,
* twig_extra?: TwigExtraConfig,
* mercure?: MercureConfig, * mercure?: MercureConfig,
* turbo?: TurboConfig,
* dama_doctrine_test?: DamaDoctrineTestConfig, * dama_doctrine_test?: DamaDoctrineTestConfig,
* react?: ReactConfig,
* vich_uploader?: VichUploaderConfig, * vich_uploader?: VichUploaderConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias

View File

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

View File

@@ -116,14 +116,13 @@ task('webpack_encore:build', function () {
sh -c '$installCmd'"); sh -c '$installCmd'");
}); });
// Restart Docker containers (entrypoint gère les migrations automatiquement) // Restart Docker containers (entrypoint gère migrations + cache:warmup automatiquement)
// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage // Le cache est regénéré par l'entrypoint AVANT que FrankenPHP ne démarre,
// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release. // ce qui évite la race condition entre FrankenPHP et un docker exec concurrent.
desc('Restart Docker containers'); desc('Restart Docker containers');
task('docker:restart', function () { task('docker:restart', function () {
run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler'); run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler');
run('docker restart mangarr'); run('docker restart mangarr');
run('docker exec mangarr php bin/console cache:clear --env=prod');
}); });
// Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin // Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin

View File

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

View File

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

3506
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ readonly class CheckMonitoredMangasHandler
{ {
$criteria = new MonitoringCriteria( $criteria = new MonitoringCriteria(
enabled: true, enabled: true,
lastCheckBefore: $command->since ?? new \DateTimeImmutable('-1 hour') lastCheckBefore: new \DateTimeImmutable('-2 hours')
); );
$monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria); $monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria);

View File

@@ -26,18 +26,26 @@ readonly class RefreshMangaChaptersHandler
throw new \RuntimeException('Manga not found'); throw new \RuntimeException('Manga not found');
} }
// Synchronisation + récupération des nouveaux IDs // Synchronisation + récupération des numéros de nouveaux chapitres
$newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga); $newChapterNumbers = $this->chapterSynchronizationService->synchronizeChapters($manga);
// Mise à jour de la date de monitoring // Mise à jour de la date de monitoring
$manga->updateLastMonitoringCheck(new \DateTimeImmutable()); $manga->updateLastMonitoringCheck(new \DateTimeImmutable());
$this->mangaRepository->save($manga); $this->mangaRepository->save($manga);
// Événement de scraping pour chaque nouveau chapitre // Événement de scraping pour chaque nouveau chapitre
foreach ($newChapterIds as $chapterId) { // On retrouve l'ID réel (PK integer) après save() car le chapitre n'a
$this->eventBus->dispatch( // son identifiant définitif qu'une fois persisté en base.
new ChapterReadyForScraping(new ChapterId($chapterId)) foreach ($newChapterNumbers as $chapterNumber) {
$saved = $this->mangaRepository->findChapterByMangaIdAndNumber(
$manga->getId()->getValue(),
$chapterNumber
); );
if ($saved) {
$this->eventBus->dispatch(
new ChapterReadyForScraping(new ChapterId($saved->getId()))
);
}
} }
} }
} }

View File

@@ -9,6 +9,7 @@ use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException; use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotAvailableException; use App\Domain\Manga\Domain\Exception\ChapterNotAvailableException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException; use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Shared\Domain\Contract\QueryHandlerInterface; use App\Domain\Shared\Domain\Contract\QueryHandlerInterface;
use App\Domain\Shared\Domain\Contract\QueryInterface; use App\Domain\Shared\Domain\Contract\QueryInterface;
use App\Domain\Shared\Domain\Contract\ResponseInterface; use App\Domain\Shared\Domain\Contract\ResponseInterface;
@@ -35,8 +36,19 @@ readonly class DownloadCbzHandler implements QueryHandlerInterface
throw new ChapterNotAvailableException($query->chapterId); throw new ChapterNotAvailableException($query->chapterId);
} }
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
if (!$manga) {
throw new MangaNotFoundException($chapter->getMangaId()->getValue());
}
$pagesDirectory = $chapter->getPagesDirectory(); $pagesDirectory = $chapter->getPagesDirectory();
$filename = basename($pagesDirectory);
$number = $chapter->getNumber();
$formattedNumber = fmod($number, 1.0) === 0.0
? sprintf('%03d', (int) $number)
: rtrim(rtrim(sprintf('%06.2f', $number), '0'), '.');
$filename = sprintf('%s - Ch.%s', $manga->getTitle()->getValue(), $formattedNumber);
try { try {
$httpResponse = $this->fileService->downloadCbz($pagesDirectory, $filename); $httpResponse = $this->fileService->downloadCbz($pagesDirectory, $filename);

View File

@@ -103,7 +103,9 @@ readonly class GetMangaChaptersHandler
$min = min($numbers); $min = min($numbers);
$max = max($numbers); $max = max($numbers);
$fmt = fn (float $n) => $n == (int) $n ? (string) (int) $n : (string) $n; $fmt = fn (float $n) => $n == (int) $n
? str_pad((string) (int) $n, 2, '0', STR_PAD_LEFT)
: (string) $n;
$range = count($group) > 1 ? $fmt($min).'-'.$fmt($max) : $fmt($min); $range = count($group) > 1 ? $fmt($min).'-'.$fmt($max) : $fmt($min);
return new ChapterResponse( return new ChapterResponse(

View File

@@ -9,7 +9,7 @@ interface ChapterSynchronizationServiceInterface
/** /**
* Synchronise les chapitres d'un manga depuis la source externe. * Synchronise les chapitres d'un manga depuis la source externe.
* *
* @return string[] IDs des nouveaux chapitres ajoutés * @return float[] Numéros des nouveaux chapitres ajoutés
*/ */
public function synchronizeChapters(Manga $manga): array; public function synchronizeChapters(Manga $manga): array;
} }

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domain\Manga\Infrastructure\CommandHandler;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use App\Domain\Manga\Application\CommandHandler\CheckMonitoredMangasHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class SymfonyCheckMonitoredMangasHandler
{
public function __construct(
private CheckMonitoredMangasHandler $handler,
) {
}
public function __invoke(CheckMonitoredMangas $command): void
{
$this->handler->handle($command);
}
}

View File

@@ -21,9 +21,7 @@ class MonitoringSchedule implements ScheduleProviderInterface
{ {
return (new Schedule())->add( return (new Schedule())->add(
// Toutes les 2 heures, vérifie les mangas qui n'ont pas été vérifiés depuis 2 heures // Toutes les 2 heures, vérifie les mangas qui n'ont pas été vérifiés depuis 2 heures
RecurringMessage::every('2 hours', new CheckMonitoredMangas( RecurringMessage::every('2 hours', new CheckMonitoredMangas())
new \DateTimeImmutable('-2 hours')
))
)->stateful($this->cache); )->stateful($this->cache);
} }
} }

View File

@@ -17,93 +17,103 @@ readonly class FileService implements FileServiceInterface
public function downloadCbz(string $filePath, string $filename): Response public function downloadCbz(string $filePath, string $filename): Response
{ {
if (!$this->cbzExists($filePath)) { if (!is_dir($filePath)) {
throw new CbzFileNotFoundException($filePath); throw new CbzFileNotFoundException($filePath);
} }
$response = new BinaryFileResponse($filePath); $images = $this->listImageFiles($filePath);
if ([] === $images) {
throw new CbzFileNotFoundException($filePath);
}
$tempCbzPath = $this->createTempCbzPath($filename);
$cbz = new \ZipArchive();
if (true !== $cbz->open($tempCbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
throw new \RuntimeException('Cannot create CBZ file');
}
$counter = 1;
foreach ($images as $imagePath) {
$extension = pathinfo($imagePath, PATHINFO_EXTENSION);
$cbz->addFile($imagePath, sprintf('%04d.%s', $counter, $extension));
++$counter;
}
$cbz->close();
if (!file_exists($tempCbzPath)) {
throw new \RuntimeException(sprintf('Failed to write CBZ file "%s"', $tempCbzPath));
}
$downloadName = str_ends_with($filename, '.cbz') ? $filename : $filename.'.cbz';
$response = new BinaryFileResponse($tempCbzPath);
$response->setContentDisposition( $response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT, ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename $downloadName
); );
$response->headers->set('Content-Type', 'application/x-cbz'); $response->headers->set('Content-Type', 'application/x-cbz');
$response->deleteFileAfterSend();
return $response; return $response;
} }
public function createVolumeCbz(array $cbzPaths, string $volumeName): Response public function createVolumeCbz(array $cbzPaths, string $volumeName): Response
{ {
$tempCbzPath = sys_get_temp_dir().'/'.$volumeName.'.cbz'; $tempCbzPath = $this->createTempCbzPath($volumeName);
$cbz = new \ZipArchive(); $cbz = new \ZipArchive();
if (true !== $cbz->open($tempCbzPath, \ZipArchive::CREATE)) { if (true !== $cbz->open($tempCbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
throw new \RuntimeException('Cannot create CBZ file'); throw new \RuntimeException('Cannot create CBZ file');
} }
$imageCounter = 1; $counter = 1;
foreach ($cbzPaths as $directory) {
foreach ($cbzPaths as $cbzPath) { if (!is_dir($directory)) {
if (!$this->cbzExists($cbzPath)) {
continue; continue;
} }
$sourceCbz = new \ZipArchive(); foreach ($this->listImageFiles($directory) as $imagePath) {
if (true !== $sourceCbz->open($cbzPath)) { $extension = pathinfo($imagePath, PATHINFO_EXTENSION);
continue; // Skip if we can't open the CBZ $cbz->addFile($imagePath, sprintf('%04d.%s', $counter, $extension));
++$counter;
} }
// Extract all images from the current CBZ
for ($i = 0; $i < $sourceCbz->numFiles; ++$i) {
$fileName = $sourceCbz->getNameIndex($i);
$fileInfo = $sourceCbz->statIndex($i);
// Skip directories and non-image files
if (0 === $fileInfo['size'] || !$this->isImageFile($fileName)) {
continue;
}
// Get the file content
$imageContent = $sourceCbz->getFromIndex($i);
if (false === $imageContent) {
continue;
}
// Get file extension
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
// Create a new filename with proper ordering
$newFileName = sprintf('%04d.%s', $imageCounter, $extension);
// Add the image to the volume CBZ
$cbz->addFromString($newFileName, $imageContent);
++$imageCounter;
}
$sourceCbz->close();
} }
$cbz->close(); $cbz->close();
if (1 === $counter || !file_exists($tempCbzPath)) {
if (file_exists($tempCbzPath)) {
@unlink($tempCbzPath);
}
throw new \RuntimeException(sprintf('No images found to build volume "%s"', $volumeName));
}
$response = new BinaryFileResponse($tempCbzPath); $response = new BinaryFileResponse($tempCbzPath);
$response->setContentDisposition( $response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT, ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$volumeName.'.cbz' $volumeName.'.cbz'
); );
$response->headers->set('Content-Type', 'application/x-cbz'); $response->headers->set('Content-Type', 'application/x-cbz');
// Clean up temp file after sending
$response->deleteFileAfterSend(); $response->deleteFileAfterSend();
return $response; return $response;
} }
private function isImageFile(string $fileName): bool private function createTempCbzPath(string $name): string
{ {
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; $safeName = preg_replace('/[^A-Za-z0-9_.-]/', '_', $name) ?? 'archive';
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
return in_array($extension, $imageExtensions); return sys_get_temp_dir().'/'.uniqid($safeName.'_', true).'.cbz';
}
private function listImageFiles(string $directory): array
{
$files = glob(rtrim($directory, '/').'/*.{jpg,jpeg,png,gif,bmp,webp,JPG,JPEG,PNG,GIF,BMP,WEBP}', GLOB_BRACE) ?: [];
natsort($files);
return array_values($files);
} }
public function deleteCbzFile(string $filePath): bool public function deleteCbzFile(string $filePath): bool

View File

@@ -96,11 +96,11 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz
$newChapterIds = []; $newChapterIds = [];
// Sauvegarde uniquement les nouveaux chapitres et collecte leurs IDs // Sauvegarde uniquement les nouveaux chapitres et collecte leurs numéros
foreach ($chaptersByNumber as $chapterNumber => $chapter) { foreach ($chaptersByNumber as $chapterNumber => $chapter) {
if (!isset($existingChapters[(float) $chapterNumber])) { if (!isset($existingChapters[(float) $chapterNumber])) {
$manga->addChapter($chapter); $manga->addChapter($chapter);
$newChapterIds[] = $chapter->getId(); $newChapterIds[] = $chapter->getNumber();
} }
} }

View File

@@ -7,6 +7,9 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler; use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
class AutoScrapingListener class AutoScrapingListener
@@ -15,6 +18,7 @@ class AutoScrapingListener
private readonly ScrapeChapterHandler $scrapeChapterHandler, private readonly ScrapeChapterHandler $scrapeChapterHandler,
private readonly ChapterRepositoryInterface $chapterRepository, private readonly ChapterRepositoryInterface $chapterRepository,
private readonly MangaRepositoryInterface $mangaRepository, private readonly MangaRepositoryInterface $mangaRepository,
private readonly JobRepositoryInterface $jobRepository,
) { ) {
} }
@@ -25,7 +29,12 @@ class AutoScrapingListener
$manga = $this->mangaRepository->getById($chapter->mangaId); $manga = $this->mangaRepository->getById($chapter->mangaId);
if ($manga->isMonitored()) { if ($manga->isMonitored()) {
$this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue())); $jobId = Uuid::uuid4()->toString();
$job = new ScrapingJob($jobId);
$job->context['chapterId'] = $event->chapterId->getValue();
$this->jobRepository->save($job);
$this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue(), $jobId));
} }
} }
} }

View File

@@ -135,18 +135,6 @@
".env" ".env"
] ]
}, },
"symfony/form": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
},
"files": [
"config/packages/csrf.yaml"
]
},
"symfony/framework-bundle": { "symfony/framework-bundle": {
"version": "7.0", "version": "7.0",
"recipe": { "recipe": {
@@ -285,20 +273,6 @@
"config/routes/security.yaml" "config/routes/security.yaml"
] ]
}, },
"symfony/stimulus-bundle": {
"version": "2.17",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "6acd9ff4f7fd5626d2962109bd4ebab351d43c43"
},
"files": [
"assets/bootstrap.js",
"assets/controllers.json",
"assets/controllers/hello_controller.js"
]
},
"symfony/twig-bundle": { "symfony/twig-bundle": {
"version": "7.0", "version": "7.0",
"recipe": { "recipe": {
@@ -312,45 +286,6 @@
"templates/base.html.twig" "templates/base.html.twig"
] ]
}, },
"symfony/ux-live-component": {
"version": "2.17",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.6",
"ref": "73e69baf18f47740d6f58688c5464b10cdacae06"
},
"files": [
"config/routes/ux_live_component.yaml"
]
},
"symfony/ux-react": {
"version": "2.23",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.9",
"ref": "e970076b31d602ae6e2106cf91a82c7e1f7ddff2"
},
"files": [
"assets/react/controllers/Hello.jsx"
]
},
"symfony/ux-turbo": {
"version": "v2.18.0"
},
"symfony/ux-twig-component": {
"version": "2.17",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "67814b5f9794798b885cec9d3f48631424449a01"
},
"files": [
"config/packages/twig_component.yaml"
]
},
"symfony/validator": { "symfony/validator": {
"version": "7.0", "version": "7.0",
"recipe": { "recipe": {
@@ -392,9 +327,6 @@
"webpack.config.js" "webpack.config.js"
] ]
}, },
"twig/extra-bundle": {
"version": "v3.10.0"
},
"vich/uploader-bundle": { "vich/uploader-bundle": {
"version": "2.9", "version": "2.9",
"recipe": { "recipe": {

View File

@@ -18,8 +18,8 @@ class InMemoryChapterSynchronizationService implements ChapterSynchronizationSer
'synchronized_at' => new \DateTimeImmutable(), 'synchronized_at' => new \DateTimeImmutable(),
]; ];
// Retourne les IDs des chapitres synchronisés (simulation) // Retourne les numéros des chapitres synchronisés (simulation)
return ['chapter-1', 'chapter-2']; return [1.0, 2.0];
} }
/** /**

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Application\CommandHandler\CheckMonitoredMangasHandler;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Shared\Adapter\InMemoryMessageBus;
use PHPUnit\Framework\TestCase;
class CheckMonitoredMangasHandlerTest extends TestCase
{
private InMemoryMangaRepository $mangaRepository;
private InMemoryMessageBus $commandBus;
private CheckMonitoredMangasHandler $handler;
protected function setUp(): void
{
$this->mangaRepository = new InMemoryMangaRepository();
$this->commandBus = new InMemoryMessageBus();
$this->commandBus->clear();
$this->handler = new CheckMonitoredMangasHandler($this->mangaRepository, $this->commandBus);
}
private function createManga(string $id): Manga
{
return new Manga(
new MangaId($id),
new MangaTitle('Manga ' . $id),
new MangaSlug('manga-' . $id),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId('ext-' . $id)
);
}
public function testDispatchesRefreshForMonitoredMangaWithOldCheck(): void
{
$manga = $this->createManga('manga-1');
$manga->enableMonitoring();
$manga->updateLastMonitoringCheck(new \DateTimeImmutable('-3 hours'));
$this->mangaRepository->save($manga);
$this->handler->handle(new CheckMonitoredMangas());
$this->assertTrue($this->commandBus->hasMessageOfType(RefreshMangaChapters::class));
$dispatched = array_filter(
$this->commandBus->getDispatchedMessages(),
fn ($m) => $m instanceof RefreshMangaChapters
);
$this->assertCount(1, $dispatched);
$this->assertSame('manga-1', array_values($dispatched)[0]->mangaId->getValue());
}
public function testDoesNotDispatchForNonMonitoredManga(): void
{
$manga = $this->createManga('manga-2');
$this->mangaRepository->save($manga);
$this->handler->handle(new CheckMonitoredMangas());
$this->assertFalse($this->commandBus->hasMessageOfType(RefreshMangaChapters::class));
}
public function testDoesNotDispatchForMangaWithRecentCheck(): void
{
$manga = $this->createManga('manga-3');
$manga->enableMonitoring();
$manga->updateLastMonitoringCheck(new \DateTimeImmutable('-30 minutes'));
$this->mangaRepository->save($manga);
$this->handler->handle(new CheckMonitoredMangas());
$this->assertFalse($this->commandBus->hasMessageOfType(RefreshMangaChapters::class));
}
public function testDispatchesOnlyMangasWithOldCheck(): void
{
$mangaOld = $this->createManga('manga-old');
$mangaOld->enableMonitoring();
$mangaOld->updateLastMonitoringCheck(new \DateTimeImmutable('-3 hours'));
$this->mangaRepository->save($mangaOld);
$mangaRecent = $this->createManga('manga-recent');
$mangaRecent->enableMonitoring();
$mangaRecent->updateLastMonitoringCheck(new \DateTimeImmutable('-30 minutes'));
$this->mangaRepository->save($mangaRecent);
$mangaDisabled = $this->createManga('manga-disabled');
$this->mangaRepository->save($mangaDisabled);
$this->handler->handle(new CheckMonitoredMangas());
$dispatched = array_filter(
$this->commandBus->getDispatchedMessages(),
fn ($m) => $m instanceof RefreshMangaChapters
);
$this->assertCount(1, $dispatched);
$this->assertSame('manga-old', array_values($dispatched)[0]->mangaId->getValue());
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Tests\Domain\Scraping\Infrastructure\EventListener;
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use App\Domain\Scraping\Domain\Model\Chapter;
use App\Domain\Scraping\Domain\Model\Manga;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Infrastructure\EventListener\AutoScrapingListener;
use App\Domain\Shared\Domain\Event\ChapterScraped;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader;
use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
use App\Tests\Domain\Scraping\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
use PHPUnit\Framework\TestCase;
class AutoScrapingListenerTest extends TestCase
{
private InMemoryChapterRepository $chapterRepository;
private InMemoryMangaRepository $mangaRepository;
private InMemoryJobRepository $jobRepository;
private InMemoryEventBus $eventBus;
private AutoScrapingListener $listener;
protected function setUp(): void
{
$this->chapterRepository = new InMemoryChapterRepository();
$this->mangaRepository = new InMemoryMangaRepository();
$this->mangaRepository->clear();
$this->jobRepository = new InMemoryJobRepository();
$this->eventBus = new InMemoryEventBus();
$handler = new ScrapeChapterHandler(
new InMemoryScraperFactory(),
new InMemoryImageDownloader(),
new InMemoryImageStorage(),
$this->jobRepository,
$this->chapterRepository,
$this->mangaRepository,
new InMemorySourceRepository(),
$this->eventBus,
);
$this->listener = new AutoScrapingListener(
$handler,
$this->chapterRepository,
$this->mangaRepository,
$this->jobRepository,
);
}
public function testCreatesJobAndScrapesWhenMangaIsMonitored(): void
{
$chapterId = 'chapter-uuid-1';
$mangaId = 'manga-monitored';
$this->chapterRepository->save(new Chapter($chapterId, $mangaId, 1177.0, null));
$this->mangaRepository->save(new Manga(
$mangaId, 'One Piece', 'one-piece', 'Desc', 'Oda', '1997', true
));
$this->listener->onChapterReadyForScraping(
new ChapterReadyForScraping(new ChapterId($chapterId))
);
$jobs = $this->jobRepository->findByType('scraping_job');
$this->assertCount(1, $jobs);
$job = array_values($jobs)[0];
$this->assertSame($chapterId, $job->context['chapterId']);
$this->assertInstanceOf(ScrapingJob::class, $job);
$hasChapterScraped = count(array_filter(
$this->eventBus->getDispatchedMessages(),
fn ($m) => $m instanceof ChapterScraped
)) > 0;
$this->assertTrue($hasChapterScraped);
}
public function testDoesNothingWhenMangaIsNotMonitored(): void
{
$chapterId = 'chapter-uuid-2';
$mangaId = 'manga-not-monitored';
$this->chapterRepository->save(new Chapter($chapterId, $mangaId, 1176.0, null));
$this->mangaRepository->save(new Manga(
$mangaId, 'One Piece', 'one-piece', 'Desc', 'Oda', '1997', false
));
$this->listener->onChapterReadyForScraping(
new ChapterReadyForScraping(new ChapterId($chapterId))
);
$this->assertEmpty($this->jobRepository->findByType('scraping_job'));
$this->assertEmpty($this->eventBus->getDispatchedMessages());
}
}

View File

@@ -27,7 +27,7 @@ class DownloadCbzTest extends AbstractApiTestCase
'number' => 1.0, 'number' => 1.0,
'title' => 'Chapter 1', 'title' => 'Chapter 1',
'visible' => true, 'visible' => true,
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz', 'pagesDirectory' => '/app/tests/Shared/Files/test-pages',
]); ]);
$chapterId = $chapter->getId(); $chapterId = $chapter->getId();
@@ -41,7 +41,7 @@ class DownloadCbzTest extends AbstractApiTestCase
$response = static::getClient()->getResponse(); $response = static::getClient()->getResponse();
$this->assertEquals('application/x-cbz', $response->headers->get('Content-Type')); $this->assertEquals('application/x-cbz', $response->headers->get('Content-Type'));
$this->assertStringContainsString('attachment; filename=', $response->headers->get('Content-Disposition')); $this->assertStringContainsString('attachment; filename=', $response->headers->get('Content-Disposition'));
$this->assertStringContainsString('test-chapter.cbz', $response->headers->get('Content-Disposition')); $this->assertStringContainsString('Ch.001.cbz', $response->headers->get('Content-Disposition'));
} }
public function testItReturns404ForNonExistentChapter(): void public function testItReturns404ForNonExistentChapter(): void

View File

@@ -27,7 +27,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'manga' => $manga, 'manga' => $manga,
'volume' => 1, 'volume' => 1,
'visible' => true, 'visible' => true,
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz', 'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
]); ]);
$mangaId = $manga->getId(); $mangaId = $manga->getId();
@@ -108,7 +108,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1, 'volume' => 1,
'number' => 1.0, 'number' => 1.0,
'visible' => true, 'visible' => true,
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz', 'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
]); ]);
ChapterFactory::createOne([ ChapterFactory::createOne([
@@ -116,7 +116,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1, 'volume' => 1,
'number' => 2.0, 'number' => 2.0,
'visible' => false, // Soft deleted 'visible' => false, // Soft deleted
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz', 'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
]); ]);
ChapterFactory::createOne([ ChapterFactory::createOne([
@@ -132,7 +132,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
'volume' => 1, 'volume' => 1,
'number' => 4.0, 'number' => 4.0,
'visible' => true, 'visible' => true,
'cbzPath' => __DIR__.'/../../Shared/Files/test-chapter.cbz', 'pagesDirectory' => __DIR__.'/../../Shared/Files/test-pages',
]); ]);
$mangaId = $manga->getId(); $mangaId = $manga->getId();

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B