9 Commits

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:48:17 +02:00
848efd3327 Merge pull request 'feat(home): toolbar filtre/affichage et modale options d'affichage' (#46) from feat/home-toolbar-display-settings into main
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Reviewed-on: #46
2026-03-27 16:26:33 +01:00
65eef59999 Merge branch 'main' into feat/home-toolbar-display-settings 2026-03-27 16:26:20 +01:00
ext.jeremy.guillot@maxicoffee.domains
e525c9b7bd feat(home): toolbar filtre/affichage et modale options d'affichage
- Correction du dropdown toolbar : prop align (left/right) pour éviter le débordement hors écran côté droit
- Filtre de collection par statut (all/completed/ongoing) persisté dans userPreferencesStore
- toolbarConfig rendu réactif (computed) avec isSelected sur Filter, Sort et View
- Modale Options d'affichage par vue (Grille, Overview, Table) avec toggles persistés
- Composant ToggleRow réutilisable
- Normalisation author → authors dans l'entité Manga (l'API renvoie author string)
2026-03-27 16:25:45 +01:00
20 changed files with 414 additions and 87 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B