Compare commits
7 Commits
2cefea3f72
...
feat/disco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420c3922c2 | ||
|
|
814fe46ce5 | ||
|
|
65453c87e5 | ||
|
|
78897eda4a | ||
| 02ad36fb34 | |||
| 929a7d0d61 | |||
|
|
9f83f9c137 |
142
.claude/skills/task-workflow/SKILL.md
Normal file
142
.claude/skills/task-workflow/SKILL.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
name: task-workflow
|
||||||
|
description: Workflow complet pour traiter une tâche du TASK.md — branche git, développement, tests, commit conventionnel, push, puis archivage dans DONE.md. Utiliser quand l'utilisateur veut implémenter une tâche listée dans TASK.md.
|
||||||
|
allowed-tools: Read, Bash, Edit, Write, Glob, Grep
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workflow de traitement d'une tâche (TASK.md → DONE.md)
|
||||||
|
|
||||||
|
Quand l'utilisateur demande de traiter une tâche du `TASK.md`, suivre **dans l'ordre** les étapes ci-dessous.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Étape 0 — Repartir d'une branche saine depuis `origin/main`
|
||||||
|
|
||||||
|
**IMPORTANT : toujours commencer par cette étape, sans exception.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout main
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensuite seulement créer la branche de travail (voir étape 2).
|
||||||
|
|
||||||
|
> Règle : ne jamais partir d'une branche de feature existante. Toujours tirer depuis `main` à jour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 1 — Lire et choisir la tâche
|
||||||
|
|
||||||
|
1. Lire `TASK.md` pour identifier la tâche à traiter (si non précisée, demander laquelle).
|
||||||
|
2. Extraire : le titre, les fichiers impactés, et la liste des sous-tâches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 2 — Créer une branche git
|
||||||
|
|
||||||
|
Nommer la branche d'après le type et le titre de la tâche :
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>/<slug-de-la-tache>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples de types : `feat`, `fix`, `style`, `refactor`, `test`, `chore`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b style/simplifier-table-homepage
|
||||||
|
```
|
||||||
|
|
||||||
|
Règle : **ne jamais committer directement sur `main`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 3 — Implémenter la tâche
|
||||||
|
|
||||||
|
- Lire tous les fichiers mentionnés dans la tâche avant de les modifier.
|
||||||
|
- Cocher mentalement chaque sous-tâche `[ ]` au fur et à mesure.
|
||||||
|
- Respecter les skills existants selon les fichiers touchés :
|
||||||
|
- Composant Vue → skill `vue-frontend`
|
||||||
|
- Domaine PHP → skills `ddd-core`, `hexagonal-arch`, `cqrs`, `api-platform`
|
||||||
|
- Tests → skill `testing-strategy`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 4 — Vérifier que tous les tests passent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
- Si des tests échouent, **corriger avant de continuer**.
|
||||||
|
- Ne pas passer à l'étape suivante tant que la suite n'est pas verte.
|
||||||
|
- Pour un test spécifique : `make test f="NomDeLaClasse"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 5 — Commit conventionnel
|
||||||
|
|
||||||
|
Format Conventional Commits :
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description courte en français>
|
||||||
|
|
||||||
|
[corps optionnel : explication du pourquoi]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types autorisés :** `feat`, `fix`, `style`, `refactor`, `test`, `chore`, `docs`
|
||||||
|
|
||||||
|
**Scope :** nom du domaine ou du composant impacté (ex: `manga-table`, `sidebar`, `homepage`)
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
```
|
||||||
|
style(manga-table): simplifier le wrapper card + hover vert sur le titre
|
||||||
|
fix(sidebar): séparer toggle et navigation sur MenuGroup
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add <fichiers modifiés>
|
||||||
|
git commit -m "style(manga-table): simplifier le wrapper card + hover vert sur le titre"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 6 — Push de la branche
|
||||||
|
|
||||||
|
**Demander confirmation à l'utilisateur avant de pusher.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin <nom-de-la-branche>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 7 — Archiver la tâche dans DONE.md
|
||||||
|
|
||||||
|
1. Retirer le bloc de la tâche de `TASK.md` (section complète, du titre `##` jusqu'au `---` suivant).
|
||||||
|
2. Ajouter la tâche dans `DONE.md` (créer le fichier s'il n'existe pas) avec la date et le sha du commit :
|
||||||
|
|
||||||
|
Format dans `DONE.md` :
|
||||||
|
```markdown
|
||||||
|
## [TYPE] Titre de la tâche — YYYY-MM-DD
|
||||||
|
|
||||||
|
> Branche : `<nom-de-la-branche>` | Commit : `<sha court>`
|
||||||
|
|
||||||
|
- [x] Sous-tâche 1
|
||||||
|
- [x] Sous-tâche 2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé du flux
|
||||||
|
|
||||||
|
```
|
||||||
|
fetch + checkout main + pull (branche saine)
|
||||||
|
→ branche git depuis main
|
||||||
|
→ TASK.md (choisir la tâche)
|
||||||
|
→ implémentation
|
||||||
|
→ make test (vert obligatoire)
|
||||||
|
→ conventional commit
|
||||||
|
→ push (après confirmation)
|
||||||
|
→ DONE.md
|
||||||
|
```
|
||||||
223
.claude/skills/ui-style/SKILL.md
Normal file
223
.claude/skills/ui-style/SKILL.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
---
|
||||||
|
name: ui-style
|
||||||
|
description: Design system et harmonisation UI de Mangarr — layout de page canonique (Toolbar + flex + sections border-t), palette Tailwind, patterns composants (boutons, badges, upload, progression). Utiliser quand on crée ou modifie une page Vue ou un composant UI.
|
||||||
|
allowed-tools: Read, Grep, Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design system Mangarr — Guide UI
|
||||||
|
|
||||||
|
Les pages de référence canoniques sont :
|
||||||
|
- `assets/vue/app/domain/manga/infrastructure/presentation/pages/NewImportPage.vue`
|
||||||
|
- `assets/vue/app/domain/conversion/infrastructure/presentation/pages/ConversionPage.vue`
|
||||||
|
|
||||||
|
En cas de doute, les lire pour vérifier le pattern en vigueur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Layout de page canonique
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<div class="px-6 py-8">
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
|
Titre section
|
||||||
|
</h2>
|
||||||
|
<!-- contenu -->
|
||||||
|
</section>
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
|
<!-- section suivante -->
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règles absolues :**
|
||||||
|
- `flex flex-col h-full` toujours à la racine du template
|
||||||
|
- `<Toolbar>` toujours en premier enfant direct de la racine
|
||||||
|
- `overflow-y-auto flex-1` pour le contenu scrollable
|
||||||
|
- `px-6 py-8` comme wrapper interne — **jamais** `container mx-auto`
|
||||||
|
- Chaque bloc logique = une `<section>` avec `border-t border-gray-200 dark:border-gray-700`
|
||||||
|
- **Jamais** de `<h1>` volant dans le contenu — le titre de page va dans `toolbarConfig.leftSection`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configuration Toolbar
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { SomeIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{ type: 'label', text: 'Titre de la page', class: 'text-sm font-medium' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: SomeIcon,
|
||||||
|
label: 'Action principale',
|
||||||
|
onClick: handler,
|
||||||
|
disabled: condition,
|
||||||
|
},
|
||||||
|
// Bouton conditionnel :
|
||||||
|
...(showAction ? [{
|
||||||
|
type: 'button',
|
||||||
|
icon: OtherIcon,
|
||||||
|
label: 'Action contextuelle',
|
||||||
|
onClick: otherHandler,
|
||||||
|
}] : []),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
- Icônes : Heroicons v24/outline (`@heroicons/vue/24/outline`)
|
||||||
|
- Boutons toolbar visibles uniquement si pertinents — utiliser le spread conditionnel
|
||||||
|
- `rightSection` peut être vide `[]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Headers de section
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Header simple -->
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
|
Titre
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Header avec info contextuelle à droite -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||||
|
Titre
|
||||||
|
</h2>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">info contextuelle</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Palette de couleurs
|
||||||
|
|
||||||
|
| Usage | Classes Tailwind |
|
||||||
|
|-------|-----------------|
|
||||||
|
| Primaire (action principale) | `bg-green-600 hover:bg-green-700` |
|
||||||
|
| Secondaire | `bg-blue-600 hover:bg-blue-700` |
|
||||||
|
| Danger | `bg-red-600 hover:bg-red-700` |
|
||||||
|
| Désactivé | `disabled:bg-gray-400 disabled:cursor-not-allowed` |
|
||||||
|
| Texte principal | `text-gray-900 dark:text-gray-100` |
|
||||||
|
| Texte secondaire | `text-gray-600 dark:text-gray-300` |
|
||||||
|
| Texte subtil | `text-gray-500 dark:text-gray-400` |
|
||||||
|
| Étiquette section | `text-gray-400 dark:text-gray-500` |
|
||||||
|
| Fond carte / panel | `bg-white dark:bg-gray-800` |
|
||||||
|
| Bordure | `border-gray-200 dark:border-gray-700` |
|
||||||
|
| Séparateur de liste | `divide-y divide-gray-100 dark:divide-gray-700/50` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Boutons
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Bouton action principale (submit, lancer, confirmer) -->
|
||||||
|
<button
|
||||||
|
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 rounded-md font-medium transition-colors"
|
||||||
|
:disabled="condition"
|
||||||
|
>
|
||||||
|
Label
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Bouton ghost / discret -->
|
||||||
|
<button class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||||
|
Label
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Barre de progression
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-1.5 mb-4">
|
||||||
|
<div
|
||||||
|
class="bg-green-600 h-1.5 transition-all duration-300"
|
||||||
|
:style="{ width: progress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important :** toujours `bg-green-600`, jamais `bg-blue-600` pour les barres de progression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Liste avec séparateurs
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center justify-between py-3"
|
||||||
|
>
|
||||||
|
<!-- contenu de l'item -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Zone de drop / upload de fichier
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
|
||||||
|
:class="isDragging
|
||||||
|
? 'border-green-500 bg-green-50 dark:bg-green-900/10'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
|
||||||
|
@dragover.prevent="isDragging = true"
|
||||||
|
@dragleave="isDragging = false"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<SomeIcon class="mx-auto h-8 w-8 text-gray-400 mb-3" />
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Message principal
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
Précision format/taille
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Pages non conformes à corriger
|
||||||
|
|
||||||
|
Les pages suivantes dévient encore du pattern canonique :
|
||||||
|
|
||||||
|
| Page | Chemin relatif | Déviations principales |
|
||||||
|
|------|---------------|----------------------|
|
||||||
|
| `HomePage.vue` | `domain/manga/.../pages/` | Pas de `px-6 py-8`, pas de sections `border-t` |
|
||||||
|
| `AddManga.vue` | `domain/manga/.../pages/` | Pas de Toolbar, pas de `flex flex-col h-full` |
|
||||||
|
| `ActivityPage.vue` | `domain/activity/.../pages/` | Pas de `flex flex-col`, pas de Toolbar intégré |
|
||||||
|
| `UserPreferencesPage.vue` | `domain/setting/.../pages/` | `h1` volant, pas de Toolbar |
|
||||||
|
| `ScrapperConfigurations.vue` | `domain/setting/.../pages/` | `h1` volant, `container mx-auto` |
|
||||||
|
| `ScrapperEdit.vue` | `domain/setting/.../pages/` | `container mx-auto` au lieu de `px-6 py-8` |
|
||||||
|
| `MangaDetails.vue` | `domain/manga/.../pages/` | Layout spécial (cover + chapitres), à traiter séparément |
|
||||||
|
| `ChapterPage.vue` | `domain/reader/.../pages/` | Layout lecteur spécialisé — **exception justifiée**, ne pas modifier |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Checklist avant de livrer une page
|
||||||
|
|
||||||
|
- [ ] Racine : `flex flex-col h-full`
|
||||||
|
- [ ] Premier enfant : `<Toolbar :config="toolbarConfig" />`
|
||||||
|
- [ ] Contenu scrollable : `overflow-y-auto flex-1`
|
||||||
|
- [ ] Wrapper interne : `px-6 py-8` (jamais `container mx-auto`)
|
||||||
|
- [ ] Blocs logiques : `<section class="border-t border-gray-200 dark:border-gray-700 pt-6">`
|
||||||
|
- [ ] Titre de page dans `toolbarConfig.leftSection`, pas de `<h1>` dans le contenu
|
||||||
|
- [ ] Headers de section : classes `text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider`
|
||||||
|
- [ ] Barres de progression : `bg-green-600` (pas `bg-blue-600`)
|
||||||
|
- [ ] Dark mode : chaque couleur a sa variante `dark:`
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -39,3 +39,10 @@ yarn-error.log
|
|||||||
src/Controller/TestController.php
|
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/
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
192
assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
Normal file
192
assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<div class="px-6 py-8">
|
||||||
|
|
||||||
|
<!-- État de chargement -->
|
||||||
|
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||||
|
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
|
||||||
|
<span class="text-sm">Chargement des recommandations...</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Message d'erreur -->
|
||||||
|
<section v-else-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Résultats -->
|
||||||
|
<section v-else-if="discoverResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Recommandations</h2>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ discoverResults.length }} manga(s)</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
|
<div
|
||||||
|
v-for="manga in discoverResults"
|
||||||
|
:key="manga.externalId"
|
||||||
|
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
|
||||||
|
@click="openMangaModal(manga)">
|
||||||
|
<img
|
||||||
|
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||||
|
alt=""
|
||||||
|
class="h-36 w-24 object-cover flex-shrink-0"
|
||||||
|
referrerpolicy="no-referrer" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
|
||||||
|
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Collection locale vide -->
|
||||||
|
<section v-else-if="!loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Ajoutez des manga pour obtenir des recommandations.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de détail -->
|
||||||
|
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||||
|
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
|
|
||||||
|
<!-- En-tête avec couverture -->
|
||||||
|
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<img
|
||||||
|
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
|
||||||
|
:alt="selectedManga.title"
|
||||||
|
class="h-64 w-44 object-cover flex-shrink-0"
|
||||||
|
referrerpolicy="no-referrer" />
|
||||||
|
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
|
||||||
|
{{ selectedManga.title }}
|
||||||
|
</DialogTitle>
|
||||||
|
<div class="mt-3 space-y-1.5">
|
||||||
|
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Publication</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Statut</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Note</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
|
||||||
|
<span
|
||||||
|
v-for="genre in selectedManga.genres"
|
||||||
|
:key="genre"
|
||||||
|
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ genre }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||||
|
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||||
|
{{ selectedManga.description }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeModal"
|
||||||
|
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addManga"
|
||||||
|
:disabled="adding"
|
||||||
|
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
|
||||||
|
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
|
||||||
|
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||||
|
import { ArrowPathIcon, ArrowPathRoundedSquareIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const mangaStore = useMangaStore();
|
||||||
|
|
||||||
|
const isModalOpen = ref(false);
|
||||||
|
const selectedManga = ref(null);
|
||||||
|
|
||||||
|
const { discoverResults, loadingDiscover: loading, discoverError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||||
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{ type: 'label', text: 'Découvrir', class: 'text-sm font-medium' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowPathRoundedSquareIcon,
|
||||||
|
label: 'Actualiser',
|
||||||
|
onClick: () => mangaStore.loadDiscoverRecommendations(),
|
||||||
|
disabled: loading.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mangaStore.loadDiscoverRecommendations();
|
||||||
|
});
|
||||||
|
|
||||||
|
const openMangaModal = manga => {
|
||||||
|
selectedManga.value = manga;
|
||||||
|
isModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isModalOpen.value = false;
|
||||||
|
selectedManga.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addManga = async () => {
|
||||||
|
if (!selectedManga.value) return;
|
||||||
|
try {
|
||||||
|
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||||
|
router.push('/manga');
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur d'ajout:", e);
|
||||||
|
} finally {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -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',
|
||||||
|
|||||||
7
src/Domain/Manga/Application/Query/DiscoverManga.php
Normal file
7
src/Domain/Manga/Application/Query/DiscoverManga.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\Query;
|
||||||
|
|
||||||
|
readonly class DiscoverManga
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\QueryHandler;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Query\DiscoverManga;
|
||||||
|
use App\Domain\Manga\Application\Response\MangaSearchItem;
|
||||||
|
use App\Domain\Manga\Application\Response\MangaSearchResponse;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
|
||||||
|
readonly class DiscoverMangaHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
|
private MangaProviderInterface $mangaProvider
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(DiscoverManga $query): MangaSearchResponse
|
||||||
|
{
|
||||||
|
$localMangas = $this->mangaRepository->findAll(page: 1, limit: 1000);
|
||||||
|
|
||||||
|
$ownedExternalIds = [];
|
||||||
|
$mangasWithRating = [];
|
||||||
|
foreach ($localMangas as $manga) {
|
||||||
|
if (!$manga->getExternalId()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ownedExternalIds[] = $manga->getExternalId()->getValue();
|
||||||
|
$mangasWithRating[] = $manga;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($mangasWithRating, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0));
|
||||||
|
$sourceIds = array_map(
|
||||||
|
fn (Manga $m) => $m->getExternalId()->getValue(),
|
||||||
|
array_slice($mangasWithRating, 0, 5)
|
||||||
|
);
|
||||||
|
|
||||||
|
$collection = $this->mangaProvider->discover($sourceIds);
|
||||||
|
|
||||||
|
$recommendations = array_values(array_filter(
|
||||||
|
$collection->getItems(),
|
||||||
|
fn (Manga $m) => $m->getExternalId() === null
|
||||||
|
|| !in_array($m->getExternalId()->getValue(), $ownedExternalIds, true)
|
||||||
|
));
|
||||||
|
|
||||||
|
return new MangaSearchResponse(
|
||||||
|
array_map(
|
||||||
|
fn (Manga $manga, int $index) => new MangaSearchItem(
|
||||||
|
id: $index,
|
||||||
|
externalId: $manga->getExternalId()->getValue(),
|
||||||
|
title: $manga->getTitle()->getValue(),
|
||||||
|
slug: $manga->getSlug()->getValue(),
|
||||||
|
description: $manga->getDescription(),
|
||||||
|
author: $manga->getAuthor(),
|
||||||
|
publicationYear: $manga->getPublicationYear(),
|
||||||
|
genres: $manga->getGenres(),
|
||||||
|
status: $manga->getStatus(),
|
||||||
|
imageUrl: $manga->getImageUrl(),
|
||||||
|
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
|
||||||
|
rating: $manga->getRating()
|
||||||
|
),
|
||||||
|
$recommendations,
|
||||||
|
array_keys($recommendations)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\DiscoverMangaStateProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'MangaDiscover',
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/manga-discover',
|
||||||
|
output: MangaSearchCollection::class,
|
||||||
|
provider: DiscoverMangaStateProvider::class
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class MangaDiscoverResource
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Domain\Manga\Application\Query\DiscoverManga;
|
||||||
|
use App\Domain\Manga\Application\QueryHandler\DiscoverMangaHandler;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchItem;
|
||||||
|
|
||||||
|
readonly class DiscoverMangaStateProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(private DiscoverMangaHandler $handler)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): MangaSearchCollection
|
||||||
|
{
|
||||||
|
$response = $this->handler->handle(new DiscoverManga());
|
||||||
|
|
||||||
|
return new MangaSearchCollection(
|
||||||
|
items: array_map(
|
||||||
|
fn ($item) => new MangaSearchItem(
|
||||||
|
externalId: $item->externalId,
|
||||||
|
title: $item->title,
|
||||||
|
slug: $item->slug,
|
||||||
|
description: $item->description,
|
||||||
|
author: $item->author,
|
||||||
|
publicationYear: $item->publicationYear,
|
||||||
|
genres: $item->genres,
|
||||||
|
status: $item->status,
|
||||||
|
imageUrl: $item->imageUrl,
|
||||||
|
thumbnailUrl: $item->thumbnailUrl,
|
||||||
|
rating: $item->rating
|
||||||
|
),
|
||||||
|
$response->items
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -43,4 +43,9 @@ class InMemoryMangaProvider implements MangaProviderInterface
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function discover(array $sourceExternalIds): MangaCollection
|
||||||
|
{
|
||||||
|
return new MangaCollection([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user