15 Commits

Author SHA1 Message Date
ext.jeremy.guillot@maxicoffee.domains
cc702cff19 style(header): ajouter bouton toggle dark mode dans le header
All checks were successful
Deploy / deploy (push) Successful in 2m46s
feat(conversion): simplifier ConversionPage et brancher les toasts
style(manga): réécriture de la liste de résultats dans AddManga
chore(task): ajouter tâche conversion CBR→CBZ dans TASK.md
2026-03-14 02:17:24 +01:00
ext.jeremy.guillot@maxicoffee.domains
b609fe0a45 style(header): remplacer le texte Mangarr par le logo de l'application
All checks were successful
Deploy / deploy (push) Successful in 2m42s
2026-03-14 01:43:12 +01:00
ext.jeremy.guillot@maxicoffee.domains
10d10d2c2f style(manga-overview): réécriture complète de MangaOverview.vue
All checks were successful
Deploy / deploy (push) Successful in 2m50s
Remplace les grandes cartes verbeux par des lignes compactes avec cover,
titre (text-2xl), badge statut, résumé tronqué et 3 boutons d'action
verticaux (éditer, sources, rafraîchir) — cohérent avec MangaTable.

Archivage de la tâche [UI] Améliorer la vue Overview dans TASK.md.
2026-03-14 01:37:20 +01:00
74f903d78d Merge pull request 'style/restyling-manga-grid' (#12) from style/restyling-manga-grid into main
All checks were successful
Deploy / deploy (push) Successful in 2m46s
Reviewed-on: #12
2026-03-14 01:04:38 +01:00
ext.jeremy.guillot@maxicoffee.domains
b997b87f51 style(manga-grid): afficher l'année de parution sous le titre, gap-3 entre les cards 2026-03-14 01:01:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
7fb73d3a69 chore: archiver tâche Restyling vue grille dans DONE.md 2026-03-14 00:58:31 +01:00
ext.jeremy.guillot@maxicoffee.domains
9a4fb26b06 style(manga-grid): cards sans arrondis, overlay actions au survol, grille plus dense
- Supprime rounded-lg et hover:scale-105 sur MangaCard
- Ajoute overlay gradient + 3 boutons (éditer, sources, rafraîchir) visibles au survol en bas à gauche de la cover
- MangaCard émet les événements edit/sources/refresh vers MangaGrid
- MangaGrid gère les modales et composables (edit, preferredSources, refresh)
- Grille plus dense : cols-3/4/5/7/8 selon breakpoint, gap-2
2026-03-14 00:58:05 +01:00
2cedd14f97 Merge pull request 'chore: rattrapage' (#11) from style/sidebar-cleanup-and-ui-polish into main
All checks were successful
Deploy / deploy (push) Successful in 2m55s
Reviewed-on: #11
2026-03-14 00:46:49 +01:00
bc0339646f Merge branch 'main' into style/sidebar-cleanup-and-ui-polish 2026-03-14 00:46:32 +01:00
ext.jeremy.guillot@maxicoffee.domains
7fba3c6fcb chore: rattrapage 2026-03-14 00:45:29 +01:00
3791a58e3c Merge pull request 'style/sidebar-cleanup-and-ui-polish' (#9) from style/sidebar-cleanup-and-ui-polish into main
Some checks failed
Deploy / deploy (push) Failing after 1m54s
Reviewed-on: #9
2026-03-14 00:37:29 +01:00
798befd642 Merge branch 'main' into style/sidebar-cleanup-and-ui-polish 2026-03-14 00:37:15 +01:00
ext.jeremy.guillot@maxicoffee.domains
8e1c4637ba chore: archiver tâches Sidebar et Calendrier dans DONE.md 2026-03-14 00:36:19 +01:00
ext.jeremy.guillot@maxicoffee.domains
d219ed1b3b style(sidebar): supprimer Calendrier, corriger isActive, séparer toggle/nav, harmoniser hover
- Retrait de l'entrée "Calendrier" du menu et de sa route Vue Router
- isActive inclut désormais les sous-items (fix: groupe Mangas actif sur /import)
- Chevron déplacé dans un <button> séparé du RouterLink (plus de double toggle/nav)
- Hover harmonisé : hover:bg-gray-700 + hover:text-white sur parent et sous-items
2026-03-14 00:33:38 +01:00
9a1d1954ad Merge pull request 'style/simplifier-table-homepage' (#8) from style/simplifier-table-homepage into main
Some checks failed
Deploy / deploy (push) Failing after 2m10s
Reviewed-on: #8
2026-03-14 00:24:07 +01:00
27 changed files with 672 additions and 412 deletions

40
DONE.md Normal file
View 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)

88
TASK.md Normal file
View File

@@ -0,0 +1,88 @@
# 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`
---
## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast
**Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application.
- [ ] Auditer le composant/template actuel de la page de conversion
- [ ] Simplifier la mise en page (réduire la complexité visuelle, harmoniser avec le reste de l'UI)
- [ ] Supprimer l'affichage inline "Conversion réussie"
- [ ] Brancher les notifications toast existantes pour signaler le succès (et l'échec) de la conversion
---

View File

@@ -20,7 +20,6 @@ export const useConversionStore = defineStore('conversion', {
// État de l'interface // État de l'interface
isDragOver: false, isDragOver: false,
showSuccessMessage: false,
}), }),
getters: { getters: {
@@ -86,7 +85,6 @@ export const useConversionStore = defineStore('conversion', {
this.clearError(); this.clearError();
this.conversionSuccess = false; this.conversionSuccess = false;
this.convertedFile = null; this.convertedFile = null;
this.showSuccessMessage = false;
// Stockage du fichier // Stockage du fichier
this.currentFile = file; this.currentFile = file;
@@ -125,7 +123,6 @@ export const useConversionStore = defineStore('conversion', {
// Stockage du fichier converti // Stockage du fichier converti
this.convertedFile = convertedFileBlob; this.convertedFile = convertedFileBlob;
this.conversionSuccess = true; this.conversionSuccess = true;
this.showSuccessMessage = true;
// Ajout à l'historique // Ajout à l'historique
this.addToHistory({ this.addToHistory({
@@ -171,7 +168,6 @@ export const useConversionStore = defineStore('conversion', {
this.currentFile = null; this.currentFile = null;
this.convertedFile = null; this.convertedFile = null;
this.conversionSuccess = false; this.conversionSuccess = false;
this.showSuccessMessage = false;
this.conversionProgress = 0; this.conversionProgress = 0;
this.clearError(); this.clearError();
}, },
@@ -183,7 +179,6 @@ export const useConversionStore = defineStore('conversion', {
setError(message) { setError(message) {
this.conversionError = message; this.conversionError = message;
this.conversionSuccess = false; this.conversionSuccess = false;
this.showSuccessMessage = false;
}, },
/** /**
@@ -193,13 +188,6 @@ export const useConversionStore = defineStore('conversion', {
this.conversionError = null; this.conversionError = null;
}, },
/**
* Cache le message de succès
*/
hideSuccessMessage() {
this.showSuccessMessage = false;
},
/** /**
* Gère l'état du drag and drop * Gère l'état du drag and drop
* @param {boolean} isDragOver - Indique si un fichier est survolé * @param {boolean} isDragOver - Indique si un fichier est survolé

View File

@@ -1,33 +1,8 @@
<template> <template>
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8 max-w-4xl"> <div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
<!-- En-tête --> <div class="overflow-y-auto flex-1">
<div class="mb-8"> <div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center space-x-3 mb-4">
<ArrowPathIcon class="w-8 h-8 text-green-600" />
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">
Convertir CBR en CBZ
</h1>
</div>
<p class="text-lg text-gray-600 dark:text-gray-400">
Convertissez vos fichiers CBR (Comic Book RAR) en CBZ (Comic Book ZIP) pour une meilleure compatibilité.
</p>
</div>
<!-- Zone principale -->
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden">
<!-- En-tête de la carte -->
<div class="bg-gray-800 text-white p-6">
<div class="flex items-center space-x-3">
<ArchiveBoxIcon class="w-6 h-6" />
<h2 class="text-xl font-semibold">
Conversion de fichiers
</h2>
</div>
</div>
<!-- Contenu de la carte -->
<div class="p-6 space-y-6">
<!-- Zone d'upload -->
<FileUploadArea <FileUploadArea
:selected-file="conversionStore.currentFile" :selected-file="conversionStore.currentFile"
:disabled="conversionStore.isProcessing" :disabled="conversionStore.isProcessing"
@@ -35,33 +10,25 @@
@file-cleared="handleFileClear" @file-cleared="handleFileClear"
/> />
<!-- Bouton de conversion --> <div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="mt-6 flex justify-center">
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="flex justify-center">
<button <button
@click="handleConvert" @click="handleConvert"
:disabled="conversionStore.isProcessing" :disabled="conversionStore.isProcessing"
:class="[ :class="[
'flex items-center space-x-2 px-6 py-3 text-white font-medium rounded-lg transition-all duration-200', 'flex items-center gap-2 px-6 py-3 text-white font-medium rounded-lg transition-colors',
conversionStore.isProcessing conversionStore.isProcessing
? 'bg-gray-400 cursor-not-allowed' ? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2' : 'bg-green-600 hover:bg-green-700'
]" ]"
> >
<ArrowPathIcon <ArrowPathIcon :class="['w-5 h-5', conversionStore.isProcessing && 'animate-spin']" />
:class="[ {{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
'w-5 h-5',
conversionStore.isProcessing && 'animate-spin'
]"
/>
<span>
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
</span>
</button> </button>
</div> </div>
<!-- Progression et résultat -->
<ConversionProgress <ConversionProgress
v-if="showProgress" v-if="showProgress"
class="mt-6"
:is-converting="conversionStore.isProcessing" :is-converting="conversionStore.isProcessing"
:progress="conversionStore.conversionProgress" :progress="conversionStore.conversionProgress"
:is-success="conversionStore.hasSucceeded" :is-success="conversionStore.hasSucceeded"
@@ -74,212 +41,101 @@
@reset="handleReset" @reset="handleReset"
/> />
<!-- Message d'information --> <div v-if="conversionStore.conversionCount > 0" class="mt-8">
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4"> <div class="flex items-center justify-between mb-3">
<div class="flex"> <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Historique</h3>
<InformationCircleIcon class="w-5 h-5 text-blue-500 flex-shrink-0" />
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300">
À propos de la conversion
</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-400 space-y-1">
<p> Les fichiers CBZ sont plus largement supportés par les lecteurs de bandes dessinées</p>
<p> La compression ZIP permet généralement une meilleure accessibilité</p>
<p> Aucune perte de qualité lors de la conversion</p>
<p> Taille maximale supportée: 150MB</p>
</div>
</div>
</div>
</div>
<!-- Historique des conversions -->
<div v-if="conversionStore.conversionCount > 0" class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Historique des conversions
</h3>
<button <button
@click="handleClearHistory" @click="conversionStore.clearHistory()"
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors" class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
> >
Effacer l'historique Effacer
</button> </button>
</div> </div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4"> <div
<div class="space-y-3"> v-for="(conversion, index) in conversionStore.conversionHistory"
<div :key="index"
v-for="(conversion, index) in conversionStore.conversionHistory" class="flex items-center justify-between py-3"
:key="index" >
class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-600 last:border-b-0" <div>
> <p class="text-sm text-gray-900 dark:text-gray-100">{{ conversion.originalName }}</p>
<div class="flex-1"> <p class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(conversion.timestamp) }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"> </div>
{{ conversion.originalName }} <div class="text-right text-sm">
</p> <p class="text-gray-600 dark:text-gray-300">
<p class="text-xs text-gray-500 dark:text-gray-400"> {{ formatFileSize(conversion.originalSize) }} {{ formatFileSize(conversion.convertedSize) }}
{{ formatDate(conversion.timestamp) }} </p>
</p> <p class="text-xs text-green-600">{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
</p>
<p class="text-xs text-green-600">
{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Toast de notification -->
<div
v-if="conversionStore.showSuccessMessage"
class="fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-3 z-50"
>
<CheckCircleIcon class="w-5 h-5" />
<span class="font-medium">Conversion réussie !</span>
<button
@click="conversionStore.hideSuccessMessage()"
class="ml-2 text-green-100 hover:text-white transition-colors"
>
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</div></div>
</template> </template>
<script> <script setup>
import { import { ArrowPathIcon } from '@heroicons/vue/24/outline';
ArchiveBoxIcon,
ArrowPathIcon,
CheckCircleIcon,
InformationCircleIcon,
XMarkIcon,
} from '@heroicons/vue/24/outline';
import { computed, onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import { useConversionStore } from '../../application/store/conversionStore'; import { useConversionStore } from '../../application/store/conversionStore';
import { useNotifications } from '../../../../shared/composables/useNotifications';
import ConversionProgress from '../components/ConversionProgress.vue'; import ConversionProgress from '../components/ConversionProgress.vue';
import FileUploadArea from '../components/FileUploadArea.vue'; import FileUploadArea from '../components/FileUploadArea.vue';
export default { const conversionStore = useConversionStore();
name: 'ConversionPage', const { showSuccess, showError } = useNotifications();
components: { const showProgress = computed(() =>
FileUploadArea, conversionStore.hasSelectedFile &&
ConversionProgress, (conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
ArrowPathIcon, );
ArchiveBoxIcon,
InformationCircleIcon,
CheckCircleIcon,
XMarkIcon,
},
setup() { const handleFileSelected = (file) => {
const conversionStore = useConversionStore(); conversionStore.selectFile(file);
// Computed properties
const showProgress = computed(() => {
return conversionStore.hasSelectedFile &&
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError);
});
// Event handlers
const handleFileSelected = (file) => {
const success = conversionStore.selectFile(file);
if (!success) {
// L'erreur est déjà gérée par le store
console.warn('Fichier non valide:', file);
}
};
const handleFileClear = () => {
conversionStore.resetConversion();
};
const handleConvert = async () => {
if (!conversionStore.currentFile) return;
const success = await conversionStore.convertCurrentFile();
if (success) {
console.log('Conversion réussie');
} else {
console.error('Échec de la conversion');
}
};
const handleDownload = () => {
conversionStore.downloadConvertedFile();
};
const handleReset = () => {
conversionStore.resetConversion();
};
const handleClearHistory = () => {
conversionStore.clearHistory();
};
// Utility functions
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 octets';
const k = 1024;
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const formatDate = (isoString) => {
const date = new Date(isoString);
return new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
const calculateSaving = (originalSize, convertedSize) => {
if (!originalSize || !convertedSize) return '';
const saving = ((originalSize - convertedSize) / originalSize) * 100;
if (saving > 0) {
return `-${saving.toFixed(1)}%`;
} else if (saving < 0) {
return `+${Math.abs(saving).toFixed(1)}%`;
}
return '0%';
};
// Lifecycle
onMounted(() => {
// Réinitialiser l'état au montage de la page
conversionStore.resetConversion();
});
return {
conversionStore,
showProgress,
handleFileSelected,
handleFileClear,
handleConvert,
handleDownload,
handleReset,
handleClearHistory,
formatFileSize,
formatDate,
calculateSaving,
};
},
}; };
</script>
<style scoped> const handleFileClear = () => {
/* Styles spécifiques si nécessaires */ conversionStore.resetConversion();
</style> };
const handleConvert = async () => {
if (!conversionStore.currentFile) return;
const success = await conversionStore.convertCurrentFile();
if (success) {
showSuccess('Conversion réussie !');
} else {
showError(conversionStore.conversionError ?? 'Échec de la conversion');
}
};
const handleDownload = () => conversionStore.downloadConvertedFile();
const handleReset = () => conversionStore.resetConversion();
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 octets';
const k = 1024;
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const formatDate = (isoString) =>
new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(isoString));
const calculateSaving = (originalSize, convertedSize) => {
if (!originalSize || !convertedSize) return '';
const saving = ((originalSize - convertedSize) / originalSize) * 100;
if (saving > 0) return `-${saving.toFixed(1)}%`;
if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`;
return '0%';
};
onMounted(() => conversionStore.resetConversion());
</script>

View File

@@ -11,7 +11,10 @@ export class Manga {
status = null, status = null,
rating = null, rating = null,
genres = [], genres = [],
createdAt = new Date().toISOString() createdAt = new Date().toISOString(),
monitored = false,
chaptersTotal = 0,
chaptersScraped = 0,
}) { }) {
this.id = id; this.id = id;
this.slug = slug; this.slug = slug;
@@ -25,6 +28,9 @@ export class Manga {
this.rating = rating; this.rating = rating;
this.genres = genres; this.genres = genres;
this.createdAt = createdAt; this.createdAt = createdAt;
this.monitored = monitored;
this.chaptersTotal = chaptersTotal;
this.chaptersScraped = chaptersScraped;
} }
static create(data) { static create(data) {

View File

@@ -1,37 +1,60 @@
<template> <template>
<RouterLink <div class="group relative bg-white dark:bg-gray-800 overflow-hidden shadow-sm">
:to="{ name: 'manga-details', params: { id: manga.id } }" <!-- Cover avec overlay -->
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-[140%]">
<div class="relative pb-[150%]"> <RouterLink
<img :to="{ name: 'manga-details', params: { id: manga.id } }"
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'" class="absolute inset-0">
:alt="manga.title" <img
class="absolute inset-0 w-full h-full object-cover bg-gray-100" /> :src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
</div> :alt="manga.title"
<div class="p-2"> class="w-full h-full object-cover bg-gray-100" />
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">{{ manga.title }}</h3> </RouterLink>
<div class="flex items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span> <!-- 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>
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400"> Added: {{ formatDate(manga.createdAt) }} </div>
</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> </template>
<script setup> <script setup>
const props = defineProps({ import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
manga: { import { RouterLink } from 'vue-router';
type: Object,
required: true
}
});
const formatDate = dateString => { defineProps({
const date = new Date(dateString); manga: {
return date.toLocaleDateString('en-US', { type: Object,
month: 'short', required: true
day: 'numeric', }
year: 'numeric' });
});
}; defineEmits(['edit', 'sources', 'refresh']);
</script> </script>

View File

@@ -1,16 +1,96 @@
<template> <template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6"> <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" /> <MangaCard
v-for="manga in mangas"
:key="manga.id"
:manga="manga"
@edit="openEdit"
@sources="openSources"
@refresh="doRefresh" />
</div> </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> </template>
<script setup> <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({ defineProps({
mangas: { mangas: {
type: Array, type: Array,
required: true 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> </script>

View File

@@ -1,84 +0,0 @@
<template>
<div class="space-y-4">
<div
v-for="manga in mangas"
:key="manga.id"
class="flex bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg p-4 space-x-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
@click="$emit('manga-click', manga)">
<!-- Cover Image -->
<div class="flex-shrink-0">
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" referrerpolicy="no-referrer" />
<!-- TODO: Add placeholder image -->
</div>
<!-- Manga Info -->
<div class="flex-1 min-w-0">
<h3 class="text-lg leading-7 font-medium text-gray-900 dark:text-gray-100 truncate">{{
manga.title
}}</h3>
<p v-if="manga.publicationYear" class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{
manga.publicationYear
}}</p>
<p v-if="manga.description" class="text-sm text-gray-700 dark:text-gray-300 mt-2">
{{ truncateDescription(manga.description) }}
</p>
<p v-if="manga.createdAt" class="text-sm text-gray-500 dark:text-gray-400 mt-2">
Added: {{ formatDate(manga.createdAt) }}
</p>
</div>
</div>
</div>
</template>
<script setup>
import { defineEmits, defineProps } from 'vue';
const emit = defineEmits(['manga-click']);
const props = defineProps({
mangas: {
type: Array,
required: true
}
});
const formatDate = dateString => {
if (!dateString) return '';
const options = { year: 'numeric', month: 'long', day: 'numeric' };
try {
return new Date(dateString).toLocaleDateString(undefined, options);
} catch (e) {
console.error('Error formatting date:', e);
return dateString;
}
};
const truncateDescription = description => {
if (!description) return '';
return description.length > 500 ? description.slice(0, 500) + '...' : description;
};
</script>
<style scoped>
/* Pour s'assurer que line-clamp fonctionne */
@supports (-webkit-line-clamp: 3) {
.line-clamp-3 {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
}
}
.description-truncate {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
max-width: 500px;
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<div>
<div class="border-t border-gray-200 dark:border-gray-700">
<div
v-for="manga in mangas"
:key="manga.id"
class="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors border-b border-gray-100 dark:border-gray-700">
<!-- Cover -->
<img
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
alt=""
class="h-36 w-24 object-cover flex-shrink-0 self-start"
referrerpolicy="no-referrer" />
<!-- Titre + méta + résumé -->
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2 flex-wrap">
<RouterLink
:to="{ name: 'manga-details', params: { id: manga.id } }"
class="text-2xl font-semibold text-gray-900 dark:text-gray-100 hover:text-green-500 dark:hover:text-green-400 transition-colors"
@click.stop>
{{ manga.title }}
</RouterLink>
<span
v-if="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">
{{ manga.description }}
</p>
</div>
<!-- Actions verticales -->
<div class="flex flex-col items-center justify-center gap-0.5 flex-shrink-0 self-stretch">
<button
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="Éditer"
@click.stop="openEdit(manga)">
<PencilIcon class="w-4 h-4" />
</button>
<button
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="Sources préférées"
@click.stop="openSources(manga)">
<Cog6ToothIcon class="w-4 h-4" />
</button>
<button
class="p-1.5 rounded-md transition-colors"
:class="refreshingId === manga.id
? 'text-blue-400 cursor-not-allowed'
: 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600'"
title="Rafraîchir"
:disabled="refreshingId === manga.id"
@click.stop="doRefresh(manga)">
<ArrowPathIcon
class="w-4 h-4"
:class="{ 'animate-spin': refreshingId === manga.id }" />
</button>
</div>
</div>
</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" />
</div>
</template>
<script setup>
import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
import { computed, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { useMangaEdit } from '../composables/useMangaEdit';
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
import { useMangaRefresh } from '../composables/useMangaRefresh';
import MangaEditModal from './MangaEditModal.vue';
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
const emit = defineEmits(['manga-click']);
const props = defineProps({
mangas: {
type: Array,
required: true
}
});
function formatDate(dateString) {
if (!dateString) return '';
try {
return new Date(dateString).toLocaleDateString();
} catch (e) {
return dateString;
}
}
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';
}
// ── Selected manga ────────────────────────────────────────
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>

View File

@@ -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>

View File

@@ -1,4 +1,5 @@
<template> <template>
<div class="overflow-y-auto h-full">
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
<!-- Barre de recherche --> <!-- Barre de recherche -->
<div class="mb-8"> <div class="mb-8">
@@ -29,10 +30,24 @@
</div> </div>
<!-- Résultats de recherche --> <!-- Résultats de recherche -->
<div class="max-w-full overflow-hidden"> <div v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700">
<MangaList v-if="searchResults.length > 0" :mangas="searchResults" @manga-click="openMangaModal" /> <div
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600 dark:text-gray-400">Aucun résultat trouvé</p> v-for="manga in searchResults"
:key="manga.externalId"
class="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors border-b border-gray-100 dark:border-gray-700 cursor-pointer"
@click="openMangaModal(manga)">
<img
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
alt=""
class="h-36 w-24 object-cover flex-shrink-0 self-start"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0">
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
</div>
</div>
</div> </div>
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600 dark:text-gray-400">Aucun résultat trouvé</p>
<!-- Modal de confirmation --> <!-- Modal de confirmation -->
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50"> <Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
@@ -79,6 +94,7 @@
</div> </div>
</Dialog> </Dialog>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
@@ -88,7 +104,6 @@ import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useMangaStore } from '../../application/store/mangaStore'; import { useMangaStore } from '../../application/store/mangaStore';
import MangaList from '../components/MangaList.vue';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();

View File

@@ -4,7 +4,7 @@
<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" />
<MangaList <MangaOverview
v-else-if="viewMode === 'list'" v-else-if="viewMode === 'list'"
:mangas="pagedItems" :mangas="pagedItems"
@manga-click="handleMangaClick" /> @manga-click="handleMangaClick" />
@@ -45,7 +45,7 @@ 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 MangaGrid from '../components/MangaGrid.vue'; import MangaGrid from '../components/MangaGrid.vue';
import MangaList from '../components/MangaList.vue'; import MangaOverview from '../components/MangaOverview.vue';
import MangaTable from '../components/MangaTable.vue'; import MangaTable from '../components/MangaTable.vue';
const router = useRouter(); const router = useRouter();

View File

@@ -64,6 +64,11 @@
@click="store.setDefaultView('list')"> @click="store.setDefaultView('list')">
{{ t('preferences.defaultView.list') }} {{ t('preferences.defaultView.list') }}
</button> </button>
<button
:class="viewButtonClass('table')"
@click="store.setDefaultView('table')">
{{ t('preferences.defaultView.table') }}
</button>
</div> </div>
</div> </div>
<!-- Mangas par page --> <!-- Mangas par page -->

View File

@@ -82,12 +82,6 @@ const routes = [
name: 'convert', name: 'convert',
component: ConversionPage component: ConversionPage
}, },
{
path: '/calendar',
name: 'calendar',
component: PlaceholderComponent,
props: { title: 'Calendrier' }
},
{ {
path: '/activity', path: '/activity',
name: 'activity', name: 'activity',

View File

@@ -15,20 +15,41 @@
<Bars3Icon class="h-6 w-6" /> <Bars3Icon class="h-6 w-6" />
</button> </button>
<div class="flex items-center flex-1"> <div class="flex items-center flex-1">
<router-link to="/" class="text-white text-2xl font-bold ml-4"> <router-link to="/" class="ml-4">
Mangarr <img src="/img/mangarr_logo.png" alt="Mangarr" class="h-10" />
</router-link> </router-link>
<SearchBar /> <SearchBar />
</div> </div>
<button
@click="toggleDarkMode"
class="mr-4 text-white p-2 hover:text-green-200 transition-colors"
:title="isDark ? 'Passer en mode clair' : 'Passer en mode sombre'"
>
<SunIcon v-if="isDark" class="h-6 w-6" />
<MoonIcon v-else class="h-6 w-6" />
</button>
</header> </header>
</template> </template>
<script setup> <script setup>
import { Bars3Icon } from '@heroicons/vue/24/outline'; import { computed } from 'vue';
import { Bars3Icon, SunIcon, MoonIcon } from '@heroicons/vue/24/outline';
import { useHeaderStore } from '../../stores/headerStore'; import { useHeaderStore } from '../../stores/headerStore';
import { useUserPreferencesStore } from '../../../domain/setting/application/store/userPreferencesStore';
import SearchBar from './SearchBar.vue'; import SearchBar from './SearchBar.vue';
const headerStore = useHeaderStore(); const headerStore = useHeaderStore();
const preferencesStore = useUserPreferencesStore();
const isDark = computed(() => {
if (preferencesStore.theme === 'dark') return true;
if (preferencesStore.theme === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
function toggleDarkMode() {
preferencesStore.setTheme(isDark.value ? 'light' : 'dark');
}
defineProps({ defineProps({
showMenuButton: { showMenuButton: {

View File

@@ -27,7 +27,6 @@
ArrowDownTrayIcon, ArrowDownTrayIcon,
ArrowsRightLeftIcon, ArrowsRightLeftIcon,
BookOpenIcon, BookOpenIcon,
CalendarIcon,
ClockIcon, ClockIcon,
Cog6ToothIcon, Cog6ToothIcon,
ComputerDesktopIcon, ComputerDesktopIcon,
@@ -69,12 +68,6 @@ import MenuGroup from './sidebar/MenuGroup.vue';
to: '/convert', to: '/convert',
id: 'convert' id: 'convert'
}, },
{
icon: CalendarIcon,
text: 'Calendrier',
to: '/calendar',
id: 'calendar'
},
{ {
icon: ClockIcon, icon: ClockIcon,
text: 'Activité', text: 'Activité',

View File

@@ -3,24 +3,25 @@
class="border-l-4" class="border-l-4"
:class="{ :class="{
'border-green-600': isActive, '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 <RouterLink
:to="to" :to="to"
class="flex-grow px-4 py-2 flex items-center" class="flex-grow px-4 py-2 flex items-center"
:class="{ :class="isActive
'text-green-600 bg-gray-800': isActive ? 'text-green-600 bg-gray-800'
}"> : 'hover:bg-gray-700 hover:text-white'">
<div class="flex items-center flex-grow"> <component :is="icon" class="w-5 h-5 mr-3" />
<component :is="icon" class="w-5 h-5 mr-3" /> <span class="px-2">{{ text }}</span>
<span class="px-2">{{ text }}</span>
</div>
<component
v-if="subItems.length > 0"
:is="expanded ? ChevronUpIcon : ChevronDownIcon"
class="w-4 h-4" />
</RouterLink> </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> </div>
<ul v-if="subItems.length > 0" class="ml-8 mt-2 space-y-4" v-show="expanded"> <ul v-if="subItems.length > 0" class="ml-8 mt-2 space-y-4" v-show="expanded">
@@ -71,14 +72,14 @@
const isActive = computed(() => { const isActive = computed(() => {
if (!props.to) { 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 === '/') { 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 => { const isRouteMatching = path => {

View File

@@ -1,9 +1,9 @@
<template> <template>
<li> <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 }} {{ text }}
</RouterLink> </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 }} {{ text }}
</button> </button>
</li> </li>

View File

@@ -27,7 +27,8 @@
"defaultView": { "defaultView": {
"label": "Default view", "label": "Default view",
"grid": "Grid", "grid": "Grid",
"list": "List" "list": "List",
"table": "Table"
}, },
"itemsPerPage": { "itemsPerPage": {
"label": "Mangas per page" "label": "Mangas per page"

View File

@@ -27,7 +27,8 @@
"defaultView": { "defaultView": {
"label": "Vue par défaut", "label": "Vue par défaut",
"grid": "Grille", "grid": "Grille",
"list": "Liste" "list": "Liste",
"table": "Tableau"
}, },
"itemsPerPage": { "itemsPerPage": {
"label": "Mangas par page" "label": "Mangas par page"

View File

@@ -24,11 +24,21 @@ readonly class GetMangaListHandler
$total = $this->mangaRepository->count(); $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( return new MangaListResponse(
mangas: $mangas, mangas: $mangas,
total: $total, total: $total,
page: $query->page, page: $query->page,
limit: $query->limit limit: $query->limit,
chapterCounts: $chapterCounts
); );
} }
} }

View File

@@ -8,7 +8,8 @@ readonly class MangaListResponse
public array $mangas, public array $mangas,
public int $total, public int $total,
public int $page, public int $page,
public int $limit public int $limit,
public array $chapterCounts = []
) { ) {
} }

View File

@@ -31,6 +31,7 @@ interface MangaRepositoryInterface
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array; public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
public function countChapters(string $mangaId): int; public function countChapters(string $mangaId): int;
public function countAvailableChapters(string $mangaId): int;
public function findChapterById(string $id): ?Chapter; public function findChapterById(string $id): ?Chapter;
public function findVisibleChapterById(string $id): ?Chapter; public function findVisibleChapterById(string $id): ?Chapter;
public function findChapterByMangaIdAndNumber(string $mangaId, float $chapterNumber): ?Chapter; public function findChapterByMangaIdAndNumber(string $mangaId, float $chapterNumber): ?Chapter;

View File

@@ -21,6 +21,9 @@ readonly class MangaListItem
public string $status, public string $status,
public ?float $rating, public ?float $rating,
public DateTimeImmutable $createdAt, public DateTimeImmutable $createdAt,
public bool $monitored = false,
public int $chaptersTotal = 0,
public int $chaptersScraped = 0,
) { ) {
} }
} }

View File

@@ -29,7 +29,10 @@ readonly class GetMangaListStateProvider implements ProviderInterface
return new MangaCollection( return new MangaCollection(
items: array_map( items: array_map(
fn (Manga $manga) => $this->createMangaListItem($manga), fn (Manga $manga) => $this->createMangaListItem(
$manga,
$response->chapterCounts[$manga->getId()->getValue()] ?? []
),
$response->mangas $response->mangas
), ),
total: $response->total, 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( return new MangaListItem(
id: $manga->getId()->getValue(), id: $manga->getId()->getValue(),
@@ -54,7 +57,10 @@ readonly class GetMangaListStateProvider implements ProviderInterface
genres: $manga->getGenres(), genres: $manga->getGenres(),
status: $manga->getStatus(), status: $manga->getStatus(),
rating: $manga->getRating(), rating: $manga->getRating(),
createdAt: $manga->getCreatedAt() createdAt: $manga->getCreatedAt(),
monitored: $manga->getMonitoringStatus()->isEnabled(),
chaptersTotal: $counts['total'] ?? 0,
chaptersScraped: $counts['scraped'] ?? 0,
); );
} }
} }

View File

@@ -196,6 +196,18 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
->getSingleScalarResult(); ->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 public function findByExternalId(ExternalId $externalId): ?DomainManga
{ {
$entity = $this->entityManager->getRepository(EntityManga::class)->findOneBy([ $entity = $this->entityManager->getRepository(EntityManga::class)->findOneBy([

View File

@@ -135,6 +135,14 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
return count($this->chapters[$mangaId] ?? []); 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 public function findChapterById(string $id): ?Chapter
{ {
return $this->chaptersById[$id] ?? null; return $this->chaptersById[$id] ?? null;