Compare commits
22 Commits
4da9742f7f
...
feat/disco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420c3922c2 | ||
|
|
814fe46ce5 | ||
|
|
65453c87e5 | ||
|
|
78897eda4a | ||
| 02ad36fb34 | |||
| 929a7d0d61 | |||
|
|
9f83f9c137 | ||
| 2cefea3f72 | |||
| 3e85167875 | |||
|
|
f72ae3cab9 | ||
| 2c7f97c8b7 | |||
|
|
1477106459 | ||
| 2243716800 | |||
| d8a47072da | |||
|
|
fb8f64ee59 | ||
| 23c1028ec6 | |||
|
|
aba8e36231 | ||
| c268b2c312 | |||
| c060e7b95e | |||
|
|
2e3abb76c3 | ||
| b40892b924 | |||
|
|
74f033f5d1 |
142
.claude/skills/task-workflow/SKILL.md
Normal file
142
.claude/skills/task-workflow/SKILL.md
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: task-workflow
|
||||
description: Workflow complet pour traiter une tâche du TASK.md — branche git, développement, tests, commit conventionnel, push, puis archivage dans DONE.md. Utiliser quand l'utilisateur veut implémenter une tâche listée dans TASK.md.
|
||||
allowed-tools: Read, Bash, Edit, Write, Glob, Grep
|
||||
---
|
||||
|
||||
# Workflow de traitement d'une tâche (TASK.md → DONE.md)
|
||||
|
||||
Quand l'utilisateur demande de traiter une tâche du `TASK.md`, suivre **dans l'ordre** les étapes ci-dessous.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Étape 0 — Repartir d'une branche saine depuis `origin/main`
|
||||
|
||||
**IMPORTANT : toujours commencer par cette étape, sans exception.**
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout main
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
Ensuite seulement créer la branche de travail (voir étape 2).
|
||||
|
||||
> Règle : ne jamais partir d'une branche de feature existante. Toujours tirer depuis `main` à jour.
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — Lire et choisir la tâche
|
||||
|
||||
1. Lire `TASK.md` pour identifier la tâche à traiter (si non précisée, demander laquelle).
|
||||
2. Extraire : le titre, les fichiers impactés, et la liste des sous-tâches.
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — Créer une branche git
|
||||
|
||||
Nommer la branche d'après le type et le titre de la tâche :
|
||||
|
||||
```
|
||||
<type>/<slug-de-la-tache>
|
||||
```
|
||||
|
||||
Exemples de types : `feat`, `fix`, `style`, `refactor`, `test`, `chore`
|
||||
|
||||
```bash
|
||||
git checkout -b style/simplifier-table-homepage
|
||||
```
|
||||
|
||||
Règle : **ne jamais committer directement sur `main`**.
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — Implémenter la tâche
|
||||
|
||||
- Lire tous les fichiers mentionnés dans la tâche avant de les modifier.
|
||||
- Cocher mentalement chaque sous-tâche `[ ]` au fur et à mesure.
|
||||
- Respecter les skills existants selon les fichiers touchés :
|
||||
- Composant Vue → skill `vue-frontend`
|
||||
- Domaine PHP → skills `ddd-core`, `hexagonal-arch`, `cqrs`, `api-platform`
|
||||
- Tests → skill `testing-strategy`
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — Vérifier que tous les tests passent
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
- Si des tests échouent, **corriger avant de continuer**.
|
||||
- Ne pas passer à l'étape suivante tant que la suite n'est pas verte.
|
||||
- Pour un test spécifique : `make test f="NomDeLaClasse"`
|
||||
|
||||
---
|
||||
|
||||
## Étape 5 — Commit conventionnel
|
||||
|
||||
Format Conventional Commits :
|
||||
|
||||
```
|
||||
<type>(<scope>): <description courte en français>
|
||||
|
||||
[corps optionnel : explication du pourquoi]
|
||||
```
|
||||
|
||||
**Types autorisés :** `feat`, `fix`, `style`, `refactor`, `test`, `chore`, `docs`
|
||||
|
||||
**Scope :** nom du domaine ou du composant impacté (ex: `manga-table`, `sidebar`, `homepage`)
|
||||
|
||||
Exemples :
|
||||
```
|
||||
style(manga-table): simplifier le wrapper card + hover vert sur le titre
|
||||
fix(sidebar): séparer toggle et navigation sur MenuGroup
|
||||
```
|
||||
|
||||
```bash
|
||||
git add <fichiers modifiés>
|
||||
git commit -m "style(manga-table): simplifier le wrapper card + hover vert sur le titre"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 6 — Push de la branche
|
||||
|
||||
**Demander confirmation à l'utilisateur avant de pusher.**
|
||||
|
||||
```bash
|
||||
git push -u origin <nom-de-la-branche>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 7 — Archiver la tâche dans DONE.md
|
||||
|
||||
1. Retirer le bloc de la tâche de `TASK.md` (section complète, du titre `##` jusqu'au `---` suivant).
|
||||
2. Ajouter la tâche dans `DONE.md` (créer le fichier s'il n'existe pas) avec la date et le sha du commit :
|
||||
|
||||
Format dans `DONE.md` :
|
||||
```markdown
|
||||
## [TYPE] Titre de la tâche — YYYY-MM-DD
|
||||
|
||||
> Branche : `<nom-de-la-branche>` | Commit : `<sha court>`
|
||||
|
||||
- [x] Sous-tâche 1
|
||||
- [x] Sous-tâche 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Résumé du flux
|
||||
|
||||
```
|
||||
fetch + checkout main + pull (branche saine)
|
||||
→ branche git depuis main
|
||||
→ TASK.md (choisir la tâche)
|
||||
→ implémentation
|
||||
→ make test (vert obligatoire)
|
||||
→ conventional commit
|
||||
→ push (après confirmation)
|
||||
→ DONE.md
|
||||
```
|
||||
223
.claude/skills/ui-style/SKILL.md
Normal file
223
.claude/skills/ui-style/SKILL.md
Normal file
@@ -0,0 +1,223 @@
|
||||
---
|
||||
name: ui-style
|
||||
description: Design system et harmonisation UI de Mangarr — layout de page canonique (Toolbar + flex + sections border-t), palette Tailwind, patterns composants (boutons, badges, upload, progression). Utiliser quand on crée ou modifie une page Vue ou un composant UI.
|
||||
allowed-tools: Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Design system Mangarr — Guide UI
|
||||
|
||||
Les pages de référence canoniques sont :
|
||||
- `assets/vue/app/domain/manga/infrastructure/presentation/pages/NewImportPage.vue`
|
||||
- `assets/vue/app/domain/conversion/infrastructure/presentation/pages/ConversionPage.vue`
|
||||
|
||||
En cas de doute, les lire pour vérifier le pattern en vigueur.
|
||||
|
||||
---
|
||||
|
||||
## 1. Layout de page canonique
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
Titre section
|
||||
</h2>
|
||||
<!-- contenu -->
|
||||
</section>
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<!-- section suivante -->
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Règles absolues :**
|
||||
- `flex flex-col h-full` toujours à la racine du template
|
||||
- `<Toolbar>` toujours en premier enfant direct de la racine
|
||||
- `overflow-y-auto flex-1` pour le contenu scrollable
|
||||
- `px-6 py-8` comme wrapper interne — **jamais** `container mx-auto`
|
||||
- Chaque bloc logique = une `<section>` avec `border-t border-gray-200 dark:border-gray-700`
|
||||
- **Jamais** de `<h1>` volant dans le contenu — le titre de page va dans `toolbarConfig.leftSection`
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration Toolbar
|
||||
|
||||
```javascript
|
||||
import { computed } from 'vue';
|
||||
import { SomeIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Titre de la page', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: SomeIcon,
|
||||
label: 'Action principale',
|
||||
onClick: handler,
|
||||
disabled: condition,
|
||||
},
|
||||
// Bouton conditionnel :
|
||||
...(showAction ? [{
|
||||
type: 'button',
|
||||
icon: OtherIcon,
|
||||
label: 'Action contextuelle',
|
||||
onClick: otherHandler,
|
||||
}] : []),
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
- Icônes : Heroicons v24/outline (`@heroicons/vue/24/outline`)
|
||||
- Boutons toolbar visibles uniquement si pertinents — utiliser le spread conditionnel
|
||||
- `rightSection` peut être vide `[]`
|
||||
|
||||
---
|
||||
|
||||
## 3. Headers de section
|
||||
|
||||
```vue
|
||||
<!-- Header simple -->
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
Titre
|
||||
</h2>
|
||||
|
||||
<!-- Header avec info contextuelle à droite -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
Titre
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">info contextuelle</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Palette de couleurs
|
||||
|
||||
| Usage | Classes Tailwind |
|
||||
|-------|-----------------|
|
||||
| Primaire (action principale) | `bg-green-600 hover:bg-green-700` |
|
||||
| Secondaire | `bg-blue-600 hover:bg-blue-700` |
|
||||
| Danger | `bg-red-600 hover:bg-red-700` |
|
||||
| Désactivé | `disabled:bg-gray-400 disabled:cursor-not-allowed` |
|
||||
| Texte principal | `text-gray-900 dark:text-gray-100` |
|
||||
| Texte secondaire | `text-gray-600 dark:text-gray-300` |
|
||||
| Texte subtil | `text-gray-500 dark:text-gray-400` |
|
||||
| Étiquette section | `text-gray-400 dark:text-gray-500` |
|
||||
| Fond carte / panel | `bg-white dark:bg-gray-800` |
|
||||
| Bordure | `border-gray-200 dark:border-gray-700` |
|
||||
| Séparateur de liste | `divide-y divide-gray-100 dark:divide-gray-700/50` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Boutons
|
||||
|
||||
```vue
|
||||
<!-- Bouton action principale (submit, lancer, confirmer) -->
|
||||
<button
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 rounded-md font-medium transition-colors"
|
||||
:disabled="condition"
|
||||
>
|
||||
Label
|
||||
</button>
|
||||
|
||||
<!-- Bouton ghost / discret -->
|
||||
<button class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||
Label
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Barre de progression
|
||||
|
||||
```vue
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-1.5 mb-4">
|
||||
<div
|
||||
class="bg-green-600 h-1.5 transition-all duration-300"
|
||||
:style="{ width: progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
> **Important :** toujours `bg-green-600`, jamais `bg-blue-600` pour les barres de progression.
|
||||
|
||||
---
|
||||
|
||||
## 7. Liste avec séparateurs
|
||||
|
||||
```vue
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="flex items-center justify-between py-3"
|
||||
>
|
||||
<!-- contenu de l'item -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Zone de drop / upload de fichier
|
||||
|
||||
```vue
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
|
||||
:class="isDragging
|
||||
? 'border-green-500 bg-green-50 dark:bg-green-900/10'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<SomeIcon class="mx-auto h-8 w-8 text-gray-400 mb-3" />
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Message principal
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Précision format/taille
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Pages non conformes à corriger
|
||||
|
||||
Les pages suivantes dévient encore du pattern canonique :
|
||||
|
||||
| Page | Chemin relatif | Déviations principales |
|
||||
|------|---------------|----------------------|
|
||||
| `HomePage.vue` | `domain/manga/.../pages/` | Pas de `px-6 py-8`, pas de sections `border-t` |
|
||||
| `AddManga.vue` | `domain/manga/.../pages/` | Pas de Toolbar, pas de `flex flex-col h-full` |
|
||||
| `ActivityPage.vue` | `domain/activity/.../pages/` | Pas de `flex flex-col`, pas de Toolbar intégré |
|
||||
| `UserPreferencesPage.vue` | `domain/setting/.../pages/` | `h1` volant, pas de Toolbar |
|
||||
| `ScrapperConfigurations.vue` | `domain/setting/.../pages/` | `h1` volant, `container mx-auto` |
|
||||
| `ScrapperEdit.vue` | `domain/setting/.../pages/` | `container mx-auto` au lieu de `px-6 py-8` |
|
||||
| `MangaDetails.vue` | `domain/manga/.../pages/` | Layout spécial (cover + chapitres), à traiter séparément |
|
||||
| `ChapterPage.vue` | `domain/reader/.../pages/` | Layout lecteur spécialisé — **exception justifiée**, ne pas modifier |
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist avant de livrer une page
|
||||
|
||||
- [ ] Racine : `flex flex-col h-full`
|
||||
- [ ] Premier enfant : `<Toolbar :config="toolbarConfig" />`
|
||||
- [ ] Contenu scrollable : `overflow-y-auto flex-1`
|
||||
- [ ] Wrapper interne : `px-6 py-8` (jamais `container mx-auto`)
|
||||
- [ ] Blocs logiques : `<section class="border-t border-gray-200 dark:border-gray-700 pt-6">`
|
||||
- [ ] Titre de page dans `toolbarConfig.leftSection`, pas de `<h1>` dans le contenu
|
||||
- [ ] Headers de section : classes `text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider`
|
||||
- [ ] Barres de progression : `bg-green-600` (pas `bg-blue-600`)
|
||||
- [ ] Dark mode : chaque couleur a sa variante `dark:`
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -39,3 +39,10 @@ yarn-error.log
|
||||
src/Controller/TestController.php
|
||||
.phpunit.cache/test-results
|
||||
/tests/Fixtures/pages/
|
||||
|
||||
# Claude Code — versionner les skills partagés, ignorer les fichiers perso
|
||||
!.claude/
|
||||
!.claude/skills/
|
||||
!.claude/skills/**
|
||||
.claude/settings.local.json
|
||||
.claude/projects/
|
||||
|
||||
41
TASK.md
41
TASK.md
@@ -75,6 +75,47 @@
|
||||
|
||||
---
|
||||
|
||||
## [Perf] Reader — Lazy-loading des pages (InfiniteReader)
|
||||
|
||||
**Problème :** `readerStore.js` charge toutes les pages avec `itemsPerPage=9999`. `InfiniteReader.vue` monte tous les composants `ReaderPage` simultanément dans le DOM. Sur un chapitre de 200 pages, cela représente 200 composants actifs et autant d'images pré-chargées.
|
||||
|
||||
- [ ] Implémenter un `IntersectionObserver` sur les wrappers de page pour ne charger les images qu'au moment où elles entrent dans le viewport (`loading="lazy"` ou src conditionnel)
|
||||
- [ ] Limiter le nombre de composants montés simultanément (virtualisation ou windowing) : ne rendre que les pages proches de la page courante (ex. fenêtre de ±3 pages)
|
||||
- [ ] Adapter `readerStore.js` : remplacer `itemsPerPage=9999` par la vraie pagination côté API si la virtualisation le justifie, sinon conserver le fetch unique mais différer le rendu
|
||||
- [ ] Vérifier que le mode `single` n'est pas impacté (il affiche déjà une seule page)
|
||||
|
||||
---
|
||||
|
||||
## [Bug] Reader — N+1 requêtes SQL dans `getChapterContext()`
|
||||
|
||||
**Problème :** `LegacyChapterRepository::getChapterContext()` émet 5 requêtes SQL pour un seul chargement : la requête principale + 2 doublons dans `getPreviousChapterId()` / `getNextChapterId()` (chacune re-fetche le chapitre courant) + les 2 requêtes de navigation.
|
||||
|
||||
- [ ] Refactorer `getPreviousChapterId()` et `getNextChapterId()` pour accepter l'entité `ChapterEntity` déjà chargée en paramètre (au lieu de re-fetcher par ID)
|
||||
- [ ] Appeler ces méthodes depuis `getChapterContext()` en passant l'entité déjà disponible
|
||||
- [ ] Résultat attendu : 3 requêtes maximum (1 pour le chapitre courant + 1 prev + 1 next), idéalement 1 seule avec une requête SQL combinée
|
||||
|
||||
---
|
||||
|
||||
## [Bug] Reader — Division par zéro dans `ChapterPagesResponse::getTotalPages()`
|
||||
|
||||
**Problème :** `ceil($totalItems / $itemsPerPage)` crashe si `itemsPerPage = 0`. Le test existant documente le bug avec un TODO et assert un HTTP 500 au lieu de corriger.
|
||||
|
||||
- [ ] Ajouter une validation dans `ChapterPagesProvider` : rejeter la requête avec HTTP 400 si `itemsPerPage <= 0`
|
||||
- [ ] Corriger le test `GetChapterPagesTest` pour vérifier HTTP 400 (et non 500)
|
||||
- [ ] Supprimer le commentaire TODO du test une fois corrigé
|
||||
|
||||
---
|
||||
|
||||
## [Bug] Reader — `totalPages` toujours égal à 0 dans `ChapterContext`
|
||||
|
||||
**Problème :** `LegacyChapterRepository::getChapterContext()` hardcode `totalPages: 0`. La méthode `getTotalPagesForChapter()` existe mais n'est jamais appelée depuis `GetChapterContextHandler`.
|
||||
|
||||
- [ ] Appeler `getTotalPagesForChapter()` dans `getChapterContext()` (ou dans le handler) pour calculer le vrai nombre de pages
|
||||
- [ ] Vérifier que la valeur est correctement sérialisée dans la réponse API Platform (`ChapterContextResponse`)
|
||||
- [ ] Adapter les tests existants qui pourraient asserter `totalPages: 0`
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -1,84 +1,77 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<FileUploadArea
|
||||
:selected-file="conversionStore.currentFile"
|
||||
:disabled="conversionStore.isProcessing"
|
||||
@file-selected="handleFileSelected"
|
||||
@file-cleared="handleFileClear"
|
||||
/>
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="mt-6 flex justify-center">
|
||||
<button
|
||||
@click="handleConvert"
|
||||
:disabled="conversionStore.isProcessing"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-6 py-3 text-white font-medium rounded-lg transition-colors',
|
||||
conversionStore.isProcessing
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
]"
|
||||
>
|
||||
<ArrowPathIcon :class="['w-5 h-5', conversionStore.isProcessing && 'animate-spin']" />
|
||||
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Zone d'upload -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichier</h2>
|
||||
<FileUploadArea
|
||||
:selected-file="conversionStore.currentFile"
|
||||
:disabled="conversionStore.isProcessing"
|
||||
@file-selected="handleFileSelected"
|
||||
@file-cleared="handleFileClear"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ConversionProgress
|
||||
v-if="showProgress"
|
||||
class="mt-6"
|
||||
:is-converting="conversionStore.isProcessing"
|
||||
:progress="conversionStore.conversionProgress"
|
||||
:is-success="conversionStore.hasSucceeded"
|
||||
:has-error="conversionStore.hasError"
|
||||
:error-message="conversionStore.conversionError"
|
||||
:file-name="conversionStore.currentFileName"
|
||||
:original-size="conversionStore.currentFile?.size || 0"
|
||||
:converted-size="conversionStore.convertedFile?.size || 0"
|
||||
@download="handleDownload"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
<!-- Progression -->
|
||||
<section v-if="showProgress" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<ConversionProgress
|
||||
:is-converting="conversionStore.isProcessing"
|
||||
:progress="conversionStore.conversionProgress"
|
||||
:is-success="conversionStore.hasSucceeded"
|
||||
:has-error="conversionStore.hasError"
|
||||
:error-message="conversionStore.conversionError"
|
||||
:file-name="conversionStore.currentFileName"
|
||||
:original-size="conversionStore.currentFile?.size || 0"
|
||||
:converted-size="conversionStore.convertedFile?.size || 0"
|
||||
@download="handleDownload"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Historique -->
|
||||
<section v-if="conversionStore.conversionCount > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Historique</h2>
|
||||
<button
|
||||
@click="conversionStore.clearHistory()"
|
||||
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="(conversion, index) in conversionStore.conversionHistory"
|
||||
:key="index"
|
||||
class="flex items-center justify-between py-3"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{{ conversion.originalName }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(conversion.timestamp) }}</p>
|
||||
</div>
|
||||
<div class="text-right text-sm">
|
||||
<p class="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>
|
||||
</section>
|
||||
|
||||
<div v-if="conversionStore.conversionCount > 0" class="mt-8">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Historique</h3>
|
||||
<button
|
||||
@click="conversionStore.clearHistory()"
|
||||
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div
|
||||
v-for="(conversion, index) in conversionStore.conversionHistory"
|
||||
:key="index"
|
||||
class="flex items-center justify-between py-3"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{{ conversion.originalName }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(conversion.timestamp) }}</p>
|
||||
</div>
|
||||
<div class="text-right text-sm">
|
||||
<p class="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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useConversionStore } from '../../application/store/conversionStore';
|
||||
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||
import ConversionProgress from '../components/ConversionProgress.vue';
|
||||
@@ -88,53 +81,68 @@ const conversionStore = useConversionStore();
|
||||
const { showSuccess, showError } = useNotifications();
|
||||
|
||||
const showProgress = computed(() =>
|
||||
conversionStore.hasSelectedFile &&
|
||||
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
|
||||
conversionStore.hasSelectedFile &&
|
||||
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
|
||||
);
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Conversion CBR → CBZ', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
...(conversionStore.hasSelectedFile && !conversionStore.hasSucceeded ? [{
|
||||
type: 'button',
|
||||
icon: ArrowPathIcon,
|
||||
label: conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ',
|
||||
onClick: handleConvert,
|
||||
disabled: conversionStore.isProcessing,
|
||||
}] : []),
|
||||
],
|
||||
}));
|
||||
|
||||
const handleFileSelected = (file) => {
|
||||
conversionStore.selectFile(file);
|
||||
conversionStore.selectFile(file);
|
||||
};
|
||||
|
||||
const handleFileClear = () => {
|
||||
conversionStore.resetConversion();
|
||||
conversionStore.resetConversion();
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
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]}`;
|
||||
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));
|
||||
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%';
|
||||
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());
|
||||
|
||||
@@ -1,228 +1,150 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- File Icon and Info -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
|
||||
<!-- File Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ file.filename }}
|
||||
</h3>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ file.getFormattedSize() }} • {{ file.getFileExtension().toUpperCase() }}
|
||||
</p>
|
||||
|
||||
<!-- Extracted Info -->
|
||||
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
|
||||
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
||||
Chapitre {{ file.getExtractedChapterNumber() }}
|
||||
</span>
|
||||
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
||||
Volume {{ file.getExtractedVolumeNumber() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">Erreur</h3>
|
||||
<div class="mt-2 text-sm text-red-700 dark:text-red-400">{{ file.errorMessage }}</div>
|
||||
<!-- Row principal : icône, nom, statut, actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||
<DocumentIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ file.getFormattedSize() }} · {{ file.getFileExtension().toUpperCase() }}
|
||||
<span v-if="file.isAnalyzed() && file.getExtractedChapterNumber()" class="ml-2 text-green-600 dark:text-green-400">
|
||||
Ch. {{ file.getExtractedChapterNumber() }}
|
||||
</span>
|
||||
<span v-if="file.isAnalyzed() && file.getExtractedVolumeNumber()" class="ml-2 text-green-600 dark:text-green-400">
|
||||
Vol. {{ file.getExtractedVolumeNumber() }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
||||
|
||||
<button
|
||||
v-if="file.isReadyForImport()"
|
||||
@click="$emit('import-file')"
|
||||
:disabled="isImporting"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
<ArrowUpTrayIcon class="w-3.5 h-3.5" />
|
||||
Importer
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="file.hasError()"
|
||||
@click="$emit('retry-file')"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="$emit('remove-file')"
|
||||
class="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manga Selection -->
|
||||
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
|
||||
</label>
|
||||
<!-- Message d'erreur -->
|
||||
<div v-if="file.hasError()" class="mt-2 flex items-start gap-2 text-xs text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-3 py-2">
|
||||
<ExclamationCircleIcon class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
{{ file.errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Matches Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<MangaMatchCard
|
||||
v-for="match in sortedMatches"
|
||||
:key="match.id"
|
||||
:match="match"
|
||||
:is-selected="file.selectedManga?.id === match.id"
|
||||
@select-match="handleMangaSelection"
|
||||
/>
|
||||
<!-- Aucun manga trouvé -->
|
||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-2 flex items-start gap-2 text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-3 py-2">
|
||||
<ExclamationTriangleIcon class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
Aucun manga correspondant trouvé. Vérifiez le nom du fichier.
|
||||
</div>
|
||||
|
||||
<!-- Sélection du manga -->
|
||||
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-3 space-y-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{{ file.getMatches().length }} correspondance(s)
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||
<MangaMatchCard
|
||||
v-for="match in sortedMatches"
|
||||
:key="match.id"
|
||||
:match="match"
|
||||
:is-selected="file.selectedManga?.id === match.id"
|
||||
@select-match="handleMangaSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Manga Preview -->
|
||||
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
|
||||
<img
|
||||
v-if="file.selectedManga.thumbnailUrl"
|
||||
:src="file.selectedManga.thumbnailUrl"
|
||||
:alt="file.selectedManga.title"
|
||||
class="w-12 h-16 object-cover rounded"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ file.selectedManga.title }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ file.selectedManga.slug }}</p>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter/Volume Number Inputs -->
|
||||
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
|
||||
<!-- Chapter Number -->
|
||||
<!-- Numéros de chapitre / volume -->
|
||||
<div v-if="file.selectedManga" class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Numéro de chapitre
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedChapterNumber ?? ''"
|
||||
@input="handleChapterNumberInput"
|
||||
:disabled="file.selectedVolumeNumber !== null"
|
||||
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Chapitre</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedChapterNumber ?? ''"
|
||||
@input="handleChapterNumberInput"
|
||||
:disabled="file.selectedVolumeNumber !== null"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:text-gray-400"
|
||||
placeholder="Ex: 1, 1.5..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Volume Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Numéro de volume
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedVolumeNumber ?? ''"
|
||||
@input="handleVolumeNumberInput"
|
||||
:disabled="file.selectedChapterNumber !== null"
|
||||
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Volume</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedVolumeNumber ?? ''"
|
||||
@input="handleVolumeNumberInput"
|
||||
:disabled="file.selectedChapterNumber !== null"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:text-gray-400"
|
||||
placeholder="Ex: 1, 1.5..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Matches Message -->
|
||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Aucun manga trouvé</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex justify-between items-center border-t dark:border-gray-700 pt-4">
|
||||
<div class="flex space-x-3">
|
||||
<!-- Import Button -->
|
||||
<button
|
||||
v-if="file.isReadyForImport()"
|
||||
@click="$emit('import-file')"
|
||||
:disabled="isImporting"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md text-sm font-medium flex items-center"
|
||||
>
|
||||
<svg v-if="isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ isImporting ? 'Import en cours...' : 'Importer' }}
|
||||
</button>
|
||||
|
||||
<!-- Retry Button -->
|
||||
<button
|
||||
v-if="file.hasError()"
|
||||
@click="$emit('retry-file')"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
@click="$emit('remove-file')"
|
||||
class="text-red-600 hover:text-red-700 text-sm font-medium"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowUpTrayIcon, DocumentIcon, ExclamationCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed } from 'vue';
|
||||
import MangaMatchCard from './MangaMatchCard.vue';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isAnalyzing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isImporting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
file: { type: Object, required: true },
|
||||
isAnalyzing: { type: Boolean, default: false },
|
||||
isImporting: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'manga-selected',
|
||||
'chapter-number-selected',
|
||||
'volume-number-selected',
|
||||
'import-file',
|
||||
'retry-file',
|
||||
'remove-file'
|
||||
'manga-selected',
|
||||
'chapter-number-selected',
|
||||
'volume-number-selected',
|
||||
'import-file',
|
||||
'retry-file',
|
||||
'remove-file',
|
||||
]);
|
||||
|
||||
// Computed property to get sorted matches
|
||||
const sortedMatches = computed(() => {
|
||||
const matches = props.file.getMatches();
|
||||
return matches.sort((a, b) => b.matchScore - a.matchScore);
|
||||
});
|
||||
const sortedMatches = computed(() =>
|
||||
[...props.file.getMatches()].sort((a, b) => b.matchScore - a.matchScore)
|
||||
);
|
||||
|
||||
const handleMangaSelection = (selectedManga) => {
|
||||
emit('manga-selected', selectedManga);
|
||||
};
|
||||
const handleMangaSelection = (manga) => emit('manga-selected', manga);
|
||||
|
||||
const handleChapterNumberInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const chapterNumber = value ? parseFloat(value) : null;
|
||||
emit('chapter-number-selected', chapterNumber);
|
||||
const value = event.target.value;
|
||||
emit('chapter-number-selected', value ? parseFloat(value) : null);
|
||||
};
|
||||
|
||||
const handleVolumeNumberInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const volumeNumber = value ? parseFloat(value) : null;
|
||||
emit('volume-number-selected', volumeNumber);
|
||||
const value = event.target.value;
|
||||
emit('volume-number-selected', value ? parseFloat(value) : null);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,96 +1,94 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6">
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/40 mb-4">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<!-- En-tête -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center h-9 w-9 bg-green-100 dark:bg-green-900/40">
|
||||
<CheckCircleIcon class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">Import terminé</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Voici le résumé de votre session d'import</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-6 text-center">
|
||||
<div>
|
||||
<div class="text-xl font-bold text-green-600">{{ importedCount }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Importés</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-bold text-red-600">{{ errorCount }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Erreurs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-bold text-gray-600 dark:text-gray-300">{{ totalCount }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Import terminé</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Voici le résumé de votre session d'import
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Importés</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
|
||||
<div class="text-sm text-gray-500">Erreurs</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-600">{{ totalCount }}</div>
|
||||
<div class="text-sm text-gray-500">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Files List -->
|
||||
<div v-if="importedFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Fichiers importés avec succès ({{ importedFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
<!-- Fichiers importés -->
|
||||
<section v-if="importedFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||
Importés ({{ importedFiles.length }})
|
||||
</h2>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="file in importedFiles"
|
||||
:key="file.id"
|
||||
class="flex items-center text-sm"
|
||||
class="flex items-center gap-2 py-2.5 text-sm"
|
||||
>
|
||||
<svg class="flex-shrink-0 h-4 w-4 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-gray-900 dark:text-gray-100">{{ file.filename }}</span>
|
||||
<span v-if="file.selectedManga" class="ml-2 text-gray-500 dark:text-gray-400">
|
||||
→ {{ file.selectedManga.title }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<CheckCircleIcon class="flex-shrink-0 h-4 w-4 text-green-400" />
|
||||
<span class="text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</span>
|
||||
<span v-if="file.selectedManga" class="text-gray-400 dark:text-gray-500 shrink-0">→ {{ file.selectedManga.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Error Files List -->
|
||||
<div v-if="errorFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Fichiers en erreur ({{ errorFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
<!-- Fichiers en erreur -->
|
||||
<section v-if="errorFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||
Erreurs ({{ errorFiles.length }})
|
||||
</h2>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="file in errorFiles"
|
||||
:key="file.id"
|
||||
class="flex items-start text-sm"
|
||||
class="flex items-start gap-2 py-2.5 text-sm"
|
||||
>
|
||||
<svg class="flex-shrink-0 h-4 w-4 text-red-400 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<XCircleIcon class="flex-shrink-0 h-4 w-4 text-red-400 mt-0.5" />
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div>
|
||||
<div class="text-red-600 dark:text-red-400 text-xs mt-1">{{ file.errorMessage }}</div>
|
||||
<div class="text-red-600 dark:text-red-400 text-xs mt-0.5">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-center space-x-4 pt-6 border-t dark:border-gray-700">
|
||||
<button
|
||||
@click="startNewImport"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Nouvel import
|
||||
</button>
|
||||
<button
|
||||
@click="goToLibrary"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Aller à la bibliothèque
|
||||
</button>
|
||||
</div>
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="startNewImport"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Nouvel import
|
||||
</button>
|
||||
<button
|
||||
@click="goToLibrary"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Aller à la bibliothèque
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/solid';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||
|
||||
@@ -1,116 +1,47 @@
|
||||
<template>
|
||||
<div
|
||||
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md"
|
||||
:class="{
|
||||
'border-blue-500 bg-blue-50 dark:bg-blue-900/20': isSelected,
|
||||
'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-500': !isSelected
|
||||
}"
|
||||
@click="$emit('select-match', match)"
|
||||
>
|
||||
<!-- Match Header with Score -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="{
|
||||
'bg-blue-500': isSelected,
|
||||
'bg-gray-300': !isSelected
|
||||
}"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Score: {{ match.matchScore }}</span>
|
||||
</div>
|
||||
<div v-if="isSelected" class="text-blue-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manga Thumbnail -->
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
v-if="match.thumbnailUrl"
|
||||
:src="match.thumbnailUrl"
|
||||
:alt="match.title"
|
||||
class="w-16 h-20 object-cover rounded border"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-16 h-20 bg-gray-200 dark:bg-gray-700 rounded border dark:border-gray-600 flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manga Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" :title="match.title">
|
||||
{{ match.title }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate" :title="match.slug">
|
||||
{{ match.slug }}
|
||||
</p>
|
||||
|
||||
<!-- Alternative Slugs -->
|
||||
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">Autres titres:</p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span
|
||||
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
|
||||
:key="altSlug"
|
||||
class="text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded"
|
||||
<div
|
||||
class="border p-2.5 cursor-pointer transition-all duration-150"
|
||||
:class="isSelected
|
||||
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800'"
|
||||
@click="$emit('select-match', match)"
|
||||
>
|
||||
<div class="flex gap-2.5">
|
||||
<!-- Couverture -->
|
||||
<img
|
||||
v-if="match.thumbnailUrl"
|
||||
:src="match.thumbnailUrl"
|
||||
:alt="match.title"
|
||||
class="w-12 h-16 object-cover shrink-0"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-12 h-16 bg-gray-100 dark:bg-gray-700 shrink-0 flex items-center justify-center"
|
||||
>
|
||||
{{ altSlug }}
|
||||
</span>
|
||||
<span
|
||||
v-if="match.alternativeSlugs.length > 2"
|
||||
class="text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
+{{ match.alternativeSlugs.length - 2 }} autres
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PhotoIcon class="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Score Bar -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<span>Correspondance</span>
|
||||
<span>{{ match.matchScore }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
'bg-blue-500': isSelected,
|
||||
'bg-gray-400': !isSelected
|
||||
}"
|
||||
:style="{ width: match.matchScore + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Infos -->
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-between py-0.5">
|
||||
<p class="text-xs font-medium text-gray-900 dark:text-gray-100 line-clamp-3 leading-snug" :title="match.title">
|
||||
{{ match.title }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ match.matchScore }}%</span>
|
||||
<CheckCircleIcon v-if="isSelected" class="w-4 h-4 text-green-500 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckCircleIcon, PhotoIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const props = defineProps({
|
||||
match: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
match: { type: Object, required: true },
|
||||
isSelected: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-match']);
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -46,10 +46,10 @@ const badgeText = computed(() => {
|
||||
});
|
||||
|
||||
const badgeClasses = computed(() => {
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 text-xs font-medium';
|
||||
|
||||
if (props.isImporting || props.isAnalyzing) {
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300`;
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
}
|
||||
|
||||
switch (props.status) {
|
||||
@@ -58,7 +58,7 @@ const badgeClasses = computed(() => {
|
||||
case 'analyzed':
|
||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
|
||||
case 'importing':
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300`;
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
case 'imported':
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
case 'error':
|
||||
|
||||
@@ -1,115 +1,103 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">Import de Bibliothèque</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<!-- Zone de dépôt -->
|
||||
<section v-if="!store.hasFiles" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichiers</h2>
|
||||
<FileUpload
|
||||
label="Importer des fichiers CBZ/CBR"
|
||||
accept=".cbz,.cbr"
|
||||
:multiple="true"
|
||||
description="Formats CBZ ou CBR uniquement"
|
||||
@files-selected="store.addFiles($event)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Fichiers en cours -->
|
||||
<template v-if="store.hasFiles && !store.allFilesProcessed">
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
{{ store.totalFiles }} fichier(s)
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ store.importedCount }}/{{ store.totalFiles }}
|
||||
<span v-if="store.errorCount > 0" class="text-red-500 ml-1">· {{ store.errorCount }} erreur(s)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-1.5 mb-4">
|
||||
<div
|
||||
class="bg-green-600 h-1.5 transition-all duration-300"
|
||||
:style="{ width: store.progressPercentage + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<FileImportCard
|
||||
v-for="file in store.files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:is-analyzing="store.analyzingFiles.has(file.id)"
|
||||
:is-importing="store.importingFiles.has(file.id)"
|
||||
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
|
||||
@chapter-number-selected="(n) => store.setFileChapterNumber(file.id, n)"
|
||||
@volume-number-selected="(n) => store.setFileVolumeNumber(file.id, n)"
|
||||
@import-file="() => importSingleFile(file.id)"
|
||||
@retry-file="() => retryFile(file.id)"
|
||||
@remove-file="() => store.removeFile(file.id)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- Résultats -->
|
||||
<ImportResults v-if="store.allFilesProcessed" />
|
||||
|
||||
<!-- Progress Bar (if files are being processed) -->
|
||||
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Progression</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ store.progressPercentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: store.progressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
<span>{{ store.importedCount }} importés</span>
|
||||
<span>{{ store.errorCount }} erreurs</span>
|
||||
<span>{{ store.totalFiles }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Zone -->
|
||||
<div v-if="!store.hasFiles || store.allFilesProcessed" class="mb-8">
|
||||
<FileUpload
|
||||
label="Importer des fichiers CBZ/CBR"
|
||||
accept=".cbz,.cbr"
|
||||
:multiple="true"
|
||||
description="Formats CBZ ou CBR uniquement"
|
||||
@files-selected="handleFilesSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Files List -->
|
||||
<div v-if="store.hasFiles" class="space-y-6">
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
<button
|
||||
v-if="store.hasReadyFiles"
|
||||
@click="importAllFiles"
|
||||
:disabled="store.isLoading"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
<LoadingSpinner v-if="store.isLoading" class="w-4 h-4 mr-2" />
|
||||
Importer tous les fichiers prêts ({{ store.readyCount }})
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="store.analyzedFiles.length > 0"
|
||||
@click="autoSelectMatches"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
Sélection automatique
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="clearAllFiles"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
Effacer tout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Files Grid -->
|
||||
<div class="grid gap-6">
|
||||
<FileImportCard
|
||||
v-for="file in store.files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:is-analyzing="store.analyzingFiles.has(file.id)"
|
||||
:is-importing="store.importingFiles.has(file.id)"
|
||||
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
|
||||
@chapter-number-selected="(chapterNumber) => store.setFileChapterNumber(file.id, chapterNumber)"
|
||||
@volume-number-selected="(volumeNumber) => store.setFileVolumeNumber(file.id, volumeNumber)"
|
||||
@import-file="() => importSingleFile(file.id)"
|
||||
@retry-file="() => retryFile(file.id)"
|
||||
@remove-file="() => store.removeFile(file.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Summary (when all files are processed) -->
|
||||
<div v-if="store.allFilesProcessed" class="mt-8">
|
||||
<ImportResults />
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onUnmounted } from 'vue';
|
||||
import { ArrowUpTrayIcon, SparklesIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, onUnmounted } from 'vue';
|
||||
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
|
||||
import LoadingSpinner from '../../../../shared/components/ui/LoadingSpinner.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||
import FileImportCard from '../components/FileImportCard.vue';
|
||||
import ImportResults from '../components/ImportResults.vue';
|
||||
|
||||
const store = useNewImportStore();
|
||||
|
||||
// === EVENT HANDLERS ===
|
||||
|
||||
const handleFilesSelected = (files) => {
|
||||
store.addFiles(files);
|
||||
};
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Import de bibliothèque', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
...(store.analyzedFiles.length > 0 ? [{
|
||||
type: 'button',
|
||||
icon: SparklesIcon,
|
||||
label: 'Sélection auto',
|
||||
onClick: () => store.autoSelectBestMatches(),
|
||||
}] : []),
|
||||
...(store.hasReadyFiles ? [{
|
||||
type: 'button',
|
||||
icon: ArrowUpTrayIcon,
|
||||
label: `Importer (${store.readyCount})`,
|
||||
onClick: importAllFiles,
|
||||
disabled: store.isLoading,
|
||||
}] : []),
|
||||
{
|
||||
type: 'button',
|
||||
icon: TrashIcon,
|
||||
label: 'Effacer',
|
||||
onClick: () => store.clearFiles(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const importAllFiles = async () => {
|
||||
try {
|
||||
@@ -135,19 +123,6 @@ const retryFile = async (fileId) => {
|
||||
}
|
||||
};
|
||||
|
||||
const autoSelectMatches = () => {
|
||||
store.autoSelectBestMatches();
|
||||
};
|
||||
|
||||
const clearAllFiles = () => {
|
||||
if (confirm('Êtes-vous sûr de vouloir effacer tous les fichiers ?')) {
|
||||
store.clearFiles();
|
||||
}
|
||||
};
|
||||
|
||||
// === LIFECYCLE ===
|
||||
|
||||
// Reset state when component unmounts
|
||||
onUnmounted(() => {
|
||||
store.resetGlobalState();
|
||||
});
|
||||
|
||||
@@ -40,7 +40,12 @@ export const useMangaStore = defineStore('manga', {
|
||||
|
||||
// --- Add Manga State ---
|
||||
addingManga: false,
|
||||
addMangaError: null
|
||||
addMangaError: null,
|
||||
|
||||
// --- Discover State ---
|
||||
discoverResults: [],
|
||||
loadingDiscover: false,
|
||||
discoverError: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -170,6 +175,25 @@ export const useMangaStore = defineStore('manga', {
|
||||
this.loadingSearch = false;
|
||||
},
|
||||
|
||||
// --- Discover Actions ---
|
||||
async loadDiscoverRecommendations() {
|
||||
if (this.loadingDiscover) return;
|
||||
|
||||
this.loadingDiscover = true;
|
||||
this.discoverError = null;
|
||||
this.discoverResults = [];
|
||||
|
||||
try {
|
||||
const data = await mangaRepository.discoverManga();
|
||||
this.discoverResults = data.items || [];
|
||||
} catch (error) {
|
||||
this.discoverError = error.message;
|
||||
throw error;
|
||||
} finally {
|
||||
this.loadingDiscover = false;
|
||||
}
|
||||
},
|
||||
|
||||
// --- Add Manga Actions ---
|
||||
async createFromMangaDex(externalId) {
|
||||
if (this.addingManga) return;
|
||||
|
||||
@@ -104,6 +104,17 @@ export class ApiMangaRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async discoverManga() {
|
||||
try {
|
||||
const response = await fetch('/api/manga-discover');
|
||||
if (!response.ok) throw new Error('Failed to fetch discover recommendations');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createFromMangaDex(externalId) {
|
||||
try {
|
||||
const response = await fetch('/api/mangas/create-from-mangadex', {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20">
|
||||
<td class="px-4 py-2 text-gray-900 dark:text-gray-100" :class="{ 'text-green-500 dark:text-green-400': chapter.isAvailable }">
|
||||
{{ String(chapter.number).padStart(2, '0') }}
|
||||
<template v-if="chapter.isVolumeGroup">Vol. {{ chapter.volume }}</template>
|
||||
<template v-else>{{ String(chapter.number).padStart(2, '0') }}</template>
|
||||
</td>
|
||||
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
|
||||
<router-link
|
||||
@@ -13,9 +14,17 @@
|
||||
chapterId: chapter.id
|
||||
}
|
||||
}">
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
<template v-if="chapter.isVolumeGroup">
|
||||
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
|
||||
</template>
|
||||
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||
</router-link>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">{{ chapter.title || 'Sans titre' }}</span>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||
<template v-if="chapter.isVolumeGroup">
|
||||
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
|
||||
</template>
|
||||
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 flex justify-end gap-2">
|
||||
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">
|
||||
|
||||
@@ -1,172 +1,234 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto h-full">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Barre de recherche -->
|
||||
<div class="mb-8">
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="performSearch"
|
||||
placeholder="Rechercher un manga..."
|
||||
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
|
||||
<button
|
||||
@click="performSearch"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<!-- État de chargement -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-400">Recherche en cours...</p>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<div v-if="error" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded relative mb-6">
|
||||
{{ error }}
|
||||
</div>
|
||||
<!-- Recherche -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Recherche</h2>
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="performSearch"
|
||||
placeholder="Rechercher un manga..."
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
|
||||
</section>
|
||||
|
||||
<!-- Résultats de recherche -->
|
||||
<div v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div
|
||||
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>
|
||||
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600 dark:text-gray-400">Aucun résultat trouvé</p>
|
||||
<!-- État de chargement -->
|
||||
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
|
||||
<span class="text-sm">Recherche en cours...</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal de confirmation -->
|
||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" aria-hidden="true" />
|
||||
<!-- Message d'erreur -->
|
||||
<section v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</section>
|
||||
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6">
|
||||
<DialogTitle class="text-lg mb-4 text-gray-900 dark:text-gray-100"> Ajouter à la bibliothèque </DialogTitle>
|
||||
|
||||
<div v-if="selectedManga">
|
||||
<div class="flex gap-4">
|
||||
<!-- Résultats -->
|
||||
<section v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Résultats</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ searchResults.length }} manga(s)</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="manga in searchResults"
|
||||
:key="manga.externalId"
|
||||
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
|
||||
@click="openMangaModal(manga)">
|
||||
<img
|
||||
:src="selectedManga.imageUrl || '/placeholder-cover.png'"
|
||||
:alt="selectedManga.title"
|
||||
class="h-48 w-32 object-cover" />
|
||||
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||
alt=""
|
||||
class="h-36 w-24 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-lg text-gray-900 dark:text-gray-100">{{ selectedManga.title }}</h4>
|
||||
<p class="mt-2 text-gray-700 dark:text-gray-300">
|
||||
{{ truncatedDescription }}
|
||||
</p>
|
||||
<p class="text-sm 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>
|
||||
</section>
|
||||
|
||||
<!-- Aucun résultat -->
|
||||
<section v-else-if="hasSearched && !loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Aucun résultat trouvé</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de détail -->
|
||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
|
||||
<!-- En-tête avec couverture -->
|
||||
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<img
|
||||
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
|
||||
:alt="selectedManga.title"
|
||||
class="h-64 w-44 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
|
||||
{{ selectedManga.title }}
|
||||
</DialogTitle>
|
||||
<div class="mt-3 space-y-1.5">
|
||||
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Publication</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Statut</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Note</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
|
||||
<span
|
||||
v-for="genre in selectedManga.genres"
|
||||
:key="genre"
|
||||
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{{ genre }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<!-- Description -->
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{{ selectedManga.description }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 dark:bg-gray-800">
|
||||
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="addManga"
|
||||
:disabled="adding"
|
||||
class="px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center">
|
||||
<span v-if="adding" class="mr-2">
|
||||
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
||||
</span>
|
||||
{{ adding ? 'Ajout en cours...' : 'Ajouter' }}
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
|
||||
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
|
||||
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/solid';
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||
import { ArrowPathIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const mangaStore = useMangaStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const mangaStore = useMangaStore();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const isModalOpen = ref(false);
|
||||
const selectedManga = ref(null);
|
||||
const searchQuery = ref('');
|
||||
const hasSearched = ref(false);
|
||||
const isModalOpen = ref(false);
|
||||
const selectedManga = ref(null);
|
||||
|
||||
// Récupération des états du store
|
||||
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||
|
||||
const truncatedDescription = computed(() => {
|
||||
if (!selectedManga.value?.description) return '';
|
||||
return selectedManga.value.description.length > 500
|
||||
? selectedManga.value.description.slice(0, 500) + '...'
|
||||
: selectedManga.value.description;
|
||||
});
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Ajouter un manga', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: MagnifyingGlassIcon,
|
||||
label: 'Rechercher',
|
||||
onClick: performSearch,
|
||||
disabled: !searchQuery.value.trim() || loading.value,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Effectuer la recherche au chargement si un paramètre q est présent
|
||||
onMounted(() => {
|
||||
const queryParam = route.query.q;
|
||||
if (queryParam) {
|
||||
searchQuery.value = queryParam;
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
let debounceTimer = null;
|
||||
watch(searchQuery, newVal => {
|
||||
clearTimeout(debounceTimer);
|
||||
if (newVal.trim().length > 3) {
|
||||
debounceTimer = setTimeout(performSearch, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Nettoyer la recherche et les résultats lors du démontage du composant
|
||||
onBeforeUnmount(() => {
|
||||
searchQuery.value = '';
|
||||
mangaStore.clearSearchResults();
|
||||
});
|
||||
onMounted(() => {
|
||||
const queryParam = route.query.q;
|
||||
if (queryParam) {
|
||||
searchQuery.value = queryParam;
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
try {
|
||||
await mangaStore.searchMangaDex(searchQuery.value);
|
||||
} catch (e) {
|
||||
console.error('Erreur de recherche:', e);
|
||||
}
|
||||
};
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(debounceTimer);
|
||||
searchQuery.value = '';
|
||||
mangaStore.clearSearchResults();
|
||||
});
|
||||
|
||||
const openMangaModal = manga => {
|
||||
selectedManga.value = manga;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
try {
|
||||
await mangaStore.searchMangaDex(searchQuery.value);
|
||||
hasSearched.value = true;
|
||||
} catch (e) {
|
||||
console.error('Erreur de recherche:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedManga.value = null;
|
||||
};
|
||||
const openMangaModal = manga => {
|
||||
selectedManga.value = manga;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const addManga = async () => {
|
||||
if (!selectedManga.value) return;
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedManga.value = null;
|
||||
};
|
||||
|
||||
try {
|
||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||
router.push('/manga');
|
||||
} catch (e) {
|
||||
console.error("Erreur d'ajout:", e);
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
const addManga = async () => {
|
||||
if (!selectedManga.value) return;
|
||||
try {
|
||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||
router.push('/manga');
|
||||
} catch (e) {
|
||||
console.error("Erreur d'ajout:", e);
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
192
assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
Normal file
192
assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<!-- État de chargement -->
|
||||
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
|
||||
<span class="text-sm">Chargement des recommandations...</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<section v-else-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Résultats -->
|
||||
<section v-else-if="discoverResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Recommandations</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ discoverResults.length }} manga(s)</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="manga in discoverResults"
|
||||
:key="manga.externalId"
|
||||
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
|
||||
@click="openMangaModal(manga)">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||
alt=""
|
||||
class="h-36 w-24 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm 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>
|
||||
</section>
|
||||
|
||||
<!-- Collection locale vide -->
|
||||
<section v-else-if="!loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Ajoutez des manga pour obtenir des recommandations.</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de détail -->
|
||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
|
||||
<!-- En-tête avec couverture -->
|
||||
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<img
|
||||
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
|
||||
:alt="selectedManga.title"
|
||||
class="h-64 w-44 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
|
||||
{{ selectedManga.title }}
|
||||
</DialogTitle>
|
||||
<div class="mt-3 space-y-1.5">
|
||||
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Publication</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Statut</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Note</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
|
||||
<span
|
||||
v-for="genre in selectedManga.genres"
|
||||
:key="genre"
|
||||
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{{ genre }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{{ selectedManga.description }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="addManga"
|
||||
:disabled="adding"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
|
||||
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
|
||||
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||
import { ArrowPathIcon, ArrowPathRoundedSquareIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
|
||||
const isModalOpen = ref(false);
|
||||
const selectedManga = ref(null);
|
||||
|
||||
const { discoverResults, loadingDiscover: loading, discoverError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Découvrir', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: ArrowPathRoundedSquareIcon,
|
||||
label: 'Actualiser',
|
||||
onClick: () => mangaStore.loadDiscoverRecommendations(),
|
||||
disabled: loading.value,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
mangaStore.loadDiscoverRecommendations();
|
||||
});
|
||||
|
||||
const openMangaModal = manga => {
|
||||
selectedManga.value = manga;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedManga.value = null;
|
||||
};
|
||||
|
||||
const addManga = async () => {
|
||||
if (!selectedManga.value) return;
|
||||
try {
|
||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||
router.push('/manga');
|
||||
} catch (e) {
|
||||
console.error("Erreur d'ajout:", e);
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -22,6 +22,7 @@
|
||||
:pages="store.pages"
|
||||
:zoom="store.zoom"
|
||||
:double-page-mode="store.effectiveDoublePageMode"
|
||||
:initial-page="store.currentPage"
|
||||
@page-visible="store.handlePageVisible"
|
||||
ref="infiniteReaderRef" />
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue
|
||||
import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue';
|
||||
import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue';
|
||||
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
|
||||
import DiscoverPage from '../domain/manga/presentation/pages/DiscoverPage.vue';
|
||||
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
|
||||
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
||||
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
||||
@@ -74,8 +75,7 @@ const routes = [
|
||||
{
|
||||
path: '/manga/discover',
|
||||
name: 'discover',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Découvrir' }
|
||||
component: DiscoverPage
|
||||
},
|
||||
{
|
||||
path: '/convert',
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<label :for="inputId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label :for="inputId" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
|
||||
:class="{ 'border-green-500 bg-green-50': isDragOver, 'hover:border-gray-400': !isDragOver }"
|
||||
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed "
|
||||
:class="{ 'border-green-500 bg-green-50 dark:bg-green-900/20': isDragOver, 'hover:border-gray-400': !isDragOver }"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
>
|
||||
<div class="space-y-1 text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<ArrowUpTrayIcon class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<label
|
||||
:for="inputId"
|
||||
class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"
|
||||
class="relative cursor-pointer font-medium text-green-600 hover:text-green-500"
|
||||
>
|
||||
<span>Sélectionner des fichiers</span>
|
||||
<input
|
||||
@@ -50,8 +38,8 @@
|
||||
</p>
|
||||
|
||||
<div v-if="selectedFiles.length > 0" class="mt-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Fichiers sélectionnés :</h4>
|
||||
<ul class="text-xs text-gray-600 space-y-1">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Fichiers sélectionnés :</h4>
|
||||
<ul class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
|
||||
@@ -64,6 +52,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowUpTrayIcon } from '@heroicons/vue/24/outline';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -126,10 +126,10 @@ services:
|
||||
tags:
|
||||
- { name: messenger.message_handler, bus: command.bus }
|
||||
|
||||
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface:
|
||||
alias: App\Domain\Scraping\Infrastructure\Service\LocalImageStorage
|
||||
App\Domain\Shared\Domain\Contract\ImageStorageInterface:
|
||||
alias: App\Domain\Shared\Infrastructure\Service\ImageStorageManager
|
||||
|
||||
App\Domain\Scraping\Infrastructure\Service\LocalImageStorage:
|
||||
App\Domain\Shared\Infrastructure\Service\ImageStorageManager:
|
||||
arguments:
|
||||
$storagePath: '%kernel.project_dir%/public/images'
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
|
||||
public: true
|
||||
|
||||
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface:
|
||||
App\Domain\Shared\Domain\Contract\ImageStorageInterface:
|
||||
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage'
|
||||
public: true
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ use App\Domain\Manga\Application\Command\ImportChapter;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||
|
||||
readonly class ImportChapterHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private MangaPathManagerInterface $pathManager
|
||||
private ImageStorageInterface $imageStorage
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -39,11 +39,15 @@ readonly class ImportChapterHandler
|
||||
throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}");
|
||||
}
|
||||
|
||||
// 4. Save the CBZ file to storage using the path manager
|
||||
$cbzPath = $this->saveCbzFile($command, $manga, $existingChapter);
|
||||
// 4. Extract CBZ into individual images storage
|
||||
$pagesDirectory = $this->imageStorage->extractFromCbz(
|
||||
$existingChapter->getId(),
|
||||
$command->fileBinary
|
||||
);
|
||||
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
|
||||
|
||||
// 5. Update existing chapter with new path through the aggregate
|
||||
$manga->updateChapterPages($existingChapter, $cbzPath, $existingChapter->getPageCount());
|
||||
$manga->updateChapterPages($existingChapter, $pagesDirectory, $pageCount);
|
||||
$this->mangaRepository->save($manga);
|
||||
}
|
||||
|
||||
@@ -53,21 +57,4 @@ readonly class ImportChapterHandler
|
||||
|
||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
||||
}
|
||||
|
||||
private function saveCbzFile(ImportChapter $command, \App\Domain\Manga\Domain\Model\Manga $manga, \App\Domain\Manga\Domain\Model\Chapter $chapter): string
|
||||
{
|
||||
$volumeNumber = $chapter->getVolume() ?? 0;
|
||||
$cbzPath = $this->pathManager->buildChapterCbzPath(
|
||||
$manga->getTitle()->getValue(),
|
||||
(string)$manga->getPublicationYear(),
|
||||
$volumeNumber,
|
||||
(string)$command->chapterNumber
|
||||
);
|
||||
|
||||
if (!file_put_contents($cbzPath, $command->fileBinary)) {
|
||||
throw new \RuntimeException('Failed to save CBZ file');
|
||||
}
|
||||
|
||||
return $cbzPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
||||
use App\Domain\Manga\Application\Command\ImportVolume;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||
|
||||
readonly class ImportVolumeHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private MangaPathManagerInterface $pathManager
|
||||
private ImageStorageInterface $imageStorage
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -40,12 +40,14 @@ readonly class ImportVolumeHandler
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Save the CBZ file to storage using the path manager
|
||||
$cbzPath = $this->saveCbzFile($command, $manga);
|
||||
// 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
|
||||
$volumeDirectoryId = sprintf('volume_%s_%d', $command->mangaId, $command->volumeNumber);
|
||||
$pagesDirectory = $this->imageStorage->extractFromCbz($volumeDirectoryId, $command->fileBinary);
|
||||
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
|
||||
|
||||
// 5. Update all chapters with the volume path through the aggregate
|
||||
foreach ($chapters as $chapter) {
|
||||
$manga->updateChapterPages($chapter, $cbzPath, $chapter->getPageCount());
|
||||
$manga->updateChapterPages($chapter, $pagesDirectory, $pageCount);
|
||||
}
|
||||
$this->mangaRepository->save($manga);
|
||||
}
|
||||
@@ -56,19 +58,4 @@ readonly class ImportVolumeHandler
|
||||
|
||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
||||
}
|
||||
|
||||
private function saveCbzFile(ImportVolume $command, \App\Domain\Manga\Domain\Model\Manga $manga): string
|
||||
{
|
||||
$cbzPath = $this->pathManager->buildVolumeCbzPath(
|
||||
$manga->getTitle()->getValue(),
|
||||
(string)$manga->getPublicationYear(),
|
||||
$command->volumeNumber
|
||||
);
|
||||
|
||||
if (!file_put_contents($cbzPath, $command->fileBinary)) {
|
||||
throw new \RuntimeException('Failed to save CBZ file');
|
||||
}
|
||||
|
||||
return $cbzPath;
|
||||
}
|
||||
}
|
||||
|
||||
7
src/Domain/Manga/Application/Query/DiscoverManga.php
Normal file
7
src/Domain/Manga/Application/Query/DiscoverManga.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Application\Query;
|
||||
|
||||
readonly class DiscoverManga
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Query\DiscoverManga;
|
||||
use App\Domain\Manga\Application\Response\MangaSearchItem;
|
||||
use App\Domain\Manga\Application\Response\MangaSearchResponse;
|
||||
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
|
||||
readonly class DiscoverMangaHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private MangaProviderInterface $mangaProvider
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(DiscoverManga $query): MangaSearchResponse
|
||||
{
|
||||
$localMangas = $this->mangaRepository->findAll(page: 1, limit: 1000);
|
||||
|
||||
$ownedExternalIds = [];
|
||||
$mangasWithRating = [];
|
||||
foreach ($localMangas as $manga) {
|
||||
if (!$manga->getExternalId()) {
|
||||
continue;
|
||||
}
|
||||
$ownedExternalIds[] = $manga->getExternalId()->getValue();
|
||||
$mangasWithRating[] = $manga;
|
||||
}
|
||||
|
||||
usort($mangasWithRating, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0));
|
||||
$sourceIds = array_map(
|
||||
fn (Manga $m) => $m->getExternalId()->getValue(),
|
||||
array_slice($mangasWithRating, 0, 5)
|
||||
);
|
||||
|
||||
$collection = $this->mangaProvider->discover($sourceIds);
|
||||
|
||||
$recommendations = array_values(array_filter(
|
||||
$collection->getItems(),
|
||||
fn (Manga $m) => $m->getExternalId() === null
|
||||
|| !in_array($m->getExternalId()->getValue(), $ownedExternalIds, true)
|
||||
));
|
||||
|
||||
return new MangaSearchResponse(
|
||||
array_map(
|
||||
fn (Manga $manga, int $index) => new MangaSearchItem(
|
||||
id: $index,
|
||||
externalId: $manga->getExternalId()->getValue(),
|
||||
title: $manga->getTitle()->getValue(),
|
||||
slug: $manga->getSlug()->getValue(),
|
||||
description: $manga->getDescription(),
|
||||
author: $manga->getAuthor(),
|
||||
publicationYear: $manga->getPublicationYear(),
|
||||
genres: $manga->getGenres(),
|
||||
status: $manga->getStatus(),
|
||||
imageUrl: $manga->getImageUrl(),
|
||||
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
|
||||
rating: $manga->getRating()
|
||||
),
|
||||
$recommendations,
|
||||
array_keys($recommendations)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,18 +23,60 @@ readonly class GetMangaChaptersHandler
|
||||
throw new MangaNotFoundException();
|
||||
}
|
||||
|
||||
$chapters = $this->mangaRepository->findChapters(
|
||||
$allChapters = $this->mangaRepository->findAllChapters(
|
||||
mangaId: $query->mangaId,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
sortOrder: $query->sortOrder
|
||||
sortOrder: 'asc'
|
||||
);
|
||||
|
||||
$total = $this->mangaRepository->countChapters($query->mangaId);
|
||||
$grouped = $this->groupChapters($allChapters);
|
||||
|
||||
if ($query->sortOrder === 'desc') {
|
||||
usort($grouped, fn (ChapterResponse $a, ChapterResponse $b) => $b->number <=> $a->number);
|
||||
}
|
||||
|
||||
$total = count($grouped);
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
$paginatedChapters = array_slice($grouped, $offset, $query->limit);
|
||||
|
||||
return new ChapterListResponse(
|
||||
chapters: array_map(
|
||||
fn (Chapter $chapter) => new ChapterResponse(
|
||||
chapters: $paginatedChapters,
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit
|
||||
);
|
||||
}
|
||||
|
||||
/** @param Chapter[] $chapters */
|
||||
private function groupChapters(array $chapters): array
|
||||
{
|
||||
$result = [];
|
||||
$currentGroup = [];
|
||||
$currentPagesDir = null;
|
||||
$currentVolume = null;
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$pagesDir = $chapter->getPagesDirectory();
|
||||
$volume = $chapter->getVolume();
|
||||
|
||||
if ($pagesDir !== null && $volume !== null) {
|
||||
if ($pagesDir === $currentPagesDir && $volume === $currentVolume) {
|
||||
$currentGroup[] = $chapter;
|
||||
} else {
|
||||
if (!empty($currentGroup)) {
|
||||
$result[] = $this->buildVolumeGroupResponse($currentGroup);
|
||||
}
|
||||
$currentGroup = [$chapter];
|
||||
$currentPagesDir = $pagesDir;
|
||||
$currentVolume = $volume;
|
||||
}
|
||||
} else {
|
||||
if (!empty($currentGroup)) {
|
||||
$result[] = $this->buildVolumeGroupResponse($currentGroup);
|
||||
$currentGroup = [];
|
||||
$currentPagesDir = null;
|
||||
$currentVolume = null;
|
||||
}
|
||||
$result[] = new ChapterResponse(
|
||||
id: $chapter->getId(),
|
||||
number: $chapter->getNumber(),
|
||||
title: $chapter->getTitle(),
|
||||
@@ -42,12 +84,39 @@ readonly class GetMangaChaptersHandler
|
||||
isVisible: $chapter->isVisible(),
|
||||
pagesDirectory: $chapter->getPagesDirectory(),
|
||||
createdAt: $chapter->getCreatedAt()->format(\DateTimeInterface::RFC3339)
|
||||
),
|
||||
$chapters
|
||||
),
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($currentGroup)) {
|
||||
$result[] = $this->buildVolumeGroupResponse($currentGroup);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param Chapter[] $group */
|
||||
private function buildVolumeGroupResponse(array $group): ChapterResponse
|
||||
{
|
||||
$first = $group[0];
|
||||
$numbers = array_map(fn (Chapter $c) => $c->getNumber(), $group);
|
||||
$min = min($numbers);
|
||||
$max = max($numbers);
|
||||
|
||||
$fmt = fn (float $n) => $n == (int) $n ? (string) (int) $n : (string) $n;
|
||||
$range = count($group) > 1 ? $fmt($min) . '-' . $fmt($max) : $fmt($min);
|
||||
|
||||
return new ChapterResponse(
|
||||
id: $first->getId(),
|
||||
number: $first->getNumber(),
|
||||
title: $first->getTitle(),
|
||||
volume: $first->getVolume(),
|
||||
isVisible: $first->isVisible(),
|
||||
pagesDirectory: $first->getPagesDirectory(),
|
||||
createdAt: $first->getCreatedAt()->format(\DateTimeInterface::RFC3339),
|
||||
isVolumeGroup: true,
|
||||
volumeChaptersRange: $range,
|
||||
volumeChapterCount: count($group)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ readonly class ChapterResponse
|
||||
public ?int $volume,
|
||||
public bool $isVisible,
|
||||
public ?string $pagesDirectory,
|
||||
public string $createdAt
|
||||
public string $createdAt,
|
||||
public bool $isVolumeGroup = false,
|
||||
public ?string $volumeChaptersRange = null,
|
||||
public int $volumeChapterCount = 0,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,4 +93,24 @@ interface MangadexClientInterface
|
||||
* }
|
||||
*/
|
||||
public function getManga(string $mangaId): array;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* data: array<array{
|
||||
* id: string,
|
||||
* attributes: array{
|
||||
* title: array<string, string>,
|
||||
* description: array<string, string>,
|
||||
* year: ?int,
|
||||
* status: string,
|
||||
* tags: array<array{attributes: array{name: array<string, string>}}>
|
||||
* },
|
||||
* relationships: array<array{
|
||||
* type: string,
|
||||
* attributes: array{name: string|null, fileName: string|null}
|
||||
* }>
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
public function getMangaRecommendations(string $mangaId): array;
|
||||
}
|
||||
|
||||
@@ -11,4 +11,9 @@ interface MangaProviderInterface
|
||||
public function search(string $title): MangaCollection;
|
||||
|
||||
public function findByExternalId(ExternalId $externalId): ?Manga;
|
||||
|
||||
/**
|
||||
* @param string[] $sourceExternalIds IDs MangaDex des manga sources
|
||||
*/
|
||||
public function discover(array $sourceExternalIds): MangaCollection;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ interface MangaRepositoryInterface
|
||||
// --- Chapters (read) ---
|
||||
|
||||
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
|
||||
|
||||
/**
|
||||
* @return Chapter[]
|
||||
*/
|
||||
public function findAllChapters(string $mangaId, string $sortOrder = 'desc'): array;
|
||||
public function countChapters(string $mangaId): int;
|
||||
public function countAvailableChapters(string $mangaId): int;
|
||||
public function findChapterById(string $id): ?Chapter;
|
||||
|
||||
@@ -14,7 +14,10 @@ readonly class ChapterListItem
|
||||
public ?int $volume,
|
||||
public bool $isVisible,
|
||||
public bool $isAvailable,
|
||||
public string $createdAt
|
||||
public string $createdAt,
|
||||
public bool $isVolumeGroup = false,
|
||||
public ?string $volumeChaptersRange = null,
|
||||
public int $volumeChapterCount = 0,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\DiscoverMangaStateProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'MangaDiscover',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/manga-discover',
|
||||
output: MangaSearchCollection::class,
|
||||
provider: DiscoverMangaStateProvider::class
|
||||
)
|
||||
]
|
||||
)]
|
||||
class MangaDiscoverResource
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Domain\Manga\Application\Query\DiscoverManga;
|
||||
use App\Domain\Manga\Application\QueryHandler\DiscoverMangaHandler;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchItem;
|
||||
|
||||
readonly class DiscoverMangaStateProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(private DiscoverMangaHandler $handler)
|
||||
{
|
||||
}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): MangaSearchCollection
|
||||
{
|
||||
$response = $this->handler->handle(new DiscoverManga());
|
||||
|
||||
return new MangaSearchCollection(
|
||||
items: array_map(
|
||||
fn ($item) => new MangaSearchItem(
|
||||
externalId: $item->externalId,
|
||||
title: $item->title,
|
||||
slug: $item->slug,
|
||||
description: $item->description,
|
||||
author: $item->author,
|
||||
publicationYear: $item->publicationYear,
|
||||
genres: $item->genres,
|
||||
status: $item->status,
|
||||
imageUrl: $item->imageUrl,
|
||||
thumbnailUrl: $item->thumbnailUrl,
|
||||
rating: $item->rating
|
||||
),
|
||||
$response->items
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,10 @@ readonly class GetMangaChaptersStateProvider implements ProviderInterface
|
||||
volume: $chapter->volume,
|
||||
isVisible: $chapter->isVisible,
|
||||
isAvailable: $chapter->pagesDirectory !== null,
|
||||
createdAt: $chapter->createdAt
|
||||
createdAt: $chapter->createdAt,
|
||||
isVolumeGroup: $chapter->isVolumeGroup,
|
||||
volumeChaptersRange: $chapter->volumeChaptersRange,
|
||||
volumeChapterCount: $chapter->volumeChapterCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,35 @@ class MangadexClient implements MangadexClientInterface
|
||||
]);
|
||||
}
|
||||
|
||||
public function getMangaRecommendations(string $mangaId): array
|
||||
{
|
||||
// L'endpoint retourne des objets manga_recommendation avec des relationships
|
||||
// vers les manga (sans détails). Il faut d'abord récupérer les IDs, puis
|
||||
// fetcher les manga en batch avec leurs détails complets.
|
||||
$recommendations = $this->get('/manga/' . $mangaId . '/recommendation');
|
||||
|
||||
$recommendedIds = [];
|
||||
foreach ($recommendations['data'] ?? [] as $item) {
|
||||
foreach ($item['relationships'] ?? [] as $rel) {
|
||||
if ($rel['type'] === 'manga' && $rel['id'] !== $mangaId) {
|
||||
$recommendedIds[] = $rel['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($recommendedIds)) {
|
||||
return ['data' => []];
|
||||
}
|
||||
|
||||
return $this->get('/manga', [
|
||||
'ids' => $recommendedIds,
|
||||
'includes' => ['cover_art', 'author'],
|
||||
'contentRating' => ['safe', 'suggestive', 'erotica'],
|
||||
'excludedTags' => self::EXCLUDED_TAGS,
|
||||
'limit' => count($recommendedIds),
|
||||
]);
|
||||
}
|
||||
|
||||
private function get(string $endpoint, array $params = []): array
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -185,6 +185,21 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
||||
);
|
||||
}
|
||||
|
||||
public function findAllChapters(string $mangaId, string $sortOrder = 'desc'): array
|
||||
{
|
||||
$queryBuilder = $this->entityManager->createQueryBuilder()
|
||||
->select('c')
|
||||
->from(EntityChapter::class, 'c')
|
||||
->where('c.manga = :mangaId')
|
||||
->orderBy('c.number', $sortOrder)
|
||||
->setParameter('mangaId', $mangaId);
|
||||
|
||||
return array_map(
|
||||
fn (EntityChapter $entity) => $this->toChapterDomain($entity),
|
||||
$queryBuilder->getQuery()->getResult()
|
||||
);
|
||||
}
|
||||
|
||||
public function countChapters(string $mangaId): int
|
||||
{
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
|
||||
@@ -135,6 +135,55 @@ readonly class MangadexProvider implements MangaProviderInterface
|
||||
}
|
||||
}
|
||||
|
||||
public function discover(array $sourceExternalIds): MangaCollection
|
||||
{
|
||||
if (empty($sourceExternalIds)) {
|
||||
return new MangaCollection([]);
|
||||
}
|
||||
|
||||
// Compter les votes : un manga recommandé par plusieurs sources est plus pertinent.
|
||||
// On conserve aussi la position d'apparition pour départager les ex-aequo.
|
||||
$votes = [];
|
||||
$firstPosition = [];
|
||||
$resultsById = [];
|
||||
$position = 0;
|
||||
|
||||
foreach ($sourceExternalIds as $externalId) {
|
||||
try {
|
||||
$response = $this->client->getMangaRecommendations($externalId);
|
||||
foreach ($response['data'] ?? [] as $result) {
|
||||
$id = $result['id'];
|
||||
$votes[$id] = ($votes[$id] ?? 0) + 1;
|
||||
if (!isset($firstPosition[$id])) {
|
||||
$firstPosition[$id] = $position++;
|
||||
$resultsById[$id] = $result;
|
||||
}
|
||||
}
|
||||
} catch (\Exception) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($resultsById)) {
|
||||
return new MangaCollection([]);
|
||||
}
|
||||
|
||||
// Trier : votes décroissants (multi-sources = plus pertinent), puis position croissante (score API)
|
||||
uksort($resultsById, function (string $a, string $b) use ($votes, $firstPosition): int {
|
||||
$voteDiff = $votes[$b] - $votes[$a];
|
||||
if ($voteDiff !== 0) {
|
||||
return $voteDiff;
|
||||
}
|
||||
|
||||
return $firstPosition[$a] <=> $firstPosition[$b];
|
||||
});
|
||||
|
||||
$mangas = $this->createMangasFromResults(array_values($resultsById));
|
||||
$this->enrichWithRatings($mangas);
|
||||
|
||||
return new MangaCollection($mangas);
|
||||
}
|
||||
|
||||
public function findByExternalId(ExternalId $externalId): ?Manga
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -154,7 +154,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
$pages[] = new Page(
|
||||
basename($files[$i]),
|
||||
new PageNumber($i + 1),
|
||||
sprintf('/images/pages/%s/%s', $chapterId->getValue(), basename($files[$i])),
|
||||
sprintf('/images/pages/%s/%s', basename($pagesDirectory), basename($files[$i])),
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
||||
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Scraping\Domain\Contract\Service;
|
||||
|
||||
interface ImageStorageInterface
|
||||
{
|
||||
/**
|
||||
* Copies images to permanent storage. Returns the pagesDirectory path.
|
||||
*
|
||||
* @param string $chapterId The chapter UUID used as directory name
|
||||
* @param string[] $localImagePaths Paths to the locally downloaded image files
|
||||
*
|
||||
* @return string Absolute path to the directory where images were stored
|
||||
*/
|
||||
public function storeChapterImages(string $chapterId, array $localImagePaths): string;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Scraping\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
||||
|
||||
readonly class LocalImageStorage implements ImageStorageInterface
|
||||
{
|
||||
public function __construct(private string $storagePath)
|
||||
{
|
||||
}
|
||||
|
||||
public function storeChapterImages(string $chapterId, array $localImagePaths): string
|
||||
{
|
||||
$targetDir = $this->storagePath . '/pages/' . $chapterId;
|
||||
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
sort($localImagePaths);
|
||||
|
||||
foreach ($localImagePaths as $index => $localPath) {
|
||||
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
|
||||
copy($localPath, $targetFile);
|
||||
}
|
||||
|
||||
return $targetDir;
|
||||
}
|
||||
}
|
||||
28
src/Domain/Shared/Domain/Contract/ImageStorageInterface.php
Normal file
28
src/Domain/Shared/Domain/Contract/ImageStorageInterface.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
interface ImageStorageInterface
|
||||
{
|
||||
/**
|
||||
* Store images from local file paths into the individual images storage.
|
||||
* Used by the scraping flow.
|
||||
*
|
||||
* @param string[] $localImagePaths
|
||||
* @return string The directory path where images are stored (pagesDirectory)
|
||||
*/
|
||||
public function storeChapterImages(string $targetId, array $localImagePaths): string;
|
||||
|
||||
/**
|
||||
* Extract images from a CBZ binary into the individual images storage.
|
||||
* Used by the import flow.
|
||||
*
|
||||
* @return string The directory path where images are stored (pagesDirectory)
|
||||
*/
|
||||
public function extractFromCbz(string $targetId, string $cbzBinary): string;
|
||||
|
||||
/**
|
||||
* Count images in a CBZ binary.
|
||||
*/
|
||||
public function countCbzImages(string $cbzBinary): int;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||
use ZipArchive;
|
||||
|
||||
class ImageStorageManager implements ImageStorageInterface
|
||||
{
|
||||
public function __construct(private string $storagePath)
|
||||
{
|
||||
}
|
||||
|
||||
public function storeChapterImages(string $targetId, array $localImagePaths): string
|
||||
{
|
||||
$targetDir = $this->storagePath . '/pages/' . $targetId;
|
||||
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
sort($localImagePaths);
|
||||
|
||||
foreach ($localImagePaths as $index => $localPath) {
|
||||
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
|
||||
copy($localPath, $targetFile);
|
||||
}
|
||||
|
||||
return $targetDir;
|
||||
}
|
||||
|
||||
public function extractFromCbz(string $targetId, string $cbzBinary): string
|
||||
{
|
||||
$targetDir = $this->storagePath . '/pages/' . $targetId;
|
||||
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
|
||||
file_put_contents($tmpFile, $cbzBinary);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($tmpFile) !== true) {
|
||||
unlink($tmpFile);
|
||||
throw new \RuntimeException('Failed to open CBZ file as ZIP archive');
|
||||
}
|
||||
|
||||
$imageEntries = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $name)) {
|
||||
$imageEntries[] = ['index' => $i, 'name' => $name];
|
||||
}
|
||||
}
|
||||
|
||||
usort($imageEntries, fn ($a, $b) => strcmp($a['name'], $b['name']));
|
||||
|
||||
foreach ($imageEntries as $seq => $entry) {
|
||||
$extension = strtolower(pathinfo($entry['name'], PATHINFO_EXTENSION)) ?: 'jpg';
|
||||
$targetFile = sprintf('%s/%03d.%s', $targetDir, $seq + 1, $extension);
|
||||
$content = $zip->getFromIndex($entry['index']);
|
||||
file_put_contents($targetFile, $content);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
unlink($tmpFile);
|
||||
|
||||
return $targetDir;
|
||||
}
|
||||
|
||||
public function countCbzImages(string $cbzBinary): int
|
||||
{
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
|
||||
file_put_contents($tmpFile, $cbzBinary);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($tmpFile) !== true) {
|
||||
unlink($tmpFile);
|
||||
throw new \RuntimeException('Failed to open CBZ file as ZIP archive');
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $name)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
unlink($tmpFile);
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -43,4 +43,9 @@ class InMemoryMangaProvider implements MangaProviderInterface
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function discover(array $sourceExternalIds): MangaCollection
|
||||
{
|
||||
return new MangaCollection([]);
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,23 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
};
|
||||
}
|
||||
|
||||
public function findAllChapters(string $mangaId, string $sortOrder = 'desc'): array
|
||||
{
|
||||
if (!isset($this->chapters[$mangaId])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$chapters = $this->chapters[$mangaId];
|
||||
|
||||
usort($chapters, function (Chapter $a, Chapter $b) use ($sortOrder) {
|
||||
return $sortOrder === 'desc'
|
||||
? $b->getNumber() <=> $a->getNumber()
|
||||
: $a->getNumber() <=> $b->getNumber();
|
||||
});
|
||||
|
||||
return $chapters;
|
||||
}
|
||||
|
||||
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array
|
||||
{
|
||||
if (!isset($this->chapters[$mangaId])) {
|
||||
@@ -198,6 +215,15 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
));
|
||||
}
|
||||
|
||||
public function addChapter(string $mangaId, Chapter $chapter): void
|
||||
{
|
||||
if (!isset($this->chapters[$mangaId])) {
|
||||
$this->chapters[$mangaId] = [];
|
||||
}
|
||||
$this->chapters[$mangaId][] = $chapter;
|
||||
$this->chaptersById[$chapter->getId()] = $chapter;
|
||||
}
|
||||
|
||||
public function addChaptersToManga(string $mangaId, int $count): void
|
||||
{
|
||||
$this->chapters[$mangaId] = [];
|
||||
|
||||
@@ -106,6 +106,11 @@ class InMemoryMangadexClient implements MangadexClientInterface
|
||||
];
|
||||
}
|
||||
|
||||
public function getMangaRecommendations(string $mangaId): array
|
||||
{
|
||||
return ['data' => []];
|
||||
}
|
||||
|
||||
public function addManga(string $id, array $data): void
|
||||
{
|
||||
$this->mangas[$id] = $data;
|
||||
|
||||
@@ -13,22 +13,22 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ImportChapterHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryMangaRepository $mangaRepository;
|
||||
private InMemoryPathManager $pathManager;
|
||||
private InMemoryImageStorage $imageStorage;
|
||||
private ImportChapterHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mangaRepository = new InMemoryMangaRepository();
|
||||
$this->pathManager = new InMemoryPathManager();
|
||||
$this->imageStorage = new InMemoryImageStorage();
|
||||
$this->handler = new ImportChapterHandler(
|
||||
$this->mangaRepository,
|
||||
$this->pathManager
|
||||
$this->imageStorage
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,22 +12,22 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ImportVolumeHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryMangaRepository $mangaRepository;
|
||||
private InMemoryPathManager $pathManager;
|
||||
private InMemoryImageStorage $imageStorage;
|
||||
private ImportVolumeHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mangaRepository = new InMemoryMangaRepository();
|
||||
$this->pathManager = new InMemoryPathManager();
|
||||
$this->imageStorage = new InMemoryImageStorage();
|
||||
$this->handler = new ImportVolumeHandler(
|
||||
$this->mangaRepository,
|
||||
$this->pathManager
|
||||
$this->imageStorage
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace App\Tests\Domain\Manga\Application\QueryHandler;
|
||||
use App\Domain\Manga\Application\Query\GetMangaChapters;
|
||||
use App\Domain\Manga\Application\QueryHandler\GetMangaChaptersHandler;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Manga\Domain\Model\Chapter;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
@@ -67,6 +69,139 @@ class GetMangaChaptersHandlerTest extends TestCase
|
||||
$this->assertTrue($response->hasPreviousPage());
|
||||
}
|
||||
|
||||
public function testGroupsVolumeChaptersWithSharedPagesDirectory(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->givenMangaExists('1');
|
||||
$sharedDir = '/manga/vol1/';
|
||||
foreach ([1, 2, 3] as $num) {
|
||||
$this->repository->addChapter('1', new Chapter(
|
||||
id: new ChapterId((string) $num),
|
||||
mangaId: new MangaId('1'),
|
||||
number: (float) $num,
|
||||
title: null,
|
||||
volume: 1,
|
||||
isVisible: true,
|
||||
pagesDirectory: $sharedDir,
|
||||
));
|
||||
}
|
||||
|
||||
// Act
|
||||
$response = $this->handler->handle(new GetMangaChapters('1'));
|
||||
|
||||
// Assert
|
||||
$this->assertCount(1, $response->chapters);
|
||||
$this->assertEquals(1, $response->total);
|
||||
$item = $response->chapters[0];
|
||||
$this->assertTrue($item->isVolumeGroup);
|
||||
$this->assertEquals('1-3', $item->volumeChaptersRange);
|
||||
$this->assertEquals(3, $item->volumeChapterCount);
|
||||
$this->assertEquals(1, $item->volume);
|
||||
$this->assertEquals(1.0, $item->number);
|
||||
}
|
||||
|
||||
public function testGroupsSingleVolumeChapter(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->givenMangaExists('1');
|
||||
$this->repository->addChapter('1', new Chapter(
|
||||
id: new ChapterId('10'),
|
||||
mangaId: new MangaId('1'),
|
||||
number: 5.0,
|
||||
title: null,
|
||||
volume: 2,
|
||||
isVisible: true,
|
||||
pagesDirectory: '/manga/vol2/',
|
||||
));
|
||||
|
||||
// Act
|
||||
$response = $this->handler->handle(new GetMangaChapters('1'));
|
||||
|
||||
// Assert
|
||||
$this->assertCount(1, $response->chapters);
|
||||
$item = $response->chapters[0];
|
||||
$this->assertTrue($item->isVolumeGroup);
|
||||
$this->assertEquals('5', $item->volumeChaptersRange);
|
||||
$this->assertEquals(1, $item->volumeChapterCount);
|
||||
}
|
||||
|
||||
public function testDoesNotGroupChaptersWithDistinctPagesDirectory(): void
|
||||
{
|
||||
// Arrange — 3 chapitres scrapés avec pagesDirectory distinctes, pas de volume
|
||||
$this->givenMangaExists('1');
|
||||
foreach ([1, 2, 3] as $num) {
|
||||
$this->repository->addChapter('1', new Chapter(
|
||||
id: new ChapterId((string) $num),
|
||||
mangaId: new MangaId('1'),
|
||||
number: (float) $num,
|
||||
title: null,
|
||||
volume: null,
|
||||
isVisible: true,
|
||||
pagesDirectory: '/manga/ch' . $num . '/',
|
||||
));
|
||||
}
|
||||
|
||||
// Act
|
||||
$response = $this->handler->handle(new GetMangaChapters('1'));
|
||||
|
||||
// Assert — 3 items distincts, aucun groupe
|
||||
$this->assertCount(3, $response->chapters);
|
||||
$this->assertEquals(3, $response->total);
|
||||
foreach ($response->chapters as $item) {
|
||||
$this->assertFalse($item->isVolumeGroup);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMixedNormalAndVolumeChapters(): void
|
||||
{
|
||||
// Arrange — 2 chapitres scrapés + 3 chapitres de volume importé
|
||||
$this->givenMangaExists('1');
|
||||
|
||||
// Chapitres scrapés (pagesDirectory individuel, pas de volume)
|
||||
foreach ([1, 2] as $num) {
|
||||
$this->repository->addChapter('1', new Chapter(
|
||||
id: new ChapterId((string) $num),
|
||||
mangaId: new MangaId('1'),
|
||||
number: (float) $num,
|
||||
title: null,
|
||||
volume: null,
|
||||
isVisible: true,
|
||||
pagesDirectory: '/manga/ch' . $num . '/',
|
||||
));
|
||||
}
|
||||
|
||||
// Volume importé — 3 chapitres avec même pagesDirectory
|
||||
$sharedDir = '/manga/vol1/';
|
||||
foreach ([3, 4, 5] as $num) {
|
||||
$this->repository->addChapter('1', new Chapter(
|
||||
id: new ChapterId((string) ($num + 10)),
|
||||
mangaId: new MangaId('1'),
|
||||
number: (float) $num,
|
||||
title: null,
|
||||
volume: 1,
|
||||
isVisible: true,
|
||||
pagesDirectory: $sharedDir,
|
||||
));
|
||||
}
|
||||
|
||||
// Act
|
||||
$response = $this->handler->handle(new GetMangaChapters('1', sortOrder: 'asc'));
|
||||
|
||||
// Assert — 2 chapitres normaux + 1 groupe = 3 items
|
||||
$this->assertCount(3, $response->chapters);
|
||||
$this->assertEquals(3, $response->total);
|
||||
|
||||
// Les 2 premiers sont des chapitres normaux
|
||||
$this->assertFalse($response->chapters[0]->isVolumeGroup);
|
||||
$this->assertFalse($response->chapters[1]->isVolumeGroup);
|
||||
|
||||
// Le 3e est un groupe de volume
|
||||
$volumeItem = $response->chapters[2];
|
||||
$this->assertTrue($volumeItem->isVolumeGroup);
|
||||
$this->assertEquals('3-5', $volumeItem->volumeChaptersRange);
|
||||
$this->assertEquals(3, $volumeItem->volumeChapterCount);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->repository->clear();
|
||||
|
||||
@@ -2,18 +2,31 @@
|
||||
|
||||
namespace App\Tests\Domain\Scraping\Adapter;
|
||||
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
||||
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||
|
||||
class InMemoryImageStorage implements ImageStorageInterface
|
||||
{
|
||||
/** @var array<string, string> chapterId => pagesDirectory */
|
||||
/** @var array<string, string> targetId => pagesDirectory */
|
||||
public array $stored = [];
|
||||
|
||||
public function storeChapterImages(string $chapterId, array $localImagePaths): string
|
||||
public function storeChapterImages(string $targetId, array $localImagePaths): string
|
||||
{
|
||||
$dir = '/fake/pages/' . $chapterId;
|
||||
$this->stored[$chapterId] = $dir;
|
||||
$dir = '/fake/pages/' . $targetId;
|
||||
$this->stored[$targetId] = $dir;
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function extractFromCbz(string $targetId, string $cbzBinary): string
|
||||
{
|
||||
$dir = '/fake/pages/' . $targetId;
|
||||
$this->stored[$targetId] = $dir;
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function countCbzImages(string $cbzBinary): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user