Compare commits
41 Commits
5a0888eb28
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 810e18c26c | |||
|
|
1905581214 | ||
| c0ab40eacd | |||
|
|
e214e1ea46 | ||
| 1f1efd1b16 | |||
|
|
41c1fc5e2e | ||
| 848efd3327 | |||
| 65eef59999 | |||
|
|
e525c9b7bd | ||
| 8d8389377d | |||
| a9c5769c8e | |||
|
|
969f4569f5 | ||
| 13eac6954d | |||
|
|
7e6bacd934 | ||
| d1279c90cc | |||
| a0729d2e6e | |||
|
|
f47d1a245f | ||
| 78cc83d465 | |||
| 7204ea7754 | |||
|
|
f42b5a9cf5 | ||
| 5edd28309f | |||
|
|
3f08e1c899 | ||
| 214f470e77 | |||
|
|
345434c25d | ||
| 2868772f5c | |||
| a2469b6c07 | |||
|
|
926f938c45 | ||
| 5551d73962 | |||
| 395a0a16cb | |||
|
|
8e2e608ad9 | ||
| 0f80cb9fec | |||
| a3477629fb | |||
|
|
cde701986e | ||
| b921768aef | |||
|
|
5f0178f784 | ||
| c610d22bd2 | |||
| ab2cf319ac | |||
|
|
69c6757cf8 | ||
|
|
21d8111734 | ||
|
|
5ed303612a | ||
| 4e30af6a16 |
@@ -1,7 +1,7 @@
|
|||||||
#syntax=docker/dockerfile:1.4
|
#syntax=docker/dockerfile:1.4
|
||||||
|
|
||||||
# Versions
|
# Versions
|
||||||
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream
|
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
||||||
|
|
||||||
# The different stages of this Dockerfile are meant to be built into separate images
|
# The different stages of this Dockerfile are meant to be built into separate images
|
||||||
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
|
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
|
||||||
@@ -108,9 +108,6 @@ RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scri
|
|||||||
FROM node:22-alpine AS node_build
|
FROM node:22-alpine AS node_build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --link package.json package-lock.json ./
|
COPY --link package.json package-lock.json ./
|
||||||
COPY --from=composer_deps /app/vendor/symfony/ux-live-component/assets ./vendor/symfony/ux-live-component/assets
|
|
||||||
COPY --from=composer_deps /app/vendor/symfony/ux-react/assets ./vendor/symfony/ux-react/assets
|
|
||||||
COPY --from=composer_deps /app/vendor/symfony/ux-turbo/assets ./vendor/symfony/ux-turbo/assets
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY --link assets ./assets
|
COPY --link assets ./assets
|
||||||
COPY --link webpack.config.js ./
|
COPY --link webpack.config.js ./
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -242,8 +242,17 @@ watch(() => props.source, (newSource) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const buildPayload = (formData) => {
|
||||||
|
const data = { ...formData };
|
||||||
|
const raw = data.testChapterNumber;
|
||||||
|
data.testChapterNumber = (raw === '' || raw === null || raw === undefined)
|
||||||
|
? null
|
||||||
|
: parseFloat(raw);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
emit('submit', { ...form.value });
|
emit('submit', buildPayload(form.value));
|
||||||
};
|
};
|
||||||
|
|
||||||
defineExpose({ submitForm: handleSubmit });
|
defineExpose({ submitForm: handleSubmit });
|
||||||
@@ -252,7 +261,7 @@ const testConfiguration = async () => {
|
|||||||
testing.value = true;
|
testing.value = true;
|
||||||
try {
|
try {
|
||||||
await emit('test', {
|
await emit('test', {
|
||||||
configuration: { ...form.value },
|
configuration: buildPayload(form.value),
|
||||||
testData: {
|
testData: {
|
||||||
mangaSlug: form.value.testSlug,
|
mangaSlug: form.value.testSlug,
|
||||||
chapterNumber: form.value.testChapterNumber,
|
chapterNumber: form.value.testChapterNumber,
|
||||||
|
|||||||
37
assets/vue/app/shared/components/ui/ToggleRow.vue
Normal file
37
assets/vue/app/shared/components/ui/ToggleRow.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,57 +3,50 @@
|
|||||||
"type": "project",
|
"type": "project",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "A minimal Symfony project recommended to create bare bones applications",
|
"description": "A minimal Symfony project recommended to create bare bones applications",
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3.1",
|
"php": ">=8.4.0",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-curl": "*",
|
"ext-curl": "*",
|
||||||
"ext-gd": "*",
|
"ext-gd": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"api-platform/core": "^3.2",
|
"api-platform/core": "^4.0",
|
||||||
"doctrine/dbal": "^3",
|
"doctrine/dbal": "^4",
|
||||||
"doctrine/doctrine-bundle": "^2.11",
|
"doctrine/doctrine-bundle": "^3.0",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||||
"doctrine/orm": "^2.17",
|
"doctrine/orm": "^3.0",
|
||||||
"guzzlehttp/guzzle": "^7.8",
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
"intervention/image": "^3.7",
|
"intervention/image": "^3.7",
|
||||||
"nelmio/cors-bundle": "^2.4",
|
"nelmio/cors-bundle": "^2.4",
|
||||||
"phpdocumentor/reflection-docblock": "^5.3",
|
"phpdocumentor/reflection-docblock": "^5.3",
|
||||||
"phpstan/phpdoc-parser": "^1.25",
|
"phpstan/phpdoc-parser": "^1.25",
|
||||||
"ramsey/uuid": "^4.7",
|
"ramsey/uuid": "^4.7",
|
||||||
"runtime/frankenphp-symfony": "^0.2.0",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/asset": "7.0.*",
|
"symfony/console": "8.0.*",
|
||||||
"symfony/console": "7.0.*",
|
"symfony/css-selector": "8.0.*",
|
||||||
"symfony/css-selector": "7.0.*",
|
"symfony/doctrine-messenger": "8.0.*",
|
||||||
"symfony/doctrine-messenger": "7.0.*",
|
"symfony/dotenv": "8.0.*",
|
||||||
"symfony/dotenv": "7.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/expression-language": "7.0.*",
|
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/form": "7.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/framework-bundle": "7.0.*",
|
"symfony/http-client": "8.0.*",
|
||||||
"symfony/http-client": "7.0.*",
|
"symfony/mercure-bundle": "^0.4",
|
||||||
"symfony/mercure-bundle": "^0.3.9",
|
"symfony/messenger": "8.0.*",
|
||||||
"symfony/messenger": "7.0.*",
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/mime": "7.0.*",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
"symfony/monolog-bundle": "^3.10",
|
|
||||||
"symfony/panther": "^2.1",
|
"symfony/panther": "^2.1",
|
||||||
"symfony/property-access": "7.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "7.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
"symfony/runtime": "7.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
"symfony/scheduler": "7.0.*",
|
"symfony/scheduler": "8.0.*",
|
||||||
"symfony/security-bundle": "7.0.*",
|
"symfony/security-bundle": "8.0.*",
|
||||||
"symfony/serializer": "7.0.*",
|
"symfony/serializer": "8.0.*",
|
||||||
"symfony/stimulus-bundle": "^2.17",
|
"symfony/twig-bundle": "8.0.*",
|
||||||
"symfony/twig-bundle": "7.0.*",
|
"symfony/validator": "8.0.*",
|
||||||
"symfony/ux-live-component": "^2.17",
|
|
||||||
"symfony/ux-react": "^2.23",
|
|
||||||
"symfony/ux-turbo": "^2.18",
|
|
||||||
"symfony/validator": "7.0.*",
|
|
||||||
"symfony/webpack-encore-bundle": "^2.1",
|
"symfony/webpack-encore-bundle": "^2.1",
|
||||||
"symfony/yaml": "7.0.*",
|
"symfony/yaml": "8.0.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
|
||||||
"twig/twig": "^2.12|^3.0",
|
"twig/twig": "^2.12|^3.0",
|
||||||
"vich/uploader-bundle": "^2.7"
|
"vich/uploader-bundle": "^2.7"
|
||||||
},
|
},
|
||||||
@@ -103,7 +96,7 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.0.*",
|
"require": "8.0.*",
|
||||||
"docker": true
|
"docker": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,18 +104,18 @@
|
|||||||
"dama/doctrine-test-bundle": "^8.2",
|
"dama/doctrine-test-bundle": "^8.2",
|
||||||
"dbrekelmans/bdi": "^1.3",
|
"dbrekelmans/bdi": "^1.3",
|
||||||
"deployer/deployer": "^7.5",
|
"deployer/deployer": "^7.5",
|
||||||
"doctrine/doctrine-fixtures-bundle": "^3.5",
|
"doctrine/doctrine-fixtures-bundle": "^4.0",
|
||||||
"friendsofphp/php-cs-fixer": "^3.48",
|
"friendsofphp/php-cs-fixer": "^3.48",
|
||||||
"mtdowling/jmespath.php": "^2.7",
|
"mtdowling/jmespath.php": "^2.7",
|
||||||
"phparkitect/phparkitect": "^0.3.33",
|
"phparkitect/phparkitect": "^0.8",
|
||||||
"phpmd/phpmd": "^2.15",
|
"phpmd/phpmd": "3.x-dev",
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5",
|
||||||
"symfony/browser-kit": "7.0.*",
|
"symfony/browser-kit": "8.0.*",
|
||||||
"symfony/maker-bundle": "^1.52",
|
"symfony/maker-bundle": "^1.52",
|
||||||
"symfony/phpunit-bridge": "^7.0",
|
"symfony/phpunit-bridge": "^8.0",
|
||||||
"symfony/stopwatch": "7.0.*",
|
"symfony/stopwatch": "8.0.*",
|
||||||
"symfony/web-profiler-bundle": "7.0.*",
|
"symfony/web-profiler-bundle": "8.0.*",
|
||||||
"zenstruck/browser": "^1.8",
|
"zenstruck/browser": "^1.8",
|
||||||
"zenstruck/foundry": "^1.36"
|
"zenstruck/foundry": "^2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4313
composer.lock
generated
4313
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ api_platform:
|
|||||||
extra_properties:
|
extra_properties:
|
||||||
standard_put: true
|
standard_put: true
|
||||||
rfc_7807_compliant_errors: true
|
rfc_7807_compliant_errors: true
|
||||||
event_listeners_backward_compatibility_layer: false
|
|
||||||
keep_legacy_inflector: false
|
|
||||||
mapping:
|
mapping:
|
||||||
paths:
|
paths:
|
||||||
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
|
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ doctrine:
|
|||||||
connections:
|
connections:
|
||||||
default:
|
default:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
use_savepoints: true
|
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
|
|
||||||
# IMPORTANT: You MUST configure your server version,
|
# IMPORTANT: You MUST configure your server version,
|
||||||
@@ -11,9 +10,6 @@ doctrine:
|
|||||||
#server_version: '16'
|
#server_version: '16'
|
||||||
|
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: true
|
|
||||||
enable_lazy_ghost_objects: true
|
|
||||||
report_fields_where_declared: true
|
|
||||||
validate_xml_mapping: true
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
@@ -40,15 +36,12 @@ when@test:
|
|||||||
dbal:
|
dbal:
|
||||||
connections:
|
connections:
|
||||||
default:
|
default:
|
||||||
use_savepoints: true
|
|
||||||
# "TEST_TOKEN" is typically set by ParaTest
|
# "TEST_TOKEN" is typically set by ParaTest
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
doctrine:
|
doctrine:
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: false
|
|
||||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
|
||||||
query_cache_driver:
|
query_cache_driver:
|
||||||
type: pool
|
type: pool
|
||||||
pool: doctrine.system_cache_pool
|
pool: doctrine.system_cache_pool
|
||||||
|
|||||||
3
config/packages/property_info.yaml
Normal file
3
config/packages/property_info.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
framework:
|
||||||
|
property_info:
|
||||||
|
with_constructor_extractor: true
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
twig_component:
|
|
||||||
anonymous_template_directory: 'components/'
|
|
||||||
defaults:
|
|
||||||
# Namespace & directory for components
|
|
||||||
App\Twig\Components\: 'components/'
|
|
||||||
2001
config/reference.php
Normal file
2001
config/reference.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
when@dev:
|
when@dev:
|
||||||
_errors:
|
_errors:
|
||||||
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
|
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||||
prefix: /_error
|
prefix: /_error
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
live_component:
|
|
||||||
resource: '@LiveComponentBundle/config/routes.php'
|
|
||||||
prefix: '/_components'
|
|
||||||
# adjust prefix to add localization to your components
|
|
||||||
#prefix: '/{_locale}/_components'
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
when@dev:
|
when@dev:
|
||||||
web_profiler_wdt:
|
web_profiler_wdt:
|
||||||
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
|
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
|
||||||
prefix: /_wdt
|
prefix: /_wdt
|
||||||
|
|
||||||
web_profiler_profiler:
|
web_profiler_profiler:
|
||||||
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
|
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
|
||||||
prefix: /_profiler
|
prefix: /_profiler
|
||||||
|
|||||||
18
deploy.php
18
deploy.php
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Deployer;
|
namespace Deployer;
|
||||||
|
|
||||||
require 'recipe/symfony.php';
|
require 'recipe/symfony.php';
|
||||||
@@ -36,12 +37,13 @@ task('deploy:vendors', function () {
|
|||||||
$releaseDir = get('release_path');
|
$releaseDir = get('release_path');
|
||||||
$previousDir = get('previous_release');
|
$previousDir = get('previous_release');
|
||||||
|
|
||||||
if ($previousDir !== null) {
|
if (null !== $previousDir) {
|
||||||
$lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1");
|
$lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1");
|
||||||
$vendorPopulated = test("[ -d $releaseDir/vendor/composer ]");
|
$vendorPopulated = test("[ -d $releaseDir/vendor/composer ]");
|
||||||
|
|
||||||
if ($lockUnchanged && $vendorPopulated) {
|
if ($lockUnchanged && $vendorPopulated) {
|
||||||
writeln('<info>deploy:vendors skipped — composer.lock unchanged</info>');
|
writeln('<info>deploy:vendors skipped — composer.lock unchanged</info>');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +69,12 @@ task('webpack_encore:build', function () {
|
|||||||
$previousDir = get('previous_release'); // null au 1er déploiement
|
$previousDir = get('previous_release'); // null au 1er déploiement
|
||||||
|
|
||||||
// --- COUCHE 1 : skip total si aucun fichier front-end n'a changé ---
|
// --- COUCHE 1 : skip total si aucun fichier front-end n'a changé ---
|
||||||
if ($previousDir !== null) {
|
if (null !== $previousDir) {
|
||||||
$watchList = ['assets', 'templates', 'package.json', 'package-lock.json',
|
$watchList = ['assets', 'templates', 'package.json', 'package-lock.json',
|
||||||
'webpack.config.js', 'postcss.config.js', 'tailwind.config.js'];
|
'webpack.config.js', 'postcss.config.js', 'tailwind.config.js'];
|
||||||
|
|
||||||
$diffChecks = implode(' && ', array_map(
|
$diffChecks = implode(' && ', array_map(
|
||||||
fn($p) => "diff -rq --no-dereference $previousDir/$p $releaseDir/$p > /dev/null 2>&1",
|
fn ($p) => "diff -rq --no-dereference $previousDir/$p $releaseDir/$p > /dev/null 2>&1",
|
||||||
$watchList
|
$watchList
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -81,13 +83,14 @@ task('webpack_encore:build', function () {
|
|||||||
if ($hasPreviousBuild && test("($diffChecks)")) {
|
if ($hasPreviousBuild && test("($diffChecks)")) {
|
||||||
run("cp -al $previousDir/public/build $releaseDir/public/build");
|
run("cp -al $previousDir/public/build $releaseDir/public/build");
|
||||||
writeln('<info>webpack_encore:build skipped — no front-end files changed</info>');
|
writeln('<info>webpack_encore:build skipped — no front-end files changed</info>');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- COUCHE 2 : skip npm install si package-lock.json inchangé ---
|
// --- COUCHE 2 : skip npm install si package-lock.json inchangé ---
|
||||||
$needsNpmInstall = true;
|
$needsNpmInstall = true;
|
||||||
if ($previousDir !== null) {
|
if (null !== $previousDir) {
|
||||||
$lockUnchanged = test("diff -q $previousDir/package-lock.json $releaseDir/package-lock.json > /dev/null 2>&1");
|
$lockUnchanged = test("diff -q $previousDir/package-lock.json $releaseDir/package-lock.json > /dev/null 2>&1");
|
||||||
$nmPopulated = test("[ -d $sharedNodeModules/.bin ]");
|
$nmPopulated = test("[ -d $sharedNodeModules/.bin ]");
|
||||||
if ($lockUnchanged && $nmPopulated) {
|
if ($lockUnchanged && $nmPopulated) {
|
||||||
@@ -113,14 +116,13 @@ task('webpack_encore:build', function () {
|
|||||||
sh -c '$installCmd'");
|
sh -c '$installCmd'");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restart Docker containers (entrypoint gère les migrations automatiquement)
|
// Restart Docker containers (entrypoint gère migrations + cache:warmup automatiquement)
|
||||||
// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage
|
// Le cache est regénéré par l'entrypoint AVANT que FrankenPHP ne démarre,
|
||||||
// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release.
|
// ce qui évite la race condition entre FrankenPHP et un docker exec concurrent.
|
||||||
desc('Restart Docker containers');
|
desc('Restart Docker containers');
|
||||||
task('docker:restart', function () {
|
task('docker:restart', function () {
|
||||||
run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler');
|
run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler');
|
||||||
run('docker restart mangarr');
|
run('docker restart mangarr');
|
||||||
run('docker exec mangarr php bin/console cache:clear --env=prod');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin
|
// Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
worker {
|
worker {
|
||||||
file ./public/index.php
|
file ./public/index.php
|
||||||
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
|
num 2
|
||||||
}
|
}
|
||||||
|
|||||||
97
migrations/Version20260326165659.php
Normal file
97
migrations/Version20260326165659.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260326165659 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Migrate manga.genres column from PHP-serialized array to JSON';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preUp(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Convert existing PHP-serialized data to JSON before changing the column type
|
||||||
|
$rows = $this->connection->fetchAllAssociative('SELECT id, genres FROM manga WHERE genres IS NOT NULL');
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$raw = $row['genres'];
|
||||||
|
// Skip if already valid JSON
|
||||||
|
json_decode($raw);
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Unserialize PHP format and re-encode as JSON
|
||||||
|
$value = @unserialize($raw);
|
||||||
|
if ($value === false && $raw !== 'b:0;') {
|
||||||
|
$value = [];
|
||||||
|
}
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'UPDATE manga SET genres = :json WHERE id = :id',
|
||||||
|
['json' => json_encode($value), 'id' => $row['id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.started_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'\'');
|
||||||
|
$this->addSql('ALTER TABLE manga ALTER genres TYPE JSON USING genres::json');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.genres IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.updated_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN source.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'\'');
|
||||||
|
$this->addSql('DROP INDEX idx_75ea56e0e3bd61ce');
|
||||||
|
$this->addSql('DROP INDEX idx_75ea56e0fb7336f0');
|
||||||
|
$this->addSql('DROP INDEX idx_75ea56e016ba31db');
|
||||||
|
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP DEFAULT');
|
||||||
|
$this->addSql('ALTER TABLE messenger_messages ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'\'');
|
||||||
|
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.started_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER TABLE manga ALTER genres TYPE TEXT');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.genres IS \'(DC2Type:array)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750');
|
||||||
|
$this->addSql('ALTER TABLE messenger_messages ALTER id SET DEFAULT nextval(\'messenger_messages_id_seq\'::regclass)');
|
||||||
|
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP IDENTITY');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('CREATE INDEX idx_75ea56e0e3bd61ce ON messenger_messages (available_at)');
|
||||||
|
$this->addSql('CREATE INDEX idx_75ea56e0fb7336f0 ON messenger_messages (queue_name)');
|
||||||
|
$this->addSql('CREATE INDEX idx_75ea56e016ba31db ON messenger_messages (delivered_at)');
|
||||||
|
$this->addSql('COMMENT ON COLUMN source.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
}
|
||||||
|
}
|
||||||
3506
package-lock.json
generated
3506
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;
|
|||||||
use Arkitect\Rules\Rule;
|
use Arkitect\Rules\Rule;
|
||||||
|
|
||||||
return static function (Config $config): void {
|
return static function (Config $config): void {
|
||||||
$domainClassSet = ClassSet::fromDir(__DIR__ . '/src/Domain');
|
$domainClassSet = ClassSet::fromDir(__DIR__.'/src/Domain');
|
||||||
$businessDomains = ['Manga', 'Reader', 'Scraping', 'Conversion'];
|
$businessDomains = ['Manga', 'Reader', 'Scraping', 'Conversion'];
|
||||||
|
|
||||||
// Classes PHP standards et utilitaires
|
// Classes PHP standards et utilitaires
|
||||||
@@ -29,7 +29,7 @@ return static function (Config $config): void {
|
|||||||
// Dépendances externes autorisées
|
// Dépendances externes autorisées
|
||||||
$externalDependencies = [
|
$externalDependencies = [
|
||||||
'Symfony\Component\Messenger',
|
'Symfony\Component\Messenger',
|
||||||
'Ramsey\Uuid'
|
'Ramsey\Uuid',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Règle pour le namespace cohérent
|
// Règle pour le namespace cohérent
|
||||||
@@ -72,7 +72,7 @@ return static function (Config $config): void {
|
|||||||
// Interdiction explicite pour l'Application d'accéder à l'Infrastructure
|
// Interdiction explicite pour l'Application d'accéder à l'Infrastructure
|
||||||
$rules[] = Rule::allClasses()
|
$rules[] = Rule::allClasses()
|
||||||
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
|
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
|
||||||
->should(new NotDependsOnTheseNamespaces("App\Domain\\$domain\Infrastructure"))
|
->should(new NotDependsOnTheseNamespaces(["App\Domain\\$domain\Infrastructure"]))
|
||||||
->because("la couche Application de $domain ne doit jamais dépendre de l'Infrastructure, même au sein de son propre domaine");
|
->because("la couche Application de $domain ne doit jamais dépendre de l'Infrastructure, même au sein de son propre domaine");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
36
src/Command/RunMonitoringCommand.php
Normal file
36
src/Command/RunMonitoringCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
class SendTestNotificationCommand extends Command
|
class SendTestNotificationCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly NotificationInterface $notification
|
private readonly NotificationInterface $notification,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,7 @@ class SendTestNotificationCommand extends Command
|
|||||||
$allowed = ['info', 'success', 'error', 'warning'];
|
$allowed = ['info', 'success', 'error', 'warning'];
|
||||||
if (!in_array($type, $allowed, true)) {
|
if (!in_array($type, $allowed, true)) {
|
||||||
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
|
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
|
||||||
|
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use ApiPlatform\Api\IriConverterInterface;
|
use ApiPlatform\Api\IriConverterInterface;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use Exception;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
@@ -13,11 +12,11 @@ use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
|||||||
class SecurityController extends AbstractController
|
class SecurityController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
|
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
|
||||||
public function login(IriConverterInterface $iriConverter, #[CurrentUser] User $user = null): Response
|
public function login(IriConverterInterface $iriConverter, #[CurrentUser] ?User $user = null): Response
|
||||||
{
|
{
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
return $this->json([
|
return $this->json([
|
||||||
'error' => 'Invalid credentials'
|
'error' => 'Invalid credentials',
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,11 +26,11 @@ class SecurityController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
|
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
{
|
{
|
||||||
throw new Exception('This method can be blank.');
|
throw new \Exception('This method can be blank.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ final readonly class ConvertFileCommand
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public string $filePath,
|
public string $filePath,
|
||||||
public string $originalFilename,
|
public string $originalFilename,
|
||||||
public int $fileSize
|
public int $fileSize,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ final readonly class ConvertFileCommandHandler
|
|||||||
private const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MB
|
private const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MB
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ConversionServiceInterface $conversionService
|
private ConversionServiceInterface $conversionService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ final readonly class ConversionResponse
|
|||||||
public string $convertedFilePath,
|
public string $convertedFilePath,
|
||||||
public string $outputFilename,
|
public string $outputFilename,
|
||||||
public int $originalFileSize,
|
public int $originalFileSize,
|
||||||
public int $convertedFileSize
|
public int $convertedFileSize,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domain\Conversion\Domain\Exception;
|
namespace App\Domain\Conversion\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
class ConversionException extends \RuntimeException
|
||||||
|
|
||||||
class ConversionException extends RuntimeException
|
|
||||||
{
|
{
|
||||||
public static function fileNotFound(string $filePath): self
|
public static function fileNotFound(string $filePath): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ final readonly class ConversionRequest
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private string $filePath,
|
private string $filePath,
|
||||||
private string $originalFilename,
|
private string $originalFilename,
|
||||||
private int $fileSize
|
private int $fileSize,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ final readonly class ConversionRequest
|
|||||||
public function getOutputFilename(): string
|
public function getOutputFilename(): string
|
||||||
{
|
{
|
||||||
$pathInfo = pathinfo($this->originalFilename, PATHINFO_FILENAME);
|
$pathInfo = pathinfo($this->originalFilename, PATHINFO_FILENAME);
|
||||||
return $pathInfo . '.cbz';
|
|
||||||
|
return $pathInfo.'.cbz';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ final readonly class ConversionResult
|
|||||||
private string $convertedFilePath,
|
private string $convertedFilePath,
|
||||||
private string $outputFilename,
|
private string $outputFilename,
|
||||||
private int $originalFileSize,
|
private int $originalFileSize,
|
||||||
private int $convertedFileSize
|
private int $convertedFileSize,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ namespace App\Domain\Conversion\Infrastructure\ApiPlatform\Controller;
|
|||||||
use App\Domain\Conversion\Application\Command\ConvertFileCommand;
|
use App\Domain\Conversion\Application\Command\ConvertFileCommand;
|
||||||
use App\Domain\Conversion\Application\CommandHandler\ConvertFileCommandHandler;
|
use App\Domain\Conversion\Application\CommandHandler\ConvertFileCommandHandler;
|
||||||
use App\Domain\Conversion\Domain\Exception\ConversionException;
|
use App\Domain\Conversion\Domain\Exception\ConversionException;
|
||||||
use App\Domain\Conversion\Infrastructure\ApiPlatform\Resource\ConvertFileResource;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
||||||
|
|
||||||
#[AsController]
|
#[AsController]
|
||||||
final class ConvertFileController extends AbstractController
|
final class ConvertFileController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ConvertFileCommandHandler $commandHandler
|
private readonly ConvertFileCommandHandler $commandHandler,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +23,7 @@ final class ConvertFileController extends AbstractController
|
|||||||
$uploadedFile = $request->files->get('file');
|
$uploadedFile = $request->files->get('file');
|
||||||
if (!$uploadedFile) {
|
if (!$uploadedFile) {
|
||||||
return $this->json([
|
return $this->json([
|
||||||
['propertyPath' => 'file', 'message' => 'Please upload a file']
|
['propertyPath' => 'file', 'message' => 'Please upload a file'],
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +56,6 @@ final class ConvertFileController extends AbstractController
|
|||||||
'Content-Disposition' => sprintf('attachment; filename=%s', $response->outputFilename),
|
'Content-Disposition' => sprintf('attachment; filename=%s', $response->outputFilename),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (ConversionException $e) {
|
} catch (ConversionException $e) {
|
||||||
return $this->json(['error' => $e->getMessage()], 400);
|
return $this->json(['error' => $e->getMessage()], 400);
|
||||||
}
|
}
|
||||||
@@ -72,8 +69,9 @@ final class ConvertFileController extends AbstractController
|
|||||||
if (!$uploadedFile->isValid()) {
|
if (!$uploadedFile->isValid()) {
|
||||||
$errors[] = [
|
$errors[] = [
|
||||||
'propertyPath' => 'file',
|
'propertyPath' => 'file',
|
||||||
'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage()
|
'message' => 'The uploaded file is not valid: '.$uploadedFile->getErrorMessage(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return $errors;
|
return $errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +80,7 @@ final class ConvertFileController extends AbstractController
|
|||||||
if ($uploadedFile->getSize() > $maxSize) {
|
if ($uploadedFile->getSize() > $maxSize) {
|
||||||
$errors[] = [
|
$errors[] = [
|
||||||
'propertyPath' => 'file',
|
'propertyPath' => 'file',
|
||||||
'message' => 'The uploaded file is too large. Allowed size is 150MB.'
|
'message' => 'The uploaded file is too large. Allowed size is 150MB.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +91,7 @@ final class ConvertFileController extends AbstractController
|
|||||||
if (!in_array($extension, $allowedExtensions)) {
|
if (!in_array($extension, $allowedExtensions)) {
|
||||||
$errors[] = [
|
$errors[] = [
|
||||||
'propertyPath' => 'file',
|
'propertyPath' => 'file',
|
||||||
'message' => 'Please upload a valid CBR or CBZ file'
|
'message' => 'Please upload a valid CBR or CBZ file',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ namespace App\Domain\Conversion\Infrastructure\ApiPlatform\Resource;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\OpenApi\Model;
|
use ApiPlatform\OpenApi\Model\Operation;
|
||||||
|
use ApiPlatform\OpenApi\Model\RequestBody;
|
||||||
use App\Domain\Conversion\Infrastructure\ApiPlatform\Controller\ConvertFileController;
|
use App\Domain\Conversion\Infrastructure\ApiPlatform\Controller\ConvertFileController;
|
||||||
use Symfony\Component\HttpFoundation\File\File;
|
use Symfony\Component\HttpFoundation\File\File;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
shortName: 'Conversion',
|
shortName: 'Conversion',
|
||||||
@@ -16,11 +16,11 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
uriTemplate: '/conversions/convert',
|
uriTemplate: '/conversions/convert',
|
||||||
controller: ConvertFileController::class,
|
controller: ConvertFileController::class,
|
||||||
deserialize: false,
|
deserialize: false,
|
||||||
openapiContext: [
|
openapi: new Operation(
|
||||||
'summary' => 'Convert comic book file to CBZ',
|
summary: 'Convert comic book file to CBZ',
|
||||||
'description' => 'Converts a CBR or CBZ file to CBZ format and returns the converted file for download',
|
description: 'Converts a CBR or CBZ file to CBZ format and returns the converted file for download',
|
||||||
'requestBody' => [
|
requestBody: new RequestBody(
|
||||||
'content' => [
|
content: new \ArrayObject([
|
||||||
'multipart/form-data' => [
|
'multipart/form-data' => [
|
||||||
'schema' => [
|
'schema' => [
|
||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
@@ -29,28 +29,28 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
'file' => [
|
'file' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'format' => 'binary',
|
'format' => 'binary',
|
||||||
'description' => 'Comic book file to convert (CBR, CBZ, max 150MB)'
|
'description' => 'Comic book file to convert (CBR, CBZ, max 150MB)',
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
],
|
],
|
||||||
'responses' => [
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
'200' => [
|
'200' => [
|
||||||
'description' => 'File converted successfully',
|
'description' => 'File converted successfully',
|
||||||
'content' => [
|
'content' => [
|
||||||
'application/x-cbz' => [
|
'application/x-cbz' => [
|
||||||
'schema' => [
|
'schema' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'format' => 'binary'
|
'format' => 'binary',
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
]
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
class ConvertFileResource
|
class ConvertFileResource
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ final class ConversionService implements ConversionServiceInterface
|
|||||||
|
|
||||||
public function __construct(string $projectDir)
|
public function __construct(string $projectDir)
|
||||||
{
|
{
|
||||||
$this->tempDir = $projectDir . '/public/tmp';
|
$this->tempDir = $projectDir.'/public/tmp';
|
||||||
$this->filesystem = new Filesystem();
|
$this->filesystem = new Filesystem();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,10 +40,10 @@ final class ConversionService implements ConversionServiceInterface
|
|||||||
|
|
||||||
private function convertCbrToCbz(string $cbrPath): string
|
private function convertCbrToCbz(string $cbrPath): string
|
||||||
{
|
{
|
||||||
$tempDir = $this->tempDir . '/' . uniqid('cbr_conversion_');
|
$tempDir = $this->tempDir.'/'.uniqid('cbr_conversion_');
|
||||||
$this->filesystem->mkdir($tempDir);
|
$this->filesystem->mkdir($tempDir);
|
||||||
|
|
||||||
$extractDir = $tempDir . '/extract';
|
$extractDir = $tempDir.'/extract';
|
||||||
$this->filesystem->mkdir($extractDir);
|
$this->filesystem->mkdir($extractDir);
|
||||||
|
|
||||||
// Essayer d'extraire avec unrar-free
|
// Essayer d'extraire avec unrar-free
|
||||||
@@ -56,16 +56,16 @@ final class ConversionService implements ConversionServiceInterface
|
|||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (!$process->isSuccessful()) {
|
if (!$process->isSuccessful()) {
|
||||||
throw new \RuntimeException("Extraction failed: " . $process->getErrorOutput());
|
throw new \RuntimeException('Extraction failed: '.$process->getErrorOutput());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Créer le CBZ
|
// Créer le CBZ
|
||||||
$cbzFileName = pathinfo($cbrPath, PATHINFO_FILENAME) . '.cbz';
|
$cbzFileName = pathinfo($cbrPath, PATHINFO_FILENAME).'.cbz';
|
||||||
$cbzPath = $this->tempDir . '/' . $cbzFileName;
|
$cbzPath = $this->tempDir.'/'.$cbzFileName;
|
||||||
$zip = new \ZipArchive();
|
$zip = new \ZipArchive();
|
||||||
if ($zip->open($cbzPath, \ZipArchive::CREATE) !== true) {
|
if (true !== $zip->open($cbzPath, \ZipArchive::CREATE)) {
|
||||||
throw new \RuntimeException("Cannot create ZIP file");
|
throw new \RuntimeException('Cannot create ZIP file');
|
||||||
}
|
}
|
||||||
|
|
||||||
$files = new \RecursiveIteratorIterator(
|
$files = new \RecursiveIteratorIterator(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ readonly class ChapterEditData
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public string $id,
|
public string $id,
|
||||||
public ?string $title = null,
|
public ?string $title = null,
|
||||||
public ?int $volume = null
|
public ?int $volume = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Domain\Manga\Application\Command;
|
namespace App\Domain\Manga\Application\Command;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
readonly class CheckMonitoredMangas
|
readonly class CheckMonitoredMangas
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public ?DateTimeImmutable $since = null
|
public ?\DateTimeImmutable $since = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ readonly class CreateManga
|
|||||||
public string $status,
|
public string $status,
|
||||||
public ?string $externalId,
|
public ?string $externalId,
|
||||||
public ?string $imageUrl,
|
public ?string $imageUrl,
|
||||||
public ?float $rating
|
public ?float $rating,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Domain\Manga\Application\Command;
|
|||||||
readonly class CreateMangaFromMangadex
|
readonly class CreateMangaFromMangadex
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $externalId
|
public string $externalId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
|||||||
readonly class DeleteCbz implements CommandInterface
|
readonly class DeleteCbz implements CommandInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $chapterId
|
public string $chapterId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
|||||||
readonly class DeleteChapter implements CommandInterface
|
readonly class DeleteChapter implements CommandInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $chapterId
|
public string $chapterId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
|||||||
readonly class DeleteManga implements CommandInterface
|
readonly class DeleteManga implements CommandInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $mangaId
|
public string $mangaId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ readonly class EditManga
|
|||||||
public ?array $genres = null,
|
public ?array $genres = null,
|
||||||
public ?string $status = null,
|
public ?string $status = null,
|
||||||
public ?float $rating = null,
|
public ?float $rating = null,
|
||||||
public ?array $alternativeSlugs = null
|
public ?array $alternativeSlugs = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ readonly class EditMultipleChapters
|
|||||||
* @param array<ChapterEditData> $chapters
|
* @param array<ChapterEditData> $chapters
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public array $chapters
|
public array $chapters,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
|||||||
readonly class FetchMangaChapters
|
readonly class FetchMangaChapters
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public MangaId $mangaId
|
public MangaId $mangaId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ readonly class ImportChapter
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public string $mangaId,
|
public string $mangaId,
|
||||||
public float $chapterNumber,
|
public float $chapterNumber,
|
||||||
public string $fileBinary
|
public string $fileBinary,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ readonly class ImportVolume
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public string $mangaId,
|
public string $mangaId,
|
||||||
public int $volumeNumber,
|
public int $volumeNumber,
|
||||||
public string $fileBinary
|
public string $fileBinary,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
|||||||
readonly class RefreshMangaChapters
|
readonly class RefreshMangaChapters
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public MangaId $mangaId
|
public MangaId $mangaId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ readonly class ToggleMangaMonitoring
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public MangaId $mangaId,
|
public MangaId $mangaId,
|
||||||
public bool $enabled
|
public bool $enabled,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
|
|||||||
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
|
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
|
||||||
use App\Domain\Manga\Application\Query\MonitoringCriteria;
|
use App\Domain\Manga\Application\Query\MonitoringCriteria;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use DateTimeImmutable;
|
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
readonly class CheckMonitoredMangasHandler
|
readonly class CheckMonitoredMangasHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private MessageBusInterface $commandBus
|
private MessageBusInterface $commandBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,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);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Domain\Manga\Application\CommandHandler;
|
namespace App\Domain\Manga\Application\CommandHandler;
|
||||||
|
|
||||||
use App\Domain\Manga\Application\Command\CreateMangaFromMangadex;
|
use App\Domain\Manga\Application\Command\CreateMangaFromMangadex;
|
||||||
use App\Domain\Manga\Application\Response\CreateMangaResponse;
|
|
||||||
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface;
|
use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface;
|
||||||
@@ -19,7 +18,7 @@ readonly class CreateMangaFromMangadexHandler
|
|||||||
private MangaProviderInterface $mangaProvider,
|
private MangaProviderInterface $mangaProvider,
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ImageProcessorInterface $imageProcessor,
|
private ImageProcessorInterface $imageProcessor,
|
||||||
private EventDispatcherInterface $eventDispatcher
|
private EventDispatcherInterface $eventDispatcher,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ readonly class CreateMangaFromMangadexHandler
|
|||||||
{
|
{
|
||||||
$manga = $this->mangaProvider->findByExternalId(new ExternalId($command->externalId));
|
$manga = $this->mangaProvider->findByExternalId(new ExternalId($command->externalId));
|
||||||
|
|
||||||
if ($manga === null) {
|
if (null === $manga) {
|
||||||
throw new MangaNotFoundException('Manga not found on Mangadex');
|
throw new MangaNotFoundException('Manga not found on Mangadex');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ readonly class CreateMangaFromMangadexHandler
|
|||||||
// Met à jour le manga avec les nouveaux chemins d'images
|
// Met à jour le manga avec les nouveaux chemins d'images
|
||||||
$manga->updateImageUrls(new ImageUrls($fullImagePath, $thumbnailPath));
|
$manga->updateImageUrls(new ImageUrls($fullImagePath, $thumbnailPath));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
throw new \RuntimeException('Erreur lors du traitement de l\'image : ' . $e->getMessage());
|
throw new \RuntimeException('Erreur lors du traitement de l\'image : '.$e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->mangaRepository->save($manga);
|
$this->mangaRepository->save($manga);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ readonly class CreateMangaHandler
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ImageProcessorInterface $imageProcessor,
|
private ImageProcessorInterface $imageProcessor,
|
||||||
private MessageBusInterface $messageBus
|
private MessageBusInterface $messageBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ readonly class CreateMangaHandler
|
|||||||
$thumbnailPath = $this->imageProcessor->createThumbnail($fullImagePath);
|
$thumbnailPath = $this->imageProcessor->createThumbnail($fullImagePath);
|
||||||
$manga->updateImageUrls(new ImageUrls($fullImagePath, $thumbnailPath));
|
$manga->updateImageUrls(new ImageUrls($fullImagePath, $thumbnailPath));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
throw new \RuntimeException('Erreur lors du traitement de l\'image : ' . $e->getMessage());
|
throw new \RuntimeException('Erreur lors du traitement de l\'image : '.$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
|||||||
use App\Domain\Manga\Application\Command\DeleteCbz;
|
use App\Domain\Manga\Application\Command\DeleteCbz;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
|
use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
|
||||||
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
|
||||||
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
|
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
|
||||||
|
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
||||||
use App\Domain\Shared\Domain\Contract\CommandHandlerInterface;
|
use App\Domain\Shared\Domain\Contract\CommandHandlerInterface;
|
||||||
use App\Domain\Shared\Domain\Contract\CommandInterface;
|
use App\Domain\Shared\Domain\Contract\CommandInterface;
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ readonly class DeleteCbzHandler implements CommandHandlerInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private FileServiceInterface $fileService
|
private FileServiceInterface $fileService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
|||||||
readonly class DeleteChapterHandler implements CommandHandlerInterface
|
readonly class DeleteChapterHandler implements CommandHandlerInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
|||||||
readonly class DeleteMangaHandler implements CommandHandlerInterface
|
readonly class DeleteMangaHandler implements CommandHandlerInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
|||||||
readonly class EditMangaHandler
|
readonly class EditMangaHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,35 +23,35 @@ readonly class EditMangaHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update only provided fields (partial update)
|
// Update only provided fields (partial update)
|
||||||
if ($command->title !== null) {
|
if (null !== $command->title) {
|
||||||
$manga->updateTitle(new MangaTitle($command->title));
|
$manga->updateTitle(new MangaTitle($command->title));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->description !== null) {
|
if (null !== $command->description) {
|
||||||
$manga->updateDescription($command->description);
|
$manga->updateDescription($command->description);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->author !== null) {
|
if (null !== $command->author) {
|
||||||
$manga->updateAuthor($command->author);
|
$manga->updateAuthor($command->author);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->publicationYear !== null) {
|
if (null !== $command->publicationYear) {
|
||||||
$manga->updatePublicationYear($command->publicationYear);
|
$manga->updatePublicationYear($command->publicationYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->genres !== null) {
|
if (null !== $command->genres) {
|
||||||
$manga->updateGenres($command->genres);
|
$manga->updateGenres($command->genres);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->status !== null) {
|
if (null !== $command->status) {
|
||||||
$manga->updateStatus($command->status);
|
$manga->updateStatus($command->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->rating !== null) {
|
if (null !== $command->rating) {
|
||||||
$manga->setRating($command->rating);
|
$manga->setRating($command->rating);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->alternativeSlugs !== null) {
|
if (null !== $command->alternativeSlugs) {
|
||||||
$manga->updateAlternativeSlugs($command->alternativeSlugs);
|
$manga->updateAlternativeSlugs($command->alternativeSlugs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
|||||||
readonly class EditMultipleChaptersHandler
|
readonly class EditMultipleChaptersHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,11 +24,11 @@ readonly class EditMultipleChaptersHandler
|
|||||||
|
|
||||||
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
|
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
|
||||||
|
|
||||||
if ($chapterData->title !== null) {
|
if (null !== $chapterData->title) {
|
||||||
$manga->updateChapterTitle($chapter, $chapterData->title);
|
$manga->updateChapterTitle($chapter, $chapterData->title);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($chapterData->volume !== null) {
|
if (null !== $chapterData->volume) {
|
||||||
$manga->updateChapterVolume($chapter, $chapterData->volume);
|
$manga->updateChapterVolume($chapter, $chapterData->volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ readonly class FetchMangaChaptersHandler
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ChapterSynchronizationServiceInterface $chapterSynchronizationService
|
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ readonly class FetchMangaChaptersHandler
|
|||||||
{
|
{
|
||||||
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
||||||
|
|
||||||
if ($manga === null) {
|
if (null === $manga) {
|
||||||
throw new MangaNotFoundException();
|
throw new MangaNotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($manga->getExternalId() === null) {
|
if (null === $manga->getExternalId()) {
|
||||||
throw new MangadexApiException("Manga has no external_id");
|
throw new MangadexApiException('Manga has no external_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronisation initiale (pas d'événements)
|
// Synchronisation initiale (pas d'événements)
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
|||||||
|
|
||||||
use App\Domain\Manga\Application\Command\ImportChapter;
|
use App\Domain\Manga\Application\Command\ImportChapter;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
|
||||||
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\ImageStorageInterface;
|
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||||
|
|
||||||
readonly class ImportChapterHandler
|
readonly class ImportChapterHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ImageStorageInterface $imageStorage
|
private ImageStorageInterface $imageStorage,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +55,6 @@ readonly class ImportChapterHandler
|
|||||||
{
|
{
|
||||||
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
|
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
|
||||||
|
|
||||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
return 0 === strpos($fileBinary, $zipMagicNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ readonly class ImportVolumeHandler
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ImageStorageInterface $imageStorage
|
private ImageStorageInterface $imageStorage,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,9 +35,7 @@ readonly class ImportVolumeHandler
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (empty($chapters)) {
|
if (empty($chapters)) {
|
||||||
throw new \InvalidArgumentException(
|
throw new \InvalidArgumentException("No chapters found for manga {$command->mangaId} in volume {$command->volumeNumber}");
|
||||||
"No chapters found for manga {$command->mangaId} in volume {$command->volumeNumber}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
|
// 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
|
||||||
@@ -56,6 +54,6 @@ readonly class ImportVolumeHandler
|
|||||||
{
|
{
|
||||||
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
|
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
|
||||||
|
|
||||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
return 0 === strpos($fileBinary, $zipMagicNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
|||||||
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
||||||
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
|
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||||
use DateTimeImmutable;
|
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
readonly class RefreshMangaChaptersHandler
|
readonly class RefreshMangaChaptersHandler
|
||||||
@@ -15,7 +14,7 @@ readonly class RefreshMangaChaptersHandler
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
|
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
|
||||||
private MessageBusInterface $eventBus
|
private MessageBusInterface $eventBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,22 +22,30 @@ readonly class RefreshMangaChaptersHandler
|
|||||||
{
|
{
|
||||||
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
||||||
|
|
||||||
if ($manga === null) {
|
if (null === $manga) {
|
||||||
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
|
||||||
|
// son identifiant définitif qu'une fois persisté en base.
|
||||||
|
foreach ($newChapterNumbers as $chapterNumber) {
|
||||||
|
$saved = $this->mangaRepository->findChapterByMangaIdAndNumber(
|
||||||
|
$manga->getId()->getValue(),
|
||||||
|
$chapterNumber
|
||||||
|
);
|
||||||
|
if ($saved) {
|
||||||
$this->eventBus->dispatch(
|
$this->eventBus->dispatch(
|
||||||
new ChapterReadyForScraping(new ChapterId($chapterId))
|
new ChapterReadyForScraping(new ChapterId($saved->getId()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
|||||||
readonly class ToggleMangaMonitoringHandler
|
readonly class ToggleMangaMonitoringHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
|||||||
readonly class MangaCreatedEventListener
|
readonly class MangaCreatedEventListener
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private FetchMangaChaptersHandler $fetchMangaChaptersHandler
|
private FetchMangaChaptersHandler $fetchMangaChaptersHandler,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ readonly class VolumeImportedEventListener
|
|||||||
}
|
}
|
||||||
|
|
||||||
$chapters = $this->mangaRepository->findChaptersByMangaIdAndVolume($manga->getId()->getValue(), (int) $event->volume);
|
$chapters = $this->mangaRepository->findChaptersByMangaIdAndVolume($manga->getId()->getValue(), (int) $event->volume);
|
||||||
if ($chapters === []) {
|
if ([] === $chapters) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\QueryInterface;
|
|||||||
readonly class DownloadCbz implements QueryInterface
|
readonly class DownloadCbz implements QueryInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $chapterId
|
public string $chapterId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ readonly class DownloadVolume implements QueryInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $mangaId,
|
public string $mangaId,
|
||||||
public int $volume
|
public int $volume,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace App\Domain\Manga\Application\Query;
|
|||||||
readonly class FindMangaMatchByFilename
|
readonly class FindMangaMatchByFilename
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $filename
|
public string $filename,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Domain\Manga\Application\Query;
|
|||||||
readonly class GetMangaById
|
readonly class GetMangaById
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $id
|
public string $id,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Domain\Manga\Application\Query;
|
|||||||
readonly class GetMangaBySlug
|
readonly class GetMangaBySlug
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $slug
|
public string $slug,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ readonly class GetMangaChapters
|
|||||||
public string $mangaId,
|
public string $mangaId,
|
||||||
public ?int $page = 1,
|
public ?int $page = 1,
|
||||||
public ?int $limit = 20,
|
public ?int $limit = 20,
|
||||||
public ?string $sortOrder = 'desc'
|
public ?string $sortOrder = 'desc',
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ readonly class GetMangaList
|
|||||||
public ?int $page = 1,
|
public ?int $page = 1,
|
||||||
public ?int $limit = 20,
|
public ?int $limit = 20,
|
||||||
public ?string $sortBy = 'title',
|
public ?string $sortBy = 'title',
|
||||||
public ?string $sortOrder = 'asc'
|
public ?string $sortOrder = 'asc',
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Domain\Manga\Application\Query;
|
namespace App\Domain\Manga\Application\Query;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
readonly class MonitoringCriteria
|
readonly class MonitoringCriteria
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public bool $enabled,
|
public bool $enabled,
|
||||||
public ?DateTimeImmutable $lastCheckBefore = null
|
public ?\DateTimeImmutable $lastCheckBefore = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ readonly class SearchLocalManga
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public string $query,
|
public string $query,
|
||||||
public int $page = 1,
|
public int $page = 1,
|
||||||
public int $limit = 20
|
public int $limit = 20,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Domain\Manga\Application\Query;
|
|||||||
readonly class SearchManga
|
readonly class SearchManga
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $title
|
public string $title,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ readonly class DiscoverMangaHandler
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private MangaProviderInterface $mangaProvider
|
private MangaProviderInterface $mangaProvider,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ readonly class DiscoverMangaHandler
|
|||||||
|
|
||||||
$recommendations = array_values(array_filter(
|
$recommendations = array_values(array_filter(
|
||||||
$collection->getItems(),
|
$collection->getItems(),
|
||||||
fn (Manga $m) => $m->getExternalId() === null
|
fn (Manga $m) => null === $m->getExternalId()
|
||||||
|| !in_array($m->getExternalId()->getValue(), $ownedExternalIds, true)
|
|| !in_array($m->getExternalId()->getValue(), $ownedExternalIds, true)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ use App\Domain\Manga\Application\Response\DownloadResponse;
|
|||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
|
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\ChapterNotFoundException;
|
|
||||||
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\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;
|
||||||
@@ -17,7 +18,7 @@ readonly class DownloadCbzHandler implements QueryHandlerInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private FileServiceInterface $fileService
|
private FileServiceInterface $fileService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ readonly class DownloadVolumeHandler implements QueryHandlerInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private FileServiceInterface $fileService
|
private FileServiceInterface $fileService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ readonly class FindMangaMatchByFilenameHandler
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private FilenameAnalyzerInterface $filenameAnalyzer,
|
private FilenameAnalyzerInterface $filenameAnalyzer,
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ readonly class FindMangaMatchByFilenameHandler
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcule un score de correspondance entre le manga et le titre recherché
|
* Calcule un score de correspondance entre le manga et le titre recherché
|
||||||
* Score plus élevé = meilleure correspondance
|
* Score plus élevé = meilleure correspondance.
|
||||||
*/
|
*/
|
||||||
private function calculateMatchScore(Manga $manga, string $searchedTitle): int
|
private function calculateMatchScore(Manga $manga, string $searchedTitle): int
|
||||||
{
|
{
|
||||||
@@ -97,12 +97,12 @@ readonly class FindMangaMatchByFilenameHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Le titre du manga contient le terme recherché
|
// Le titre du manga contient le terme recherché
|
||||||
if (stripos($mangaTitle, $searchedTitle) !== false) {
|
if (false !== stripos($mangaTitle, $searchedTitle)) {
|
||||||
$score += 50;
|
$score += 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le terme recherché contient le titre du manga
|
// Le terme recherché contient le titre du manga
|
||||||
if (stripos($searchedTitle, $mangaTitle) !== false) {
|
if (false !== stripos($searchedTitle, $mangaTitle)) {
|
||||||
$score += 40;
|
$score += 40;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
|||||||
readonly class GetMangaByIdHandler
|
readonly class GetMangaByIdHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
|||||||
readonly class GetMangaBySlugHandler
|
readonly class GetMangaBySlugHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use App\Domain\Manga\Domain\Model\Chapter;
|
|||||||
readonly class GetMangaChaptersHandler
|
readonly class GetMangaChaptersHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ readonly class GetMangaChaptersHandler
|
|||||||
|
|
||||||
$grouped = $this->groupChapters($allChapters);
|
$grouped = $this->groupChapters($allChapters);
|
||||||
|
|
||||||
if ($query->sortOrder === 'desc') {
|
if ('desc' === $query->sortOrder) {
|
||||||
usort($grouped, fn (ChapterResponse $a, ChapterResponse $b) => $b->number <=> $a->number);
|
usort($grouped, fn (ChapterResponse $a, ChapterResponse $b) => $b->number <=> $a->number);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ readonly class GetMangaChaptersHandler
|
|||||||
$pagesDir = $chapter->getPagesDirectory();
|
$pagesDir = $chapter->getPagesDirectory();
|
||||||
$volume = $chapter->getVolume();
|
$volume = $chapter->getVolume();
|
||||||
|
|
||||||
if ($pagesDir !== null && $volume !== null) {
|
if (null !== $pagesDir && null !== $volume) {
|
||||||
if ($pagesDir === $currentPagesDir && $volume === $currentVolume) {
|
if ($pagesDir === $currentPagesDir && $volume === $currentVolume) {
|
||||||
$currentGroup[] = $chapter;
|
$currentGroup[] = $chapter;
|
||||||
} else {
|
} else {
|
||||||
@@ -103,8 +103,10 @@ 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
|
||||||
$range = count($group) > 1 ? $fmt($min) . '-' . $fmt($max) : $fmt($min);
|
? str_pad((string) (int) $n, 2, '0', STR_PAD_LEFT)
|
||||||
|
: (string) $n;
|
||||||
|
$range = count($group) > 1 ? $fmt($min).'-'.$fmt($max) : $fmt($min);
|
||||||
|
|
||||||
return new ChapterResponse(
|
return new ChapterResponse(
|
||||||
id: $first->getId(),
|
id: $first->getId(),
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
namespace App\Domain\Manga\Application\QueryHandler;
|
namespace App\Domain\Manga\Application\QueryHandler;
|
||||||
|
|
||||||
use App\Domain\Manga\Application\Query\GetMangaList;
|
use App\Domain\Manga\Application\Query\GetMangaList;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
|
||||||
use App\Domain\Manga\Application\Response\MangaListResponse;
|
use App\Domain\Manga\Application\Response\MangaListResponse;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
|
|
||||||
readonly class GetMangaListHandler
|
readonly class GetMangaListHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use App\Domain\Manga\Domain\Model\Manga;
|
|||||||
readonly class SearchLocalMangaHandler
|
readonly class SearchLocalMangaHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $repository
|
private MangaRepositoryInterface $repository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use App\Domain\Manga\Domain\Model\Manga;
|
|||||||
readonly class SearchMangaHandler
|
readonly class SearchMangaHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaProviderInterface $mangaProvider
|
private MangaProviderInterface $mangaProvider,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +19,6 @@ readonly class SearchMangaHandler
|
|||||||
{
|
{
|
||||||
$mangaCollection = $this->mangaProvider->search($query->title);
|
$mangaCollection = $this->mangaProvider->search($query->title);
|
||||||
|
|
||||||
|
|
||||||
return new MangaSearchResponse(
|
return new MangaSearchResponse(
|
||||||
array_map(
|
array_map(
|
||||||
fn (Manga $manga, int $index) => new MangaSearchItem(
|
fn (Manga $manga, int $index) => new MangaSearchItem(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ readonly class ChapterListResponse
|
|||||||
public array $chapters,
|
public array $chapters,
|
||||||
public int $total,
|
public int $total,
|
||||||
public int $page,
|
public int $page,
|
||||||
public int $limit
|
public int $limit,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
readonly class DownloadResponse implements ResponseInterface
|
readonly class DownloadResponse implements ResponseInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public Response $httpResponse
|
public Response $httpResponse,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ readonly class MangaListResponse
|
|||||||
public int $total,
|
public int $total,
|
||||||
public int $page,
|
public int $page,
|
||||||
public int $limit,
|
public int $limit,
|
||||||
public array $chapterCounts = []
|
public array $chapterCounts = [],
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ readonly class MangaMatchItem
|
|||||||
public ?string $thumbnailUrl,
|
public ?string $thumbnailUrl,
|
||||||
public int $matchScore,
|
public int $matchScore,
|
||||||
public ?float $chapterNumber = null,
|
public ?float $chapterNumber = null,
|
||||||
public ?float $volumeNumber = null
|
public ?float $volumeNumber = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ readonly class MangaMatchResponse
|
|||||||
public array $matches,
|
public array $matches,
|
||||||
public ?float $chapterNumber,
|
public ?float $chapterNumber,
|
||||||
public ?float $volumeNumber,
|
public ?float $volumeNumber,
|
||||||
public array $possibleTitles
|
public array $possibleTitles,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user