Compare commits
11 Commits
style/simp
...
b997b87f51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b997b87f51 | ||
|
|
7fb73d3a69 | ||
|
|
9a4fb26b06 | ||
| 2cedd14f97 | |||
| bc0339646f | |||
|
|
7fba3c6fcb | ||
| 3791a58e3c | |||
| 798befd642 | |||
|
|
8e1c4637ba | ||
|
|
d219ed1b3b | ||
| 9a1d1954ad |
40
DONE.md
Normal file
40
DONE.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# DONE.md — Tâches terminées
|
||||
|
||||
## [UI] Passe sur le menu latéral (Sidebar) — 2026-03-14
|
||||
|
||||
> Branche : `style/sidebar-cleanup-and-ui-polish` | Commit : `d219ed1`
|
||||
|
||||
- [x] **`isActive` incorrect** : inclut désormais les sous-items dans le calcul (groupe Mangas actif sur `/import`)
|
||||
- [x] **Double déclenchement toggle/navigation** : chevron déplacé dans un `<button>` séparé du `RouterLink`
|
||||
- [x] **Parent items** (`MenuGroup.vue`) : ajout `hover:text-white` aligné avec le style SubMenuItem
|
||||
- [x] **SubMenuItems** (`SubMenuItem.vue`) : ajout `hover:bg-gray-700` pour harmoniser avec le parent
|
||||
- [x] **État actif vs hover** : logique couleur unifiée sur les deux niveaux
|
||||
|
||||
## [UI] Supprimer "Calendrier" du menu — 2026-03-14
|
||||
|
||||
> Branche : `style/sidebar-cleanup-and-ui-polish` | Commit : `d219ed1`
|
||||
|
||||
- [x] Retirer l'entrée "Calendrier" de la Sidebar
|
||||
- [x] Supprimer la route Vue Router `/calendar`
|
||||
|
||||
---
|
||||
|
||||
## [UI] Simplifier l'affichage table de la HomePage — 2026-03-14
|
||||
|
||||
> Branche : `style/simplifier-table-homepage` | Commit : `cc27fc4`
|
||||
|
||||
- [x] Supprimer le wrapper card (`bg-white shadow rounded-lg overflow-hidden`) — remplacer par un simple `border-t`
|
||||
- [x] Lien du titre : passer le hover de bleu (`hover:text-blue-600`) à vert (`hover:text-green-500`)
|
||||
- [x] Icône monitoring : remplacer `BellIcon` / `BellSlashIcon` par `BookmarkIcon` / `BookmarkSlashIcon`
|
||||
- [x] Supprimer le padding du wrapper + `container mx-auto` pour tableau pleine largeur
|
||||
|
||||
---
|
||||
|
||||
## [UI] Restyling vue grille des mangas — 2026-03-14
|
||||
|
||||
> Branche : `style/restyling-manga-grid` | Commit : `9a4fb26`
|
||||
|
||||
- [x] **Réduire la taille des cards** : grille plus dense (cols-3/4/5/7/8 selon breakpoint, gap-2)
|
||||
- [x] **Supprimer les arrondis** : retrait de `rounded-lg` et `hover:scale-105`
|
||||
- [x] **Overlay icônes au survol** : gradient + 3 boutons (éditer, sources, rafraîchir) en bas à gauche de la cover, visibles au `group-hover`
|
||||
- [x] MangaCard émet les événements, MangaGrid gère les modales (edit, sources, refresh)
|
||||
101
TASK.md
Normal file
101
TASK.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# TASK.md — Tâches à venir
|
||||
|
||||
## [Feature] Découvrir — Suggestions de mangas via MangaDex
|
||||
|
||||
**Objectif :** Page "Découvrir" qui propose des mangas populaires/récents depuis l'API MangaDex, en excluant ceux déjà présents en base (comparaison via `externalId` = ID MangaDex).
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] **Consulter la doc API MangaDex** pour identifier le(s) endpoint(s) pertinents (mangas populaires, récemment mis à jour, tendances…) et les paramètres disponibles (filtres langue, statut, contentRating, etc.)
|
||||
- [ ] **Étendre le client MangaDex existant** pour exposer le(s) nouvel(aux) endpoint(s) identifiés (nouveau(x) méthode(s) dans le client + adapter le contrat d'interface si besoin)
|
||||
- [ ] Query `GetDiscoverMangaListQuery` + handler qui appelle le client MangaDex et filtre les résultats dont l'`externalId` est déjà en base
|
||||
- [ ] Response DTO `DiscoverMangaListResponse` avec les champs nécessaires à l'affichage (id MangaDex, titre, couverture, genres, statut…)
|
||||
- [ ] State Provider API Platform sur la route `GET /api/manga/discover`
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Page `DiscoverPage.vue` avec grille de cards (réutiliser `MangaCard.vue` ou créer `DiscoverMangaCard.vue`)
|
||||
- [ ] Composable TanStack Query `useDiscoverMangaList`
|
||||
- [ ] Route Vue Router `/discover`
|
||||
- [ ] Entrée dans la Sidebar
|
||||
|
||||
---
|
||||
|
||||
## [Domain] Créer le domaine "System"
|
||||
|
||||
**Objectif :** Poser la structure DDD hexagonale du nouveau domaine `System` qui servira de socle aux fonctionnalités Status et Logs.
|
||||
|
||||
- [ ] Créer l'arborescence `src/Domain/System/Domain/`, `Application/`, `Infrastructure/`
|
||||
- [ ] Créer l'arborescence frontend `assets/vue/app/domain/system/`
|
||||
- [ ] Vérifier la conformité avec `phparkitect.php` (ajouter le domaine si nécessaire)
|
||||
|
||||
---
|
||||
|
||||
## [Feature] System — Page "Status"
|
||||
|
||||
**Objectif :** Page de monitoring affichant l'état général de l'application.
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] Query `GetSystemStatusQuery` + handler qui agrège :
|
||||
- Version de l'application (depuis `composer.json` ou variable d'env)
|
||||
- Statut des services critiques (base de données, Messenger workers, stockage)
|
||||
- Poids total des images (scan du dossier `IMAGE_DATA_PATH`)
|
||||
- Poids total des CBZ (scan du dossier `MANGA_DATA_PATH`)
|
||||
- Liens / chemins vers les dossiers de stockage configurés
|
||||
- [ ] Response DTO `SystemStatusResponse`
|
||||
- [ ] State Provider API Platform sur la route `GET /api/system/status`
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Page `StatusPage.vue` avec sections (Général, Stockage, Services)
|
||||
- [ ] Composable TanStack Query `useSystemStatus`
|
||||
- [ ] Route Vue Router `/system/status`
|
||||
|
||||
---
|
||||
|
||||
## [Feature] System — Page "Logs"
|
||||
|
||||
**Objectif :** Page de consultation des logs d'erreur des workers Messenger, avec filtres.
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] Définir le contrat `WorkerLogRepositoryInterface` dans `System/Domain/Contract/Repository/`
|
||||
- [ ] Implémenter `DoctrineWorkerLogRepository` (ou lecture des logs Monolog selon la stratégie retenue) dans `Infrastructure/`
|
||||
- [ ] Query `GetWorkerLogsQuery` avec paramètres de filtrage (date début/fin, source, niveau, worker/transport) + handler
|
||||
- [ ] Response DTO `WorkerLogListResponse` (liste paginée)
|
||||
- [ ] State Provider API Platform sur la route `GET /api/system/logs`
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Page `LogsPage.vue` avec tableau paginé + panneau de filtres
|
||||
- [ ] Filtres disponibles : plage de dates, source (transport Messenger), niveau d'erreur, manga associé (source préférée)
|
||||
- [ ] Composable TanStack Query `useWorkerLogs` (avec paramètres de filtre réactifs)
|
||||
- [ ] Route Vue Router `/system/logs`
|
||||
|
||||
---
|
||||
|
||||
## [UI] Simplifier l'affichage overview des mangas
|
||||
|
||||
**Objectif :** Améliorer l'ergonomie de la page d'accueil mangas en simplifiant l'affichage et en ajoutant des raccourcis rapides vers les actions courantes.
|
||||
|
||||
### Vue table
|
||||
|
||||
- [ ] **Raccourcis actions rapides** : dans chaque ligne de la table, ajouter un menu d'actions (icônes ou dropdown) permettant d'ouvrir directement les modales : éditer le manga, gérer les sources, lancer un scrape, supprimer
|
||||
- [ ] **Colonne statut de lecture** : afficher le statut (en cours / terminé / abandonné…) directement dans la ligne sans ouvrir la modale
|
||||
- [ ] **Colonne dernière activité** : date du dernier chapitre scrapé ou lu
|
||||
- [ ] **Raccourci ouverture rapide** : clic sur le titre ouvre le reader / detail page directement
|
||||
|
||||
### Vue grille (MangaCard)
|
||||
|
||||
- [ ] **Overlay actions** : au survol d'une card, afficher des boutons d'action rapide superposés (éditer, sources, scrape)
|
||||
- [ ] **Badge statut source préférée** : indicateur visuel si la source préférée est configurée ou non
|
||||
|
||||
### Modales accessibles
|
||||
|
||||
- [ ] **Modale "Éditer manga"** : accessible depuis la table (icône crayon) et la card (overlay)
|
||||
- [ ] **Modale "Gérer sources"** : accessible depuis la table et la card
|
||||
- [ ] **Confirmation scrape rapide** : déclencher un scrape depuis la liste sans naviguer vers le détail
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,10 @@ export class Manga {
|
||||
status = null,
|
||||
rating = null,
|
||||
genres = [],
|
||||
createdAt = new Date().toISOString()
|
||||
createdAt = new Date().toISOString(),
|
||||
monitored = false,
|
||||
chaptersTotal = 0,
|
||||
chaptersScraped = 0,
|
||||
}) {
|
||||
this.id = id;
|
||||
this.slug = slug;
|
||||
@@ -25,6 +28,9 @@ export class Manga {
|
||||
this.rating = rating;
|
||||
this.genres = genres;
|
||||
this.createdAt = createdAt;
|
||||
this.monitored = monitored;
|
||||
this.chaptersTotal = chaptersTotal;
|
||||
this.chaptersScraped = chaptersScraped;
|
||||
}
|
||||
|
||||
static create(data) {
|
||||
|
||||
@@ -1,37 +1,60 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
|
||||
<div class="relative pb-[150%]">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||
:alt="manga.title"
|
||||
class="absolute inset-0 w-full h-full object-cover bg-gray-100" />
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">{{ manga.title }}</h3>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
|
||||
<div class="group relative bg-white dark:bg-gray-800 overflow-hidden shadow-sm">
|
||||
<!-- Cover avec overlay -->
|
||||
<div class="relative pb-[140%]">
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="absolute inset-0">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||
:alt="manga.title"
|
||||
class="w-full h-full object-cover bg-gray-100" />
|
||||
</RouterLink>
|
||||
|
||||
<!-- Gradient + actions au survol -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
<div class="absolute bottom-2 left-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||
title="Éditer"
|
||||
@click="$emit('edit', manga)">
|
||||
<PencilIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||
title="Sources préférées"
|
||||
@click="$emit('sources', manga)">
|
||||
<Cog6ToothIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||
title="Rafraîchir"
|
||||
@click="$emit('refresh', manga)">
|
||||
<ArrowPathIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400"> Added: {{ formatDate(manga.createdAt) }} </div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Titre + année -->
|
||||
<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>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
manga: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||
import { RouterLink } from 'vue-router';
|
||||
|
||||
const formatDate = dateString => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
defineProps({
|
||||
manga: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['edit', 'sources', 'refresh']);
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,96 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6">
|
||||
<MangaCard v-for="manga in mangas" :key="manga.id" :manga="manga" />
|
||||
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3 p-4">
|
||||
<MangaCard
|
||||
v-for="manga in mangas"
|
||||
:key="manga.id"
|
||||
:manga="manga"
|
||||
@edit="openEdit"
|
||||
@sources="openSources"
|
||||
@refresh="doRefresh" />
|
||||
</div>
|
||||
|
||||
<!-- Modales -->
|
||||
<MangaEditModal
|
||||
:is-open="isEditModalOpen"
|
||||
:manga="selectedManga"
|
||||
:is-saving="editIsLoading"
|
||||
:error="editError"
|
||||
@close="closeEditModal"
|
||||
@save="handleSaveEdit" />
|
||||
|
||||
<MangaPreferredSourcesModal
|
||||
:is-open="isSourcesModalOpen"
|
||||
:sources="preferredSources"
|
||||
:is-loading="sourcesIsLoading"
|
||||
:error="sourcesError"
|
||||
:is-saving="sourcesIsSaving"
|
||||
@close="isSourcesModalOpen = false"
|
||||
@save="handleSaveSources" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MangaCard from './MangaCard.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||
import MangaCard from './MangaCard.vue';
|
||||
import MangaEditModal from './MangaEditModal.vue';
|
||||
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||
|
||||
defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const selectedManga = ref(null);
|
||||
const isSourcesModalOpen = ref(false);
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────
|
||||
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||
|
||||
function openEdit(manga) {
|
||||
selectedManga.value = manga;
|
||||
openEditModal();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(data) {
|
||||
if (!selectedManga.value) return;
|
||||
await editManga(selectedManga.value.id, data);
|
||||
}
|
||||
|
||||
// ── Sources préférées ─────────────────────────────────────
|
||||
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||
const {
|
||||
sources: preferredSources,
|
||||
isLoading: sourcesIsLoading,
|
||||
error: sourcesError,
|
||||
isSaving: sourcesIsSaving,
|
||||
savePreferredSources
|
||||
} = useMangaPreferredSources(selectedMangaId);
|
||||
|
||||
function openSources(manga) {
|
||||
selectedManga.value = manga;
|
||||
isSourcesModalOpen.value = true;
|
||||
}
|
||||
|
||||
function handleSaveSources(sourceIds) {
|
||||
savePreferredSources(sourceIds);
|
||||
isSourcesModalOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────
|
||||
const { refreshMetadata } = useMangaRefresh();
|
||||
const refreshingId = ref(null);
|
||||
|
||||
async function doRefresh(manga) {
|
||||
if (refreshingId.value) return;
|
||||
refreshingId.value = manga.id;
|
||||
try {
|
||||
await refreshMetadata(manga.id);
|
||||
} finally {
|
||||
refreshingId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<span v-if="isLoading" class="text-gray-400 dark:text-gray-600 text-xs">…</span>
|
||||
<span v-else-if="sources.length" class="text-gray-700 dark:text-gray-300 truncate max-w-xs block">{{ sources[0].name }}</span>
|
||||
<span v-else class="text-gray-400 dark:text-gray-600">—</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||
|
||||
const props = defineProps({
|
||||
mangaId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const mangaIdRef = toRef(props, 'mangaId');
|
||||
const { sources, isLoading } = useMangaPreferredSources(mangaIdRef);
|
||||
</script>
|
||||
@@ -64,6 +64,11 @@
|
||||
@click="store.setDefaultView('list')">
|
||||
{{ t('preferences.defaultView.list') }}
|
||||
</button>
|
||||
<button
|
||||
:class="viewButtonClass('table')"
|
||||
@click="store.setDefaultView('table')">
|
||||
{{ t('preferences.defaultView.table') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mangas par page -->
|
||||
|
||||
@@ -82,12 +82,6 @@ const routes = [
|
||||
name: 'convert',
|
||||
component: ConversionPage
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
name: 'calendar',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Calendrier' }
|
||||
},
|
||||
{
|
||||
path: '/activity',
|
||||
name: 'activity',
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
ArrowDownTrayIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
BookOpenIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
Cog6ToothIcon,
|
||||
ComputerDesktopIcon,
|
||||
@@ -69,12 +68,6 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
||||
to: '/convert',
|
||||
id: 'convert'
|
||||
},
|
||||
{
|
||||
icon: CalendarIcon,
|
||||
text: 'Calendrier',
|
||||
to: '/calendar',
|
||||
id: 'calendar'
|
||||
},
|
||||
{
|
||||
icon: ClockIcon,
|
||||
text: 'Activité',
|
||||
|
||||
@@ -3,24 +3,25 @@
|
||||
class="border-l-4"
|
||||
:class="{
|
||||
'border-green-600': isActive,
|
||||
'hover:bg-gray-700 border-transparent': !isActive
|
||||
'border-transparent': !isActive
|
||||
}">
|
||||
<div class="flex w-full" @click="toggleExpanded">
|
||||
<div class="flex w-full">
|
||||
<RouterLink
|
||||
:to="to"
|
||||
class="flex-grow px-4 py-2 flex items-center"
|
||||
:class="{
|
||||
'text-green-600 bg-gray-800': isActive
|
||||
}">
|
||||
<div class="flex items-center flex-grow">
|
||||
<component :is="icon" class="w-5 h-5 mr-3" />
|
||||
<span class="px-2">{{ text }}</span>
|
||||
</div>
|
||||
<component
|
||||
v-if="subItems.length > 0"
|
||||
:is="expanded ? ChevronUpIcon : ChevronDownIcon"
|
||||
class="w-4 h-4" />
|
||||
:class="isActive
|
||||
? 'text-green-600 bg-gray-800'
|
||||
: 'hover:bg-gray-700 hover:text-white'">
|
||||
<component :is="icon" class="w-5 h-5 mr-3" />
|
||||
<span class="px-2">{{ text }}</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="subItems.length > 0"
|
||||
class="px-3 hover:bg-gray-700"
|
||||
:class="isActive ? 'text-green-600 bg-gray-800' : 'hover:text-white'"
|
||||
@click="toggleExpanded">
|
||||
<component :is="expanded ? ChevronUpIcon : ChevronDownIcon" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-if="subItems.length > 0" class="ml-8 mt-2 space-y-4" v-show="expanded">
|
||||
@@ -71,14 +72,14 @@
|
||||
|
||||
const isActive = computed(() => {
|
||||
if (!props.to) {
|
||||
return props.subItems?.some(subItem => route.path === subItem.to) || false;
|
||||
return props.subItems?.some(subItem => route.path.startsWith(subItem.to)) || false;
|
||||
}
|
||||
|
||||
if (props.to === '/') {
|
||||
return route.path === props.to || props.subItems.map(item => item.to).includes(route.path);
|
||||
return route.path === props.to || props.subItems.some(item => route.path.startsWith(item.to));
|
||||
}
|
||||
|
||||
return route.path.startsWith(props.to);
|
||||
return route.path.startsWith(props.to) || props.subItems.some(item => route.path.startsWith(item.to));
|
||||
});
|
||||
|
||||
const isRouteMatching = path => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<li>
|
||||
<RouterLink v-if="to" :to="to" class="block hover:text-green-600" role="menuitem">
|
||||
<RouterLink v-if="to" :to="to" class="block px-2 py-1 rounded hover:bg-gray-700 hover:text-white" role="menuitem">
|
||||
{{ text }}
|
||||
</RouterLink>
|
||||
<button v-else @click="$emit('click')" class="w-full text-left hover:text-green-600" role="menuitem">
|
||||
<button v-else @click="$emit('click')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-700 hover:text-white" role="menuitem">
|
||||
{{ text }}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"defaultView": {
|
||||
"label": "Default view",
|
||||
"grid": "Grid",
|
||||
"list": "List"
|
||||
"list": "List",
|
||||
"table": "Table"
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"label": "Mangas per page"
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"defaultView": {
|
||||
"label": "Vue par défaut",
|
||||
"grid": "Grille",
|
||||
"list": "Liste"
|
||||
"list": "Liste",
|
||||
"table": "Tableau"
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"label": "Mangas par page"
|
||||
|
||||
@@ -24,11 +24,21 @@ readonly class GetMangaListHandler
|
||||
|
||||
$total = $this->mangaRepository->count();
|
||||
|
||||
$chapterCounts = [];
|
||||
foreach ($mangas as $manga) {
|
||||
$id = $manga->getId()->getValue();
|
||||
$chapterCounts[$id] = [
|
||||
'total' => $this->mangaRepository->countChapters($id),
|
||||
'scraped' => $this->mangaRepository->countAvailableChapters($id),
|
||||
];
|
||||
}
|
||||
|
||||
return new MangaListResponse(
|
||||
mangas: $mangas,
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit
|
||||
limit: $query->limit,
|
||||
chapterCounts: $chapterCounts
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ readonly class MangaListResponse
|
||||
public array $mangas,
|
||||
public int $total,
|
||||
public int $page,
|
||||
public int $limit
|
||||
public int $limit,
|
||||
public array $chapterCounts = []
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ interface MangaRepositoryInterface
|
||||
|
||||
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
|
||||
public function countChapters(string $mangaId): int;
|
||||
public function countAvailableChapters(string $mangaId): int;
|
||||
public function findChapterById(string $id): ?Chapter;
|
||||
public function findVisibleChapterById(string $id): ?Chapter;
|
||||
public function findChapterByMangaIdAndNumber(string $mangaId, float $chapterNumber): ?Chapter;
|
||||
|
||||
@@ -21,6 +21,9 @@ readonly class MangaListItem
|
||||
public string $status,
|
||||
public ?float $rating,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public bool $monitored = false,
|
||||
public int $chaptersTotal = 0,
|
||||
public int $chaptersScraped = 0,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ readonly class GetMangaListStateProvider implements ProviderInterface
|
||||
|
||||
return new MangaCollection(
|
||||
items: array_map(
|
||||
fn (Manga $manga) => $this->createMangaListItem($manga),
|
||||
fn (Manga $manga) => $this->createMangaListItem(
|
||||
$manga,
|
||||
$response->chapterCounts[$manga->getId()->getValue()] ?? []
|
||||
),
|
||||
$response->mangas
|
||||
),
|
||||
total: $response->total,
|
||||
@@ -40,7 +43,7 @@ readonly class GetMangaListStateProvider implements ProviderInterface
|
||||
);
|
||||
}
|
||||
|
||||
private function createMangaListItem(Manga $manga): MangaListItem
|
||||
private function createMangaListItem(Manga $manga, array $counts = []): MangaListItem
|
||||
{
|
||||
return new MangaListItem(
|
||||
id: $manga->getId()->getValue(),
|
||||
@@ -54,7 +57,10 @@ readonly class GetMangaListStateProvider implements ProviderInterface
|
||||
genres: $manga->getGenres(),
|
||||
status: $manga->getStatus(),
|
||||
rating: $manga->getRating(),
|
||||
createdAt: $manga->getCreatedAt()
|
||||
createdAt: $manga->getCreatedAt(),
|
||||
monitored: $manga->getMonitoringStatus()->isEnabled(),
|
||||
chaptersTotal: $counts['total'] ?? 0,
|
||||
chaptersScraped: $counts['scraped'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,18 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function countAvailableChapters(string $mangaId): int
|
||||
{
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(c.id)')
|
||||
->from(EntityChapter::class, 'c')
|
||||
->where('c.manga = :mangaId')
|
||||
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
|
||||
->setParameter('mangaId', $mangaId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function findByExternalId(ExternalId $externalId): ?DomainManga
|
||||
{
|
||||
$entity = $this->entityManager->getRepository(EntityManga::class)->findOneBy([
|
||||
|
||||
@@ -135,6 +135,14 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
return count($this->chapters[$mangaId] ?? []);
|
||||
}
|
||||
|
||||
public function countAvailableChapters(string $mangaId): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$this->chapters[$mangaId] ?? [],
|
||||
fn (Chapter $c) => $c->isAvailable()
|
||||
));
|
||||
}
|
||||
|
||||
public function findChapterById(string $id): ?Chapter
|
||||
{
|
||||
return $this->chaptersById[$id] ?? null;
|
||||
|
||||
Reference in New Issue
Block a user