10 Commits

Author SHA1 Message Date
ext.jeremy.guillot@maxicoffee.domains
420c3922c2 fix(manga): corriger le conflit de shortName sur MangaDiscoverResource 2026-03-15 21:55:06 +01:00
ext.jeremy.guillot@maxicoffee.domains
814fe46ce5 feat(manga): implémenter la page Découvrir avec recommandations MangaDex
- Endpoint GET /api/manga-discover via DiscoverMangaStateProvider + DiscoverMangaHandler
- Algorithme : top 5 manga de la collection → appel /manga/{id}/recommendation
  par source → agrégation avec système de votes (multi-sources = plus pertinent)
- Filtrage : tags exclus (Oneshot, Doujinshi, Self-Published), contentRating,
  et suppression des manga déjà en bibliothèque
- Page Vue DiscoverPage.vue : chargement auto au montage, bouton Actualiser,
  modal détail, ajout à la bibliothèque
- Adapteurs InMemory de test mis à jour (discover + getMangaRecommendations)
2026-03-15 21:43:57 +01:00
ext.jeremy.guillot@maxicoffee.domains
65453c87e5 style(manga): refondre la page d'ajout de manga sur le design system
- Layout canonique : flex flex-col h-full + Toolbar + overflow-y-auto flex-1
- Titre de page dans la Toolbar, bouton Rechercher toujours visible (disabled si vide)
- Auto-search debounced 500ms au-delà de 3 caractères
- Suppression de tous les rounded-* pour cohérence globale
- Modale enrichie : auteur, année, statut, note, genres, description complète
2026-03-15 20:55:46 +01:00
ext.jeremy.guillot@maxicoffee.domains
78897eda4a chore(claude): versionner les skills partagés dans le repo
All checks were successful
Deploy / deploy (push) Successful in 2m47s
Ajoute les exceptions .gitignore pour tracker .claude/skills/ tout en
continuant d'ignorer settings.local.json et projects/ (fichiers perso).
Inclut les skills task-workflow et ui-style.
2026-03-15 20:42:48 +01:00
02ad36fb34 Merge pull request 'style(conversion): aligner l'UI de conversion sur le design system import' (#20) from style/conversion-ui-align-import into main
All checks were successful
Deploy / deploy (push) Successful in 2m51s
Reviewed-on: #20
2026-03-15 20:24:42 +01:00
929a7d0d61 Merge branch 'main' into style/conversion-ui-align-import 2026-03-15 20:24:31 +01:00
ext.jeremy.guillot@maxicoffee.domains
9f83f9c137 style(conversion): aligner l'UI de conversion sur le design system import
Ajout du Toolbar avec titre et bouton d'action, restructuration en sections
avec bordures et titres typographiques, harmonisation des espacements et
classes Tailwind avec NewImportPage.vue.
2026-03-15 20:24:05 +01:00
2cefea3f72 Merge pull request 'style(import): aligner l'UI d'import sur le design system settings' (#19) from style/import-ui-settings-layout into main
All checks were successful
Deploy / deploy (push) Successful in 3m1s
Reviewed-on: #19
2026-03-15 20:14:09 +01:00
3e85167875 Merge branch 'main' into style/import-ui-settings-layout 2026-03-15 20:13:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
f72ae3cab9 style(import): aligner l'UI d'import sur le design system settings
- Layout max-width supprimé → pleine largeur disponible
- Sections avec border-t et titres uppercase comme les settings
- FileImportCard : card → row (divide-y, py-3, pas de shadow/border)
- ImportResults : card → sections border-t inline dans la page
- Inputs : padding explicite, border explicite, sans rounded
- Suppression de tous les rounded-* sur la page (boutons, badges, images, zone upload)
2026-03-15 20:13:31 +01:00
25 changed files with 1302 additions and 385 deletions

View 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
```

View 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
View File

@@ -39,3 +39,10 @@ yarn-error.log
src/Controller/TestController.php src/Controller/TestController.php
.phpunit.cache/test-results .phpunit.cache/test-results
/tests/Fixtures/pages/ /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/

View File

@@ -1,84 +1,77 @@
<template> <template>
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900"> <div class="flex flex-col h-full">
<div class="overflow-y-auto flex-1"> <Toolbar :config="toolbarConfig" />
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<FileUploadArea <div class="overflow-y-auto flex-1">
:selected-file="conversionStore.currentFile" <div class="px-6 py-8">
:disabled="conversionStore.isProcessing"
@file-selected="handleFileSelected"
@file-cleared="handleFileClear"
/>
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="mt-6 flex justify-center"> <!-- Zone d'upload -->
<button <section class="border-t border-gray-200 dark:border-gray-700 pt-6">
@click="handleConvert" <h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichier</h2>
:disabled="conversionStore.isProcessing" <FileUploadArea
:class="[ :selected-file="conversionStore.currentFile"
'flex items-center gap-2 px-6 py-3 text-white font-medium rounded-lg transition-colors', :disabled="conversionStore.isProcessing"
conversionStore.isProcessing @file-selected="handleFileSelected"
? 'bg-gray-400 cursor-not-allowed' @file-cleared="handleFileClear"
: 'bg-green-600 hover:bg-green-700' />
]" </section>
>
<ArrowPathIcon :class="['w-5 h-5', conversionStore.isProcessing && 'animate-spin']" />
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
</button>
</div>
<ConversionProgress <!-- Progression -->
v-if="showProgress" <section v-if="showProgress" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
class="mt-6" <ConversionProgress
:is-converting="conversionStore.isProcessing" :is-converting="conversionStore.isProcessing"
:progress="conversionStore.conversionProgress" :progress="conversionStore.conversionProgress"
:is-success="conversionStore.hasSucceeded" :is-success="conversionStore.hasSucceeded"
:has-error="conversionStore.hasError" :has-error="conversionStore.hasError"
:error-message="conversionStore.conversionError" :error-message="conversionStore.conversionError"
:file-name="conversionStore.currentFileName" :file-name="conversionStore.currentFileName"
:original-size="conversionStore.currentFile?.size || 0" :original-size="conversionStore.currentFile?.size || 0"
:converted-size="conversionStore.convertedFile?.size || 0" :converted-size="conversionStore.convertedFile?.size || 0"
@download="handleDownload" @download="handleDownload"
@reset="handleReset" @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>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { ArrowPathIcon } from '@heroicons/vue/24/outline'; import { ArrowPathIcon } from '@heroicons/vue/24/outline';
import { computed, onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useConversionStore } from '../../application/store/conversionStore'; import { useConversionStore } from '../../application/store/conversionStore';
import { useNotifications } from '../../../../shared/composables/useNotifications'; import { useNotifications } from '../../../../shared/composables/useNotifications';
import ConversionProgress from '../components/ConversionProgress.vue'; import ConversionProgress from '../components/ConversionProgress.vue';
@@ -88,53 +81,68 @@ const conversionStore = useConversionStore();
const { showSuccess, showError } = useNotifications(); const { showSuccess, showError } = useNotifications();
const showProgress = computed(() => const showProgress = computed(() =>
conversionStore.hasSelectedFile && conversionStore.hasSelectedFile &&
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError) (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) => { const handleFileSelected = (file) => {
conversionStore.selectFile(file); conversionStore.selectFile(file);
}; };
const handleFileClear = () => { const handleFileClear = () => {
conversionStore.resetConversion(); conversionStore.resetConversion();
}; };
const handleConvert = async () => { const handleConvert = async () => {
if (!conversionStore.currentFile) return; if (!conversionStore.currentFile) return;
const success = await conversionStore.convertCurrentFile(); const success = await conversionStore.convertCurrentFile();
if (success) { if (success) {
showSuccess('Conversion réussie !'); showSuccess('Conversion réussie !');
} else { } else {
showError(conversionStore.conversionError ?? 'Échec de la conversion'); showError(conversionStore.conversionError ?? 'Échec de la conversion');
} }
}; };
const handleDownload = () => conversionStore.downloadConvertedFile(); const handleDownload = () => conversionStore.downloadConvertedFile();
const handleReset = () => conversionStore.resetConversion(); const handleReset = () => conversionStore.resetConversion();
const formatFileSize = (bytes) => { const formatFileSize = (bytes) => {
if (bytes === 0) return '0 octets'; if (bytes === 0) return '0 octets';
const k = 1024; const k = 1024;
const sizes = ['octets', 'Ko', 'Mo', 'Go']; const sizes = ['octets', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}; };
const formatDate = (isoString) => const formatDate = (isoString) =>
new Intl.DateTimeFormat('fr-FR', { new Intl.DateTimeFormat('fr-FR', {
day: 'numeric', day: 'numeric',
month: 'short', month: 'short',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}).format(new Date(isoString)); }).format(new Date(isoString));
const calculateSaving = (originalSize, convertedSize) => { const calculateSaving = (originalSize, convertedSize) => {
if (!originalSize || !convertedSize) return ''; if (!originalSize || !convertedSize) return '';
const saving = ((originalSize - convertedSize) / originalSize) * 100; const saving = ((originalSize - convertedSize) / originalSize) * 100;
if (saving > 0) return `-${saving.toFixed(1)}%`; if (saving > 0) return `-${saving.toFixed(1)}%`;
if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`; if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`;
return '0%'; return '0%';
}; };
onMounted(() => conversionStore.resetConversion()); onMounted(() => conversionStore.resetConversion());

View File

@@ -1,20 +1,20 @@
<template> <template>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4"> <div class="py-3">
<!-- En-tête : icône, nom, statut, actions --> <!-- Row principal : icône, nom, statut, actions -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-9 h-9 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center shrink-0"> <div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 flex items-center justify-center shrink-0">
<DocumentIcon class="w-5 h-5 text-gray-500 dark:text-gray-400" /> <DocumentIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
</div> </div>
<div class="flex-1 min-w-0"> <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-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"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ file.getFormattedSize() }} · {{ file.getFileExtension().toUpperCase() }} {{ file.getFormattedSize() }} · {{ file.getFileExtension().toUpperCase() }}
<span v-if="file.isAnalyzed() && file.getExtractedChapterNumber()" class="ml-2 text-blue-600 dark:text-blue-400"> <span v-if="file.isAnalyzed() && file.getExtractedChapterNumber()" class="ml-2 text-green-600 dark:text-green-400">
Ch. {{ file.getExtractedChapterNumber() }} Ch. {{ file.getExtractedChapterNumber() }}
</span> </span>
<span v-if="file.isAnalyzed() && file.getExtractedVolumeNumber()" class="ml-2 text-purple-600 dark:text-purple-400"> <span v-if="file.isAnalyzed() && file.getExtractedVolumeNumber()" class="ml-2 text-green-600 dark:text-green-400">
Vol. {{ file.getExtractedVolumeNumber() }} Vol. {{ file.getExtractedVolumeNumber() }}
</span> </span>
</p> </p>
@@ -27,7 +27,7 @@
v-if="file.isReadyForImport()" v-if="file.isReadyForImport()"
@click="$emit('import-file')" @click="$emit('import-file')"
:disabled="isImporting" :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 rounded-md transition-colors" 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" /> <ArrowUpTrayIcon class="w-3.5 h-3.5" />
Importer Importer
@@ -36,14 +36,14 @@
<button <button
v-if="file.hasError()" v-if="file.hasError()"
@click="$emit('retry-file')" @click="$emit('retry-file')"
class="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-md transition-colors" 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 Réessayer
</button> </button>
<button <button
@click="$emit('remove-file')" @click="$emit('remove-file')"
class="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors rounded" class="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
title="Supprimer" title="Supprimer"
> >
<XMarkIcon class="w-4 h-4" /> <XMarkIcon class="w-4 h-4" />
@@ -52,19 +52,19 @@
</div> </div>
<!-- Message d'erreur --> <!-- Message d'erreur -->
<div v-if="file.hasError()" class="mt-3 flex items-start gap-2 text-xs text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded-md px-3 py-2"> <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" /> <ExclamationCircleIcon class="w-4 h-4 shrink-0 mt-0.5" />
{{ file.errorMessage }} {{ file.errorMessage }}
</div> </div>
<!-- Aucun manga trouvé --> <!-- Aucun manga trouvé -->
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-3 flex items-start gap-2 text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 rounded-md px-3 py-2"> <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" /> <ExclamationTriangleIcon class="w-4 h-4 shrink-0 mt-0.5" />
Aucun manga correspondant trouvé. Vérifiez le nom du fichier. Aucun manga correspondant trouvé. Vérifiez le nom du fichier.
</div> </div>
<!-- Sélection du manga --> <!-- Sélection du manga -->
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3"> <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"> <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{{ file.getMatches().length }} correspondance(s) {{ file.getMatches().length }} correspondance(s)
</p> </p>
@@ -80,29 +80,29 @@
</div> </div>
</div> </div>
<!-- Numéros de chapitre / volume (une fois un manga sélectionné) --> <!-- Numéros de chapitre / volume -->
<div v-if="file.selectedManga" class="mt-3 grid grid-cols-2 gap-3"> <div v-if="file.selectedManga" class="mt-3 grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapitre</label> <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Chapitre</label>
<input <input
type="number" type="number"
step="0.5" step="0.5"
:value="file.selectedChapterNumber ?? ''" :value="file.selectedChapterNumber ?? ''"
@input="handleChapterNumberInput" @input="handleChapterNumberInput"
:disabled="file.selectedVolumeNumber !== null" :disabled="file.selectedVolumeNumber !== null"
class="w-full text-sm 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" 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..." placeholder="Ex: 1, 1.5..."
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Volume</label> <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Volume</label>
<input <input
type="number" type="number"
step="0.5" step="0.5"
:value="file.selectedVolumeNumber ?? ''" :value="file.selectedVolumeNumber ?? ''"
@input="handleVolumeNumberInput" @input="handleVolumeNumberInput"
:disabled="file.selectedChapterNumber !== null" :disabled="file.selectedChapterNumber !== null"
class="w-full text-sm 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" 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..." placeholder="Ex: 1, 1.5..."
/> />
</div> </div>

View File

@@ -1,96 +1,94 @@
<template> <template>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6"> <div>
<div class="text-center mb-6"> <!-- En-tête -->
<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"> <section class="border-t border-gray-200 dark:border-gray-700 pt-6">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="flex items-center justify-between">
<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" /> <div class="flex items-center gap-3">
</svg> <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> </div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Import terminé</h3> </section>
<p class="text-sm text-gray-500 dark:text-gray-400">
Voici le résumé de votre session d'import
</p>
</div>
<!-- Statistics --> <!-- Fichiers importés -->
<div class="grid grid-cols-3 gap-4 mb-6"> <section v-if="importedFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="text-center"> <h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div> Importés ({{ importedFiles.length }})
<div class="text-sm text-gray-500 dark:text-gray-400">Importés</div> </h2>
</div> <div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div class="text-center"> <div
<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
v-for="file in importedFiles" v-for="file in importedFiles"
:key="file.id" :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"> <CheckCircleIcon class="flex-shrink-0 h-4 w-4 text-green-400" />
<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" /> <span class="text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</span>
</svg> <span v-if="file.selectedManga" class="text-gray-400 dark:text-gray-500 shrink-0">→ {{ file.selectedManga.title }}</span>
<span class="text-gray-900 dark:text-gray-100">{{ file.filename }}</span> </div>
<span v-if="file.selectedManga" class="ml-2 text-gray-500 dark:text-gray-400"> </div>
→ {{ file.selectedManga.title }} </section>
</span>
</li>
</ul>
</div>
<!-- Error Files List --> <!-- Fichiers en erreur -->
<div v-if="errorFiles.length > 0" class="mb-6"> <section v-if="errorFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3"> <h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
Fichiers en erreur ({{ errorFiles.length }}) Erreurs ({{ errorFiles.length }})
</h4> </h2>
<ul class="space-y-2"> <div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<li <div
v-for="file in errorFiles" v-for="file in errorFiles"
:key="file.id" :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"> <XCircleIcon class="flex-shrink-0 h-4 w-4 text-red-400 mt-0.5" />
<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> <div>
<div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</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> </div>
</li> </div>
</ul> </div>
</div> </section>
<!-- Actions --> <!-- Actions -->
<div class="flex justify-center space-x-4 pt-6 border-t dark:border-gray-700"> <section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<button <div class="flex gap-3">
@click="startNewImport" <button
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium" @click="startNewImport"
> class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 text-sm font-medium"
Nouvel import >
</button> Nouvel import
<button </button>
@click="goToLibrary" <button
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium" @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> Aller à la bibliothèque
</div> </button>
</div>
</section>
</div> </div>
</template> </template>
<script setup> <script setup>
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/solid';
import { computed } from 'vue'; import { computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useNewImportStore } from '../../application/store/newImportStore'; import { useNewImportStore } from '../../application/store/newImportStore';

View File

@@ -1,8 +1,8 @@
<template> <template>
<div <div
class="border rounded-lg p-2.5 cursor-pointer transition-all duration-150" class="border p-2.5 cursor-pointer transition-all duration-150"
:class="isSelected :class="isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' ? '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'" : '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)" @click="$emit('select-match', match)"
> >
@@ -12,11 +12,11 @@
v-if="match.thumbnailUrl" v-if="match.thumbnailUrl"
:src="match.thumbnailUrl" :src="match.thumbnailUrl"
:alt="match.title" :alt="match.title"
class="w-12 h-16 object-cover rounded shrink-0" class="w-12 h-16 object-cover shrink-0"
/> />
<div <div
v-else v-else
class="w-12 h-16 bg-gray-100 dark:bg-gray-700 rounded shrink-0 flex items-center justify-center" class="w-12 h-16 bg-gray-100 dark:bg-gray-700 shrink-0 flex items-center justify-center"
> >
<PhotoIcon class="w-6 h-6 text-gray-400" /> <PhotoIcon class="w-6 h-6 text-gray-400" />
</div> </div>
@@ -28,7 +28,7 @@
</p> </p>
<div class="flex items-center justify-between mt-1"> <div class="flex items-center justify-between mt-1">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ match.matchScore }}%</span> <span class="text-xs text-gray-400 dark:text-gray-500">{{ match.matchScore }}%</span>
<CheckCircleIcon v-if="isSelected" class="w-4 h-4 text-blue-500 shrink-0" /> <CheckCircleIcon v-if="isSelected" class="w-4 h-4 text-green-500 shrink-0" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -46,10 +46,10 @@ const badgeText = computed(() => {
}); });
const badgeClasses = 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) { 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) { switch (props.status) {
@@ -58,7 +58,7 @@ const badgeClasses = computed(() => {
case 'analyzed': case 'analyzed':
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`; return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
case 'importing': 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': case 'imported':
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`; return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
case 'error': case 'error':

View File

@@ -1,50 +1,57 @@
<template> <template>
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900"> <div class="flex flex-col h-full">
<Toolbar v-if="store.hasFiles && !store.allFilesProcessed" :config="toolbarConfig" /> <Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1"> <div class="overflow-y-auto flex-1">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6"> <div class="px-6 py-8">
<!-- Zone de dépôt --> <!-- Zone de dépôt -->
<FileUpload <section v-if="!store.hasFiles" class="border-t border-gray-200 dark:border-gray-700 pt-6">
v-if="!store.hasFiles" <h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichiers</h2>
label="Importer des fichiers CBZ/CBR" <FileUpload
accept=".cbz,.cbr" label="Importer des fichiers CBZ/CBR"
:multiple="true" accept=".cbz,.cbr"
description="Formats CBZ ou CBR uniquement" :multiple="true"
@files-selected="store.addFiles($event)" description="Formats CBZ ou CBR uniquement"
/> @files-selected="store.addFiles($event)"
<!-- Barre de progression -->
<div v-if="store.hasFiles && !store.allFilesProcessed" class="flex items-center gap-3">
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
class="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
:style="{ width: store.progressPercentage + '%' }"
/>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0">
{{ store.importedCount }}/{{ store.totalFiles }}
<span v-if="store.errorCount > 0" class="text-red-500 ml-1">· {{ store.errorCount }} erreur(s)</span>
</span>
</div>
<!-- Liste des fichiers -->
<div v-if="store.hasFiles && !store.allFilesProcessed" class="space-y-4">
<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>
<!-- 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 --> <!-- Résultats -->
<ImportResults v-if="store.allFilesProcessed" /> <ImportResults v-if="store.allFilesProcessed" />
@@ -66,7 +73,9 @@ import ImportResults from '../components/ImportResults.vue';
const store = useNewImportStore(); const store = useNewImportStore();
const toolbarConfig = computed(() => ({ const toolbarConfig = computed(() => ({
leftSection: [], leftSection: [
{ type: 'label', text: 'Import de bibliothèque', class: 'text-sm font-medium' },
],
rightSection: [ rightSection: [
...(store.analyzedFiles.length > 0 ? [{ ...(store.analyzedFiles.length > 0 ? [{
type: 'button', type: 'button',

View File

@@ -40,7 +40,12 @@ export const useMangaStore = defineStore('manga', {
// --- Add Manga State --- // --- Add Manga State ---
addingManga: false, addingManga: false,
addMangaError: null addMangaError: null,
// --- Discover State ---
discoverResults: [],
loadingDiscover: false,
discoverError: null
}), }),
getters: { getters: {
@@ -170,6 +175,25 @@ export const useMangaStore = defineStore('manga', {
this.loadingSearch = false; 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 --- // --- Add Manga Actions ---
async createFromMangaDex(externalId) { async createFromMangaDex(externalId) {
if (this.addingManga) return; if (this.addingManga) return;

View File

@@ -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) { async createFromMangaDex(externalId) {
try { try {
const response = await fetch('/api/mangas/create-from-mangadex', { const response = await fetch('/api/mangas/create-from-mangadex', {

View File

@@ -1,172 +1,234 @@
<template> <template>
<div class="overflow-y-auto h-full"> <div class="flex flex-col h-full">
<div class="container mx-auto px-4 py-8"> <Toolbar :config="toolbarConfig" />
<!-- 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>
<!-- État de chargement --> <div class="overflow-y-auto flex-1">
<div v-if="loading" class="text-center py-8"> <div class="px-6 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>
<!-- Message d'erreur --> <!-- Recherche -->
<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"> <section class="border-t border-gray-200 dark:border-gray-700 pt-6">
{{ error }} <h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Recherche</h2>
</div> <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 --> <!-- État de chargement -->
<div v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700"> <section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div <div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
v-for="manga in searchResults" <div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
:key="manga.externalId" <span class="text-sm">Recherche en cours...</span>
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" </div>
@click="openMangaModal(manga)"> </section>
<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>
<!-- Modal de confirmation --> <!-- Message d'erreur -->
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50"> <section v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" aria-hidden="true" /> <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"> <!-- Résultats -->
<DialogPanel class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6"> <section v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<DialogTitle class="text-lg mb-4 text-gray-900 dark:text-gray-100"> Ajouter à la bibliothèque </DialogTitle> <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>
<div v-if="selectedManga"> <span class="text-xs text-gray-500 dark:text-gray-400">{{ searchResults.length }} manga(s)</span>
<div class="flex gap-4"> </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 <img
:src="selectedManga.imageUrl || '/placeholder-cover.png'" :src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
:alt="selectedManga.title" alt=""
class="h-48 w-32 object-cover" /> class="h-36 w-24 object-cover flex-shrink-0"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h4 class="text-lg text-gray-900 dark:text-gray-100">{{ selectedManga.title }}</h4> <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
<p class="mt-2 text-gray-700 dark:text-gray-300"> <p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
{{ truncatedDescription }} </div>
</p> </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>
</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 <button
type="button" type="button"
@click="closeModal" @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 Annuler
</button> </button>
<button <button
type="button" type="button"
@click="addManga" @click="addManga"
:disabled="adding" :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"> 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">
<span v-if="adding" class="mr-2"> <ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
<ArrowPathIcon class="h-5 w-5 animate-spin" /> {{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
</span>
{{ adding ? 'Ajout en cours...' : 'Ajouter' }}
</button> </button>
</div> </div>
</DialogPanel> </DialogPanel>
</div> </div>
</Dialog> </Dialog>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'; import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon } from '@heroicons/vue/24/solid'; import { ArrowPathIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia'; 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 { useRoute, useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore'; import { useMangaStore } from '../../application/store/mangaStore';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const mangaStore = useMangaStore(); const mangaStore = useMangaStore();
const searchQuery = ref(''); const searchQuery = ref('');
const isModalOpen = ref(false); const hasSearched = ref(false);
const selectedManga = ref(null); 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(() => { const toolbarConfig = computed(() => ({
if (!selectedManga.value?.description) return ''; leftSection: [
return selectedManga.value.description.length > 500 { type: 'label', text: 'Ajouter un manga', class: 'text-sm font-medium' },
? selectedManga.value.description.slice(0, 500) + '...' ],
: selectedManga.value.description; 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 let debounceTimer = null;
onMounted(() => { watch(searchQuery, newVal => {
const queryParam = route.query.q; clearTimeout(debounceTimer);
if (queryParam) { if (newVal.trim().length > 3) {
searchQuery.value = queryParam; debounceTimer = setTimeout(performSearch, 500);
performSearch(); }
} });
});
// Nettoyer la recherche et les résultats lors du démontage du composant onMounted(() => {
onBeforeUnmount(() => { const queryParam = route.query.q;
searchQuery.value = ''; if (queryParam) {
mangaStore.clearSearchResults(); searchQuery.value = queryParam;
}); performSearch();
}
});
const performSearch = async () => { onBeforeUnmount(() => {
if (!searchQuery.value.trim()) return; clearTimeout(debounceTimer);
try { searchQuery.value = '';
await mangaStore.searchMangaDex(searchQuery.value); mangaStore.clearSearchResults();
} catch (e) { });
console.error('Erreur de recherche:', e);
}
};
const openMangaModal = manga => { const performSearch = async () => {
selectedManga.value = manga; if (!searchQuery.value.trim()) return;
isModalOpen.value = true; try {
}; await mangaStore.searchMangaDex(searchQuery.value);
hasSearched.value = true;
} catch (e) {
console.error('Erreur de recherche:', e);
}
};
const closeModal = () => { const openMangaModal = manga => {
isModalOpen.value = false; selectedManga.value = manga;
selectedManga.value = null; isModalOpen.value = true;
}; };
const addManga = async () => { const closeModal = () => {
if (!selectedManga.value) return; isModalOpen.value = false;
selectedManga.value = null;
};
try { const addManga = async () => {
await mangaStore.createFromMangaDex(selectedManga.value.externalId); if (!selectedManga.value) return;
router.push('/manga'); try {
} catch (e) { await mangaStore.createFromMangaDex(selectedManga.value.externalId);
console.error("Erreur d'ajout:", e); router.push('/manga');
} finally { } catch (e) {
closeModal(); console.error("Erreur d'ajout:", e);
} } finally {
}; closeModal();
}
};
</script> </script>

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

View File

@@ -3,6 +3,7 @@ import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue
import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue'; import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue';
import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue'; import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue';
import AddManga from '../domain/manga/presentation/pages/AddManga.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 HomePage from '../domain/manga/presentation/pages/HomePage.vue';
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue'; import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue'; import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
@@ -74,8 +75,7 @@ const routes = [
{ {
path: '/manga/discover', path: '/manga/discover',
name: 'discover', name: 'discover',
component: PlaceholderComponent, component: DiscoverPage
props: { title: 'Découvrir' }
}, },
{ {
path: '/convert', path: '/convert',

View File

@@ -1,35 +1,23 @@
<template> <template>
<div class="file-upload"> <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 }}
</label> </label>
<div <div
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md" 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': isDragOver, 'hover:border-gray-400': !isDragOver }" :class="{ 'border-green-500 bg-green-50 dark:bg-green-900/20': isDragOver, 'hover:border-gray-400': !isDragOver }"
@drop.prevent="handleDrop" @drop.prevent="handleDrop"
@dragover.prevent="isDragOver = true" @dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false" @dragleave.prevent="isDragOver = false"
> >
<div class="space-y-1 text-center"> <div class="space-y-1 text-center">
<svg <ArrowUpTrayIcon class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
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>
<div class="flex text-sm text-gray-600"> <div class="flex text-sm text-gray-600">
<label <label
:for="inputId" :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> <span>Sélectionner des fichiers</span>
<input <input
@@ -50,8 +38,8 @@
</p> </p>
<div v-if="selectedFiles.length > 0" class="mt-4"> <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> <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 space-y-1"> <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"> <li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
<span class="truncate">{{ file.name }}</span> <span class="truncate">{{ file.name }}</span>
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span> <span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
@@ -64,6 +52,7 @@
</template> </template>
<script setup> <script setup>
import { ArrowUpTrayIcon } from '@heroicons/vue/24/outline';
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
const props = defineProps({ const props = defineProps({

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Domain\Manga\Application\Query;
readonly class DiscoverManga
{
}

View File

@@ -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)
)
);
}
}

View File

@@ -93,4 +93,24 @@ interface MangadexClientInterface
* } * }
*/ */
public function getManga(string $mangaId): array; 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;
} }

View File

@@ -11,4 +11,9 @@ interface MangaProviderInterface
public function search(string $title): MangaCollection; public function search(string $title): MangaCollection;
public function findByExternalId(ExternalId $externalId): ?Manga; public function findByExternalId(ExternalId $externalId): ?Manga;
/**
* @param string[] $sourceExternalIds IDs MangaDex des manga sources
*/
public function discover(array $sourceExternalIds): MangaCollection;
} }

View File

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

View File

@@ -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
)
);
}
}

View File

@@ -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 private function get(string $endpoint, array $params = []): array
{ {
try { try {

View File

@@ -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 public function findByExternalId(ExternalId $externalId): ?Manga
{ {
try { try {

View File

@@ -43,4 +43,9 @@ class InMemoryMangaProvider implements MangaProviderInterface
return null; return null;
} }
public function discover(array $sourceExternalIds): MangaCollection
{
return new MangaCollection([]);
}
} }

View File

@@ -106,6 +106,11 @@ class InMemoryMangadexClient implements MangadexClientInterface
]; ];
} }
public function getMangaRecommendations(string $mangaId): array
{
return ['data' => []];
}
public function addManga(string $id, array $data): void public function addManga(string $id, array $data): void
{ {
$this->mangas[$id] = $data; $this->mangas[$id] = $data;