8 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
19 changed files with 1146 additions and 225 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
.phpunit.cache/test-results
/tests/Fixtures/pages/
# Claude Code — versionner les skills partagés, ignorer les fichiers perso
!.claude/
!.claude/skills/
!.claude/skills/**
.claude/settings.local.json
.claude/projects/

View File

@@ -1,34 +1,24 @@
<template>
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
<div class="overflow-y-auto flex-1">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<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
:selected-file="conversionStore.currentFile"
:disabled="conversionStore.isProcessing"
@file-selected="handleFileSelected"
@file-cleared="handleFileClear"
/>
</section>
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="mt-6 flex justify-center">
<button
@click="handleConvert"
:disabled="conversionStore.isProcessing"
:class="[
'flex items-center gap-2 px-6 py-3 text-white font-medium rounded-lg transition-colors',
conversionStore.isProcessing
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700'
]"
>
<ArrowPathIcon :class="['w-5 h-5', conversionStore.isProcessing && 'animate-spin']" />
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
</button>
</div>
<!-- Progression -->
<section v-if="showProgress" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<ConversionProgress
v-if="showProgress"
class="mt-6"
:is-converting="conversionStore.isProcessing"
:progress="conversionStore.conversionProgress"
:is-success="conversionStore.hasSucceeded"
@@ -40,10 +30,12 @@
@download="handleDownload"
@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">
<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
@click="conversionStore.clearHistory()"
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
@@ -51,7 +43,7 @@
Effacer
</button>
</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
v-for="(conversion, index) in conversionStore.conversionHistory"
:key="index"
@@ -69,7 +61,7 @@
</div>
</div>
</div>
</div>
</section>
</div>
</div>
@@ -79,6 +71,7 @@
<script setup>
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
import { computed, onMounted } from 'vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useConversionStore } from '../../application/store/conversionStore';
import { useNotifications } from '../../../../shared/composables/useNotifications';
import ConversionProgress from '../components/ConversionProgress.vue';
@@ -92,6 +85,21 @@ const showProgress = computed(() =>
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
);
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Conversion CBR CBZ', class: 'text-sm font-medium' },
],
rightSection: [
...(conversionStore.hasSelectedFile && !conversionStore.hasSucceeded ? [{
type: 'button',
icon: ArrowPathIcon,
label: conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ',
onClick: handleConvert,
disabled: conversionStore.isProcessing,
}] : []),
],
}));
const handleFileSelected = (file) => {
conversionStore.selectFile(file);
};

View File

@@ -40,7 +40,12 @@ export const useMangaStore = defineStore('manga', {
// --- Add Manga State ---
addingManga: false,
addMangaError: null
addMangaError: null,
// --- Discover State ---
discoverResults: [],
loadingDiscover: false,
discoverError: null
}),
getters: {
@@ -170,6 +175,25 @@ export const useMangaStore = defineStore('manga', {
this.loadingSearch = false;
},
// --- Discover Actions ---
async loadDiscoverRecommendations() {
if (this.loadingDiscover) return;
this.loadingDiscover = true;
this.discoverError = null;
this.discoverResults = [];
try {
const data = await mangaRepository.discoverManga();
this.discoverResults = data.items || [];
} catch (error) {
this.discoverError = error.message;
throw error;
} finally {
this.loadingDiscover = false;
}
},
// --- Add Manga Actions ---
async createFromMangaDex(externalId) {
if (this.addingManga) return;

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

View File

@@ -1,165 +1,227 @@
<template>
<div class="overflow-y-auto h-full">
<div class="container mx-auto px-4 py-8">
<!-- Barre de recherche -->
<div class="mb-8">
<div class="flex gap-4">
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<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
type="text"
v-model="searchQuery"
@keyup.enter="performSearch"
placeholder="Rechercher un manga..."
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
<button
@click="performSearch"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Rechercher
</button>
</div>
</div>
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
</section>
<!-- État de chargement -->
<div v-if="loading" class="text-center py-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-4 text-gray-600 dark:text-gray-400">Recherche en cours...</p>
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
<span class="text-sm">Recherche en cours...</span>
</div>
</section>
<!-- Message d'erreur -->
<div v-if="error" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded relative mb-6">
{{ error }}
</div>
<section v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</section>
<!-- Résultats de recherche -->
<div v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700">
<!-- Résultats -->
<section v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Résultats</h2>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ searchResults.length }} manga(s)</span>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div
v-for="manga in searchResults"
:key="manga.externalId"
class="flex items-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)">
<img
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
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" />
<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>
</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">
<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">
<DialogPanel class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6">
<DialogTitle class="text-lg mb-4 text-gray-900 dark:text-gray-100"> Ajouter à la bibliothèque </DialogTitle>
<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]">
<div v-if="selectedManga">
<div class="flex gap-4">
<!-- En-tête avec couverture -->
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
<img
:src="selectedManga.imageUrl || '/placeholder-cover.png'"
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
:alt="selectedManga.title"
class="h-48 w-32 object-cover" />
<div class="flex-1 min-w-0">
<h4 class="text-lg text-gray-900 dark:text-gray-100">{{ selectedManga.title }}</h4>
<p class="mt-2 text-gray-700 dark:text-gray-300">
{{ truncatedDescription }}
class="h-64 w-44 object-cover flex-shrink-0"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
<div>
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
{{ selectedManga.title }}
</DialogTitle>
<div class="mt-3 space-y-1.5">
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
</p>
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Publication</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
</p>
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Statut</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
</p>
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Note</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
</p>
</div>
</div>
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
<span
v-for="genre in selectedManga.genres"
:key="genre"
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{{ genre }}
</span>
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<!-- Description -->
<div class="px-6 py-4 overflow-y-auto flex-1">
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{{ selectedManga.description }}
</p>
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
</div>
<!-- Actions -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
@click="closeModal"
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 dark:bg-gray-800">
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
Annuler
</button>
<button
type="button"
@click="addManga"
:disabled="adding"
class="px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center">
<span v-if="adding" class="mr-2">
<ArrowPathIcon class="h-5 w-5 animate-spin" />
</span>
{{ adding ? 'Ajout en cours...' : 'Ajouter' }}
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</div>
</div>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon } from '@heroicons/vue/24/solid';
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
const router = useRouter();
const route = useRoute();
const mangaStore = useMangaStore();
const router = useRouter();
const route = useRoute();
const mangaStore = useMangaStore();
const searchQuery = ref('');
const isModalOpen = ref(false);
const selectedManga = ref(null);
const searchQuery = ref('');
const hasSearched = ref(false);
const isModalOpen = ref(false);
const selectedManga = ref(null);
// Récupération des états du store
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
const truncatedDescription = computed(() => {
if (!selectedManga.value?.description) return '';
return selectedManga.value.description.length > 500
? selectedManga.value.description.slice(0, 500) + '...'
: selectedManga.value.description;
});
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Ajouter un manga', class: 'text-sm font-medium' },
],
rightSection: [
{
type: 'button',
icon: MagnifyingGlassIcon,
label: 'Rechercher',
onClick: performSearch,
disabled: !searchQuery.value.trim() || loading.value,
},
],
}));
// Effectuer la recherche au chargement si un paramètre q est présent
onMounted(() => {
let debounceTimer = null;
watch(searchQuery, newVal => {
clearTimeout(debounceTimer);
if (newVal.trim().length > 3) {
debounceTimer = setTimeout(performSearch, 500);
}
});
onMounted(() => {
const queryParam = route.query.q;
if (queryParam) {
searchQuery.value = queryParam;
performSearch();
}
});
});
// Nettoyer la recherche et les résultats lors du démontage du composant
onBeforeUnmount(() => {
onBeforeUnmount(() => {
clearTimeout(debounceTimer);
searchQuery.value = '';
mangaStore.clearSearchResults();
});
});
const performSearch = async () => {
const performSearch = async () => {
if (!searchQuery.value.trim()) return;
try {
await mangaStore.searchMangaDex(searchQuery.value);
hasSearched.value = true;
} catch (e) {
console.error('Erreur de recherche:', e);
}
};
};
const openMangaModal = manga => {
const openMangaModal = manga => {
selectedManga.value = manga;
isModalOpen.value = true;
};
};
const closeModal = () => {
const closeModal = () => {
isModalOpen.value = false;
selectedManga.value = null;
};
};
const addManga = async () => {
const addManga = async () => {
if (!selectedManga.value) return;
try {
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
router.push('/manga');
@@ -168,5 +230,5 @@ import { useMangaStore } from '../../application/store/mangaStore';
} finally {
closeModal();
}
};
};
</script>

View File

@@ -0,0 +1,192 @@
<template>
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<!-- État de chargement -->
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
<span class="text-sm">Chargement des recommandations...</span>
</div>
</section>
<!-- Message d'erreur -->
<section v-else-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</section>
<!-- Résultats -->
<section v-else-if="discoverResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Recommandations</h2>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ discoverResults.length }} manga(s)</span>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div
v-for="manga in discoverResults"
:key="manga.externalId"
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
@click="openMangaModal(manga)">
<img
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
alt=""
class="h-36 w-24 object-cover flex-shrink-0"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
</div>
</div>
</div>
</section>
<!-- Collection locale vide -->
<section v-else-if="!loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Ajoutez des manga pour obtenir des recommandations.</p>
</section>
</div>
</div>
<!-- Modal de détail -->
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
<div class="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
<!-- En-tête avec couverture -->
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
<img
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
:alt="selectedManga.title"
class="h-64 w-44 object-cover flex-shrink-0"
referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
<div>
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
{{ selectedManga.title }}
</DialogTitle>
<div class="mt-3 space-y-1.5">
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
</p>
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Publication</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
</p>
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Statut</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
</p>
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Note</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
</p>
</div>
</div>
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
<span
v-for="genre in selectedManga.genres"
:key="genre"
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{{ genre }}
</span>
</div>
</div>
</div>
<!-- Description -->
<div class="px-6 py-4 overflow-y-auto flex-1">
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{{ selectedManga.description }}
</p>
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
</div>
<!-- Actions -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
@click="closeModal"
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
Annuler
</button>
<button
type="button"
@click="addManga"
:disabled="adding"
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</div>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon, ArrowPathRoundedSquareIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
const router = useRouter();
const mangaStore = useMangaStore();
const isModalOpen = ref(false);
const selectedManga = ref(null);
const { discoverResults, loadingDiscover: loading, discoverError: error, addingManga: adding } = storeToRefs(mangaStore);
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Découvrir', class: 'text-sm font-medium' },
],
rightSection: [
{
type: 'button',
icon: ArrowPathRoundedSquareIcon,
label: 'Actualiser',
onClick: () => mangaStore.loadDiscoverRecommendations(),
disabled: loading.value,
},
],
}));
onMounted(() => {
mangaStore.loadDiscoverRecommendations();
});
const openMangaModal = manga => {
selectedManga.value = manga;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
selectedManga.value = null;
};
const addManga = async () => {
if (!selectedManga.value) return;
try {
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
router.push('/manga');
} catch (e) {
console.error("Erreur d'ajout:", e);
} finally {
closeModal();
}
};
</script>

View File

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

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;
/**
* @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 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
{
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
{
try {

View File

@@ -43,4 +43,9 @@ class InMemoryMangaProvider implements MangaProviderInterface
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
{
$this->mangas[$id] = $data;