16 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
2c7f97c8b7 Merge pull request 'style(import): simplifier et harmoniser l'interface d'import de bibliothèque' (#18) from style/import-ui-simplification into main
All checks were successful
Deploy / deploy (push) Successful in 2m50s
Reviewed-on: #18
2026-03-15 19:42:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
1477106459 style(import): simplifier et harmoniser l'interface d'import de bibliothèque
- NewImportPage : layout flex/h-full + bg-gray-50 cohérent avec ConversionPage,
  Toolbar sombre pour les actions (sélection auto, importer, effacer),
  suppression du grand header h1 et du confirm() natif,
  ImportResults seul affiché en fin de session
- FileImportCard : en-tête compact avec actions inline (import + ×),
  suppression du bloc "selected manga preview" redondant,
  SVG inline remplacés par Heroicons, grille de correspondances élargie
- MangaMatchCard : suppression de la barre de score (doublon) et des
  slugs alternatifs, carte compacte avec coche de sélection Heroicons
2026-03-15 19:42:35 +01:00
2243716800 Merge pull request 'feat(manga): regrouper les chapitres d'un volume importé dans la liste API' (#17) from feat/volume-chapter-grouping into main
All checks were successful
Deploy / deploy (push) Successful in 2m59s
Reviewed-on: #17
2026-03-15 19:21:44 +01:00
d8a47072da Merge branch 'main' into feat/volume-chapter-grouping 2026-03-15 19:21:35 +01:00
23c1028ec6 Merge pull request 'perf(reader): virtual rendering avec IntersectionObserver en mode scroll' (#16) from perf/reader-virtual-rendering into main
All checks were successful
Deploy / deploy (push) Successful in 2m49s
Reviewed-on: #16
2026-03-15 18:51:26 +01:00
ext.jeremy.guillot@maxicoffee.domains
aba8e36231 perf(reader): virtual rendering avec IntersectionObserver en mode scroll
Remplace le rendu de tous les composants ReaderPage par un système de
virtual rendering : seules les pages dans la zone ±1000px du viewport
sont montées, les autres sont remplacées par un placeholder dimensionné.

- InfiniteReader : ajout visibilityObserver + mountedPageIndices (Set
  réactif), helper getPlaceholderHeight(), suppression de 5 console.log
- ReaderPage : prop windowWidth injectable depuis le parent, listener
  resize conditionnel, suppression de 3 console.log de debug
2026-03-15 18:51:06 +01:00
27 changed files with 1561 additions and 821 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,34 +1,24 @@
<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">
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<!-- 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 <FileUploadArea
:selected-file="conversionStore.currentFile" :selected-file="conversionStore.currentFile"
:disabled="conversionStore.isProcessing" :disabled="conversionStore.isProcessing"
@file-selected="handleFileSelected" @file-selected="handleFileSelected"
@file-cleared="handleFileClear" @file-cleared="handleFileClear"
/> />
</section>
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="mt-6 flex justify-center"> <!-- Progression -->
<button <section v-if="showProgress" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
@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>
<ConversionProgress <ConversionProgress
v-if="showProgress"
class="mt-6"
:is-converting="conversionStore.isProcessing" :is-converting="conversionStore.isProcessing"
:progress="conversionStore.conversionProgress" :progress="conversionStore.conversionProgress"
:is-success="conversionStore.hasSucceeded" :is-success="conversionStore.hasSucceeded"
@@ -40,10 +30,12 @@
@download="handleDownload" @download="handleDownload"
@reset="handleReset" @reset="handleReset"
/> />
</section>
<div v-if="conversionStore.conversionCount > 0" class="mt-8"> <!-- 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"> <div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Historique</h3> <h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Historique</h2>
<button <button
@click="conversionStore.clearHistory()" @click="conversionStore.clearHistory()"
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors" class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
@@ -51,7 +43,7 @@
Effacer Effacer
</button> </button>
</div> </div>
<div class="divide-y divide-gray-200 dark:divide-gray-700"> <div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div <div
v-for="(conversion, index) in conversionStore.conversionHistory" v-for="(conversion, index) in conversionStore.conversionHistory"
:key="index" :key="index"
@@ -69,7 +61,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </section>
</div> </div>
</div> </div>
@@ -79,6 +71,7 @@
<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';
@@ -92,6 +85,21 @@ const showProgress = computed(() =>
(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);
}; };

View File

@@ -1,64 +1,75 @@
<template> <template>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6"> <div class="py-3">
<div class="flex items-start space-x-4">
<!-- File Icon and Info --> <!-- Row principal : icône, nom, statut, actions -->
<div class="flex-shrink-0"> <div class="flex items-center gap-3">
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center"> <div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <DocumentIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
<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>
<!-- File Details -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center justify-between"> <p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</p>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 truncate"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ file.filename }} {{ file.getFormattedSize() }} · {{ file.getFileExtension().toUpperCase() }}
</h3> <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>
<!-- Status Badge --> <div class="flex items-center gap-2 shrink-0">
<div class="flex-shrink-0 ml-4">
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" /> <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> </div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> <!-- Message d'erreur -->
{{ file.getFormattedSize() }} {{ file.getFileExtension().toUpperCase() }} <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>
<!-- 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> </p>
<!-- Extracted Info --> <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
<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>
</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>
<!-- Matches Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<MangaMatchCard <MangaMatchCard
v-for="match in sortedMatches" v-for="match in sortedMatches"
:key="match.id" :key="match.id"
@@ -69,130 +80,47 @@
</div> </div>
</div> </div>
<!-- Selected Manga Preview --> <!-- Numéros de chapitre / volume -->
<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"> <div v-if="file.selectedManga" class="mt-3 grid grid-cols-2 gap-3">
<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 -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Chapitre</label>
Numéro de 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 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, 2..." placeholder="Ex: 1, 1.5..."
/> />
</div> </div>
<!-- Volume Number -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Volume</label>
Numéro de 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 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, 2..." placeholder="Ex: 1, 1.5..."
/> />
</div> </div>
</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> </div>
</template> </template>
<script setup> <script setup>
import { ArrowUpTrayIcon, DocumentIcon, ExclamationCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { computed } from 'vue'; import { computed } from 'vue';
import MangaMatchCard from './MangaMatchCard.vue'; import MangaMatchCard from './MangaMatchCard.vue';
import StatusBadge from './StatusBadge.vue'; import StatusBadge from './StatusBadge.vue';
const props = defineProps({ const props = defineProps({
file: { file: { type: Object, required: true },
type: Object, isAnalyzing: { type: Boolean, default: false },
required: true isImporting: { type: Boolean, default: false },
},
isAnalyzing: {
type: Boolean,
default: false
},
isImporting: {
type: Boolean,
default: false
}
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -201,28 +129,22 @@ const emit = defineEmits([
'volume-number-selected', 'volume-number-selected',
'import-file', 'import-file',
'retry-file', 'retry-file',
'remove-file' 'remove-file',
]); ]);
// Computed property to get sorted matches const sortedMatches = computed(() =>
const sortedMatches = computed(() => { [...props.file.getMatches()].sort((a, b) => b.matchScore - a.matchScore)
const matches = props.file.getMatches(); );
return matches.sort((a, b) => b.matchScore - a.matchScore);
});
const handleMangaSelection = (selectedManga) => { const handleMangaSelection = (manga) => emit('manga-selected', manga);
emit('manga-selected', selectedManga);
};
const handleChapterNumberInput = (event) => { const handleChapterNumberInput = (event) => {
const value = event.target.value; const value = event.target.value;
const chapterNumber = value ? parseFloat(value) : null; emit('chapter-number-selected', value ? parseFloat(value) : null);
emit('chapter-number-selected', chapterNumber);
}; };
const handleVolumeNumberInput = (event) => { const handleVolumeNumberInput = (event) => {
const value = event.target.value; const value = event.target.value;
const volumeNumber = value ? parseFloat(value) : null; emit('volume-number-selected', value ? parseFloat(value) : null);
emit('volume-number-selected', volumeNumber);
}; };
</script> </script>

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-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Import terminé</h3> <div>
<p class="text-sm text-gray-500 dark:text-gray-400"> <h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">Import terminé</h3>
Voici le résumé de votre session d'import <p class="text-xs text-gray-500 dark:text-gray-400">Voici le résumé de votre session d'import</p>
</p>
</div> </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>
</section>
<!-- 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>
<span v-if="file.selectedManga" class="ml-2 text-gray-500 dark:text-gray-400">
→ {{ file.selectedManga.title }}
</span>
</li>
</ul>
</div> </div>
</div>
</section>
<!-- 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>
</ul>
</div> </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">
<div class="flex gap-3">
<button <button
@click="startNewImport" @click="startNewImport"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 text-sm font-medium"
> >
Nouvel import Nouvel import
</button> </button>
<button <button
@click="goToLibrary" @click="goToLibrary"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 text-sm font-medium"
> >
Aller à la bibliothèque Aller à la bibliothèque
</button> </button>
</div> </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,116 +1,47 @@
<template> <template>
<div <div
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md" class="border p-2.5 cursor-pointer transition-all duration-150"
:class="{ :class="isSelected
'border-blue-500 bg-blue-50 dark:bg-blue-900/20': 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-500': !isSelected : '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)"
> >
<!-- Match Header with Score --> <div class="flex gap-2.5">
<div class="flex items-center justify-between mb-3"> <!-- Couverture -->
<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 <img
v-if="match.thumbnailUrl" v-if="match.thumbnailUrl"
:src="match.thumbnailUrl" :src="match.thumbnailUrl"
:alt="match.title" :alt="match.title"
class="w-16 h-20 object-cover rounded border" class="w-12 h-16 object-cover shrink-0"
/> />
<div <div
v-else 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" class="w-12 h-16 bg-gray-100 dark:bg-gray-700 shrink-0 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"> <PhotoIcon class="w-6 h-6 text-gray-400" />
<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> </div>
<!-- Manga Info --> <!-- Infos -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0 flex flex-col justify-between py-0.5">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" :title="match.title"> <p class="text-xs font-medium text-gray-900 dark:text-gray-100 line-clamp-3 leading-snug" :title="match.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> </p>
<div class="flex items-center justify-between mt-1">
<!-- Alternative Slugs --> <span class="text-xs text-gray-400 dark:text-gray-500">{{ match.matchScore }}%</span>
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2"> <CheckCircleIcon v-if="isSelected" class="w-4 h-4 text-green-500 shrink-0" />
<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"
>
{{ 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>
</div> </div>
</div> </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>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { CheckCircleIcon, PhotoIcon } from '@heroicons/vue/24/outline';
const props = defineProps({ const props = defineProps({
match: { match: { type: Object, required: true },
type: Object, isSelected: { type: Boolean, default: false },
required: true
},
isSelected: {
type: Boolean,
default: false
}
}); });
const emit = defineEmits(['select-match']); const emit = defineEmits(['select-match']);
</script> </script>

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,77 +1,41 @@
<template> <template>
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8"> <div class="flex flex-col h-full">
<!-- Header --> <Toolbar :config="toolbarConfig" />
<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>
<!-- Progress Bar (if files are being processed) --> <div class="overflow-y-auto flex-1">
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8"> <div class="px-6 py-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 --> <!-- Zone de dépôt -->
<div v-if="!store.hasFiles || store.allFilesProcessed" class="mb-8"> <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 <FileUpload
label="Importer des fichiers CBZ/CBR" label="Importer des fichiers CBZ/CBR"
accept=".cbz,.cbr" accept=".cbz,.cbr"
:multiple="true" :multiple="true"
description="Formats CBZ ou CBR uniquement" description="Formats CBZ ou CBR uniquement"
@files-selected="handleFilesSelected" @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>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<!-- 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 <FileImportCard
v-for="file in store.files" v-for="file in store.files"
:key="file.id" :key="file.id"
@@ -79,37 +43,61 @@
:is-analyzing="store.analyzingFiles.has(file.id)" :is-analyzing="store.analyzingFiles.has(file.id)"
:is-importing="store.importingFiles.has(file.id)" :is-importing="store.importingFiles.has(file.id)"
@manga-selected="(manga) => store.setFileManga(file.id, manga)" @manga-selected="(manga) => store.setFileManga(file.id, manga)"
@chapter-number-selected="(chapterNumber) => store.setFileChapterNumber(file.id, chapterNumber)" @chapter-number-selected="(n) => store.setFileChapterNumber(file.id, n)"
@volume-number-selected="(volumeNumber) => store.setFileVolumeNumber(file.id, volumeNumber)" @volume-number-selected="(n) => store.setFileVolumeNumber(file.id, n)"
@import-file="() => importSingleFile(file.id)" @import-file="() => importSingleFile(file.id)"
@retry-file="() => retryFile(file.id)" @retry-file="() => retryFile(file.id)"
@remove-file="() => store.removeFile(file.id)" @remove-file="() => store.removeFile(file.id)"
/> />
</div> </div>
</div> </section>
</template>
<!-- Résultats -->
<ImportResults v-if="store.allFilesProcessed" />
<!-- Results Summary (when all files are processed) -->
<div v-if="store.allFilesProcessed" class="mt-8">
<ImportResults />
</div> </div>
</div></div> </div>
</div>
</template> </template>
<script setup> <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 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 { useNewImportStore } from '../../application/store/newImportStore';
import FileImportCard from '../components/FileImportCard.vue'; import FileImportCard from '../components/FileImportCard.vue';
import ImportResults from '../components/ImportResults.vue'; import ImportResults from '../components/ImportResults.vue';
const store = useNewImportStore(); const store = useNewImportStore();
// === EVENT HANDLERS === const toolbarConfig = computed(() => ({
leftSection: [
const handleFilesSelected = (files) => { { type: 'label', text: 'Import de bibliothèque', class: 'text-sm font-medium' },
store.addFiles(files); ],
}; 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 () => { const importAllFiles = async () => {
try { 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(() => { onUnmounted(() => {
store.resetGlobalState(); store.resetGlobalState();
}); });

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,165 +1,227 @@
<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="overflow-y-auto flex-1">
<div class="flex gap-4"> <div class="px-6 py-8">
<!-- 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 <input
type="text" type="text"
v-model="searchQuery" v-model="searchQuery"
@keyup.enter="performSearch" @keyup.enter="performSearch"
placeholder="Rechercher un manga..." 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" /> 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" />
<button </section>
@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 --> <!-- État de chargement -->
<div v-if="loading" class="text-center py-8"> <section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div> <div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
<p class="mt-4 text-gray-600 dark:text-gray-400">Recherche en cours...</p> <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> </div>
</section>
<!-- Message d'erreur --> <!-- 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"> <section v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
{{ error }} <p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</div> </section>
<!-- Résultats de recherche --> <!-- Résultats -->
<div v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700"> <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 <div
v-for="manga in searchResults" v-for="manga in searchResults"
:key="manga.externalId" :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" 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)"> @click="openMangaModal(manga)">
<img <img
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'" :src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
alt="" alt=""
class="h-36 w-24 object-cover flex-shrink-0 self-start" class="h-36 w-24 object-cover flex-shrink-0"
referrerpolicy="no-referrer" /> referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</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> <p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
</div> </div>
</div> </div>
</div> </div>
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600 dark:text-gray-400">Aucun résultat trouvé</p> </section>
<!-- Modal de confirmation --> <!-- 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"> <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" /> <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"> <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"> <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]">
<DialogTitle class="text-lg mb-4 text-gray-900 dark:text-gray-100"> Ajouter à la bibliothèque </DialogTitle>
<div v-if="selectedManga"> <!-- En-tête avec couverture -->
<div class="flex gap-4"> <div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
<img <img
:src="selectedManga.imageUrl || '/placeholder-cover.png'" :src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
:alt="selectedManga.title" :alt="selectedManga.title"
class="h-48 w-32 object-cover" /> class="h-64 w-44 object-cover flex-shrink-0"
<div class="flex-1 min-w-0"> referrerpolicy="no-referrer" />
<h4 class="text-lg text-gray-900 dark:text-gray-100">{{ selectedManga.title }}</h4> <div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
<p class="mt-2 text-gray-700 dark:text-gray-300"> <div>
{{ truncatedDescription }} <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> </p>
</div> </div>
</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 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 => {
clearTimeout(debounceTimer);
if (newVal.trim().length > 3) {
debounceTimer = setTimeout(performSearch, 500);
}
});
onMounted(() => {
const queryParam = route.query.q; const queryParam = route.query.q;
if (queryParam) { if (queryParam) {
searchQuery.value = queryParam; searchQuery.value = queryParam;
performSearch(); performSearch();
} }
}); });
// Nettoyer la recherche et les résultats lors du démontage du composant onBeforeUnmount(() => {
onBeforeUnmount(() => { clearTimeout(debounceTimer);
searchQuery.value = ''; searchQuery.value = '';
mangaStore.clearSearchResults(); mangaStore.clearSearchResults();
}); });
const performSearch = async () => { const performSearch = async () => {
if (!searchQuery.value.trim()) return; if (!searchQuery.value.trim()) return;
try { try {
await mangaStore.searchMangaDex(searchQuery.value); await mangaStore.searchMangaDex(searchQuery.value);
hasSearched.value = true;
} catch (e) { } catch (e) {
console.error('Erreur de recherche:', e); console.error('Erreur de recherche:', e);
} }
}; };
const openMangaModal = manga => { const openMangaModal = manga => {
selectedManga.value = manga; selectedManga.value = manga;
isModalOpen.value = true; isModalOpen.value = true;
}; };
const closeModal = () => { const closeModal = () => {
isModalOpen.value = false; isModalOpen.value = false;
selectedManga.value = null; selectedManga.value = null;
}; };
const addManga = async () => { const addManga = async () => {
if (!selectedManga.value) return; if (!selectedManga.value) return;
try { try {
await mangaStore.createFromMangaDex(selectedManga.value.externalId); await mangaStore.createFromMangaDex(selectedManga.value.externalId);
router.push('/manga'); router.push('/manga');
@@ -168,5 +230,5 @@ import { useMangaStore } from '../../application/store/mangaStore';
} finally { } finally {
closeModal(); 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

@@ -1,15 +1,26 @@
<template> <template>
<div class="infinite-reader" ref="containerRef"> <div class="infinite-reader" ref="containerRef">
<div v-for="(page, index) in pages" :key="index" class="page-wrapper" :data-page-index="index"> <div v-for="(page, index) in pages" :key="index"
<ReaderPage class="page-wrapper" :data-page-index="index">
v-if="isPageInWindow(index) && page?.url"
<!-- Pas d'URL : spinner de chargement -->
<div v-if="!page?.url" class="loading">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<!-- Hors de la zone de rendu : placeholder dimensionné -->
<div v-else-if="!mountedPageIndices.has(index)"
class="page-placeholder"
:style="{ height: getPlaceholderHeight(page) + 'px' }" />
<!-- Dans la zone : composant complet -->
<ReaderPage v-else
:page-data="page" :page-data="page"
:page-number="index + 1" :page-number="index + 1"
:zoom="zoom" :zoom="zoom"
:double-page-mode="doublePageMode" :double-page-mode="doublePageMode"
loading="eager" :window-width="windowWidth"
/> loading="lazy" />
<div v-else class="page-placeholder" :style="getPlaceholderStyle(page)" />
</div> </div>
<!-- Bouton flottant pour revenir en haut --> <!-- Bouton flottant pour revenir en haut -->
@@ -38,29 +49,10 @@
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useHeaderStore } from '../../../../shared/stores/headerStore'; import { useHeaderStore } from '../../../../shared/stores/headerStore';
import ReaderPage from './ReaderPage.vue'; import ReaderPage from './ReaderPage.vue';
const WINDOW_SIZE = 3;
const currentVisibleIndex = ref(0); // initialisé via prop initialPage dans onMounted
const isPageInWindow = (index) => Math.abs(index - currentVisibleIndex.value) <= WINDOW_SIZE;
const getPlaceholderStyle = (page) => {
if (page?.dimensions?.width && page?.dimensions?.height) {
const maxW = windowWidth.value < 1200
? windowWidth.value * 0.95
: 1200;
return {
aspectRatio: `${page.dimensions.width} / ${page.dimensions.height}`,
width: '100%',
maxWidth: `${Math.min(page.dimensions.width, maxW)}px`,
};
}
return { height: '800px', width: '100%' };
};
const props = defineProps({ const props = defineProps({
pages: { pages: {
type: Array, type: Array,
@@ -73,10 +65,6 @@ import ReaderPage from './ReaderPage.vue';
doublePageMode: { doublePageMode: {
type: String, type: String,
required: true required: true
},
initialPage: {
type: Number,
default: 0
} }
}); });
@@ -85,6 +73,8 @@ import ReaderPage from './ReaderPage.vue';
const headerStore = useHeaderStore(); const headerStore = useHeaderStore();
const containerRef = ref(null); const containerRef = ref(null);
const observer = ref(null); const observer = ref(null);
const visibilityObserver = ref(null);
const mountedPageIndices = reactive(new Set());
const windowWidth = ref(window.innerWidth); const windowWidth = ref(window.innerWidth);
// État unique pour tous les boutons flottants avec timer de 3 secondes // État unique pour tous les boutons flottants avec timer de 3 secondes
@@ -96,35 +86,55 @@ import ReaderPage from './ReaderPage.vue';
let scrollDirection = 'down'; let scrollDirection = 'down';
const observeIntersection = entries => { const observeIntersection = entries => {
const intersectingIndices = entries entries.forEach(entry => {
.filter(e => e.isIntersecting) if (entry.isIntersecting) {
.map(e => parseInt(e.target.getAttribute('data-page-index'))); const pageIndex = parseInt(entry.target.getAttribute('data-page-index'));
emit('pageVisible', pageIndex);
if (intersectingIndices.length > 0) {
const minIdx = Math.min(...intersectingIndices);
currentVisibleIndex.value = minIdx;
emit('pageVisible', minIdx);
} }
});
}; };
const setupIntersectionObserver = () => { // Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
if (observer.value) { const getPlaceholderHeight = (page) => {
observer.value.disconnect(); const dims = page?.dimensions;
} if (!dims?.width || !dims?.height) return 800;
const displayWidth = windowWidth.value < 1200
? Math.min(dims.width, windowWidth.value * 0.95)
: Math.min(dims.width, 1200);
return Math.round((dims.height / dims.width) * displayWidth);
};
const setupObservers = () => {
observer.value?.disconnect();
visibilityObserver.value?.disconnect();
observer.value = new IntersectionObserver(observeIntersection, { observer.value = new IntersectionObserver(observeIntersection, {
root: null, root: null,
threshold: 0.5 threshold: 0.5
}); });
nextTick(() => { visibilityObserver.value = new IntersectionObserver(
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper'); (entries) => {
if (pageElements) { entries.forEach(entry => {
pageElements.forEach(element => { const idx = parseInt(entry.target.getAttribute('data-page-index'));
observer.value.observe(element); if (entry.isIntersecting) {
}); mountedPageIndices.add(idx);
} else {
mountedPageIndices.delete(idx);
} }
}); });
},
{ root: null, rootMargin: '1000px 0px', threshold: 0 }
);
nextTick(() => {
const els = containerRef.value?.querySelectorAll('.page-wrapper');
els?.forEach((el, i) => {
el.setAttribute('data-page-index', i);
observer.value.observe(el);
visibilityObserver.value.observe(el);
});
});
}; };
// Fonction unique pour gérer la visibilité de tous les boutons flottants // Fonction unique pour gérer la visibilité de tous les boutons flottants
@@ -207,21 +217,16 @@ import ReaderPage from './ReaderPage.vue';
// Fonction pour revenir en haut de la page // Fonction pour revenir en haut de la page
const scrollToTop = () => { const scrollToTop = () => {
console.log('scrollToTop appelée'); // Debug
// Réinitialiser le timer lors du clic // Réinitialiser le timer lors du clic
resetButtonsTimer(); resetButtonsTimer();
// Stratégie 1: Scroll sur le conteneur direct // Stratégie 1: Scroll sur le conteneur direct
if (containerRef.value) { if (containerRef.value) {
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
if (containerRef.value.scrollTop > 0) { if (containerRef.value.scrollTop > 0) {
containerRef.value.scrollTo({ containerRef.value.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
}); });
console.log('Scroll sur containerRef effectué'); // Debug
return; return;
} }
} }
@@ -231,7 +236,6 @@ import ReaderPage from './ReaderPage.vue';
while (currentElement) { while (currentElement) {
const styles = window.getComputedStyle(currentElement); const styles = window.getComputedStyle(currentElement);
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) { if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
currentElement.scrollTo({ currentElement.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
@@ -242,7 +246,6 @@ import ReaderPage from './ReaderPage.vue';
} }
// Stratégie 3: Scroll sur la fenêtre entière // Stratégie 3: Scroll sur la fenêtre entière
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
window.scrollTo({ window.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
@@ -258,7 +261,8 @@ import ReaderPage from './ReaderPage.vue';
watch( watch(
() => props.pages, () => props.pages,
() => { () => {
setupIntersectionObserver(); mountedPageIndices.clear();
setupObservers();
}, },
{ immediate: true } { immediate: true }
); );
@@ -277,8 +281,7 @@ import ReaderPage from './ReaderPage.vue';
}; };
onMounted(() => { onMounted(() => {
currentVisibleIndex.value = props.initialPage; setupObservers();
setupIntersectionObserver();
// Activer l'auto-hide du header si la largeur < 1200px // Activer l'auto-hide du header si la largeur < 1200px
if (windowWidth.value < 1200) { if (windowWidth.value < 1200) {
@@ -298,9 +301,8 @@ import ReaderPage from './ReaderPage.vue';
}); });
onUnmounted(() => { onUnmounted(() => {
if (observer.value) { observer.value?.disconnect();
observer.value.disconnect(); visibilityObserver.value?.disconnect();
}
// Désactiver l'auto-hide du header en quittant // Désactiver l'auto-hide du header en quittant
headerStore.disableAutoHide(); headerStore.disableAutoHide();
@@ -335,25 +337,34 @@ import ReaderPage from './ReaderPage.vue';
} }
.page-placeholder { .page-placeholder {
@apply flex justify-center; @apply w-full;
background: transparent; max-width: 1200px;
min-height: 400px;
} }
.loading,
.error { .error {
@apply text-red-500 text-xl bg-red-500/10 rounded-lg flex items-center justify-center min-h-[400px]; @apply flex items-center justify-center min-h-[400px];
width: 95vw; /* Largeur adaptative selon la taille d'écran */
width: 95vw; /* Mobile : 95% de la largeur */
} }
@screen sm { @screen sm {
.loading,
.error { .error {
width: 80vw; width: 80vw; /* Tablette : 80% de la largeur */
} }
} }
@screen lg { @screen lg {
.loading,
.error { .error {
width: 70vw; width: 70vw; /* Desktop : 70% de la largeur */
} }
} }
.error {
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
}
</style> </style>

View File

@@ -15,7 +15,6 @@
:alt="`Page ${pageNumber} (Double page)`" :alt="`Page ${pageNumber} (Double page)`"
class="page-image rotated" class="page-image rotated"
:style="doublePageRotatedStyle" :style="doublePageRotatedStyle"
:loading="loading"
@load="handleImageLoad" @load="handleImageLoad"
ref="imageRef" /> ref="imageRef" />
<div class="rotation-hint"> <div class="rotation-hint">
@@ -34,7 +33,6 @@
:alt="`Page ${pageNumber} (Double page)`" :alt="`Page ${pageNumber} (Double page)`"
class="page-image scrollable" class="page-image scrollable"
:style="doublePageScrollStyle" :style="doublePageScrollStyle"
:loading="loading"
@load="handleImageLoad" @load="handleImageLoad"
ref="imageRef" /> ref="imageRef" />
</div> </div>
@@ -54,7 +52,6 @@
:alt="`Page ${pageNumber}`" :alt="`Page ${pageNumber}`"
class="page-image" class="page-image"
:style="imageStyle" :style="imageStyle"
:loading="loading"
@load="handleImageLoad" @load="handleImageLoad"
ref="imageRef" /> ref="imageRef" />
</div> </div>
@@ -82,9 +79,9 @@ import { useReaderStore } from '../../application/store/readerStore';
default: 'rotate', // 'rotate', 'scroll', 'normal' default: 'rotate', // 'rotate', 'scroll', 'normal'
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value) validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
}, },
loading: { windowWidth: {
type: String, type: Number,
default: 'lazy', default: null
} }
}); });
@@ -103,8 +100,11 @@ import { useReaderStore } from '../../application/store/readerStore';
const scrollContainerRef = ref(null); const scrollContainerRef = ref(null);
const naturalWidth = ref(0); const naturalWidth = ref(0);
const naturalHeight = ref(0); const naturalHeight = ref(0);
const windowWidth = ref(window.innerWidth); const localWindowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 768); const effectiveWindowWidth = computed(() =>
props.windowWidth !== null ? props.windowWidth : localWindowWidth.value
);
const isMobile = computed(() => effectiveWindowWidth.value < 768);
const imageLoaded = ref(false); const imageLoaded = ref(false);
const imageSource = computed(() => { const imageSource = computed(() => {
@@ -123,17 +123,13 @@ import { useReaderStore } from '../../application/store/readerStore';
// Utiliser d'abord les dimensions de l'API si disponibles // Utiliser d'abord les dimensions de l'API si disponibles
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) { if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height; const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
const isDouble = ratio > threshold; return ratio > threshold;
console.log(`API Dimensions - Page ${props.pageNumber}: ${props.pageData.dimensions.width}x${props.pageData.dimensions.height}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
return isDouble;
} }
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée) // Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) { if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
const ratio = naturalWidth.value / naturalHeight.value; const ratio = naturalWidth.value / naturalHeight.value;
const isDouble = ratio > threshold; return ratio > threshold;
console.log(`Natural Dimensions - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
return isDouble;
} }
return false; return false;
@@ -144,7 +140,6 @@ import { useReaderStore } from '../../application/store/readerStore';
naturalWidth.value = imageRef.value.naturalWidth; naturalWidth.value = imageRef.value.naturalWidth;
naturalHeight.value = imageRef.value.naturalHeight; naturalHeight.value = imageRef.value.naturalHeight;
imageLoaded.value = true; imageLoaded.value = true;
console.log(`Image loaded - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}`);
// Positionner le scroll à droite si c'est le mode scroll // Positionner le scroll à droite si c'est le mode scroll
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) { if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
@@ -195,7 +190,7 @@ import { useReaderStore } from '../../application/store/readerStore';
if (!width || !height) return null; if (!width || !height) return null;
const availableWidth = windowWidth.value; const availableWidth = effectiveWindowWidth.value;
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur // Si la largeur disponible est < 1200px : utiliser 95% de la largeur
if (availableWidth < 1200) { if (availableWidth < 1200) {
@@ -244,7 +239,7 @@ import { useReaderStore } from '../../application/store/readerStore';
if (!width || !height) return {}; if (!width || !height) return {};
// En mode rotation : maximiser l'utilisation de l'espace // En mode rotation : maximiser l'utilisation de l'espace
const availableWidth = windowWidth.value; const availableWidth = effectiveWindowWidth.value;
const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles
// Après rotation, la largeur originale devient la hauteur affichée // Après rotation, la largeur originale devient la hauteur affichée
@@ -294,20 +289,18 @@ import { useReaderStore } from '../../application/store/readerStore';
}; };
}); });
// Gestion du redimensionnement de la fenêtre let ownResizeHandler = null;
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => { onMounted(() => {
if (imageRef.value && imageRef.value.complete) { if (props.windowWidth === null) {
handleImageLoad(); ownResizeHandler = () => { localWindowWidth.value = window.innerWidth; };
window.addEventListener('resize', ownResizeHandler, { passive: true });
} }
window.addEventListener('resize', handleResize); if (imageRef.value?.complete) handleImageLoad();
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize); if (ownResizeHandler) window.removeEventListener('resize', ownResizeHandler);
}); });
</script> </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;