Compare commits
31 Commits
feature/up
...
65eef59999
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4e30af6a16 |
@@ -108,9 +108,6 @@ RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scri
|
||||
FROM node:22-alpine AS node_build
|
||||
WORKDIR /app
|
||||
COPY --link package.json package-lock.json ./
|
||||
COPY --from=composer_deps /app/vendor/symfony/ux-live-component/assets ./vendor/symfony/ux-live-component/assets
|
||||
COPY --from=composer_deps /app/vendor/symfony/ux-react/assets ./vendor/symfony/ux-react/assets
|
||||
COPY --from=composer_deps /app/vendor/symfony/ux-turbo/assets ./vendor/symfony/ux-turbo/assets
|
||||
RUN npm install
|
||||
COPY --link assets ./assets
|
||||
COPY --link webpack.config.js ./
|
||||
|
||||
@@ -4,6 +4,7 @@ export class Manga {
|
||||
slug,
|
||||
title,
|
||||
description = null,
|
||||
author = null,
|
||||
authors = [],
|
||||
imageUrl = null,
|
||||
thumbnailUrl = null,
|
||||
@@ -20,7 +21,7 @@ export class Manga {
|
||||
this.slug = slug;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.authors = authors;
|
||||
this.authors = authors.length ? authors : (author ? [author] : []);
|
||||
this.imageUrl = imageUrl;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
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>
|
||||
|
||||
<!-- Titre + année -->
|
||||
<!-- Titre + méta -->
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="block p-2">
|
||||
<h3 class="text-xs font-medium text-gray-800 dark:text-gray-100 truncate">{{ manga.title }}</h3>
|
||||
<span v-if="manga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
|
||||
<h3 v-if="options.showTitle" class="text-xs font-medium text-gray-800 dark:text-gray-100 truncate">{{ manga.title }}</h3>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -53,6 +54,10 @@ defineProps({
|
||||
manga: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({ showTitle: true, showYear: true, showAuthor: false })
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
chapterId: chapter.id
|
||||
}
|
||||
}">
|
||||
<template v-if="chapter.isVolumeGroup">
|
||||
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
|
||||
<template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
|
||||
Chapitres {{ chapter.volumeChaptersRange }}
|
||||
</template>
|
||||
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||
</router-link>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||
<template v-if="chapter.isVolumeGroup">
|
||||
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
|
||||
<template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
|
||||
Chapitres {{ chapter.volumeChaptersRange }}
|
||||
</template>
|
||||
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||
</span>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
v-for="manga in mangas"
|
||||
:key="manga.id"
|
||||
:manga="manga"
|
||||
:options="options"
|
||||
@edit="openEdit"
|
||||
@sources="openSources"
|
||||
@refresh="doRefresh" />
|
||||
@@ -41,6 +42,10 @@ defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({ showTitle: true, showYear: true, showAuthor: false })
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<!-- Cover -->
|
||||
<img
|
||||
v-if="options.showCover"
|
||||
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||
alt=""
|
||||
class="h-36 w-24 object-cover flex-shrink-0 self-start"
|
||||
@@ -23,13 +24,21 @@
|
||||
{{ manga.title }}
|
||||
</RouterLink>
|
||||
<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="statusClass(manga.status)">
|
||||
{{ manga.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -100,6 +109,10 @@ const props = defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
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">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<th class="w-10 px-4 py-3"></th>
|
||||
<th 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 w-44">Source préférée</th>
|
||||
<th class="py-3 pr-4 text-left font-medium w-44">Chapitres</th>
|
||||
<th v-if="options.showAuthor" class="py-3 pr-4 text-left font-medium w-36">Auteur</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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -18,7 +21,7 @@
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
|
||||
|
||||
<!-- Monitoring -->
|
||||
<td class="px-4 py-3 text-center">
|
||||
<td v-if="options.showMonitoring" class="px-4 py-3 text-center">
|
||||
<button
|
||||
:title="manga.monitored ? 'Monitoring actif — cliquer pour désactiver' : 'Monitoring inactif — cliquer pour activer'"
|
||||
:class="manga.monitored
|
||||
@@ -41,13 +44,34 @@
|
||||
</RouterLink>
|
||||
</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 -->
|
||||
<td class="py-3 pr-4">
|
||||
<td v-if="options.showPreferredSource" class="py-3 pr-4">
|
||||
<MangaPreferredSourceCell :manga-id="manga.id" />
|
||||
</td>
|
||||
|
||||
<!-- 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 class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
|
||||
@@ -139,9 +163,19 @@ const props = defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
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) {
|
||||
if (!manga.chaptersTotal) return 0;
|
||||
return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100);
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="w-full">
|
||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" :options="prefs.displayOptions.grid" />
|
||||
<MangaOverview
|
||||
v-else-if="viewMode === 'list'"
|
||||
:mangas="pagedItems"
|
||||
:options="prefs.displayOptions.overview"
|
||||
@manga-click="handleMangaClick" />
|
||||
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" />
|
||||
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" :options="prefs.displayOptions.table" />
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:current-page="currentPage"
|
||||
@@ -25,6 +26,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeDisplaySettingsModal
|
||||
:is-open="isDisplaySettingsOpen"
|
||||
:options="prefs.displayOptions"
|
||||
@close="isDisplaySettingsOpen = false"
|
||||
@update="({ view, key, value }) => prefs.setDisplayOption(view, key, value)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,6 +51,7 @@ import { useUserPreferencesStore } from '../../../../domain/setting/application/
|
||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import HomeDisplaySettingsModal from '../components/HomeDisplaySettingsModal.vue';
|
||||
import MangaGrid from '../components/MangaGrid.vue';
|
||||
import MangaOverview from '../components/MangaOverview.vue';
|
||||
import MangaTable from '../components/MangaTable.vue';
|
||||
@@ -61,6 +69,7 @@ import MangaTable from '../components/MangaTable.vue';
|
||||
|
||||
const viewMode = ref(prefs.defaultView);
|
||||
const currentPage = ref(1);
|
||||
const isDisplaySettingsOpen = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
mangaStore.loadCollection();
|
||||
@@ -71,7 +80,12 @@ import MangaTable from '../components/MangaTable.vue';
|
||||
};
|
||||
|
||||
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') {
|
||||
items.sort((a, b) => a.title.localeCompare(b.title));
|
||||
} else if (prefs.sortBy === 'addedAt') {
|
||||
@@ -91,7 +105,7 @@ import MangaTable from '../components/MangaTable.vue';
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
const toolbarConfig = {
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{
|
||||
icon: ArrowPathIcon,
|
||||
@@ -103,15 +117,15 @@ import MangaTable from '../components/MangaTable.vue';
|
||||
{ icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} }
|
||||
],
|
||||
rightSection: [
|
||||
{ icon: Cog6ToothIcon, type: 'button', onClick: () => {} },
|
||||
{ icon: Cog6ToothIcon, label: 'Options', type: 'button', onClick: () => { isDisplaySettingsOpen.value = true; } },
|
||||
{
|
||||
icon: EyeIcon,
|
||||
type: 'dropdown',
|
||||
label: 'View',
|
||||
items: [
|
||||
{ label: 'Overview', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
|
||||
{ label: 'Grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
|
||||
{ label: 'Table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
|
||||
{ label: 'Overview', isSelected: prefs.defaultView === 'list', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
|
||||
{ label: 'Grid', isSelected: prefs.defaultView === 'grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
|
||||
{ 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',
|
||||
label: 'Sort',
|
||||
items: [
|
||||
{ label: 'Title', onClick: () => prefs.setSortBy('title') },
|
||||
{ label: "Date d'ajout", onClick: () => prefs.setSortBy('addedAt') },
|
||||
{ label: 'Progression', onClick: () => prefs.setSortBy('progress') }
|
||||
{ label: 'Title', isSelected: prefs.sortBy === 'title', onClick: () => prefs.setSortBy('title') },
|
||||
{ label: "Date d'ajout", isSelected: prefs.sortBy === 'addedAt', onClick: () => prefs.setSortBy('addedAt') },
|
||||
{ label: 'Progression', isSelected: prefs.sortBy === 'progress', onClick: () => prefs.setSortBy('progress') }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -129,11 +143,11 @@ import MangaTable from '../components/MangaTable.vue';
|
||||
type: 'dropdown',
|
||||
label: 'Filter',
|
||||
items: [
|
||||
{ label: 'All', onClick: () => {} },
|
||||
{ label: 'Completed', onClick: () => {} },
|
||||
{ label: 'In Progress', onClick: () => {} }
|
||||
{ label: 'All', isSelected: prefs.filterBy === 'all', onClick: () => prefs.setFilterBy('all') },
|
||||
{ label: 'Completed', isSelected: prefs.filterBy === 'completed', onClick: () => prefs.setFilterBy('completed') },
|
||||
{ label: 'In Progress', isSelected: prefs.filterBy === 'ongoing', onClick: () => prefs.setFilterBy('ongoing') }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}));
|
||||
</script>
|
||||
|
||||
@@ -8,6 +8,12 @@ const defaultState = {
|
||||
defaultView: 'grid',
|
||||
itemsPerPage: 20,
|
||||
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',
|
||||
readingMode: 'scroll',
|
||||
autoFullscreen: false,
|
||||
@@ -88,6 +94,16 @@ export const useUserPreferencesStore = defineStore('userPreferences', {
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setFilterBy(filter) {
|
||||
this.filterBy = filter;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setDisplayOption(view, key, value) {
|
||||
this.displayOptions[view][key] = value;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setReadingDirection(direction) {
|
||||
this.readingDirection = direction;
|
||||
this.persist();
|
||||
@@ -127,6 +143,8 @@ export const useUserPreferencesStore = defineStore('userPreferences', {
|
||||
defaultView: this.defaultView,
|
||||
itemsPerPage: this.itemsPerPage,
|
||||
sortBy: this.sortBy,
|
||||
filterBy: this.filterBy,
|
||||
displayOptions: this.displayOptions,
|
||||
readingDirection: this.readingDirection,
|
||||
readingMode: this.readingMode,
|
||||
autoFullscreen: this.autoFullscreen,
|
||||
|
||||
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" />
|
||||
|
||||
<!-- Right section -->
|
||||
<ToolbarSection :items="config.rightSection" />
|
||||
<ToolbarSection :items="config.rightSection" align="right" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<MenuItem v-for="(item, index) in items" :key="index" v-slot="{ active }" :disabled="item.disabled">
|
||||
<button
|
||||
@@ -50,6 +53,11 @@ import ToolbarLabel from './ToolbarLabel.vue';
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
validator: v => ['left', 'right'].includes(v)
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:active="item.active"
|
||||
:items="item.items" />
|
||||
:items="item.items"
|
||||
:align="align" />
|
||||
<Divider v-else-if="item.type === 'divider'" />
|
||||
<span
|
||||
v-else-if="item.type === 'label'"
|
||||
@@ -43,6 +44,10 @@
|
||||
(item.type === 'dropdown' && Array.isArray(item.items)))
|
||||
);
|
||||
}
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
default: 'left'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -42,8 +42,6 @@ when@test:
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
auto_generate_proxy_classes: false
|
||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||
query_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.system_cache_pool
|
||||
|
||||
@@ -116,14 +116,13 @@ task('webpack_encore:build', function () {
|
||||
sh -c '$installCmd'");
|
||||
});
|
||||
|
||||
// Restart Docker containers (entrypoint gère les migrations automatiquement)
|
||||
// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage
|
||||
// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release.
|
||||
// Restart Docker containers (entrypoint gère migrations + cache:warmup automatiquement)
|
||||
// Le cache est regénéré par l'entrypoint AVANT que FrankenPHP ne démarre,
|
||||
// ce qui évite la race condition entre FrankenPHP et un docker exec concurrent.
|
||||
desc('Restart Docker containers');
|
||||
task('docker:restart', function () {
|
||||
run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler');
|
||||
run('docker restart mangarr');
|
||||
run('docker exec mangarr php bin/console cache:clear --env=prod');
|
||||
});
|
||||
|
||||
// Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin
|
||||
|
||||
@@ -53,6 +53,14 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
|
||||
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 -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
||||
fi
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
worker {
|
||||
file ./public/index.php
|
||||
num 2
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ readonly class CheckMonitoredMangasHandler
|
||||
{
|
||||
$criteria = new MonitoringCriteria(
|
||||
enabled: true,
|
||||
lastCheckBefore: $command->since ?? new \DateTimeImmutable('-1 hour')
|
||||
lastCheckBefore: new \DateTimeImmutable('-2 hours')
|
||||
);
|
||||
|
||||
$monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria);
|
||||
|
||||
@@ -26,18 +26,26 @@ readonly class RefreshMangaChaptersHandler
|
||||
throw new \RuntimeException('Manga not found');
|
||||
}
|
||||
|
||||
// Synchronisation + récupération des nouveaux IDs
|
||||
$newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga);
|
||||
// Synchronisation + récupération des numéros de nouveaux chapitres
|
||||
$newChapterNumbers = $this->chapterSynchronizationService->synchronizeChapters($manga);
|
||||
|
||||
// Mise à jour de la date de monitoring
|
||||
$manga->updateLastMonitoringCheck(new \DateTimeImmutable());
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
// Événement de scraping pour chaque nouveau chapitre
|
||||
foreach ($newChapterIds as $chapterId) {
|
||||
$this->eventBus->dispatch(
|
||||
new ChapterReadyForScraping(new ChapterId($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(
|
||||
new ChapterReadyForScraping(new ChapterId($saved->getId()))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ChapterSynchronizationServiceInterface
|
||||
/**
|
||||
* Synchronise les chapitres d'un manga depuis la source externe.
|
||||
*
|
||||
* @return string[] IDs des nouveaux chapitres ajoutés
|
||||
* @return float[] Numéros des nouveaux chapitres ajoutés
|
||||
*/
|
||||
public function synchronizeChapters(Manga $manga): array;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\CommandHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
|
||||
use App\Domain\Manga\Application\CommandHandler\CheckMonitoredMangasHandler;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
readonly class SymfonyCheckMonitoredMangasHandler
|
||||
{
|
||||
public function __construct(
|
||||
private CheckMonitoredMangasHandler $handler,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CheckMonitoredMangas $command): void
|
||||
{
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,7 @@ class MonitoringSchedule implements ScheduleProviderInterface
|
||||
{
|
||||
return (new Schedule())->add(
|
||||
// Toutes les 2 heures, vérifie les mangas qui n'ont pas été vérifiés depuis 2 heures
|
||||
RecurringMessage::every('2 hours', new CheckMonitoredMangas(
|
||||
new \DateTimeImmutable('-2 hours')
|
||||
))
|
||||
RecurringMessage::every('2 hours', new CheckMonitoredMangas())
|
||||
)->stateful($this->cache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +96,11 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz
|
||||
|
||||
$newChapterIds = [];
|
||||
|
||||
// Sauvegarde uniquement les nouveaux chapitres et collecte leurs IDs
|
||||
// Sauvegarde uniquement les nouveaux chapitres et collecte leurs numéros
|
||||
foreach ($chaptersByNumber as $chapterNumber => $chapter) {
|
||||
if (!isset($existingChapters[(float) $chapterNumber])) {
|
||||
$manga->addChapter($chapter);
|
||||
$newChapterIds[] = $chapter->getId();
|
||||
$newChapterIds[] = $chapter->getNumber();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
||||
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
class AutoScrapingListener
|
||||
@@ -15,6 +18,7 @@ class AutoScrapingListener
|
||||
private readonly ScrapeChapterHandler $scrapeChapterHandler,
|
||||
private readonly ChapterRepositoryInterface $chapterRepository,
|
||||
private readonly MangaRepositoryInterface $mangaRepository,
|
||||
private readonly JobRepositoryInterface $jobRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -25,7 +29,12 @@ class AutoScrapingListener
|
||||
$manga = $this->mangaRepository->getById($chapter->mangaId);
|
||||
|
||||
if ($manga->isMonitored()) {
|
||||
$this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue()));
|
||||
$jobId = Uuid::uuid4()->toString();
|
||||
$job = new ScrapingJob($jobId);
|
||||
$job->context['chapterId'] = $event->chapterId->getValue();
|
||||
$this->jobRepository->save($job);
|
||||
|
||||
$this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue(), $jobId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ class InMemoryChapterSynchronizationService implements ChapterSynchronizationSer
|
||||
'synchronized_at' => new \DateTimeImmutable(),
|
||||
];
|
||||
|
||||
// Retourne les IDs des chapitres synchronisés (simulation)
|
||||
return ['chapter-1', 'chapter-2'];
|
||||
// Retourne les numéros des chapitres synchronisés (simulation)
|
||||
return [1.0, 2.0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Domain\Manga\Application\CommandHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
|
||||
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
|
||||
use App\Domain\Manga\Application\CommandHandler\CheckMonitoredMangasHandler;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use App\Tests\Shared\Adapter\InMemoryMessageBus;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CheckMonitoredMangasHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryMangaRepository $mangaRepository;
|
||||
private InMemoryMessageBus $commandBus;
|
||||
private CheckMonitoredMangasHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mangaRepository = new InMemoryMangaRepository();
|
||||
$this->commandBus = new InMemoryMessageBus();
|
||||
$this->commandBus->clear();
|
||||
$this->handler = new CheckMonitoredMangasHandler($this->mangaRepository, $this->commandBus);
|
||||
}
|
||||
|
||||
private function createManga(string $id): Manga
|
||||
{
|
||||
return new Manga(
|
||||
new MangaId($id),
|
||||
new MangaTitle('Manga ' . $id),
|
||||
new MangaSlug('manga-' . $id),
|
||||
'Description',
|
||||
'Author',
|
||||
2024,
|
||||
[],
|
||||
'ongoing',
|
||||
new ExternalId('ext-' . $id)
|
||||
);
|
||||
}
|
||||
|
||||
public function testDispatchesRefreshForMonitoredMangaWithOldCheck(): void
|
||||
{
|
||||
$manga = $this->createManga('manga-1');
|
||||
$manga->enableMonitoring();
|
||||
$manga->updateLastMonitoringCheck(new \DateTimeImmutable('-3 hours'));
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
$this->handler->handle(new CheckMonitoredMangas());
|
||||
|
||||
$this->assertTrue($this->commandBus->hasMessageOfType(RefreshMangaChapters::class));
|
||||
$dispatched = array_filter(
|
||||
$this->commandBus->getDispatchedMessages(),
|
||||
fn ($m) => $m instanceof RefreshMangaChapters
|
||||
);
|
||||
$this->assertCount(1, $dispatched);
|
||||
$this->assertSame('manga-1', array_values($dispatched)[0]->mangaId->getValue());
|
||||
}
|
||||
|
||||
public function testDoesNotDispatchForNonMonitoredManga(): void
|
||||
{
|
||||
$manga = $this->createManga('manga-2');
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
$this->handler->handle(new CheckMonitoredMangas());
|
||||
|
||||
$this->assertFalse($this->commandBus->hasMessageOfType(RefreshMangaChapters::class));
|
||||
}
|
||||
|
||||
public function testDoesNotDispatchForMangaWithRecentCheck(): void
|
||||
{
|
||||
$manga = $this->createManga('manga-3');
|
||||
$manga->enableMonitoring();
|
||||
$manga->updateLastMonitoringCheck(new \DateTimeImmutable('-30 minutes'));
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
$this->handler->handle(new CheckMonitoredMangas());
|
||||
|
||||
$this->assertFalse($this->commandBus->hasMessageOfType(RefreshMangaChapters::class));
|
||||
}
|
||||
|
||||
public function testDispatchesOnlyMangasWithOldCheck(): void
|
||||
{
|
||||
$mangaOld = $this->createManga('manga-old');
|
||||
$mangaOld->enableMonitoring();
|
||||
$mangaOld->updateLastMonitoringCheck(new \DateTimeImmutable('-3 hours'));
|
||||
$this->mangaRepository->save($mangaOld);
|
||||
|
||||
$mangaRecent = $this->createManga('manga-recent');
|
||||
$mangaRecent->enableMonitoring();
|
||||
$mangaRecent->updateLastMonitoringCheck(new \DateTimeImmutable('-30 minutes'));
|
||||
$this->mangaRepository->save($mangaRecent);
|
||||
|
||||
$mangaDisabled = $this->createManga('manga-disabled');
|
||||
$this->mangaRepository->save($mangaDisabled);
|
||||
|
||||
$this->handler->handle(new CheckMonitoredMangas());
|
||||
|
||||
$dispatched = array_filter(
|
||||
$this->commandBus->getDispatchedMessages(),
|
||||
fn ($m) => $m instanceof RefreshMangaChapters
|
||||
);
|
||||
$this->assertCount(1, $dispatched);
|
||||
$this->assertSame('manga-old', array_values($dispatched)[0]->mangaId->getValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Domain\Scraping\Infrastructure\EventListener;
|
||||
|
||||
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
|
||||
use App\Domain\Scraping\Domain\Model\Chapter;
|
||||
use App\Domain\Scraping\Domain\Model\Manga;
|
||||
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
||||
use App\Domain\Scraping\Infrastructure\EventListener\AutoScrapingListener;
|
||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
use App\Domain\Shared\Domain\Model\JobStatus;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryMangaRepository;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
|
||||
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AutoScrapingListenerTest extends TestCase
|
||||
{
|
||||
private InMemoryChapterRepository $chapterRepository;
|
||||
private InMemoryMangaRepository $mangaRepository;
|
||||
private InMemoryJobRepository $jobRepository;
|
||||
private InMemoryEventBus $eventBus;
|
||||
private AutoScrapingListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->chapterRepository = new InMemoryChapterRepository();
|
||||
$this->mangaRepository = new InMemoryMangaRepository();
|
||||
$this->mangaRepository->clear();
|
||||
$this->jobRepository = new InMemoryJobRepository();
|
||||
$this->eventBus = new InMemoryEventBus();
|
||||
|
||||
$handler = new ScrapeChapterHandler(
|
||||
new InMemoryScraperFactory(),
|
||||
new InMemoryImageDownloader(),
|
||||
new InMemoryImageStorage(),
|
||||
$this->jobRepository,
|
||||
$this->chapterRepository,
|
||||
$this->mangaRepository,
|
||||
new InMemorySourceRepository(),
|
||||
$this->eventBus,
|
||||
);
|
||||
|
||||
$this->listener = new AutoScrapingListener(
|
||||
$handler,
|
||||
$this->chapterRepository,
|
||||
$this->mangaRepository,
|
||||
$this->jobRepository,
|
||||
);
|
||||
}
|
||||
|
||||
public function testCreatesJobAndScrapesWhenMangaIsMonitored(): void
|
||||
{
|
||||
$chapterId = 'chapter-uuid-1';
|
||||
$mangaId = 'manga-monitored';
|
||||
|
||||
$this->chapterRepository->save(new Chapter($chapterId, $mangaId, 1177.0, null));
|
||||
$this->mangaRepository->save(new Manga(
|
||||
$mangaId, 'One Piece', 'one-piece', 'Desc', 'Oda', '1997', true
|
||||
));
|
||||
|
||||
$this->listener->onChapterReadyForScraping(
|
||||
new ChapterReadyForScraping(new ChapterId($chapterId))
|
||||
);
|
||||
|
||||
$jobs = $this->jobRepository->findByType('scraping_job');
|
||||
$this->assertCount(1, $jobs);
|
||||
|
||||
$job = array_values($jobs)[0];
|
||||
$this->assertSame($chapterId, $job->context['chapterId']);
|
||||
$this->assertInstanceOf(ScrapingJob::class, $job);
|
||||
|
||||
$hasChapterScraped = count(array_filter(
|
||||
$this->eventBus->getDispatchedMessages(),
|
||||
fn ($m) => $m instanceof ChapterScraped
|
||||
)) > 0;
|
||||
$this->assertTrue($hasChapterScraped);
|
||||
}
|
||||
|
||||
public function testDoesNothingWhenMangaIsNotMonitored(): void
|
||||
{
|
||||
$chapterId = 'chapter-uuid-2';
|
||||
$mangaId = 'manga-not-monitored';
|
||||
|
||||
$this->chapterRepository->save(new Chapter($chapterId, $mangaId, 1176.0, null));
|
||||
$this->mangaRepository->save(new Manga(
|
||||
$mangaId, 'One Piece', 'one-piece', 'Desc', 'Oda', '1997', false
|
||||
));
|
||||
|
||||
$this->listener->onChapterReadyForScraping(
|
||||
new ChapterReadyForScraping(new ChapterId($chapterId))
|
||||
);
|
||||
|
||||
$this->assertEmpty($this->jobRepository->findByType('scraping_job'));
|
||||
$this->assertEmpty($this->eventBus->getDispatchedMessages());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user