Compare commits
51 Commits
156d2eea37
...
feat/disco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420c3922c2 | ||
|
|
814fe46ce5 | ||
|
|
65453c87e5 | ||
|
|
78897eda4a | ||
| 02ad36fb34 | |||
| 929a7d0d61 | |||
|
|
9f83f9c137 | ||
| 2cefea3f72 | |||
| 3e85167875 | |||
|
|
f72ae3cab9 | ||
| 2c7f97c8b7 | |||
|
|
1477106459 | ||
| 2243716800 | |||
| d8a47072da | |||
|
|
fb8f64ee59 | ||
| 23c1028ec6 | |||
|
|
aba8e36231 | ||
| c268b2c312 | |||
| c060e7b95e | |||
|
|
2e3abb76c3 | ||
| b40892b924 | |||
|
|
74f033f5d1 | ||
| be8a3c6de8 | |||
|
|
9c47c717d0 | ||
|
|
cc702cff19 | ||
|
|
b609fe0a45 | ||
|
|
10d10d2c2f | ||
| 74f903d78d | |||
|
|
b997b87f51 | ||
|
|
7fb73d3a69 | ||
|
|
9a4fb26b06 | ||
| 2cedd14f97 | |||
| bc0339646f | |||
|
|
7fba3c6fcb | ||
| 3791a58e3c | |||
| 798befd642 | |||
|
|
8e1c4637ba | ||
|
|
d219ed1b3b | ||
| 9a1d1954ad | |||
|
|
cc27fc4564 | ||
|
|
e1909b9804 | ||
|
|
07d3b56d1b | ||
|
|
ac19cc53ca | ||
|
|
15cb59e420 | ||
|
|
d4e456961a | ||
|
|
465a05c13b | ||
|
|
2ffe559832 | ||
|
|
5eb650df6f | ||
| b60a68cbd7 | |||
|
|
ec1ef8fe68 | ||
| 48d819ba72 |
142
.claude/skills/task-workflow/SKILL.md
Normal file
142
.claude/skills/task-workflow/SKILL.md
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: task-workflow
|
||||
description: Workflow complet pour traiter une tâche du TASK.md — branche git, développement, tests, commit conventionnel, push, puis archivage dans DONE.md. Utiliser quand l'utilisateur veut implémenter une tâche listée dans TASK.md.
|
||||
allowed-tools: Read, Bash, Edit, Write, Glob, Grep
|
||||
---
|
||||
|
||||
# Workflow de traitement d'une tâche (TASK.md → DONE.md)
|
||||
|
||||
Quand l'utilisateur demande de traiter une tâche du `TASK.md`, suivre **dans l'ordre** les étapes ci-dessous.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Étape 0 — Repartir d'une branche saine depuis `origin/main`
|
||||
|
||||
**IMPORTANT : toujours commencer par cette étape, sans exception.**
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout main
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
Ensuite seulement créer la branche de travail (voir étape 2).
|
||||
|
||||
> Règle : ne jamais partir d'une branche de feature existante. Toujours tirer depuis `main` à jour.
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — Lire et choisir la tâche
|
||||
|
||||
1. Lire `TASK.md` pour identifier la tâche à traiter (si non précisée, demander laquelle).
|
||||
2. Extraire : le titre, les fichiers impactés, et la liste des sous-tâches.
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — Créer une branche git
|
||||
|
||||
Nommer la branche d'après le type et le titre de la tâche :
|
||||
|
||||
```
|
||||
<type>/<slug-de-la-tache>
|
||||
```
|
||||
|
||||
Exemples de types : `feat`, `fix`, `style`, `refactor`, `test`, `chore`
|
||||
|
||||
```bash
|
||||
git checkout -b style/simplifier-table-homepage
|
||||
```
|
||||
|
||||
Règle : **ne jamais committer directement sur `main`**.
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — Implémenter la tâche
|
||||
|
||||
- Lire tous les fichiers mentionnés dans la tâche avant de les modifier.
|
||||
- Cocher mentalement chaque sous-tâche `[ ]` au fur et à mesure.
|
||||
- Respecter les skills existants selon les fichiers touchés :
|
||||
- Composant Vue → skill `vue-frontend`
|
||||
- Domaine PHP → skills `ddd-core`, `hexagonal-arch`, `cqrs`, `api-platform`
|
||||
- Tests → skill `testing-strategy`
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — Vérifier que tous les tests passent
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
- Si des tests échouent, **corriger avant de continuer**.
|
||||
- Ne pas passer à l'étape suivante tant que la suite n'est pas verte.
|
||||
- Pour un test spécifique : `make test f="NomDeLaClasse"`
|
||||
|
||||
---
|
||||
|
||||
## Étape 5 — Commit conventionnel
|
||||
|
||||
Format Conventional Commits :
|
||||
|
||||
```
|
||||
<type>(<scope>): <description courte en français>
|
||||
|
||||
[corps optionnel : explication du pourquoi]
|
||||
```
|
||||
|
||||
**Types autorisés :** `feat`, `fix`, `style`, `refactor`, `test`, `chore`, `docs`
|
||||
|
||||
**Scope :** nom du domaine ou du composant impacté (ex: `manga-table`, `sidebar`, `homepage`)
|
||||
|
||||
Exemples :
|
||||
```
|
||||
style(manga-table): simplifier le wrapper card + hover vert sur le titre
|
||||
fix(sidebar): séparer toggle et navigation sur MenuGroup
|
||||
```
|
||||
|
||||
```bash
|
||||
git add <fichiers modifiés>
|
||||
git commit -m "style(manga-table): simplifier le wrapper card + hover vert sur le titre"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 6 — Push de la branche
|
||||
|
||||
**Demander confirmation à l'utilisateur avant de pusher.**
|
||||
|
||||
```bash
|
||||
git push -u origin <nom-de-la-branche>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 7 — Archiver la tâche dans DONE.md
|
||||
|
||||
1. Retirer le bloc de la tâche de `TASK.md` (section complète, du titre `##` jusqu'au `---` suivant).
|
||||
2. Ajouter la tâche dans `DONE.md` (créer le fichier s'il n'existe pas) avec la date et le sha du commit :
|
||||
|
||||
Format dans `DONE.md` :
|
||||
```markdown
|
||||
## [TYPE] Titre de la tâche — YYYY-MM-DD
|
||||
|
||||
> Branche : `<nom-de-la-branche>` | Commit : `<sha court>`
|
||||
|
||||
- [x] Sous-tâche 1
|
||||
- [x] Sous-tâche 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Résumé du flux
|
||||
|
||||
```
|
||||
fetch + checkout main + pull (branche saine)
|
||||
→ branche git depuis main
|
||||
→ TASK.md (choisir la tâche)
|
||||
→ implémentation
|
||||
→ make test (vert obligatoire)
|
||||
→ conventional commit
|
||||
→ push (après confirmation)
|
||||
→ DONE.md
|
||||
```
|
||||
223
.claude/skills/ui-style/SKILL.md
Normal file
223
.claude/skills/ui-style/SKILL.md
Normal file
@@ -0,0 +1,223 @@
|
||||
---
|
||||
name: ui-style
|
||||
description: Design system et harmonisation UI de Mangarr — layout de page canonique (Toolbar + flex + sections border-t), palette Tailwind, patterns composants (boutons, badges, upload, progression). Utiliser quand on crée ou modifie une page Vue ou un composant UI.
|
||||
allowed-tools: Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Design system Mangarr — Guide UI
|
||||
|
||||
Les pages de référence canoniques sont :
|
||||
- `assets/vue/app/domain/manga/infrastructure/presentation/pages/NewImportPage.vue`
|
||||
- `assets/vue/app/domain/conversion/infrastructure/presentation/pages/ConversionPage.vue`
|
||||
|
||||
En cas de doute, les lire pour vérifier le pattern en vigueur.
|
||||
|
||||
---
|
||||
|
||||
## 1. Layout de page canonique
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
Titre section
|
||||
</h2>
|
||||
<!-- contenu -->
|
||||
</section>
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<!-- section suivante -->
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Règles absolues :**
|
||||
- `flex flex-col h-full` toujours à la racine du template
|
||||
- `<Toolbar>` toujours en premier enfant direct de la racine
|
||||
- `overflow-y-auto flex-1` pour le contenu scrollable
|
||||
- `px-6 py-8` comme wrapper interne — **jamais** `container mx-auto`
|
||||
- Chaque bloc logique = une `<section>` avec `border-t border-gray-200 dark:border-gray-700`
|
||||
- **Jamais** de `<h1>` volant dans le contenu — le titre de page va dans `toolbarConfig.leftSection`
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration Toolbar
|
||||
|
||||
```javascript
|
||||
import { computed } from 'vue';
|
||||
import { SomeIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Titre de la page', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: SomeIcon,
|
||||
label: 'Action principale',
|
||||
onClick: handler,
|
||||
disabled: condition,
|
||||
},
|
||||
// Bouton conditionnel :
|
||||
...(showAction ? [{
|
||||
type: 'button',
|
||||
icon: OtherIcon,
|
||||
label: 'Action contextuelle',
|
||||
onClick: otherHandler,
|
||||
}] : []),
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
- Icônes : Heroicons v24/outline (`@heroicons/vue/24/outline`)
|
||||
- Boutons toolbar visibles uniquement si pertinents — utiliser le spread conditionnel
|
||||
- `rightSection` peut être vide `[]`
|
||||
|
||||
---
|
||||
|
||||
## 3. Headers de section
|
||||
|
||||
```vue
|
||||
<!-- Header simple -->
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
Titre
|
||||
</h2>
|
||||
|
||||
<!-- Header avec info contextuelle à droite -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
Titre
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">info contextuelle</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Palette de couleurs
|
||||
|
||||
| Usage | Classes Tailwind |
|
||||
|-------|-----------------|
|
||||
| Primaire (action principale) | `bg-green-600 hover:bg-green-700` |
|
||||
| Secondaire | `bg-blue-600 hover:bg-blue-700` |
|
||||
| Danger | `bg-red-600 hover:bg-red-700` |
|
||||
| Désactivé | `disabled:bg-gray-400 disabled:cursor-not-allowed` |
|
||||
| Texte principal | `text-gray-900 dark:text-gray-100` |
|
||||
| Texte secondaire | `text-gray-600 dark:text-gray-300` |
|
||||
| Texte subtil | `text-gray-500 dark:text-gray-400` |
|
||||
| Étiquette section | `text-gray-400 dark:text-gray-500` |
|
||||
| Fond carte / panel | `bg-white dark:bg-gray-800` |
|
||||
| Bordure | `border-gray-200 dark:border-gray-700` |
|
||||
| Séparateur de liste | `divide-y divide-gray-100 dark:divide-gray-700/50` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Boutons
|
||||
|
||||
```vue
|
||||
<!-- Bouton action principale (submit, lancer, confirmer) -->
|
||||
<button
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 rounded-md font-medium transition-colors"
|
||||
:disabled="condition"
|
||||
>
|
||||
Label
|
||||
</button>
|
||||
|
||||
<!-- Bouton ghost / discret -->
|
||||
<button class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||
Label
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Barre de progression
|
||||
|
||||
```vue
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-1.5 mb-4">
|
||||
<div
|
||||
class="bg-green-600 h-1.5 transition-all duration-300"
|
||||
:style="{ width: progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
> **Important :** toujours `bg-green-600`, jamais `bg-blue-600` pour les barres de progression.
|
||||
|
||||
---
|
||||
|
||||
## 7. Liste avec séparateurs
|
||||
|
||||
```vue
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="flex items-center justify-between py-3"
|
||||
>
|
||||
<!-- contenu de l'item -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Zone de drop / upload de fichier
|
||||
|
||||
```vue
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
|
||||
:class="isDragging
|
||||
? 'border-green-500 bg-green-50 dark:bg-green-900/10'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<SomeIcon class="mx-auto h-8 w-8 text-gray-400 mb-3" />
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Message principal
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Précision format/taille
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Pages non conformes à corriger
|
||||
|
||||
Les pages suivantes dévient encore du pattern canonique :
|
||||
|
||||
| Page | Chemin relatif | Déviations principales |
|
||||
|------|---------------|----------------------|
|
||||
| `HomePage.vue` | `domain/manga/.../pages/` | Pas de `px-6 py-8`, pas de sections `border-t` |
|
||||
| `AddManga.vue` | `domain/manga/.../pages/` | Pas de Toolbar, pas de `flex flex-col h-full` |
|
||||
| `ActivityPage.vue` | `domain/activity/.../pages/` | Pas de `flex flex-col`, pas de Toolbar intégré |
|
||||
| `UserPreferencesPage.vue` | `domain/setting/.../pages/` | `h1` volant, pas de Toolbar |
|
||||
| `ScrapperConfigurations.vue` | `domain/setting/.../pages/` | `h1` volant, `container mx-auto` |
|
||||
| `ScrapperEdit.vue` | `domain/setting/.../pages/` | `container mx-auto` au lieu de `px-6 py-8` |
|
||||
| `MangaDetails.vue` | `domain/manga/.../pages/` | Layout spécial (cover + chapitres), à traiter séparément |
|
||||
| `ChapterPage.vue` | `domain/reader/.../pages/` | Layout lecteur spécialisé — **exception justifiée**, ne pas modifier |
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist avant de livrer une page
|
||||
|
||||
- [ ] Racine : `flex flex-col h-full`
|
||||
- [ ] Premier enfant : `<Toolbar :config="toolbarConfig" />`
|
||||
- [ ] Contenu scrollable : `overflow-y-auto flex-1`
|
||||
- [ ] Wrapper interne : `px-6 py-8` (jamais `container mx-auto`)
|
||||
- [ ] Blocs logiques : `<section class="border-t border-gray-200 dark:border-gray-700 pt-6">`
|
||||
- [ ] Titre de page dans `toolbarConfig.leftSection`, pas de `<h1>` dans le contenu
|
||||
- [ ] Headers de section : classes `text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider`
|
||||
- [ ] Barres de progression : `bg-green-600` (pas `bg-blue-600`)
|
||||
- [ ] Dark mode : chaque couleur a sa variante `dark:`
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -39,3 +39,10 @@ yarn-error.log
|
||||
src/Controller/TestController.php
|
||||
.phpunit.cache/test-results
|
||||
/tests/Fixtures/pages/
|
||||
|
||||
# Claude Code — versionner les skills partagés, ignorer les fichiers perso
|
||||
!.claude/
|
||||
!.claude/skills/
|
||||
!.claude/skills/**
|
||||
.claude/settings.local.json
|
||||
.claude/projects/
|
||||
|
||||
40
DONE.md
Normal file
40
DONE.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# DONE.md — Tâches terminées
|
||||
|
||||
## [UI] Passe sur le menu latéral (Sidebar) — 2026-03-14
|
||||
|
||||
> Branche : `style/sidebar-cleanup-and-ui-polish` | Commit : `d219ed1`
|
||||
|
||||
- [x] **`isActive` incorrect** : inclut désormais les sous-items dans le calcul (groupe Mangas actif sur `/import`)
|
||||
- [x] **Double déclenchement toggle/navigation** : chevron déplacé dans un `<button>` séparé du `RouterLink`
|
||||
- [x] **Parent items** (`MenuGroup.vue`) : ajout `hover:text-white` aligné avec le style SubMenuItem
|
||||
- [x] **SubMenuItems** (`SubMenuItem.vue`) : ajout `hover:bg-gray-700` pour harmoniser avec le parent
|
||||
- [x] **État actif vs hover** : logique couleur unifiée sur les deux niveaux
|
||||
|
||||
## [UI] Supprimer "Calendrier" du menu — 2026-03-14
|
||||
|
||||
> Branche : `style/sidebar-cleanup-and-ui-polish` | Commit : `d219ed1`
|
||||
|
||||
- [x] Retirer l'entrée "Calendrier" de la Sidebar
|
||||
- [x] Supprimer la route Vue Router `/calendar`
|
||||
|
||||
---
|
||||
|
||||
## [UI] Simplifier l'affichage table de la HomePage — 2026-03-14
|
||||
|
||||
> Branche : `style/simplifier-table-homepage` | Commit : `cc27fc4`
|
||||
|
||||
- [x] Supprimer le wrapper card (`bg-white shadow rounded-lg overflow-hidden`) — remplacer par un simple `border-t`
|
||||
- [x] Lien du titre : passer le hover de bleu (`hover:text-blue-600`) à vert (`hover:text-green-500`)
|
||||
- [x] Icône monitoring : remplacer `BellIcon` / `BellSlashIcon` par `BookmarkIcon` / `BookmarkSlashIcon`
|
||||
- [x] Supprimer le padding du wrapper + `container mx-auto` pour tableau pleine largeur
|
||||
|
||||
---
|
||||
|
||||
## [UI] Restyling vue grille des mangas — 2026-03-14
|
||||
|
||||
> Branche : `style/restyling-manga-grid` | Commit : `9a4fb26`
|
||||
|
||||
- [x] **Réduire la taille des cards** : grille plus dense (cols-3/4/5/7/8 selon breakpoint, gap-2)
|
||||
- [x] **Supprimer les arrondis** : retrait de `rounded-lg` et `hover:scale-105`
|
||||
- [x] **Overlay icônes au survol** : gradient + 3 boutons (éditer, sources, rafraîchir) en bas à gauche de la cover, visibles au `group-hover`
|
||||
- [x] MangaCard émet les événements, MangaGrid gère les modales (edit, sources, refresh)
|
||||
129
TASK.md
Normal file
129
TASK.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# TASK.md — Tâches à venir
|
||||
|
||||
## [Feature] Découvrir — Suggestions de mangas via MangaDex
|
||||
|
||||
**Objectif :** Page "Découvrir" qui propose des mangas populaires/récents depuis l'API MangaDex, en excluant ceux déjà présents en base (comparaison via `externalId` = ID MangaDex).
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] **Consulter la doc API MangaDex** pour identifier le(s) endpoint(s) pertinents (mangas populaires, récemment mis à jour, tendances…) et les paramètres disponibles (filtres langue, statut, contentRating, etc.)
|
||||
- [ ] **Étendre le client MangaDex existant** pour exposer le(s) nouvel(aux) endpoint(s) identifiés (nouveau(x) méthode(s) dans le client + adapter le contrat d'interface si besoin)
|
||||
- [ ] Query `GetDiscoverMangaListQuery` + handler qui appelle le client MangaDex et filtre les résultats dont l'`externalId` est déjà en base
|
||||
- [ ] Response DTO `DiscoverMangaListResponse` avec les champs nécessaires à l'affichage (id MangaDex, titre, couverture, genres, statut…)
|
||||
- [ ] State Provider API Platform sur la route `GET /api/manga/discover`
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Page `DiscoverPage.vue` avec grille de cards (réutiliser `MangaCard.vue` ou créer `DiscoverMangaCard.vue`)
|
||||
- [ ] Composable TanStack Query `useDiscoverMangaList`
|
||||
- [ ] Route Vue Router `/discover`
|
||||
- [ ] Entrée dans la Sidebar
|
||||
|
||||
---
|
||||
|
||||
## [Domain] Créer le domaine "System"
|
||||
|
||||
**Objectif :** Poser la structure DDD hexagonale du nouveau domaine `System` qui servira de socle aux fonctionnalités Status et Logs.
|
||||
|
||||
- [ ] Créer l'arborescence `src/Domain/System/Domain/`, `Application/`, `Infrastructure/`
|
||||
- [ ] Créer l'arborescence frontend `assets/vue/app/domain/system/`
|
||||
- [ ] Vérifier la conformité avec `phparkitect.php` (ajouter le domaine si nécessaire)
|
||||
|
||||
---
|
||||
|
||||
## [Feature] System — Page "Status"
|
||||
|
||||
**Objectif :** Page de monitoring affichant l'état général de l'application.
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] Query `GetSystemStatusQuery` + handler qui agrège :
|
||||
- Version de l'application (depuis `composer.json` ou variable d'env)
|
||||
- Statut des services critiques (base de données, Messenger workers, stockage)
|
||||
- Poids total des images (scan du dossier `IMAGE_DATA_PATH`)
|
||||
- Poids total des CBZ (scan du dossier `MANGA_DATA_PATH`)
|
||||
- Liens / chemins vers les dossiers de stockage configurés
|
||||
- [ ] Response DTO `SystemStatusResponse`
|
||||
- [ ] State Provider API Platform sur la route `GET /api/system/status`
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Page `StatusPage.vue` avec sections (Général, Stockage, Services)
|
||||
- [ ] Composable TanStack Query `useSystemStatus`
|
||||
- [ ] Route Vue Router `/system/status`
|
||||
|
||||
---
|
||||
|
||||
## [Feature] System — Page "Logs"
|
||||
|
||||
**Objectif :** Page de consultation des logs d'erreur des workers Messenger, avec filtres.
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] Définir le contrat `WorkerLogRepositoryInterface` dans `System/Domain/Contract/Repository/`
|
||||
- [ ] Implémenter `DoctrineWorkerLogRepository` (ou lecture des logs Monolog selon la stratégie retenue) dans `Infrastructure/`
|
||||
- [ ] Query `GetWorkerLogsQuery` avec paramètres de filtrage (date début/fin, source, niveau, worker/transport) + handler
|
||||
- [ ] Response DTO `WorkerLogListResponse` (liste paginée)
|
||||
- [ ] State Provider API Platform sur la route `GET /api/system/logs`
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Page `LogsPage.vue` avec tableau paginé + panneau de filtres
|
||||
- [ ] Filtres disponibles : plage de dates, source (transport Messenger), niveau d'erreur, manga associé (source préférée)
|
||||
- [ ] Composable TanStack Query `useWorkerLogs` (avec paramètres de filtre réactifs)
|
||||
- [ ] Route Vue Router `/system/logs`
|
||||
|
||||
---
|
||||
|
||||
## [Perf] Reader — Lazy-loading des pages (InfiniteReader)
|
||||
|
||||
**Problème :** `readerStore.js` charge toutes les pages avec `itemsPerPage=9999`. `InfiniteReader.vue` monte tous les composants `ReaderPage` simultanément dans le DOM. Sur un chapitre de 200 pages, cela représente 200 composants actifs et autant d'images pré-chargées.
|
||||
|
||||
- [ ] Implémenter un `IntersectionObserver` sur les wrappers de page pour ne charger les images qu'au moment où elles entrent dans le viewport (`loading="lazy"` ou src conditionnel)
|
||||
- [ ] Limiter le nombre de composants montés simultanément (virtualisation ou windowing) : ne rendre que les pages proches de la page courante (ex. fenêtre de ±3 pages)
|
||||
- [ ] Adapter `readerStore.js` : remplacer `itemsPerPage=9999` par la vraie pagination côté API si la virtualisation le justifie, sinon conserver le fetch unique mais différer le rendu
|
||||
- [ ] Vérifier que le mode `single` n'est pas impacté (il affiche déjà une seule page)
|
||||
|
||||
---
|
||||
|
||||
## [Bug] Reader — N+1 requêtes SQL dans `getChapterContext()`
|
||||
|
||||
**Problème :** `LegacyChapterRepository::getChapterContext()` émet 5 requêtes SQL pour un seul chargement : la requête principale + 2 doublons dans `getPreviousChapterId()` / `getNextChapterId()` (chacune re-fetche le chapitre courant) + les 2 requêtes de navigation.
|
||||
|
||||
- [ ] Refactorer `getPreviousChapterId()` et `getNextChapterId()` pour accepter l'entité `ChapterEntity` déjà chargée en paramètre (au lieu de re-fetcher par ID)
|
||||
- [ ] Appeler ces méthodes depuis `getChapterContext()` en passant l'entité déjà disponible
|
||||
- [ ] Résultat attendu : 3 requêtes maximum (1 pour le chapitre courant + 1 prev + 1 next), idéalement 1 seule avec une requête SQL combinée
|
||||
|
||||
---
|
||||
|
||||
## [Bug] Reader — Division par zéro dans `ChapterPagesResponse::getTotalPages()`
|
||||
|
||||
**Problème :** `ceil($totalItems / $itemsPerPage)` crashe si `itemsPerPage = 0`. Le test existant documente le bug avec un TODO et assert un HTTP 500 au lieu de corriger.
|
||||
|
||||
- [ ] Ajouter une validation dans `ChapterPagesProvider` : rejeter la requête avec HTTP 400 si `itemsPerPage <= 0`
|
||||
- [ ] Corriger le test `GetChapterPagesTest` pour vérifier HTTP 400 (et non 500)
|
||||
- [ ] Supprimer le commentaire TODO du test une fois corrigé
|
||||
|
||||
---
|
||||
|
||||
## [Bug] Reader — `totalPages` toujours égal à 0 dans `ChapterContext`
|
||||
|
||||
**Problème :** `LegacyChapterRepository::getChapterContext()` hardcode `totalPages: 0`. La méthode `getTotalPagesForChapter()` existe mais n'est jamais appelée depuis `GetChapterContextHandler`.
|
||||
|
||||
- [ ] Appeler `getTotalPagesForChapter()` dans `getChapterContext()` (ou dans le handler) pour calculer le vrai nombre de pages
|
||||
- [ ] Vérifier que la valeur est correctement sérialisée dans la réponse API Platform (`ChapterContextResponse`)
|
||||
- [ ] Adapter les tests existants qui pourraient asserter `totalPages: 0`
|
||||
|
||||
---
|
||||
|
||||
## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast
|
||||
|
||||
**Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application.
|
||||
|
||||
- [ ] Auditer le composant/template actuel de la page de conversion
|
||||
- [ ] Simplifier la mise en page (réduire la complexité visuelle, harmoniser avec le reste de l'UI)
|
||||
- [ ] Supprimer l'affichage inline "Conversion réussie"
|
||||
- [ ] Brancher les notifications toast existantes pour signaler le succès (et l'échec) de la conversion
|
||||
|
||||
---
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
html, body {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: white;
|
||||
}
|
||||
@@ -82,6 +87,33 @@ body {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
|
||||
/* Firefox uniquement — évite le conflit avec les pseudo-éléments webkit sur Chrome 121+ */
|
||||
@supports (-moz-appearance: none) {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #16a34a transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: #16a34a #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode — webkit track */
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
/* Supprime les flèches de la scrollbar */
|
||||
::-webkit-scrollbar-button:start:decrement,
|
||||
::-webkit-scrollbar-button:end:increment,
|
||||
::-webkit-scrollbar-button:start:increment,
|
||||
::-webkit-scrollbar-button:end:decrement {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
///* Custom styles for the scrollbar buttons */
|
||||
//::-webkit-scrollbar-button {
|
||||
// @apply bg-gray-700;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out"
|
||||
class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition duration-150 ease-in-out"
|
||||
:class="{
|
||||
'bg-yellow-50': job.status === 'pending',
|
||||
'bg-blue-50': job.status === 'in_progress',
|
||||
'bg-green-50': job.status === 'completed',
|
||||
'bg-red-50': job.status === 'failed'
|
||||
'bg-yellow-50 dark:bg-yellow-900/20': job.status === 'pending',
|
||||
'bg-blue-50 dark:bg-blue-900/20': job.status === 'in_progress',
|
||||
'bg-green-50 dark:bg-green-900/20': job.status === 'completed',
|
||||
'bg-red-50 dark:bg-red-900/20': job.status === 'failed'
|
||||
}">
|
||||
<td class="py-4 px-4 text-center">
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" />
|
||||
@@ -20,37 +20,37 @@
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-800': job.status === 'pending',
|
||||
'bg-blue-100 text-blue-800': job.status === 'in_progress',
|
||||
'bg-green-100 text-green-800': job.status === 'completed',
|
||||
'bg-red-100 text-red-800': job.status === 'failed'
|
||||
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': job.status === 'pending',
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': job.status === 'in_progress',
|
||||
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': job.status === 'completed',
|
||||
'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300': job.status === 'failed'
|
||||
}">
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div v-if="job.error" class="text-sm text-red-600">
|
||||
<div v-if="job.error" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ job.error }}
|
||||
</div>
|
||||
<div v-else-if="job.context?.mangaTitle || job.context?.chapterNumber !== undefined || job.context?.sourceId"
|
||||
class="text-sm text-gray-700 space-y-0.5">
|
||||
class="text-sm text-gray-700 dark:text-gray-300 space-y-0.5">
|
||||
<div v-if="job.context.mangaTitle" class="font-medium">
|
||||
{{ job.context.mangaTitle }}
|
||||
</div>
|
||||
<div v-if="job.context.chapterNumber !== undefined" class="text-gray-500">
|
||||
<div v-if="job.context.chapterNumber !== undefined" class="text-gray-500 dark:text-gray-400">
|
||||
Chapitre {{ job.context.chapterNumber }}
|
||||
</div>
|
||||
<div v-if="job.context.sourceId" class="text-xs text-gray-400">
|
||||
<div v-if="job.context.sourceId" class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Source : {{ job.context.sourceId }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-600">
|
||||
<div v-else class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ formatDate(job.createdAt) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div v-if="job.status === 'in_progress'" class="mt-2">
|
||||
<div class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||
:style="{ width: `${job.progress}%` }"></div>
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||
style="width: 100%"></div>
|
||||
@@ -67,7 +67,7 @@
|
||||
100%
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-red-400 transition-all duration-300 ease-out"
|
||||
style="width: 100%"></div>
|
||||
@@ -75,17 +75,17 @@
|
||||
Erreur
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-yellow-400 transition-all duration-300 ease-out"
|
||||
style="width: 0%"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600">
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||
En attente
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="job.maxAttempts > 1 || job.attempts > 0"
|
||||
class="text-xs text-gray-400 mt-1 text-center">
|
||||
class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">
|
||||
{{ job.attempts }} / {{ job.maxAttempts }} tentative{{ job.maxAttempts > 1 ? 's' : '' }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="overflow-y-auto h-full">
|
||||
<Toolbar :config="toolbarConfig" class="mb-6" />
|
||||
|
||||
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activityStore.error" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<div v-else-if="activityStore.error" class="bg-red-100 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-400 p-4 mb-6">
|
||||
<p>{{ activityStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="container mx-auto p-2">
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white">
|
||||
<table class="min-w-full bg-white dark:bg-gray-800">
|
||||
<thead>
|
||||
<tr class="bg-gray-200 text-gray-800">
|
||||
<tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
|
||||
<th class="w-1/12 py-3 px-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -29,14 +29,14 @@
|
||||
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-700">
|
||||
<tbody class="text-gray-700 dark:text-gray-300">
|
||||
<template v-if="activityStore.jobs.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="py-8 px-4 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<ClockIcon class="h-12 w-12 text-gray-300 mb-4" />
|
||||
<p class="text-lg font-medium">Aucune activité trouvée</p>
|
||||
<p class="text-sm">Aucune activité ne correspond aux filtres actuels.</p>
|
||||
<ClockIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p class="text-lg font-medium dark:text-gray-300">Aucune activité trouvée</p>
|
||||
<p class="text-sm dark:text-gray-400">Aucune activité ne correspond aux filtres actuels.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -20,7 +20,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
|
||||
// État de l'interface
|
||||
isDragOver: false,
|
||||
showSuccessMessage: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -86,7 +85,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
this.clearError();
|
||||
this.conversionSuccess = false;
|
||||
this.convertedFile = null;
|
||||
this.showSuccessMessage = false;
|
||||
|
||||
// Stockage du fichier
|
||||
this.currentFile = file;
|
||||
@@ -125,7 +123,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
// Stockage du fichier converti
|
||||
this.convertedFile = convertedFileBlob;
|
||||
this.conversionSuccess = true;
|
||||
this.showSuccessMessage = true;
|
||||
|
||||
// Ajout à l'historique
|
||||
this.addToHistory({
|
||||
@@ -171,7 +168,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
this.currentFile = null;
|
||||
this.convertedFile = null;
|
||||
this.conversionSuccess = false;
|
||||
this.showSuccessMessage = false;
|
||||
this.conversionProgress = 0;
|
||||
this.clearError();
|
||||
},
|
||||
@@ -183,7 +179,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
setError(message) {
|
||||
this.conversionError = message;
|
||||
this.conversionSuccess = false;
|
||||
this.showSuccessMessage = false;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -193,13 +188,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
this.conversionError = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cache le message de succès
|
||||
*/
|
||||
hideSuccessMessage() {
|
||||
this.showSuccessMessage = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gère l'état du drag and drop
|
||||
* @param {boolean} isDragOver - Indique si un fichier est survolé
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
|
||||
<!-- Message de statut -->
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
<p v-if="fileName" class="text-xs text-gray-500">
|
||||
<p v-if="fileName" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ fileName }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -35,11 +35,11 @@
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div v-if="showProgress" class="space-y-2">
|
||||
<div class="flex justify-between text-xs text-gray-600">
|
||||
<div class="flex justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>Progression</span>
|
||||
<span>{{ Math.round(progress) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300 ease-out"
|
||||
:style="{ width: `${progress}%` }"
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Détails de la conversion -->
|
||||
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 space-y-1">
|
||||
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<div v-if="originalSize" class="flex justify-between">
|
||||
<span>Taille originale:</span>
|
||||
<span>{{ formatFileSize(originalSize) }}</span>
|
||||
@@ -77,7 +77,7 @@
|
||||
<button
|
||||
v-if="canReset"
|
||||
@click="$emit('reset')"
|
||||
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
>
|
||||
<ArrowPathIcon class="w-4 h-4" />
|
||||
<span>Convertir un autre fichier</span>
|
||||
@@ -85,14 +85,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur détaillé -->
|
||||
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div class="flex">
|
||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">
|
||||
Erreur de conversion
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-red-700">
|
||||
<p class="mt-1 text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
:class="[
|
||||
'border-2 border-dashed rounded-lg p-8 text-center transition-all duration-200',
|
||||
isDragOver
|
||||
? 'border-green-400 bg-green-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
? 'border-green-400 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
]"
|
||||
>
|
||||
<!-- Zone d'upload -->
|
||||
@@ -28,13 +28,13 @@
|
||||
|
||||
<!-- Message principal -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ isDragOver ? 'Déposez votre fichier ici' : 'Sélectionnez un fichier CBR ou CBZ' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Glissez-déposez votre fichier ou cliquez pour le sélectionner
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Fichiers supportés: .cbr, .cbz (max. 150MB)
|
||||
</p>
|
||||
</div>
|
||||
@@ -63,20 +63,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Informations du fichier sélectionné -->
|
||||
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<DocumentIcon class="w-8 h-8 text-gray-600" />
|
||||
<DocumentIcon class="w-8 h-8 text-gray-600 dark:text-gray-400" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ selectedFile.name }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatFileSize(selectedFile.size) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="clearFile"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
class="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
title="Supprimer le fichier"
|
||||
>
|
||||
<XMarkIcon class="w-5 h-5" />
|
||||
|
||||
@@ -1,285 +1,149 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<!-- En-tête -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-3 mb-4">
|
||||
<ArrowPathIcon class="w-8 h-8 text-green-600" />
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
Convertir CBR en CBZ
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-lg text-gray-600">
|
||||
Convertissez vos fichiers CBR (Comic Book RAR) en CBZ (Comic Book ZIP) pour une meilleure compatibilité.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<!-- Zone principale -->
|
||||
<div class="bg-white shadow-lg rounded-lg overflow-hidden">
|
||||
<!-- En-tête de la carte -->
|
||||
<div class="bg-gray-800 text-white p-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<ArchiveBoxIcon class="w-6 h-6" />
|
||||
<h2 class="text-xl font-semibold">
|
||||
Conversion de fichiers
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<!-- Contenu de la carte -->
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Zone d'upload -->
|
||||
<FileUploadArea
|
||||
:selected-file="conversionStore.currentFile"
|
||||
:disabled="conversionStore.isProcessing"
|
||||
@file-selected="handleFileSelected"
|
||||
@file-cleared="handleFileClear"
|
||||
/>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Bouton de conversion -->
|
||||
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="flex justify-center">
|
||||
<button
|
||||
@click="handleConvert"
|
||||
:disabled="conversionStore.isProcessing"
|
||||
:class="[
|
||||
'flex items-center space-x-2 px-6 py-3 text-white font-medium rounded-lg transition-all duration-200',
|
||||
conversionStore.isProcessing
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2'
|
||||
]"
|
||||
>
|
||||
<ArrowPathIcon
|
||||
:class="[
|
||||
'w-5 h-5',
|
||||
conversionStore.isProcessing && 'animate-spin'
|
||||
]"
|
||||
/>
|
||||
<span>
|
||||
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Progression -->
|
||||
<section v-if="showProgress" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<ConversionProgress
|
||||
:is-converting="conversionStore.isProcessing"
|
||||
:progress="conversionStore.conversionProgress"
|
||||
:is-success="conversionStore.hasSucceeded"
|
||||
:has-error="conversionStore.hasError"
|
||||
:error-message="conversionStore.conversionError"
|
||||
:file-name="conversionStore.currentFileName"
|
||||
:original-size="conversionStore.currentFile?.size || 0"
|
||||
:converted-size="conversionStore.convertedFile?.size || 0"
|
||||
@download="handleDownload"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Progression et résultat -->
|
||||
<ConversionProgress
|
||||
v-if="showProgress"
|
||||
:is-converting="conversionStore.isProcessing"
|
||||
:progress="conversionStore.conversionProgress"
|
||||
:is-success="conversionStore.hasSucceeded"
|
||||
:has-error="conversionStore.hasError"
|
||||
:error-message="conversionStore.conversionError"
|
||||
:file-name="conversionStore.currentFileName"
|
||||
:original-size="conversionStore.currentFile?.size || 0"
|
||||
:converted-size="conversionStore.convertedFile?.size || 0"
|
||||
@download="handleDownload"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
<!-- Historique -->
|
||||
<section v-if="conversionStore.conversionCount > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Historique</h2>
|
||||
<button
|
||||
@click="conversionStore.clearHistory()"
|
||||
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="(conversion, index) in conversionStore.conversionHistory"
|
||||
:key="index"
|
||||
class="flex items-center justify-between py-3"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{{ conversion.originalName }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(conversion.timestamp) }}</p>
|
||||
</div>
|
||||
<div class="text-right text-sm">
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
||||
</p>
|
||||
<p class="text-xs text-green-600">{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Message d'information -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<InformationCircleIcon class="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">
|
||||
À propos de la conversion
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 space-y-1">
|
||||
<p>• Les fichiers CBZ sont plus largement supportés par les lecteurs de bandes dessinées</p>
|
||||
<p>• La compression ZIP permet généralement une meilleure accessibilité</p>
|
||||
<p>• Aucune perte de qualité lors de la conversion</p>
|
||||
<p>• Taille maximale supportée: 150MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historique des conversions -->
|
||||
<div v-if="conversionStore.conversionCount > 0" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Historique des conversions
|
||||
</h3>
|
||||
<button
|
||||
@click="handleClearHistory"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Effacer l'historique
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(conversion, index) in conversionStore.conversionHistory"
|
||||
:key="index"
|
||||
class="flex items-center justify-between py-2 border-b border-gray-200 last:border-b-0"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ conversion.originalName }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatDate(conversion.timestamp) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
||||
</p>
|
||||
<p class="text-xs text-green-600">
|
||||
{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast de notification -->
|
||||
<div
|
||||
v-if="conversionStore.showSuccessMessage"
|
||||
class="fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-3 z-50"
|
||||
>
|
||||
<CheckCircleIcon class="w-5 h-5" />
|
||||
<span class="font-medium">Conversion réussie !</span>
|
||||
<button
|
||||
@click="conversionStore.hideSuccessMessage()"
|
||||
class="ml-2 text-green-100 hover:text-white transition-colors"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
ArchiveBoxIcon,
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
InformationCircleIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
<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';
|
||||
import FileUploadArea from '../components/FileUploadArea.vue';
|
||||
|
||||
export default {
|
||||
name: 'ConversionPage',
|
||||
const conversionStore = useConversionStore();
|
||||
const { showSuccess, showError } = useNotifications();
|
||||
|
||||
components: {
|
||||
FileUploadArea,
|
||||
ConversionProgress,
|
||||
ArrowPathIcon,
|
||||
ArchiveBoxIcon,
|
||||
InformationCircleIcon,
|
||||
CheckCircleIcon,
|
||||
XMarkIcon,
|
||||
},
|
||||
const showProgress = computed(() =>
|
||||
conversionStore.hasSelectedFile &&
|
||||
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
|
||||
);
|
||||
|
||||
setup() {
|
||||
const conversionStore = useConversionStore();
|
||||
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,
|
||||
}] : []),
|
||||
],
|
||||
}));
|
||||
|
||||
// Computed properties
|
||||
const showProgress = computed(() => {
|
||||
return conversionStore.hasSelectedFile &&
|
||||
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError);
|
||||
});
|
||||
const handleFileSelected = (file) => {
|
||||
conversionStore.selectFile(file);
|
||||
};
|
||||
|
||||
// Event handlers
|
||||
const handleFileSelected = (file) => {
|
||||
const success = conversionStore.selectFile(file);
|
||||
if (!success) {
|
||||
// L'erreur est déjà gérée par le store
|
||||
console.warn('Fichier non valide:', file);
|
||||
}
|
||||
};
|
||||
const handleFileClear = () => {
|
||||
conversionStore.resetConversion();
|
||||
};
|
||||
|
||||
const handleFileClear = () => {
|
||||
conversionStore.resetConversion();
|
||||
};
|
||||
const handleConvert = async () => {
|
||||
if (!conversionStore.currentFile) return;
|
||||
const success = await conversionStore.convertCurrentFile();
|
||||
if (success) {
|
||||
showSuccess('Conversion réussie !');
|
||||
} else {
|
||||
showError(conversionStore.conversionError ?? 'Échec de la conversion');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvert = async () => {
|
||||
if (!conversionStore.currentFile) return;
|
||||
const handleDownload = () => conversionStore.downloadConvertedFile();
|
||||
const handleReset = () => conversionStore.resetConversion();
|
||||
|
||||
const success = await conversionStore.convertCurrentFile();
|
||||
if (success) {
|
||||
console.log('Conversion réussie');
|
||||
} else {
|
||||
console.error('Échec de la conversion');
|
||||
}
|
||||
};
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 octets';
|
||||
const k = 1024;
|
||||
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
conversionStore.downloadConvertedFile();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
conversionStore.resetConversion();
|
||||
};
|
||||
|
||||
const handleClearHistory = () => {
|
||||
conversionStore.clearHistory();
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 octets';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatDate = (isoString) => {
|
||||
const date = new Date(isoString);
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
const formatDate = (isoString) =>
|
||||
new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
}).format(new Date(isoString));
|
||||
|
||||
const calculateSaving = (originalSize, convertedSize) => {
|
||||
if (!originalSize || !convertedSize) return '';
|
||||
|
||||
const saving = ((originalSize - convertedSize) / originalSize) * 100;
|
||||
if (saving > 0) {
|
||||
return `-${saving.toFixed(1)}%`;
|
||||
} else if (saving < 0) {
|
||||
return `+${Math.abs(saving).toFixed(1)}%`;
|
||||
}
|
||||
return '0%';
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Réinitialiser l'état au montage de la page
|
||||
conversionStore.resetConversion();
|
||||
});
|
||||
|
||||
return {
|
||||
conversionStore,
|
||||
showProgress,
|
||||
handleFileSelected,
|
||||
handleFileClear,
|
||||
handleConvert,
|
||||
handleDownload,
|
||||
handleReset,
|
||||
handleClearHistory,
|
||||
formatFileSize,
|
||||
formatDate,
|
||||
calculateSaving,
|
||||
};
|
||||
},
|
||||
const calculateSaving = (originalSize, convertedSize) => {
|
||||
if (!originalSize || !convertedSize) return '';
|
||||
const saving = ((originalSize - convertedSize) / originalSize) * 100;
|
||||
if (saving > 0) return `-${saving.toFixed(1)}%`;
|
||||
if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`;
|
||||
return '0%';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Styles spécifiques si nécessaires */
|
||||
</style>
|
||||
onMounted(() => conversionStore.resetConversion());
|
||||
</script>
|
||||
|
||||
@@ -1,228 +1,150 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- File Icon and Info -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
|
||||
<!-- File Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900 truncate">
|
||||
{{ file.filename }}
|
||||
</h3>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{{ file.getFormattedSize() }} • {{ file.getFileExtension().toUpperCase() }}
|
||||
</p>
|
||||
|
||||
<!-- Extracted Info -->
|
||||
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
|
||||
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 text-blue-700">
|
||||
Chapitre {{ file.getExtractedChapterNumber() }}
|
||||
</span>
|
||||
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 text-purple-700">
|
||||
Volume {{ file.getExtractedVolumeNumber() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Erreur</h3>
|
||||
<div class="mt-2 text-sm text-red-700">{{ file.errorMessage }}</div>
|
||||
<!-- Row principal : icône, nom, statut, actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||
<DocumentIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ file.getFormattedSize() }} · {{ file.getFileExtension().toUpperCase() }}
|
||||
<span v-if="file.isAnalyzed() && file.getExtractedChapterNumber()" class="ml-2 text-green-600 dark:text-green-400">
|
||||
Ch. {{ file.getExtractedChapterNumber() }}
|
||||
</span>
|
||||
<span v-if="file.isAnalyzed() && file.getExtractedVolumeNumber()" class="ml-2 text-green-600 dark:text-green-400">
|
||||
Vol. {{ file.getExtractedVolumeNumber() }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
||||
|
||||
<button
|
||||
v-if="file.isReadyForImport()"
|
||||
@click="$emit('import-file')"
|
||||
:disabled="isImporting"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
<ArrowUpTrayIcon class="w-3.5 h-3.5" />
|
||||
Importer
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="file.hasError()"
|
||||
@click="$emit('retry-file')"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="$emit('remove-file')"
|
||||
class="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manga Selection -->
|
||||
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">
|
||||
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
|
||||
</label>
|
||||
<!-- Message d'erreur -->
|
||||
<div v-if="file.hasError()" class="mt-2 flex items-start gap-2 text-xs text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-3 py-2">
|
||||
<ExclamationCircleIcon class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
{{ file.errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Matches Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<MangaMatchCard
|
||||
v-for="match in sortedMatches"
|
||||
:key="match.id"
|
||||
:match="match"
|
||||
:is-selected="file.selectedManga?.id === match.id"
|
||||
@select-match="handleMangaSelection"
|
||||
/>
|
||||
<!-- Aucun manga trouvé -->
|
||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-2 flex items-start gap-2 text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-3 py-2">
|
||||
<ExclamationTriangleIcon class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
Aucun manga correspondant trouvé. Vérifiez le nom du fichier.
|
||||
</div>
|
||||
|
||||
<!-- Sélection du manga -->
|
||||
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-3 space-y-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{{ file.getMatches().length }} correspondance(s)
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||
<MangaMatchCard
|
||||
v-for="match in sortedMatches"
|
||||
:key="match.id"
|
||||
:match="match"
|
||||
:is-selected="file.selectedManga?.id === match.id"
|
||||
@select-match="handleMangaSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Manga Preview -->
|
||||
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<img
|
||||
v-if="file.selectedManga.thumbnailUrl"
|
||||
:src="file.selectedManga.thumbnailUrl"
|
||||
:alt="file.selectedManga.title"
|
||||
class="w-12 h-16 object-cover rounded"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900">{{ file.selectedManga.title }}</p>
|
||||
<p class="text-sm text-gray-500">{{ file.selectedManga.slug }}</p>
|
||||
<p class="text-xs text-blue-600 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter/Volume Number Inputs -->
|
||||
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
|
||||
<!-- Chapter Number -->
|
||||
<!-- Numéros de chapitre / volume -->
|
||||
<div v-if="file.selectedManga" class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Numéro de chapitre
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedChapterNumber ?? ''"
|
||||
@input="handleChapterNumberInput"
|
||||
:disabled="file.selectedVolumeNumber !== null"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Chapitre</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedChapterNumber ?? ''"
|
||||
@input="handleChapterNumberInput"
|
||||
:disabled="file.selectedVolumeNumber !== null"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:text-gray-400"
|
||||
placeholder="Ex: 1, 1.5..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Volume Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Numéro de volume
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedVolumeNumber ?? ''"
|
||||
@input="handleVolumeNumberInput"
|
||||
:disabled="file.selectedChapterNumber !== null"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Volume</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedVolumeNumber ?? ''"
|
||||
@input="handleVolumeNumberInput"
|
||||
:disabled="file.selectedChapterNumber !== null"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:text-gray-400"
|
||||
placeholder="Ex: 1, 1.5..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Matches Message -->
|
||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Aucun manga trouvé</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<div class="flex space-x-3">
|
||||
<!-- Import Button -->
|
||||
<button
|
||||
v-if="file.isReadyForImport()"
|
||||
@click="$emit('import-file')"
|
||||
:disabled="isImporting"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md text-sm font-medium flex items-center"
|
||||
>
|
||||
<svg v-if="isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ isImporting ? 'Import en cours...' : 'Importer' }}
|
||||
</button>
|
||||
|
||||
<!-- Retry Button -->
|
||||
<button
|
||||
v-if="file.hasError()"
|
||||
@click="$emit('retry-file')"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
@click="$emit('remove-file')"
|
||||
class="text-red-600 hover:text-red-700 text-sm font-medium"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowUpTrayIcon, DocumentIcon, ExclamationCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed } from 'vue';
|
||||
import MangaMatchCard from './MangaMatchCard.vue';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isAnalyzing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isImporting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
file: { type: Object, required: true },
|
||||
isAnalyzing: { type: Boolean, default: false },
|
||||
isImporting: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'manga-selected',
|
||||
'chapter-number-selected',
|
||||
'volume-number-selected',
|
||||
'import-file',
|
||||
'retry-file',
|
||||
'remove-file'
|
||||
'manga-selected',
|
||||
'chapter-number-selected',
|
||||
'volume-number-selected',
|
||||
'import-file',
|
||||
'retry-file',
|
||||
'remove-file',
|
||||
]);
|
||||
|
||||
// Computed property to get sorted matches
|
||||
const sortedMatches = computed(() => {
|
||||
const matches = props.file.getMatches();
|
||||
return matches.sort((a, b) => b.matchScore - a.matchScore);
|
||||
});
|
||||
const sortedMatches = computed(() =>
|
||||
[...props.file.getMatches()].sort((a, b) => b.matchScore - a.matchScore)
|
||||
);
|
||||
|
||||
const handleMangaSelection = (selectedManga) => {
|
||||
emit('manga-selected', selectedManga);
|
||||
};
|
||||
const handleMangaSelection = (manga) => emit('manga-selected', manga);
|
||||
|
||||
const handleChapterNumberInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const chapterNumber = value ? parseFloat(value) : null;
|
||||
emit('chapter-number-selected', chapterNumber);
|
||||
const value = event.target.value;
|
||||
emit('chapter-number-selected', value ? parseFloat(value) : null);
|
||||
};
|
||||
|
||||
const handleVolumeNumberInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const volumeNumber = value ? parseFloat(value) : null;
|
||||
emit('volume-number-selected', volumeNumber);
|
||||
const value = event.target.value;
|
||||
emit('volume-number-selected', value ? parseFloat(value) : null);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,96 +1,94 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<!-- En-tête -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center h-9 w-9 bg-green-100 dark:bg-green-900/40">
|
||||
<CheckCircleIcon class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">Import terminé</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Voici le résumé de votre session d'import</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-6 text-center">
|
||||
<div>
|
||||
<div class="text-xl font-bold text-green-600">{{ importedCount }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Importés</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-bold text-red-600">{{ errorCount }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Erreurs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-bold text-gray-600 dark:text-gray-300">{{ totalCount }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Import terminé</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Voici le résumé de votre session d'import
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
|
||||
<div class="text-sm text-gray-500">Importés</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
|
||||
<div class="text-sm text-gray-500">Erreurs</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-600">{{ totalCount }}</div>
|
||||
<div class="text-sm text-gray-500">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Files List -->
|
||||
<div v-if="importedFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
Fichiers importés avec succès ({{ importedFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
<!-- Fichiers importés -->
|
||||
<section v-if="importedFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||
Importés ({{ importedFiles.length }})
|
||||
</h2>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="file in importedFiles"
|
||||
:key="file.id"
|
||||
class="flex items-center text-sm"
|
||||
class="flex items-center gap-2 py-2.5 text-sm"
|
||||
>
|
||||
<svg class="flex-shrink-0 h-4 w-4 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-gray-900">{{ file.filename }}</span>
|
||||
<span v-if="file.selectedManga" class="ml-2 text-gray-500">
|
||||
→ {{ file.selectedManga.title }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<CheckCircleIcon class="flex-shrink-0 h-4 w-4 text-green-400" />
|
||||
<span class="text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</span>
|
||||
<span v-if="file.selectedManga" class="text-gray-400 dark:text-gray-500 shrink-0">→ {{ file.selectedManga.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Error Files List -->
|
||||
<div v-if="errorFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
Fichiers en erreur ({{ errorFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
<!-- Fichiers en erreur -->
|
||||
<section v-if="errorFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||
Erreurs ({{ errorFiles.length }})
|
||||
</h2>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="file in errorFiles"
|
||||
:key="file.id"
|
||||
class="flex items-start text-sm"
|
||||
class="flex items-start gap-2 py-2.5 text-sm"
|
||||
>
|
||||
<svg class="flex-shrink-0 h-4 w-4 text-red-400 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<XCircleIcon class="flex-shrink-0 h-4 w-4 text-red-400 mt-0.5" />
|
||||
<div>
|
||||
<div class="text-gray-900">{{ file.filename }}</div>
|
||||
<div class="text-red-600 text-xs mt-1">{{ file.errorMessage }}</div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div>
|
||||
<div class="text-red-600 dark:text-red-400 text-xs mt-0.5">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-center space-x-4 pt-6 border-t">
|
||||
<button
|
||||
@click="startNewImport"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Nouvel import
|
||||
</button>
|
||||
<button
|
||||
@click="goToLibrary"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Aller à la bibliothèque
|
||||
</button>
|
||||
</div>
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="startNewImport"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Nouvel import
|
||||
</button>
|
||||
<button
|
||||
@click="goToLibrary"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Aller à la bibliothèque
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/solid';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||
|
||||
@@ -1,116 +1,47 @@
|
||||
<template>
|
||||
<div
|
||||
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md"
|
||||
:class="{
|
||||
'border-blue-500 bg-blue-50': isSelected,
|
||||
'border-gray-200 hover:border-gray-300': !isSelected
|
||||
}"
|
||||
@click="$emit('select-match', match)"
|
||||
>
|
||||
<!-- Match Header with Score -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="{
|
||||
'bg-blue-500': isSelected,
|
||||
'bg-gray-300': !isSelected
|
||||
}"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-gray-700">Score: {{ match.matchScore }}</span>
|
||||
</div>
|
||||
<div v-if="isSelected" class="text-blue-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manga Thumbnail -->
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
v-if="match.thumbnailUrl"
|
||||
:src="match.thumbnailUrl"
|
||||
:alt="match.title"
|
||||
class="w-16 h-20 object-cover rounded border"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-16 h-20 bg-gray-200 rounded border flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manga Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 truncate" :title="match.title">
|
||||
{{ match.title }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 mt-1 truncate" :title="match.slug">
|
||||
{{ match.slug }}
|
||||
</p>
|
||||
|
||||
<!-- Alternative Slugs -->
|
||||
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
|
||||
<p class="text-xs text-gray-400">Autres titres:</p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span
|
||||
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
|
||||
:key="altSlug"
|
||||
class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"
|
||||
<div
|
||||
class="border p-2.5 cursor-pointer transition-all duration-150"
|
||||
:class="isSelected
|
||||
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800'"
|
||||
@click="$emit('select-match', match)"
|
||||
>
|
||||
<div class="flex gap-2.5">
|
||||
<!-- Couverture -->
|
||||
<img
|
||||
v-if="match.thumbnailUrl"
|
||||
:src="match.thumbnailUrl"
|
||||
:alt="match.title"
|
||||
class="w-12 h-16 object-cover shrink-0"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-12 h-16 bg-gray-100 dark:bg-gray-700 shrink-0 flex items-center justify-center"
|
||||
>
|
||||
{{ altSlug }}
|
||||
</span>
|
||||
<span
|
||||
v-if="match.alternativeSlugs.length > 2"
|
||||
class="text-xs text-gray-400"
|
||||
>
|
||||
+{{ match.alternativeSlugs.length - 2 }} autres
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PhotoIcon class="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Score Bar -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Correspondance</span>
|
||||
<span>{{ match.matchScore }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
'bg-blue-500': isSelected,
|
||||
'bg-gray-400': !isSelected
|
||||
}"
|
||||
:style="{ width: match.matchScore + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Infos -->
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-between py-0.5">
|
||||
<p class="text-xs font-medium text-gray-900 dark:text-gray-100 line-clamp-3 leading-snug" :title="match.title">
|
||||
{{ match.title }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ match.matchScore }}%</span>
|
||||
<CheckCircleIcon v-if="isSelected" class="w-4 h-4 text-green-500 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckCircleIcon, PhotoIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const props = defineProps({
|
||||
match: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
match: { type: Object, required: true },
|
||||
isSelected: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-match']);
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -46,25 +46,25 @@ const badgeText = computed(() => {
|
||||
});
|
||||
|
||||
const badgeClasses = computed(() => {
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 text-xs font-medium';
|
||||
|
||||
if (props.isImporting || props.isAnalyzing) {
|
||||
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
}
|
||||
|
||||
switch (props.status) {
|
||||
case 'pending':
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
|
||||
case 'analyzed':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`;
|
||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
|
||||
case 'importing':
|
||||
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
case 'imported':
|
||||
return `${baseClasses} bg-green-100 text-green-800`;
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
case 'error':
|
||||
return `${baseClasses} bg-red-100 text-red-800`;
|
||||
return `${baseClasses} bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,115 +1,103 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Import de Bibliothèque</h1>
|
||||
<p class="text-gray-600">
|
||||
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<!-- Zone de dépôt -->
|
||||
<section v-if="!store.hasFiles" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichiers</h2>
|
||||
<FileUpload
|
||||
label="Importer des fichiers CBZ/CBR"
|
||||
accept=".cbz,.cbr"
|
||||
:multiple="true"
|
||||
description="Formats CBZ ou CBR uniquement"
|
||||
@files-selected="store.addFiles($event)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Fichiers en cours -->
|
||||
<template v-if="store.hasFiles && !store.allFilesProcessed">
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
{{ store.totalFiles }} fichier(s)
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ store.importedCount }}/{{ store.totalFiles }}
|
||||
<span v-if="store.errorCount > 0" class="text-red-500 ml-1">· {{ store.errorCount }} erreur(s)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-1.5 mb-4">
|
||||
<div
|
||||
class="bg-green-600 h-1.5 transition-all duration-300"
|
||||
:style="{ width: store.progressPercentage + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<FileImportCard
|
||||
v-for="file in store.files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:is-analyzing="store.analyzingFiles.has(file.id)"
|
||||
:is-importing="store.importingFiles.has(file.id)"
|
||||
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
|
||||
@chapter-number-selected="(n) => store.setFileChapterNumber(file.id, n)"
|
||||
@volume-number-selected="(n) => store.setFileVolumeNumber(file.id, n)"
|
||||
@import-file="() => importSingleFile(file.id)"
|
||||
@retry-file="() => retryFile(file.id)"
|
||||
@remove-file="() => store.removeFile(file.id)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- Résultats -->
|
||||
<ImportResults v-if="store.allFilesProcessed" />
|
||||
|
||||
<!-- Progress Bar (if files are being processed) -->
|
||||
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">Progression</span>
|
||||
<span class="text-sm text-gray-500">{{ store.progressPercentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: store.progressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||
<span>{{ store.importedCount }} importés</span>
|
||||
<span>{{ store.errorCount }} erreurs</span>
|
||||
<span>{{ store.totalFiles }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Zone -->
|
||||
<div v-if="!store.hasFiles || store.allFilesProcessed" class="mb-8">
|
||||
<FileUpload
|
||||
label="Importer des fichiers CBZ/CBR"
|
||||
accept=".cbz,.cbr"
|
||||
:multiple="true"
|
||||
description="Formats CBZ ou CBR uniquement"
|
||||
@files-selected="handleFilesSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Files List -->
|
||||
<div v-if="store.hasFiles" class="space-y-6">
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
<button
|
||||
v-if="store.hasReadyFiles"
|
||||
@click="importAllFiles"
|
||||
:disabled="store.isLoading"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
<LoadingSpinner v-if="store.isLoading" class="w-4 h-4 mr-2" />
|
||||
Importer tous les fichiers prêts ({{ store.readyCount }})
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="store.analyzedFiles.length > 0"
|
||||
@click="autoSelectMatches"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
Sélection automatique
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="clearAllFiles"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
Effacer tout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Files Grid -->
|
||||
<div class="grid gap-6">
|
||||
<FileImportCard
|
||||
v-for="file in store.files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:is-analyzing="store.analyzingFiles.has(file.id)"
|
||||
:is-importing="store.importingFiles.has(file.id)"
|
||||
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
|
||||
@chapter-number-selected="(chapterNumber) => store.setFileChapterNumber(file.id, chapterNumber)"
|
||||
@volume-number-selected="(volumeNumber) => store.setFileVolumeNumber(file.id, volumeNumber)"
|
||||
@import-file="() => importSingleFile(file.id)"
|
||||
@retry-file="() => retryFile(file.id)"
|
||||
@remove-file="() => store.removeFile(file.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Summary (when all files are processed) -->
|
||||
<div v-if="store.allFilesProcessed" class="mt-8">
|
||||
<ImportResults />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onUnmounted } from 'vue';
|
||||
import { ArrowUpTrayIcon, SparklesIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, onUnmounted } from 'vue';
|
||||
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
|
||||
import LoadingSpinner from '../../../../shared/components/ui/LoadingSpinner.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||
import FileImportCard from '../components/FileImportCard.vue';
|
||||
import ImportResults from '../components/ImportResults.vue';
|
||||
|
||||
const store = useNewImportStore();
|
||||
|
||||
// === EVENT HANDLERS ===
|
||||
|
||||
const handleFilesSelected = (files) => {
|
||||
store.addFiles(files);
|
||||
};
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Import de bibliothèque', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
...(store.analyzedFiles.length > 0 ? [{
|
||||
type: 'button',
|
||||
icon: SparklesIcon,
|
||||
label: 'Sélection auto',
|
||||
onClick: () => store.autoSelectBestMatches(),
|
||||
}] : []),
|
||||
...(store.hasReadyFiles ? [{
|
||||
type: 'button',
|
||||
icon: ArrowUpTrayIcon,
|
||||
label: `Importer (${store.readyCount})`,
|
||||
onClick: importAllFiles,
|
||||
disabled: store.isLoading,
|
||||
}] : []),
|
||||
{
|
||||
type: 'button',
|
||||
icon: TrashIcon,
|
||||
label: 'Effacer',
|
||||
onClick: () => store.clearFiles(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const importAllFiles = async () => {
|
||||
try {
|
||||
@@ -135,19 +123,6 @@ const retryFile = async (fileId) => {
|
||||
}
|
||||
};
|
||||
|
||||
const autoSelectMatches = () => {
|
||||
store.autoSelectBestMatches();
|
||||
};
|
||||
|
||||
const clearAllFiles = () => {
|
||||
if (confirm('Êtes-vous sûr de vouloir effacer tous les fichiers ?')) {
|
||||
store.clearFiles();
|
||||
}
|
||||
};
|
||||
|
||||
// === LIFECYCLE ===
|
||||
|
||||
// Reset state when component unmounts
|
||||
onUnmounted(() => {
|
||||
store.resetGlobalState();
|
||||
});
|
||||
|
||||
@@ -40,7 +40,12 @@ export const useMangaStore = defineStore('manga', {
|
||||
|
||||
// --- Add Manga State ---
|
||||
addingManga: false,
|
||||
addMangaError: null
|
||||
addMangaError: null,
|
||||
|
||||
// --- Discover State ---
|
||||
discoverResults: [],
|
||||
loadingDiscover: false,
|
||||
discoverError: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -170,6 +175,25 @@ export const useMangaStore = defineStore('manga', {
|
||||
this.loadingSearch = false;
|
||||
},
|
||||
|
||||
// --- Discover Actions ---
|
||||
async loadDiscoverRecommendations() {
|
||||
if (this.loadingDiscover) return;
|
||||
|
||||
this.loadingDiscover = true;
|
||||
this.discoverError = null;
|
||||
this.discoverResults = [];
|
||||
|
||||
try {
|
||||
const data = await mangaRepository.discoverManga();
|
||||
this.discoverResults = data.items || [];
|
||||
} catch (error) {
|
||||
this.discoverError = error.message;
|
||||
throw error;
|
||||
} finally {
|
||||
this.loadingDiscover = false;
|
||||
}
|
||||
},
|
||||
|
||||
// --- Add Manga Actions ---
|
||||
async createFromMangaDex(externalId) {
|
||||
if (this.addingManga) return;
|
||||
|
||||
@@ -11,7 +11,10 @@ export class Manga {
|
||||
status = null,
|
||||
rating = null,
|
||||
genres = [],
|
||||
createdAt = new Date().toISOString()
|
||||
createdAt = new Date().toISOString(),
|
||||
monitored = false,
|
||||
chaptersTotal = 0,
|
||||
chaptersScraped = 0,
|
||||
}) {
|
||||
this.id = id;
|
||||
this.slug = slug;
|
||||
@@ -25,6 +28,9 @@ export class Manga {
|
||||
this.rating = rating;
|
||||
this.genres = genres;
|
||||
this.createdAt = createdAt;
|
||||
this.monitored = monitored;
|
||||
this.chaptersTotal = chaptersTotal;
|
||||
this.chaptersScraped = chaptersScraped;
|
||||
}
|
||||
|
||||
static create(data) {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="handleClose"></div>
|
||||
|
||||
<!-- Modal avec style Material Design -->
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<!-- Header Material Design -->
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<FolderIcon class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-medium text-gray-900 leading-6">
|
||||
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100 leading-6">
|
||||
Gérer les chapitres
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ manga?.title }}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ manga?.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
|
||||
class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center transition-colors duration-200"
|
||||
>
|
||||
<XMarkIcon class="h-5 w-5 text-gray-600" />
|
||||
<XMarkIcon class="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content avec style Material Design -->
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-8">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-8">
|
||||
<div v-if="isLoading" class="flex justify-center items-center h-32">
|
||||
<div class="relative">
|
||||
<div class="w-8 h-8 border-4 border-green-200 rounded-full"></div>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
||||
<div v-else-if="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
||||
<div class="w-5 h-5 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<XMarkIcon class="h-3 w-3 text-red-600" />
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Actions avec style Material Design -->
|
||||
<div class="flex items-center justify-between bg-gray-50 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
@click="showCreateVolumeModal = true"
|
||||
@@ -58,7 +58,7 @@
|
||||
</button>
|
||||
<button
|
||||
@click="showUnassignedChapters = !showUnassignedChapters"
|
||||
class="text-gray-600 hover:text-gray-800 text-sm font-medium hover:bg-gray-100 px-3 py-2 rounded-lg transition-colors duration-200"
|
||||
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700 px-3 py-2 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{{ showUnassignedChapters ? 'Masquer' : 'Afficher' }} les chapitres non assignés
|
||||
</button>
|
||||
@@ -88,17 +88,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 bg-white px-3 py-1.5 rounded-lg border border-gray-200">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-700 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
{{ totalChapters }} chapitres, {{ volumes.length }} volumes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arborescence avec style Material Design -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden shadow-sm">
|
||||
<!-- Chapitres non assignés -->
|
||||
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
|
||||
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700/50 dark:to-gray-700/30 border-b border-gray-200 dark:border-gray-600">
|
||||
<div class="px-6 py-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center space-x-2">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center space-x-2">
|
||||
<DocumentIcon class="h-4 w-4 text-gray-500" />
|
||||
<span>Chapitres non assignés ({{ unassignedChapters.length }})</span>
|
||||
</h4>
|
||||
@@ -119,11 +119,11 @@
|
||||
/>
|
||||
</div>
|
||||
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
||||
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<div class="flex-1">
|
||||
<div v-if="!chapter.isEditing" class="flex items-center">
|
||||
<span
|
||||
class="text-sm text-gray-900 cursor-pointer hover:text-green-600 transition-colors duration-200"
|
||||
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
||||
@click="startEditingTitle(chapter)"
|
||||
>
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
@@ -173,22 +173,22 @@
|
||||
</div>
|
||||
|
||||
<!-- Volumes avec style Material Design -->
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<div
|
||||
v-for="volume in volumes"
|
||||
:key="volume.number"
|
||||
class="bg-white"
|
||||
class="bg-white dark:bg-gray-800"
|
||||
>
|
||||
<!-- En-tête du volume Material Design -->
|
||||
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 border-b border-green-100">
|
||||
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-b border-green-100 dark:border-green-900/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<FolderIcon class="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-green-900">Volume {{ volume.number }}</span>
|
||||
<span class="text-xs text-green-600 ml-2">({{ volume.chapters.length }} chapitres)</span>
|
||||
<span class="text-sm font-semibold text-green-900 dark:text-green-300">Volume {{ volume.number }}</span>
|
||||
<span class="text-xs text-green-600 dark:text-green-400 ml-2">({{ volume.chapters.length }} chapitres)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -211,10 +211,10 @@
|
||||
|
||||
<!-- Chapitres du volume -->
|
||||
<div v-if="volume.isExpanded" class="px-6 py-4">
|
||||
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500">
|
||||
<DocumentIcon class="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<DocumentIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
|
||||
<p class="text-sm">Aucun chapitre assigné à ce volume.</p>
|
||||
<p class="text-xs text-gray-400 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
@@ -233,11 +233,11 @@
|
||||
/>
|
||||
</div>
|
||||
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
||||
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<div class="flex-1">
|
||||
<div v-if="!chapter.isEditing" class="flex items-center">
|
||||
<span
|
||||
class="text-sm text-gray-900 cursor-pointer hover:text-green-600 transition-colors duration-200"
|
||||
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
||||
@click="startEditingTitle(chapter)"
|
||||
>
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
@@ -291,12 +291,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer Material Design -->
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="handleClose"
|
||||
:disabled="isSaving"
|
||||
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
@@ -320,24 +320,24 @@
|
||||
<div v-if="showCreateVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showCreateVolumeModal = false"></div>
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<PlusIcon class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900">Créer un nouveau volume</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Créer un nouveau volume</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Numéro du volume</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Numéro du volume</label>
|
||||
<input
|
||||
v-model="newVolumeNumber"
|
||||
type="number"
|
||||
min="1"
|
||||
class="block w-full border border-gray-300 rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
||||
class="block w-full border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-3 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
||||
placeholder="Ex: 1"
|
||||
/>
|
||||
</div>
|
||||
@@ -351,7 +351,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showCreateVolumeModal = false"
|
||||
@@ -376,8 +376,8 @@
|
||||
<div v-if="showAssignModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showAssignModal = false"></div>
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<DocumentIcon class="h-5 w-5 text-green-600" />
|
||||
@@ -385,7 +385,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Assigner le chapitre {{ selectedChapter?.number }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Volume</label>
|
||||
@@ -401,7 +401,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showAssignModal = false"
|
||||
@@ -426,8 +426,8 @@
|
||||
<div v-if="showMoveToVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showMoveToVolumeModal = false"></div>
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<ArrowPathIcon class="h-5 w-5 text-green-600" />
|
||||
@@ -435,7 +435,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Déplacer {{ selectedChapters.length }} chapitre(s)</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<p class="text-sm text-green-800 font-medium">
|
||||
@@ -457,7 +457,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showMoveToVolumeModal = false"
|
||||
@@ -491,7 +491,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Séparer le volume 00</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<p class="text-sm text-green-800 font-medium">
|
||||
@@ -517,7 +517,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showSplitVolumeZeroModal = false"
|
||||
|
||||
@@ -1,37 +1,60 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="bg-white rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
|
||||
<div class="relative pb-[150%]">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||
:alt="manga.title"
|
||||
class="absolute inset-0 w-full h-full object-cover bg-gray-100" />
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ manga.title }}</h3>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-500">{{ manga.publicationYear }}</span>
|
||||
<div class="group relative bg-white dark:bg-gray-800 overflow-hidden shadow-sm">
|
||||
<!-- Cover avec overlay -->
|
||||
<div class="relative pb-[140%]">
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="absolute inset-0">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||
:alt="manga.title"
|
||||
class="w-full h-full object-cover bg-gray-100" />
|
||||
</RouterLink>
|
||||
|
||||
<!-- Gradient + actions au survol -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
<div class="absolute bottom-2 left-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||
title="Éditer"
|
||||
@click="$emit('edit', manga)">
|
||||
<PencilIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||
title="Sources préférées"
|
||||
@click="$emit('sources', manga)">
|
||||
<Cog6ToothIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||
title="Rafraîchir"
|
||||
@click="$emit('refresh', manga)">
|
||||
<ArrowPathIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500"> Added: {{ formatDate(manga.createdAt) }} </div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Titre + année -->
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="block p-2">
|
||||
<h3 class="text-xs font-medium text-gray-800 dark:text-gray-100 truncate">{{ manga.title }}</h3>
|
||||
<span v-if="manga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
manga: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||
import { RouterLink } from 'vue-router';
|
||||
|
||||
const formatDate = dateString => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
defineProps({
|
||||
manga: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['edit', 'sources', 'refresh']);
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
<template>
|
||||
<tr class="border-t hover:bg-green-100">
|
||||
<td class="px-4 py-2" :class="{ 'text-green-500': chapter.isAvailable }">
|
||||
{{ String(chapter.number).padStart(2, '0') }}
|
||||
<tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20">
|
||||
<td class="px-4 py-2 text-gray-900 dark:text-gray-100" :class="{ 'text-green-500 dark:text-green-400': chapter.isAvailable }">
|
||||
<template v-if="chapter.isVolumeGroup">Vol. {{ chapter.volume }}</template>
|
||||
<template v-else>{{ String(chapter.number).padStart(2, '0') }}</template>
|
||||
</td>
|
||||
<td class="px-4 py-2 w-full text-left">
|
||||
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
|
||||
<router-link
|
||||
v-if="chapter.isAvailable"
|
||||
class="hover:text-green-500 dark:hover:text-green-400"
|
||||
:to="{
|
||||
name: 'reader',
|
||||
params: {
|
||||
chapterId: chapter.id
|
||||
}
|
||||
}">
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
<template v-if="chapter.isVolumeGroup">
|
||||
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
|
||||
</template>
|
||||
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||
</router-link>
|
||||
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||
<template v-if="chapter.isVolumeGroup">
|
||||
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
|
||||
</template>
|
||||
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 flex justify-end gap-2">
|
||||
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="p-2 border-t">
|
||||
<div class="p-2 border-t dark:border-gray-700">
|
||||
<table class="min-w-full table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<tr class="text-gray-700 dark:text-gray-300">
|
||||
<th class="px-4 py-2 text-left">#</th>
|
||||
<th class="px-4 py-2 text-left">Titre</th>
|
||||
<th class="px-4 py-2 text-right">Actions</th>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,15 +24,15 @@
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div class="mb-6">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Supprimer le manga
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-if="error" class="mb-6 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">
|
||||
{{ error.message || 'Une erreur est survenue lors de la suppression.' }}
|
||||
</div>
|
||||
|
||||
@@ -40,19 +40,19 @@
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
|
||||
<span class="text-sm font-medium text-gray-900">Action irréversible</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Action irréversible</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Êtes-vous sûr de vouloir supprimer le manga <strong>"{{ manga?.title }}"</strong> ?
|
||||
</p>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">
|
||||
Attention
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<p>Cette action supprimera définitivement :</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Le manga et toutes ses métadonnées</li>
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="closeModal"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,15 +24,15 @@
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
||||
<div class="mb-6">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Edit Manga
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-if="error" class="mb-6 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">
|
||||
{{ error.message || 'Une erreur est survenue lors de la sauvegarde.' }}
|
||||
</div>
|
||||
|
||||
@@ -41,49 +41,49 @@
|
||||
<!-- Titre et Slug -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Titre</label>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="formData.title"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Titre du manga"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700 mb-2">Slug</label>
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slug</label>
|
||||
<input
|
||||
id="slug"
|
||||
:value="manga?.slug || ''"
|
||||
type="text"
|
||||
disabled
|
||||
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm text-gray-500"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-600 shadow-sm sm:text-sm text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Année de publication -->
|
||||
<div>
|
||||
<label for="publicationYear" class="block text-sm font-medium text-gray-700 mb-2">Année de publication</label>
|
||||
<label for="publicationYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Année de publication</label>
|
||||
<input
|
||||
id="publicationYear"
|
||||
v-model.number="formData.publicationYear"
|
||||
type="number"
|
||||
min="1900"
|
||||
:max="new Date().getFullYear()"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="2023"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
rows="4"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Description du manga"
|
||||
/>
|
||||
</div>
|
||||
@@ -91,22 +91,22 @@
|
||||
<!-- Auteur et Statut -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="author" class="block text-sm font-medium text-gray-700 mb-2">Auteur</label>
|
||||
<label for="author" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Auteur</label>
|
||||
<input
|
||||
id="author"
|
||||
v-model="formData.author"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Auteur du manga"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-2">Statut</label>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Statut</label>
|
||||
<input
|
||||
id="status"
|
||||
v-model="formData.status"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="ongoing"
|
||||
/>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
<!-- Note -->
|
||||
<div>
|
||||
<label for="rating" class="block text-sm font-medium text-gray-700 mb-2">Note</label>
|
||||
<label for="rating" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Note</label>
|
||||
<input
|
||||
id="rating"
|
||||
v-model.number="formData.rating"
|
||||
@@ -122,20 +122,20 @@
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.001"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="9.541"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Slugs alternatifs -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Slugs alternatifs</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slugs alternatifs</label>
|
||||
<div class="space-y-2">
|
||||
<div v-if="formData.alternativeSlugs.length > 0" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(slug, index) in formData.alternativeSlugs"
|
||||
:key="index"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300"
|
||||
>
|
||||
{{ slug }}
|
||||
<button
|
||||
@@ -158,7 +158,7 @@
|
||||
<input
|
||||
v-model="newAlternativeSlug"
|
||||
type="text"
|
||||
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Nouveau slug alternatif"
|
||||
@keyup.enter="addAlternativeSlug"
|
||||
/>
|
||||
@@ -175,19 +175,19 @@
|
||||
|
||||
<!-- Genres -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Genres</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Genres</label>
|
||||
<div class="space-y-3">
|
||||
<div v-if="formData.genres.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<span
|
||||
v-for="(genre, index) in formData.genres"
|
||||
:key="index"
|
||||
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 text-gray-800"
|
||||
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{{ genre }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeGenre(index)"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 hover:text-gray-600"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMarkIcon class="w-3 h-3" />
|
||||
</button>
|
||||
@@ -204,7 +204,7 @@
|
||||
<input
|
||||
v-model="newGenre"
|
||||
type="text"
|
||||
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Nouveau genre"
|
||||
@keyup.enter="addGenre"
|
||||
/>
|
||||
@@ -224,7 +224,7 @@
|
||||
<div class="mt-8 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
@click="closeModal"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
|
||||
@@ -1,16 +1,96 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6">
|
||||
<MangaCard v-for="manga in mangas" :key="manga.id" :manga="manga" />
|
||||
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3 p-4">
|
||||
<MangaCard
|
||||
v-for="manga in mangas"
|
||||
:key="manga.id"
|
||||
:manga="manga"
|
||||
@edit="openEdit"
|
||||
@sources="openSources"
|
||||
@refresh="doRefresh" />
|
||||
</div>
|
||||
|
||||
<!-- Modales -->
|
||||
<MangaEditModal
|
||||
:is-open="isEditModalOpen"
|
||||
:manga="selectedManga"
|
||||
:is-saving="editIsLoading"
|
||||
:error="editError"
|
||||
@close="closeEditModal"
|
||||
@save="handleSaveEdit" />
|
||||
|
||||
<MangaPreferredSourcesModal
|
||||
:is-open="isSourcesModalOpen"
|
||||
:sources="preferredSources"
|
||||
:is-loading="sourcesIsLoading"
|
||||
:error="sourcesError"
|
||||
:is-saving="sourcesIsSaving"
|
||||
@close="isSourcesModalOpen = false"
|
||||
@save="handleSaveSources" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MangaCard from './MangaCard.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||
import MangaCard from './MangaCard.vue';
|
||||
import MangaEditModal from './MangaEditModal.vue';
|
||||
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||
|
||||
defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const selectedManga = ref(null);
|
||||
const isSourcesModalOpen = ref(false);
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────
|
||||
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||
|
||||
function openEdit(manga) {
|
||||
selectedManga.value = manga;
|
||||
openEditModal();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(data) {
|
||||
if (!selectedManga.value) return;
|
||||
await editManga(selectedManga.value.id, data);
|
||||
}
|
||||
|
||||
// ── Sources préférées ─────────────────────────────────────
|
||||
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||
const {
|
||||
sources: preferredSources,
|
||||
isLoading: sourcesIsLoading,
|
||||
error: sourcesError,
|
||||
isSaving: sourcesIsSaving,
|
||||
savePreferredSources
|
||||
} = useMangaPreferredSources(selectedMangaId);
|
||||
|
||||
function openSources(manga) {
|
||||
selectedManga.value = manga;
|
||||
isSourcesModalOpen.value = true;
|
||||
}
|
||||
|
||||
function handleSaveSources(sourceIds) {
|
||||
savePreferredSources(sourceIds);
|
||||
isSourcesModalOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────
|
||||
const { refreshMetadata } = useMangaRefresh();
|
||||
const refreshingId = ref(null);
|
||||
|
||||
async function doRefresh(manga) {
|
||||
if (refreshingId.value) return;
|
||||
refreshingId.value = manga.id;
|
||||
try {
|
||||
await refreshMetadata(manga.id);
|
||||
} finally {
|
||||
refreshingId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="manga in mangas"
|
||||
:key="manga.id"
|
||||
class="flex bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg p-4 space-x-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
@click="$emit('manga-click', manga)">
|
||||
<!-- Cover Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" />
|
||||
<!-- TODO: Add placeholder image -->
|
||||
</div>
|
||||
|
||||
<!-- Manga Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg leading-7 font-medium text-gray-900 dark:text-gray-100 truncate">{{
|
||||
manga.title
|
||||
}}</h3>
|
||||
<p v-if="manga.publicationYear" class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{
|
||||
manga.publicationYear
|
||||
}}</p>
|
||||
<p v-if="manga.description" class="text-sm text-gray-700 dark:text-gray-300 mt-2">
|
||||
{{ truncateDescription(manga.description) }}
|
||||
</p>
|
||||
<p v-if="manga.createdAt" class="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Added: {{ formatDate(manga.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineEmits, defineProps } from 'vue';
|
||||
|
||||
const emit = defineEmits(['manga-click']);
|
||||
|
||||
const props = defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const formatDate = dateString => {
|
||||
if (!dateString) return '';
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString(undefined, options);
|
||||
} catch (e) {
|
||||
console.error('Error formatting date:', e);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const truncateDescription = description => {
|
||||
if (!description) return '';
|
||||
return description.length > 500 ? description.slice(0, 500) + '...' : description;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Pour s'assurer que line-clamp fonctionne */
|
||||
@supports (-webkit-line-clamp: 3) {
|
||||
.line-clamp-3 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.description-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div
|
||||
v-for="manga in mangas"
|
||||
:key="manga.id"
|
||||
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">
|
||||
|
||||
<!-- Cover -->
|
||||
<img
|
||||
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||
alt=""
|
||||
class="h-36 w-24 object-cover flex-shrink-0 self-start"
|
||||
referrerpolicy="no-referrer" />
|
||||
|
||||
<!-- Titre + méta + résumé -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start gap-2 flex-wrap">
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="text-2xl font-semibold text-gray-900 dark:text-gray-100 hover:text-green-500 dark:hover:text-green-400 transition-colors"
|
||||
@click.stop>
|
||||
{{ manga.title }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="manga.status"
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
|
||||
:class="statusClass(manga.status)">
|
||||
{{ manga.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">
|
||||
{{ manga.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions verticales -->
|
||||
<div class="flex flex-col items-center justify-center gap-0.5 flex-shrink-0 self-stretch">
|
||||
<button
|
||||
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="Éditer"
|
||||
@click.stop="openEdit(manga)">
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="Sources préférées"
|
||||
@click.stop="openSources(manga)">
|
||||
<Cog6ToothIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md transition-colors"
|
||||
:class="refreshingId === manga.id
|
||||
? 'text-blue-400 cursor-not-allowed'
|
||||
: 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600'"
|
||||
title="Rafraîchir"
|
||||
:disabled="refreshingId === manga.id"
|
||||
@click.stop="doRefresh(manga)">
|
||||
<ArrowPathIcon
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin': refreshingId === manga.id }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modales -->
|
||||
<MangaEditModal
|
||||
:is-open="isEditModalOpen"
|
||||
:manga="selectedManga"
|
||||
:is-saving="editIsLoading"
|
||||
:error="editError"
|
||||
@close="closeEditModal"
|
||||
@save="handleSaveEdit" />
|
||||
|
||||
<MangaPreferredSourcesModal
|
||||
:is-open="isSourcesModalOpen"
|
||||
:sources="preferredSources"
|
||||
:is-loading="sourcesIsLoading"
|
||||
:error="sourcesError"
|
||||
:is-saving="sourcesIsSaving"
|
||||
@close="isSourcesModalOpen = false"
|
||||
@save="handleSaveSources" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||
import MangaEditModal from './MangaEditModal.vue';
|
||||
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||
|
||||
const emit = defineEmits(['manga-click']);
|
||||
|
||||
const props = defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === 'ongoing') return 'text-blue-600 bg-blue-50 dark:bg-blue-900/20';
|
||||
if (status === 'completed') return 'text-green-600 bg-green-50 dark:bg-green-900/20';
|
||||
return 'text-gray-500 bg-gray-100 dark:bg-gray-700';
|
||||
}
|
||||
|
||||
// ── Selected manga ────────────────────────────────────────
|
||||
const selectedManga = ref(null);
|
||||
const isSourcesModalOpen = ref(false);
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────
|
||||
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||
|
||||
function openEdit(manga) {
|
||||
selectedManga.value = manga;
|
||||
openEditModal();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(data) {
|
||||
if (!selectedManga.value) return;
|
||||
await editManga(selectedManga.value.id, data);
|
||||
}
|
||||
|
||||
// ── Sources préférées ─────────────────────────────────────
|
||||
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||
const {
|
||||
sources: preferredSources,
|
||||
isLoading: sourcesIsLoading,
|
||||
error: sourcesError,
|
||||
isSaving: sourcesIsSaving,
|
||||
savePreferredSources
|
||||
} = useMangaPreferredSources(selectedMangaId);
|
||||
|
||||
function openSources(manga) {
|
||||
selectedManga.value = manga;
|
||||
isSourcesModalOpen.value = true;
|
||||
}
|
||||
|
||||
function handleSaveSources(sourceIds) {
|
||||
savePreferredSources(sourceIds);
|
||||
isSourcesModalOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────
|
||||
const { refreshMetadata } = useMangaRefresh();
|
||||
const refreshingId = ref(null);
|
||||
|
||||
async function doRefresh(manga) {
|
||||
if (refreshingId.value) return;
|
||||
refreshingId.value = manga.id;
|
||||
try {
|
||||
await refreshMetadata(manga.id);
|
||||
} finally {
|
||||
refreshingId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<span v-if="isLoading" class="text-gray-400 dark:text-gray-600 text-xs">…</span>
|
||||
<span v-else-if="sources.length" class="text-gray-700 dark:text-gray-300 truncate max-w-xs block">{{ sources[0].name }}</span>
|
||||
<span v-else class="text-gray-400 dark:text-gray-600">—</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||
|
||||
const props = defineProps({
|
||||
mangaId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const mangaIdRef = toRef(props, 'mangaId');
|
||||
const { sources, isLoading } = useMangaPreferredSources(mangaIdRef);
|
||||
</script>
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,17 +24,17 @@
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<div>
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
||||
<Cog6ToothIcon class="h-6 w-6 text-blue-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Sources préférées
|
||||
</DialogTitle>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configurez l'ordre de priorité des sources pour ce manga. Glissez-déposez les sources pour les réorganiser.
|
||||
</p>
|
||||
</div>
|
||||
@@ -47,13 +47,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="mt-5 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-else-if="error" class="mt-5 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">
|
||||
{{ error.message || 'Une erreur est survenue lors du chargement des sources.' }}
|
||||
</div>
|
||||
|
||||
<!-- Sources list -->
|
||||
<div v-else class="mt-5">
|
||||
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500">
|
||||
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Aucune source disponible
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
@@ -63,10 +63,10 @@
|
||||
:class="[
|
||||
'group relative flex items-center p-4 rounded-lg border-2 transition-all duration-200 cursor-grab active:cursor-grabbing select-none',
|
||||
{
|
||||
'bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-300 shadow-md': index === 0,
|
||||
'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300': index === 1,
|
||||
'bg-gradient-to-r from-yellow-50 to-amber-50 border-yellow-300': index === 2,
|
||||
'bg-gray-50 border-gray-200': index > 2,
|
||||
'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-300 dark:border-blue-700 shadow-md': index === 0,
|
||||
'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-green-300 dark:border-green-700': index === 1,
|
||||
'bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border-yellow-300 dark:border-yellow-700': index === 2,
|
||||
'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600': index > 2,
|
||||
'scale-105 shadow-lg border-blue-400': draggedIndex === index,
|
||||
'opacity-50': dragOverIndex === index && draggedIndex !== index,
|
||||
'scale-95 active:scale-95': isPressed === index
|
||||
@@ -102,10 +102,10 @@
|
||||
<div :class="[
|
||||
'flex items-center space-x-1 px-3 py-1 rounded-full text-xs font-semibold',
|
||||
{
|
||||
'bg-blue-100 text-blue-800': index === 0,
|
||||
'bg-green-100 text-green-800': index === 1,
|
||||
'bg-yellow-100 text-yellow-800': index === 2,
|
||||
'bg-gray-100 text-gray-600': index > 2
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': index === 0,
|
||||
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': index === 1,
|
||||
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': index === 2,
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300': index > 2
|
||||
}
|
||||
]">
|
||||
<span v-if="index === 0">🥇 Priorité haute</span>
|
||||
@@ -117,14 +117,14 @@
|
||||
|
||||
<!-- Informations de la source -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-gray-900 truncate">{{ source.name }}</div>
|
||||
<div class="text-sm text-gray-600 truncate">
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100 truncate">{{ source.name }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
<a :href="source.baseUrl" target="_blank" class="hover:text-blue-600 hover:underline">{{ source.baseUrl }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur de drag -->
|
||||
<div class="ml-4 text-gray-400 group-hover:text-gray-600 transition-colors duration-200">
|
||||
<div class="ml-4 text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors duration-200">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9h8M8 15h8" />
|
||||
</svg>
|
||||
@@ -148,7 +148,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:col-start-1 sm:mt-0"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:col-start-1 sm:mt-0"
|
||||
@click="closeModal"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<th class="w-10 px-4 py-3"></th>
|
||||
<th class="py-3 pr-4 text-left font-medium">Titre</th>
|
||||
<th class="py-3 pr-4 text-left font-medium w-44">Source préférée</th>
|
||||
<th class="py-3 pr-4 text-left font-medium w-44">Chapitres</th>
|
||||
<th class="py-3 px-4 text-right font-medium w-28">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<tr
|
||||
v-for="manga in mangas"
|
||||
:key="manga.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
|
||||
|
||||
<!-- Monitoring -->
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button
|
||||
:title="manga.monitored ? 'Monitoring actif — cliquer pour désactiver' : 'Monitoring inactif — cliquer pour activer'"
|
||||
:class="manga.monitored
|
||||
? 'text-green-500 hover:text-green-600'
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500'"
|
||||
class="transition-colors"
|
||||
@click="doToggleMonitoring(manga)">
|
||||
<component
|
||||
:is="manga.monitored ? BookmarkIcon : BookmarkSlashIcon"
|
||||
class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<!-- Titre -->
|
||||
<td class="py-3 pr-4">
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="font-medium text-gray-900 dark:text-gray-100 hover:text-green-500 dark:hover:text-green-400 transition-colors">
|
||||
{{ manga.title }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
|
||||
<!-- Source préférée -->
|
||||
<td class="py-3 pr-4">
|
||||
<MangaPreferredSourceCell :manga-id="manga.id" />
|
||||
</td>
|
||||
|
||||
<!-- Chapitres — barre de progression -->
|
||||
<td class="py-3 pr-4">
|
||||
<div v-if="manga.chaptersTotal > 0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
|
||||
{{ manga.chaptersScraped }} / {{ manga.chaptersTotal }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ progressPercent(manga) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 dark:bg-gray-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="progressPercent(manga) >= 100
|
||||
? 'bg-green-500'
|
||||
: 'bg-blue-500'"
|
||||
:style="{ width: progressPercent(manga) + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400 dark:text-gray-600 text-xs">—</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center justify-end gap-0.5">
|
||||
<button
|
||||
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="Éditer"
|
||||
@click="openEdit(manga)">
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="Sources préférées"
|
||||
@click="openSources(manga)">
|
||||
<Cog6ToothIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md transition-colors"
|
||||
:class="refreshingId === manga.id
|
||||
? 'text-blue-400 cursor-not-allowed'
|
||||
: 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600'"
|
||||
title="Rafraîchir"
|
||||
:disabled="refreshingId === manga.id"
|
||||
@click="doRefresh(manga)">
|
||||
<ArrowPathIcon
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin': refreshingId === manga.id }" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modales -->
|
||||
<MangaEditModal
|
||||
:is-open="isEditModalOpen"
|
||||
:manga="selectedManga"
|
||||
:is-saving="editIsLoading"
|
||||
:error="editError"
|
||||
@close="closeEditModal"
|
||||
@save="handleSaveEdit" />
|
||||
|
||||
<MangaPreferredSourcesModal
|
||||
:is-open="isSourcesModalOpen"
|
||||
:sources="preferredSources"
|
||||
:is-loading="sourcesIsLoading"
|
||||
:error="sourcesError"
|
||||
:is-saving="sourcesIsSaving"
|
||||
@close="isSourcesModalOpen = false"
|
||||
@save="handleSaveSources" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowPathIcon, BookmarkIcon, BookmarkSlashIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||
import { useMangaMonitoring } from '../composables/useMangaMonitoring';
|
||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||
import MangaEditModal from './MangaEditModal.vue';
|
||||
import MangaPreferredSourceCell from './MangaPreferredSourceCell.vue';
|
||||
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
function progressPercent(manga) {
|
||||
if (!manga.chaptersTotal) return 0;
|
||||
return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100);
|
||||
}
|
||||
|
||||
// ── Monitoring ────────────────────────────────────────────
|
||||
const { toggleMonitoring } = useMangaMonitoring();
|
||||
|
||||
async function doToggleMonitoring(manga) {
|
||||
await toggleMonitoring(manga.id, !manga.monitored);
|
||||
manga.monitored = !manga.monitored;
|
||||
}
|
||||
|
||||
// ── Selected manga ────────────────────────────────────────
|
||||
const selectedManga = ref(null);
|
||||
const isSourcesModalOpen = ref(false);
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────
|
||||
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||
|
||||
function openEdit(manga) {
|
||||
selectedManga.value = manga;
|
||||
openEditModal();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(data) {
|
||||
if (!selectedManga.value) return;
|
||||
await editManga(selectedManga.value.id, data);
|
||||
}
|
||||
|
||||
// ── Sources préférées ─────────────────────────────────────
|
||||
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||
const {
|
||||
sources: preferredSources,
|
||||
isLoading: sourcesIsLoading,
|
||||
error: sourcesError,
|
||||
isSaving: sourcesIsSaving,
|
||||
savePreferredSources
|
||||
} = useMangaPreferredSources(selectedMangaId);
|
||||
|
||||
function openSources(manga) {
|
||||
selectedManga.value = manga;
|
||||
isSourcesModalOpen.value = true;
|
||||
}
|
||||
|
||||
function handleSaveSources(sourceIds) {
|
||||
savePreferredSources(sourceIds);
|
||||
isSourcesModalOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────
|
||||
const { refreshMetadata } = useMangaRefresh();
|
||||
const refreshingId = ref(null);
|
||||
|
||||
async function doRefresh(manga) {
|
||||
if (refreshingId.value) return;
|
||||
refreshingId.value = manga.id;
|
||||
try {
|
||||
await refreshMetadata(manga.id);
|
||||
} finally {
|
||||
refreshingId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-sm shadow mb-2">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-sm shadow mb-2">
|
||||
<!-- En-tête du volume -->
|
||||
<div class="relative bg-white p-3 sm:p-4 rounded-t-sm">
|
||||
<div class="relative bg-white dark:bg-gray-800 p-3 sm:p-4 rounded-t-sm">
|
||||
<!-- Layout mobile/desktop -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Partie gauche -->
|
||||
<div class="flex items-center space-x-1 sm:space-x-4 flex-1 min-w-0">
|
||||
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 flex-shrink-0" />
|
||||
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
|
||||
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 dark:text-gray-400 flex-shrink-0" />
|
||||
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0 dark:text-gray-100">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
:class="[
|
||||
@@ -65,7 +65,7 @@
|
||||
<MangaChapterList v-show="isOpen" :chapters="volume.chapters" :manga-slug="mangaSlug" :manga-id="mangaId" />
|
||||
|
||||
<!-- Chevron de fermeture -->
|
||||
<div v-show="isOpen" class="flex justify-center p-2 bg-white rounded-b-sm">
|
||||
<div v-show="isOpen" class="flex justify-center p-2 bg-white dark:bg-gray-800 rounded-b-sm">
|
||||
<button @click="toggleVolume" class="w-8 h-8 flex items-center justify-center">
|
||||
<ChevronUpIcon
|
||||
class="h-5 w-5 sm:h-6 sm:w-6 bg-gray-400 rounded-full p-1 text-white hover:bg-green-500 cursor-pointer"
|
||||
|
||||
@@ -1,80 +1,142 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Barre de recherche -->
|
||||
<div class="mb-8">
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="performSearch"
|
||||
placeholder="Rechercher un manga..."
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<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 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="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 -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<section v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Résultats</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ searchResults.length }} manga(s)</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="manga in searchResults"
|
||||
:key="manga.externalId"
|
||||
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
|
||||
@click="openMangaModal(manga)">
|
||||
<img
|
||||
:src="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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- É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">Recherche en cours...</p>
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Résultats de recherche -->
|
||||
<div class="max-w-full overflow-hidden">
|
||||
<MangaList v-if="searchResults.length > 0" :mangas="searchResults" @manga-click="openMangaModal" />
|
||||
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600">Aucun résultat trouvé</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal de confirmation -->
|
||||
<!-- Modal de détail -->
|
||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 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 rounded-xl shadow-xl p-6">
|
||||
<DialogTitle class="text-lg mb-4"> 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">
|
||||
<img
|
||||
:src="selectedManga.imageUrl || '/placeholder-cover.png'"
|
||||
:alt="selectedManga.title"
|
||||
class="h-48 w-32 object-cover" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-lg">{{ selectedManga.title }}</h4>
|
||||
<p class="mt-2">
|
||||
{{ truncatedDescription }}
|
||||
</p>
|
||||
<!-- En-tête avec couverture -->
|
||||
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<img
|
||||
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
|
||||
:alt="selectedManga.title"
|
||||
class="h-64 w-44 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
|
||||
{{ selectedManga.title }}
|
||||
</DialogTitle>
|
||||
<div class="mt-3 space-y-1.5">
|
||||
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Publication</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Statut</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Note</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
|
||||
<span
|
||||
v-for="genre in selectedManga.genres"
|
||||
:key="genre"
|
||||
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{{ genre }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<!-- Description -->
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{{ selectedManga.description }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50">
|
||||
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>
|
||||
@@ -82,76 +144,91 @@
|
||||
</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';
|
||||
import MangaList from '../components/MangaList.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const mangaStore = useMangaStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const mangaStore = useMangaStore();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const isModalOpen = ref(false);
|
||||
const selectedManga = ref(null);
|
||||
const searchQuery = ref('');
|
||||
const hasSearched = ref(false);
|
||||
const isModalOpen = ref(false);
|
||||
const selectedManga = ref(null);
|
||||
|
||||
// Récupération des états du store
|
||||
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||
|
||||
const truncatedDescription = computed(() => {
|
||||
if (!selectedManga.value?.description) return '';
|
||||
return selectedManga.value.description.length > 500
|
||||
? selectedManga.value.description.slice(0, 500) + '...'
|
||||
: selectedManga.value.description;
|
||||
});
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Ajouter un manga', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: MagnifyingGlassIcon,
|
||||
label: 'Rechercher',
|
||||
onClick: performSearch,
|
||||
disabled: !searchQuery.value.trim() || loading.value,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Effectuer la recherche au chargement si un paramètre q est présent
|
||||
onMounted(() => {
|
||||
const queryParam = route.query.q;
|
||||
if (queryParam) {
|
||||
searchQuery.value = queryParam;
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
let debounceTimer = null;
|
||||
watch(searchQuery, newVal => {
|
||||
clearTimeout(debounceTimer);
|
||||
if (newVal.trim().length > 3) {
|
||||
debounceTimer = setTimeout(performSearch, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Nettoyer la recherche et les résultats lors du démontage du composant
|
||||
onBeforeUnmount(() => {
|
||||
searchQuery.value = '';
|
||||
mangaStore.clearSearchResults();
|
||||
});
|
||||
onMounted(() => {
|
||||
const queryParam = route.query.q;
|
||||
if (queryParam) {
|
||||
searchQuery.value = queryParam;
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
try {
|
||||
await mangaStore.searchMangaDex(searchQuery.value);
|
||||
} catch (e) {
|
||||
console.error('Erreur de recherche:', e);
|
||||
}
|
||||
};
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(debounceTimer);
|
||||
searchQuery.value = '';
|
||||
mangaStore.clearSearchResults();
|
||||
});
|
||||
|
||||
const openMangaModal = manga => {
|
||||
selectedManga.value = manga;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
try {
|
||||
await mangaStore.searchMangaDex(searchQuery.value);
|
||||
hasSearched.value = true;
|
||||
} catch (e) {
|
||||
console.error('Erreur de recherche:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedManga.value = null;
|
||||
};
|
||||
const openMangaModal = manga => {
|
||||
selectedManga.value = manga;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const addManga = async () => {
|
||||
if (!selectedManga.value) return;
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedManga.value = null;
|
||||
};
|
||||
|
||||
try {
|
||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||
router.push('/manga');
|
||||
} catch (e) {
|
||||
console.error("Erreur d'ajout:", e);
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
const addManga = async () => {
|
||||
if (!selectedManga.value) return;
|
||||
try {
|
||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||
router.push('/manga');
|
||||
} catch (e) {
|
||||
console.error("Erreur d'ajout:", e);
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
192
assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
Normal file
192
assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<!-- État de chargement -->
|
||||
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
|
||||
<span class="text-sm">Chargement des recommandations...</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<section v-else-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Résultats -->
|
||||
<section v-else-if="discoverResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Recommandations</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ discoverResults.length }} manga(s)</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="manga in discoverResults"
|
||||
:key="manga.externalId"
|
||||
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
|
||||
@click="openMangaModal(manga)">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||
alt=""
|
||||
class="h-36 w-24 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
|
||||
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Collection locale vide -->
|
||||
<section v-else-if="!loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Ajoutez des manga pour obtenir des recommandations.</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de détail -->
|
||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
|
||||
<!-- En-tête avec couverture -->
|
||||
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<img
|
||||
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
|
||||
:alt="selectedManga.title"
|
||||
class="h-64 w-44 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
|
||||
{{ selectedManga.title }}
|
||||
</DialogTitle>
|
||||
<div class="mt-3 space-y-1.5">
|
||||
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Publication</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Statut</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Note</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
|
||||
<span
|
||||
v-for="genre in selectedManga.genres"
|
||||
:key="genre"
|
||||
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{{ genre }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{{ selectedManga.description }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="addManga"
|
||||
:disabled="adding"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
|
||||
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
|
||||
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||
import { ArrowPathIcon, ArrowPathRoundedSquareIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
|
||||
const isModalOpen = ref(false);
|
||||
const selectedManga = ref(null);
|
||||
|
||||
const { discoverResults, loadingDiscover: loading, discoverError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Découvrir', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: ArrowPathRoundedSquareIcon,
|
||||
label: 'Actualiser',
|
||||
onClick: () => mangaStore.loadDiscoverRecommendations(),
|
||||
disabled: loading.value,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
mangaStore.loadDiscoverRecommendations();
|
||||
});
|
||||
|
||||
const openMangaModal = manga => {
|
||||
selectedManga.value = manga;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedManga.value = null;
|
||||
};
|
||||
|
||||
const addManga = async () => {
|
||||
if (!selectedManga.value) return;
|
||||
try {
|
||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||
router.push('/manga');
|
||||
} catch (e) {
|
||||
console.error("Erreur d'ajout:", e);
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,18 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="container mx-auto px-4">
|
||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="collection?.items || []" />
|
||||
<MangaList
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="w-full">
|
||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
||||
<MangaOverview
|
||||
v-else-if="viewMode === 'list'"
|
||||
:mangas="collection?.items || []"
|
||||
:mangas="pagedItems"
|
||||
@manga-click="handleMangaClick" />
|
||||
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" />
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
:total="sortedCollection.length"
|
||||
:limit="prefs.itemsPerPage"
|
||||
:has-next-page="currentPage < totalPages"
|
||||
:has-previous-page="currentPage > 1"
|
||||
@page-change="currentPage = $event" />
|
||||
<div
|
||||
v-if="isBackgroundLoading"
|
||||
class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Mise à jour en cours...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,15 +38,19 @@
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaGrid from '../components/MangaGrid.vue';
|
||||
import MangaList from '../components/MangaList.vue';
|
||||
import MangaOverview from '../components/MangaOverview.vue';
|
||||
import MangaTable from '../components/MangaTable.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
const prefs = useUserPreferencesStore();
|
||||
|
||||
const {
|
||||
collection,
|
||||
@@ -43,7 +59,8 @@ import MangaList from '../components/MangaList.vue';
|
||||
isBackgroundLoadingCollection: isBackgroundLoading
|
||||
} = storeToRefs(mangaStore);
|
||||
|
||||
const viewMode = ref('grid');
|
||||
const viewMode = ref(prefs.defaultView);
|
||||
const currentPage = ref(1);
|
||||
|
||||
onMounted(() => {
|
||||
mangaStore.loadCollection();
|
||||
@@ -53,6 +70,27 @@ import MangaList from '../components/MangaList.vue';
|
||||
router.push({ name: 'manga-details', params: { id: manga.id } });
|
||||
};
|
||||
|
||||
const sortedCollection = computed(() => {
|
||||
const items = [...(collection.value?.items || [])];
|
||||
if (prefs.sortBy === 'title') {
|
||||
items.sort((a, b) => a.title.localeCompare(b.title));
|
||||
} else if (prefs.sortBy === 'addedAt') {
|
||||
items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const pagedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * prefs.itemsPerPage;
|
||||
return sortedCollection.value.slice(start, start + prefs.itemsPerPage);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.ceil(sortedCollection.value.length / prefs.itemsPerPage));
|
||||
|
||||
watch(() => prefs.itemsPerPage, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
const toolbarConfig = {
|
||||
leftSection: [
|
||||
{
|
||||
@@ -71,8 +109,9 @@ import MangaList from '../components/MangaList.vue';
|
||||
type: 'dropdown',
|
||||
label: 'View',
|
||||
items: [
|
||||
{ label: 'List', onClick: () => (viewMode.value = 'list') },
|
||||
{ label: 'Grid', onClick: () => (viewMode.value = 'grid') }
|
||||
{ label: 'Overview', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
|
||||
{ label: 'Grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
|
||||
{ label: 'Table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -80,10 +119,9 @@ import MangaList from '../components/MangaList.vue';
|
||||
type: 'dropdown',
|
||||
label: 'Sort',
|
||||
items: [
|
||||
{ label: 'Title', onClick: () => {} },
|
||||
{ label: 'Author', onClick: () => {} },
|
||||
{ label: 'Status', onClick: () => {} },
|
||||
{ label: 'Year', onClick: () => {} }
|
||||
{ label: 'Title', onClick: () => prefs.setSortBy('title') },
|
||||
{ label: "Date d'ajout", onClick: () => prefs.setSortBy('addedAt') },
|
||||
{ label: 'Progression', onClick: () => prefs.setSortBy('progress') }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Notifications Toast -->
|
||||
<NotificationToast />
|
||||
|
||||
<div v-if="errorDetails" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mx-4 mt-4">
|
||||
<Toolbar v-if="currentManga" :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
|
||||
<div v-if="errorDetails" 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 mx-4 mt-4">
|
||||
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
|
||||
</div>
|
||||
|
||||
@@ -11,9 +15,7 @@
|
||||
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
||||
<MercureListener :manga-id="String(mangaId)" />
|
||||
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
|
||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 z-20">
|
||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 dark:text-gray-400 z-20">
|
||||
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
|
||||
@@ -24,7 +26,7 @@
|
||||
<div v-if="isLoadingVolumes" class="flex justify-center items-center h-32">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
<div v-else-if="errorVolumes" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-else-if="errorVolumes" 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">
|
||||
{{ errorVolumes.message || 'Une erreur est survenue lors du chargement des volumes.' }}
|
||||
</div>
|
||||
<MangaVolumeList
|
||||
@@ -84,9 +86,11 @@
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-gray-500 py-10 px-4">
|
||||
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-10 px-4">
|
||||
Aucun manga sélectionné ou trouvé.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useUserPreferencesStore } from '../../../setting/application/store/userPreferencesStore';
|
||||
import { Chapter } from '../../domain/entities/Chapter';
|
||||
import { ApiChapterRepository } from '../../infrastructure/repository/ApiChapterRepository';
|
||||
|
||||
@@ -163,6 +164,16 @@ export const useReaderStore = defineStore('reader', {
|
||||
loadPreferences() {
|
||||
try {
|
||||
const stored = localStorage.getItem('mangarr-reader-preferences');
|
||||
if (!stored) {
|
||||
const userPrefs = useUserPreferencesStore();
|
||||
this.readingDirection = userPrefs.readingDirection;
|
||||
const modeMap = { scroll: 'infinite', single: 'single', double: 'single' };
|
||||
this.readingMode = modeMap[userPrefs.readingMode] ?? 'single';
|
||||
if (userPrefs.readingMode === 'double') {
|
||||
this.doublePageSettings.autoDetect = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stored) {
|
||||
const preferences = JSON.parse(stored);
|
||||
|
||||
|
||||
@@ -9,19 +9,6 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="reader-content">
|
||||
<ReaderControls
|
||||
v-if="store.readingMode === 'single'"
|
||||
:current-page="store.currentPage"
|
||||
:total-pages="store.totalPages"
|
||||
:is-first-page="store.isFirstPage"
|
||||
:is-last-page="store.isLastPage"
|
||||
:available-chapters="availableChapters"
|
||||
:settings-open="settingsOpen"
|
||||
@previous="store.previousPage"
|
||||
@next="store.nextPage"
|
||||
@chapter-selected="handleChapterSelected"
|
||||
@toggle-settings="toggleSettings" />
|
||||
|
||||
<template v-if="store.readingMode === 'single'">
|
||||
<SingleModeReader
|
||||
:page-data="store.currentPageData"
|
||||
@@ -35,29 +22,10 @@
|
||||
:pages="store.pages"
|
||||
:zoom="store.zoom"
|
||||
:double-page-mode="store.effectiveDoublePageMode"
|
||||
:initial-page="store.currentPage"
|
||||
@page-visible="store.handlePageVisible"
|
||||
@buttons-visibility-change="handleButtonsVisibilityChange"
|
||||
ref="infiniteReaderRef" />
|
||||
</template>
|
||||
|
||||
<ReaderSettings
|
||||
:reading-mode="store.readingMode"
|
||||
:reading-direction="store.readingDirection"
|
||||
:zoom="store.zoom"
|
||||
:double-page-mode="store.effectiveDoublePageMode"
|
||||
:double-page-settings="store.doublePageSettings"
|
||||
:visible="showFloatingButtons"
|
||||
:force-open="store.readingMode === 'single' ? settingsOpen : null"
|
||||
@toggle-reading-mode="toggleReadingMode"
|
||||
@toggle-reading-direction="toggleReadingDirection"
|
||||
@zoom-in="zoomIn"
|
||||
@zoom-out="zoomOut"
|
||||
@zoom-change="handleZoomChange"
|
||||
@double-page-mode-change="handleDoublePageModeChange"
|
||||
@double-page-auto-detect-change="handleDoublePageAutoDetectChange"
|
||||
@detection-threshold-change="handleDetectionThresholdChange"
|
||||
@reset-preferences="handleResetPreferences"
|
||||
@button-click="resetButtonsTimer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,10 +33,9 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||
import { useReaderStore } from '../../application/store/readerStore';
|
||||
import InfiniteReader from './InfiniteReader.vue';
|
||||
import ReaderControls from './ReaderControls.vue';
|
||||
import ReaderSettings from './ReaderSettings.vue';
|
||||
import SingleModeReader from './SingleModeReader.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -84,128 +51,64 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
|
||||
const store = useReaderStore();
|
||||
const headerStore = useHeaderStore();
|
||||
const prefs = useUserPreferencesStore();
|
||||
|
||||
// Référence vers InfiniteReader pour accéder à ses méthodes
|
||||
const infiniteReaderRef = ref(null);
|
||||
|
||||
// État pour la visibilité des boutons (géré par InfiniteReader en mode infini, localement en mode simple)
|
||||
const showFloatingButtons = ref(false);
|
||||
const settingsOpen = ref(false); // Nouvel état pour gérer l'ouverture des paramètres
|
||||
let localButtonsTimer = null;
|
||||
|
||||
// Actions de l'interface lecteur
|
||||
const toggleReadingMode = () => {
|
||||
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
|
||||
store.setReadingMode(newMode);
|
||||
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
|
||||
|
||||
// Gérer la visibilité selon le mode
|
||||
if (newMode === 'single') {
|
||||
headerStore.disableAutoHide();
|
||||
// En mode simple : toujours visible
|
||||
showFloatingButtons.value = true;
|
||||
clearTimeout(localButtonsTimer); // Annuler tout timer local
|
||||
headerStore.disableReaderToolbarAutoHide();
|
||||
} else {
|
||||
// En mode infini : utiliser la logique d'InfiniteReader
|
||||
headerStore.enableReaderToolbarAutoHide();
|
||||
headerStore.enableAutoHide();
|
||||
showButtonsWithTimer();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReadingDirection = () => {
|
||||
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
|
||||
resetButtonsTimer();
|
||||
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
|
||||
store.setReadingDirection(newDir);
|
||||
prefs.setReadingDirection(newDir);
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||
resetButtonsTimer();
|
||||
};
|
||||
const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||
const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||
|
||||
const zoomOut = () => {
|
||||
store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||
resetButtonsTimer();
|
||||
};
|
||||
const handleZoomChange = (zoom) => store.setZoom(zoom);
|
||||
|
||||
const handleZoomChange = (zoom) => {
|
||||
store.setZoom(zoom);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
const handleDoublePageModeChange = (mode) => store.setDoublePageMode(mode);
|
||||
const handleDoublePageAutoDetectChange = (enabled) => store.setDoublePageAutoDetect(enabled);
|
||||
const handleDetectionThresholdChange = (threshold) => store.setDoublePageDetectionThreshold(threshold);
|
||||
const handleResetPreferences = () => store.resetPreferences();
|
||||
|
||||
// Fonctions pour les doubles pages
|
||||
const handleDoublePageModeChange = (mode) => {
|
||||
store.setDoublePageMode(mode);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const handleDoublePageAutoDetectChange = (enabled) => {
|
||||
store.setDoublePageAutoDetect(enabled);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const handleDetectionThresholdChange = (threshold) => {
|
||||
store.setDoublePageDetectionThreshold(threshold);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const handleResetPreferences = () => {
|
||||
store.resetPreferences();
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
// Fonction pour afficher les boutons avec timer (avec fallback pour mode simple)
|
||||
const showButtonsWithTimer = () => {
|
||||
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
||||
// Mode infini : utiliser la logique d'InfiniteReader
|
||||
infiniteReaderRef.value.showButtonsWithTimer();
|
||||
} else {
|
||||
// Mode simple : toujours visible, pas de timer
|
||||
showFloatingButtons.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction centralisée pour réinitialiser le timer
|
||||
const resetButtonsTimer = () => {
|
||||
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
||||
// Mode infini : utiliser la logique d'InfiniteReader
|
||||
infiniteReaderRef.value.resetButtonsTimer();
|
||||
} else {
|
||||
// Mode simple : toujours visible, pas de timer
|
||||
showFloatingButtons.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Gestionnaire pour les changements de visibilité des boutons
|
||||
const handleButtonsVisibilityChange = (visible) => {
|
||||
if (store.readingMode === 'infinite') {
|
||||
showFloatingButtons.value = visible;
|
||||
}
|
||||
// En mode simple, on ignore les changements et on reste toujours visible
|
||||
};
|
||||
|
||||
const handleKeyPress = event => {
|
||||
if (store.readingMode === 'single') {
|
||||
if (event.key === 'ArrowRight') {
|
||||
store.nextPage();
|
||||
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
store.previousPage();
|
||||
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChapterSelected = (chapterId) => {
|
||||
// La navigation est déjà gérée par le ChapterSelector via le store
|
||||
// Cette fonction est là pour d'éventuelles actions supplémentaires
|
||||
console.log('Chapitre sélectionné:', chapterId);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
// Gestion des paramètres via le bouton intégré
|
||||
const toggleSettings = () => {
|
||||
settingsOpen.value = !settingsOpen.value;
|
||||
resetButtonsTimer(); // Réinitialiser le timer lors de l'interaction
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.chapterId,
|
||||
newId => {
|
||||
@@ -217,28 +120,46 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Charger les préférences sauvegardées
|
||||
store.loadPreferences();
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
// Afficher les boutons au démarrage
|
||||
showButtonsWithTimer();
|
||||
if (prefs.autoHideHeaderReader) {
|
||||
headerStore.enableAutoHide();
|
||||
}
|
||||
|
||||
if (store.readingMode === 'infinite') {
|
||||
headerStore.enableReaderToolbarAutoHide();
|
||||
}
|
||||
|
||||
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyPress);
|
||||
// S'assurer que l'auto-hide est désactivé en quittant le lecteur
|
||||
headerStore.disableAutoHide();
|
||||
// Nettoyer le timer local
|
||||
clearTimeout(localButtonsTimer);
|
||||
headerStore.disableReaderToolbarAutoHide();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
toggleReadingMode,
|
||||
toggleReadingDirection,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
handleZoomChange,
|
||||
handleDoublePageModeChange,
|
||||
handleDoublePageAutoDetectChange,
|
||||
handleDetectionThresholdChange,
|
||||
handleResetPreferences,
|
||||
resetButtonsTimer,
|
||||
showButtonsWithTimer,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.chapter-reader {
|
||||
@apply w-full h-full flex flex-col items-center justify-center bg-gray-900 text-white;
|
||||
@apply p-0 sm:p-2;
|
||||
@apply w-full h-full flex flex-col bg-gray-900 text-white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -250,8 +171,7 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
}
|
||||
|
||||
.reader-content {
|
||||
@apply w-full h-full flex flex-col;
|
||||
@apply p-0 sm:p-2;
|
||||
@apply w-full flex-1 flex flex-col min-h-0;
|
||||
}
|
||||
|
||||
.rtl {
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
<template>
|
||||
<div class="infinite-reader" ref="containerRef">
|
||||
<!-- Navigation en haut -->
|
||||
<div class="navigation-wrapper top">
|
||||
<ChapterNavigation position="top" />
|
||||
</div>
|
||||
<div v-for="(page, index) in pages" :key="index"
|
||||
class="page-wrapper" :data-page-index="index">
|
||||
|
||||
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
|
||||
<!-- Pas d'URL : spinner de chargement -->
|
||||
<div v-if="!page?.url" class="loading">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<!-- Navigation en bas -->
|
||||
<div class="navigation-wrapper bottom">
|
||||
<ChapterNavigation position="bottom" />
|
||||
<!-- Hors de la zone de rendu : placeholder dimensionné -->
|
||||
<div v-else-if="!mountedPageIndices.has(index)"
|
||||
class="page-placeholder"
|
||||
:style="{ height: getPlaceholderHeight(page) + 'px' }" />
|
||||
|
||||
<!-- Dans la zone : composant complet -->
|
||||
<ReaderPage v-else
|
||||
:page-data="page"
|
||||
:page-number="index + 1"
|
||||
:zoom="zoom"
|
||||
:double-page-mode="doublePageMode"
|
||||
:window-width="windowWidth"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
|
||||
<!-- Bouton flottant pour revenir en haut -->
|
||||
@@ -29,22 +35,22 @@
|
||||
<button
|
||||
v-show="showFloatingButtons"
|
||||
@click="scrollToTop"
|
||||
class="fixed bottom-6 right-6 z-[9999] bg-blue-600 hover:bg-blue-700 text-white w-12 h-12 rounded-full shadow-lg hover:shadow-xl flex items-center justify-center transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
class="fixed bottom-6 right-6 z-[9999] bg-gray-800 hover:bg-gray-700 text-white hover:text-green-500 flex flex-col items-center justify-center w-12 h-12 rounded shadow-lg transition-colors duration-200"
|
||||
title="Revenir en haut"
|
||||
type="button"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
<span class="text-xs hidden sm:inline">Haut</span>
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import ChapterNavigation from './ChapterNavigation.vue';
|
||||
import ReaderPage from './ReaderPage.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -67,6 +73,8 @@ import ReaderPage from './ReaderPage.vue';
|
||||
const headerStore = useHeaderStore();
|
||||
const containerRef = ref(null);
|
||||
const observer = ref(null);
|
||||
const visibilityObserver = ref(null);
|
||||
const mountedPageIndices = reactive(new Set());
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
|
||||
// État unique pour tous les boutons flottants avec timer de 3 secondes
|
||||
@@ -86,24 +94,46 @@ import ReaderPage from './ReaderPage.vue';
|
||||
});
|
||||
};
|
||||
|
||||
const setupIntersectionObserver = () => {
|
||||
if (observer.value) {
|
||||
observer.value.disconnect();
|
||||
}
|
||||
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
|
||||
const getPlaceholderHeight = (page) => {
|
||||
const dims = page?.dimensions;
|
||||
if (!dims?.width || !dims?.height) return 800;
|
||||
const displayWidth = windowWidth.value < 1200
|
||||
? Math.min(dims.width, windowWidth.value * 0.95)
|
||||
: Math.min(dims.width, 1200);
|
||||
return Math.round((dims.height / dims.width) * displayWidth);
|
||||
};
|
||||
|
||||
const setupObservers = () => {
|
||||
observer.value?.disconnect();
|
||||
visibilityObserver.value?.disconnect();
|
||||
|
||||
observer.value = new IntersectionObserver(observeIntersection, {
|
||||
root: null,
|
||||
threshold: 0.5
|
||||
});
|
||||
|
||||
nextTick(() => {
|
||||
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
|
||||
if (pageElements) {
|
||||
pageElements.forEach((element, index) => {
|
||||
element.setAttribute('data-page-index', index);
|
||||
observer.value.observe(element);
|
||||
visibilityObserver.value = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach(entry => {
|
||||
const idx = parseInt(entry.target.getAttribute('data-page-index'));
|
||||
if (entry.isIntersecting) {
|
||||
mountedPageIndices.add(idx);
|
||||
} else {
|
||||
mountedPageIndices.delete(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ root: null, rootMargin: '1000px 0px', threshold: 0 }
|
||||
);
|
||||
|
||||
nextTick(() => {
|
||||
const els = containerRef.value?.querySelectorAll('.page-wrapper');
|
||||
els?.forEach((el, i) => {
|
||||
el.setAttribute('data-page-index', i);
|
||||
observer.value.observe(el);
|
||||
visibilityObserver.value.observe(el);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -169,10 +199,8 @@ import ReaderPage from './ReaderPage.vue';
|
||||
scrollDirection = 'up';
|
||||
}
|
||||
|
||||
// Gestion du header auto-hide (seulement si largeur < 1200px)
|
||||
if (windowWidth.value < 1200) {
|
||||
headerStore.updateScrollDirection(scrollTop);
|
||||
}
|
||||
// Gestion du header auto-hide (header : seulement si largeur < 1200px, toolbar : toujours)
|
||||
headerStore.updateScrollDirection(scrollTop);
|
||||
|
||||
// Gestion de la visibilité des boutons flottants (même condition pour tous)
|
||||
// Afficher si on scroll et qu'on est à plus de 300px
|
||||
@@ -189,21 +217,16 @@ import ReaderPage from './ReaderPage.vue';
|
||||
|
||||
// Fonction pour revenir en haut de la page
|
||||
const scrollToTop = () => {
|
||||
console.log('scrollToTop appelée'); // Debug
|
||||
|
||||
// Réinitialiser le timer lors du clic
|
||||
resetButtonsTimer();
|
||||
|
||||
// Stratégie 1: Scroll sur le conteneur direct
|
||||
if (containerRef.value) {
|
||||
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
|
||||
|
||||
if (containerRef.value.scrollTop > 0) {
|
||||
containerRef.value.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
console.log('Scroll sur containerRef effectué'); // Debug
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -213,7 +236,6 @@ import ReaderPage from './ReaderPage.vue';
|
||||
while (currentElement) {
|
||||
const styles = window.getComputedStyle(currentElement);
|
||||
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
|
||||
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
|
||||
currentElement.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
@@ -224,7 +246,6 @@ import ReaderPage from './ReaderPage.vue';
|
||||
}
|
||||
|
||||
// Stratégie 3: Scroll sur la fenêtre entière
|
||||
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
@@ -240,7 +261,8 @@ import ReaderPage from './ReaderPage.vue';
|
||||
watch(
|
||||
() => props.pages,
|
||||
() => {
|
||||
setupIntersectionObserver();
|
||||
mountedPageIndices.clear();
|
||||
setupObservers();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
@@ -259,7 +281,7 @@ import ReaderPage from './ReaderPage.vue';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver();
|
||||
setupObservers();
|
||||
|
||||
// Activer l'auto-hide du header si la largeur < 1200px
|
||||
if (windowWidth.value < 1200) {
|
||||
@@ -279,9 +301,8 @@ import ReaderPage from './ReaderPage.vue';
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer.value) {
|
||||
observer.value.disconnect();
|
||||
}
|
||||
observer.value?.disconnect();
|
||||
visibilityObserver.value?.disconnect();
|
||||
|
||||
// Désactiver l'auto-hide du header en quittant
|
||||
headerStore.disableAutoHide();
|
||||
@@ -304,19 +325,23 @@ import ReaderPage from './ReaderPage.vue';
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.infinite-reader {
|
||||
@apply flex-1 flex flex-col items-center overflow-y-auto relative;
|
||||
@apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
|
||||
/* Réduction du padding sur mobile */
|
||||
@apply py-2 sm:py-8;
|
||||
height: calc(100vh - 8rem);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
@apply w-full flex justify-center min-h-[200px];
|
||||
/* Réduction des marges sur mobile */
|
||||
@apply w-full flex justify-center;
|
||||
@apply mb-2 sm:mb-4 px-1 sm:px-4;
|
||||
}
|
||||
|
||||
.page-placeholder {
|
||||
@apply w-full;
|
||||
max-width: 1200px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
@apply flex items-center justify-center min-h-[400px];
|
||||
@@ -342,15 +367,4 @@ import ReaderPage from './ReaderPage.vue';
|
||||
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
|
||||
}
|
||||
|
||||
.navigation-wrapper {
|
||||
@apply w-full max-w-4xl mx-auto px-4 mb-6;
|
||||
}
|
||||
|
||||
.navigation-wrapper.top {
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
.navigation-wrapper.bottom {
|
||||
@apply mt-8 mb-4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="page-container" :style="{ transform: `scale(${zoom})` }">
|
||||
<div
|
||||
class="page-container"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div>
|
||||
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
|
||||
|
||||
@@ -75,16 +78,33 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
type: String,
|
||||
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
||||
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
||||
},
|
||||
windowWidth: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const store = useReaderStore();
|
||||
|
||||
// En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles)
|
||||
// En mode infinite : zoom via transform: scale (pas d'impact layout souhaité)
|
||||
const containerStyle = computed(() => {
|
||||
if (store.readingMode === 'single') {
|
||||
return { zoom: props.zoom };
|
||||
}
|
||||
return { transform: `scale(${props.zoom})` };
|
||||
});
|
||||
|
||||
const imageRef = ref(null);
|
||||
const scrollContainerRef = ref(null);
|
||||
const naturalWidth = ref(0);
|
||||
const naturalHeight = ref(0);
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
const localWindowWidth = ref(window.innerWidth);
|
||||
const effectiveWindowWidth = computed(() =>
|
||||
props.windowWidth !== null ? props.windowWidth : localWindowWidth.value
|
||||
);
|
||||
const isMobile = computed(() => effectiveWindowWidth.value < 768);
|
||||
const imageLoaded = ref(false);
|
||||
|
||||
const imageSource = computed(() => {
|
||||
@@ -103,17 +123,13 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
// Utiliser d'abord les dimensions de l'API si disponibles
|
||||
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
||||
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
|
||||
const isDouble = ratio > threshold;
|
||||
console.log(`API Dimensions - Page ${props.pageNumber}: ${props.pageData.dimensions.width}x${props.pageData.dimensions.height}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
|
||||
return isDouble;
|
||||
return ratio > threshold;
|
||||
}
|
||||
|
||||
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
|
||||
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
|
||||
const ratio = naturalWidth.value / naturalHeight.value;
|
||||
const isDouble = ratio > threshold;
|
||||
console.log(`Natural Dimensions - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
|
||||
return isDouble;
|
||||
return ratio > threshold;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -124,7 +140,6 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
naturalWidth.value = imageRef.value.naturalWidth;
|
||||
naturalHeight.value = imageRef.value.naturalHeight;
|
||||
imageLoaded.value = true;
|
||||
console.log(`Image loaded - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}`);
|
||||
|
||||
// Positionner le scroll à droite si c'est le mode scroll
|
||||
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
|
||||
@@ -175,7 +190,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
|
||||
if (!width || !height) return null;
|
||||
|
||||
const availableWidth = windowWidth.value;
|
||||
const availableWidth = effectiveWindowWidth.value;
|
||||
|
||||
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur
|
||||
if (availableWidth < 1200) {
|
||||
@@ -187,13 +202,27 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
});
|
||||
|
||||
const imageStyle = computed(() => {
|
||||
if (!maxWidth.value) return {};
|
||||
// Mode simple : laisser CSS contraindre les deux dimensions proportionnellement
|
||||
if (store.readingMode === 'single') {
|
||||
return {
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: `${maxWidth.value}px`,
|
||||
// Mode scroll : fixer la largeur, hauteur libre
|
||||
const style = {
|
||||
height: 'auto',
|
||||
maxWidth: '100%'
|
||||
maxWidth: '100%',
|
||||
};
|
||||
|
||||
if (maxWidth.value) {
|
||||
style.width = `${maxWidth.value}px`;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
// Styles spéciaux pour les doubles pages
|
||||
@@ -210,7 +239,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
if (!width || !height) return {};
|
||||
|
||||
// En mode rotation : maximiser l'utilisation de l'espace
|
||||
const availableWidth = windowWidth.value;
|
||||
const availableWidth = effectiveWindowWidth.value;
|
||||
const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles
|
||||
|
||||
// Après rotation, la largeur originale devient la hauteur affichée
|
||||
@@ -260,36 +289,32 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
};
|
||||
});
|
||||
|
||||
// Gestion du redimensionnement de la fenêtre
|
||||
const handleResize = () => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
};
|
||||
let ownResizeHandler = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (imageRef.value && imageRef.value.complete) {
|
||||
handleImageLoad();
|
||||
if (props.windowWidth === null) {
|
||||
ownResizeHandler = () => { localWindowWidth.value = window.innerWidth; };
|
||||
window.addEventListener('resize', ownResizeHandler, { passive: true });
|
||||
}
|
||||
window.addEventListener('resize', handleResize);
|
||||
if (imageRef.value?.complete) handleImageLoad();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (ownResizeHandler) window.removeEventListener('resize', ownResizeHandler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.page-container {
|
||||
@apply flex-1 flex items-center justify-center overflow-hidden;
|
||||
@apply flex items-center justify-center;
|
||||
transform-origin: center;
|
||||
/* Réduction des marges sur mobile */
|
||||
@apply p-0 sm:p-2;
|
||||
}
|
||||
|
||||
.page-image {
|
||||
@apply object-contain;
|
||||
/* La largeur est gérée par le JavaScript, on garde juste les contraintes max */
|
||||
/* La largeur et max-height sont gérées par imageStyle selon le mode */
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Styles pour les doubles pages sur mobile */
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
<template>
|
||||
<div class="reader-settings">
|
||||
<!-- Bouton pour ouvrir/fermer les paramètres -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-5 scale-75"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-5 scale-75"
|
||||
>
|
||||
<button
|
||||
v-show="visible"
|
||||
@click="toggleSettings"
|
||||
class="settings-toggle"
|
||||
:class="{ 'active': effectiveIsOpen }"
|
||||
:data-external-control="forceOpen !== null"
|
||||
title="Paramètres du lecteur"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||
</svg>
|
||||
</button>
|
||||
</Transition>
|
||||
|
||||
<!-- Panel des paramètres -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
@@ -32,63 +8,9 @@
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-4 scale-95"
|
||||
>
|
||||
<div v-show="effectiveIsOpen" class="settings-panel" :data-external-control="forceOpen !== null" ref="panelRef">
|
||||
<!-- Paramètres de base -->
|
||||
<div class="settings-section">
|
||||
<h3 class="section-title">Mode de lecture</h3>
|
||||
<div class="setting-group">
|
||||
<button
|
||||
@click="onToggleReadingMode"
|
||||
class="setting-button"
|
||||
:class="{ 'active': readingMode === 'infinite' }"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
||||
{{ readingMode === 'single' ? 'Mode Infini' : 'Mode Simple' }}
|
||||
</button>
|
||||
<div v-show="open" class="settings-panel" ref="panelRef">
|
||||
|
||||
<button
|
||||
@click="onToggleReadingDirection"
|
||||
class="setting-button"
|
||||
:class="{ 'active': readingDirection === 'rtl' }"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18" />
|
||||
</svg>
|
||||
{{ readingDirection === 'ltr' ? 'RTL' : 'LTR' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contrôles du zoom -->
|
||||
<div class="settings-section">
|
||||
<h3 class="section-title">Zoom</h3>
|
||||
<div class="zoom-controls">
|
||||
<button @click="onZoomOut" class="zoom-button" :disabled="zoom <= 0.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="zoom-display">{{ Math.round(zoom * 100) }}%</span>
|
||||
<button @click="onZoomIn" class="zoom-button" :disabled="zoom >= 2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:value="zoom"
|
||||
@input="onZoomChange($event.target.value)"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.1"
|
||||
class="zoom-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Paramètres des doubles pages -->
|
||||
<!-- Paramètres des doubles pages (mobile uniquement) -->
|
||||
<div class="settings-section" v-if="isMobile">
|
||||
<h3 class="section-title">
|
||||
<svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -97,7 +19,6 @@
|
||||
Doubles pages (Mobile)
|
||||
</h3>
|
||||
|
||||
<!-- Activation/désactivation -->
|
||||
<div class="setting-item">
|
||||
<label class="setting-label">
|
||||
<input
|
||||
@@ -113,7 +34,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mode d'affichage (si la détection automatique est activée) -->
|
||||
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
||||
<label class="setting-label">Mode d'affichage</label>
|
||||
<select
|
||||
@@ -125,22 +45,13 @@
|
||||
<option value="scroll">Défilement horizontal</option>
|
||||
<option value="normal">Affichage normal</option>
|
||||
</select>
|
||||
|
||||
<!-- Descriptions des modes -->
|
||||
<p class="setting-description">
|
||||
<span v-if="doublePageMode === 'rotate'">
|
||||
Suggère de tourner l'appareil pour une meilleure lecture
|
||||
</span>
|
||||
<span v-else-if="doublePageMode === 'scroll'">
|
||||
Permet le défilement horizontal pour naviguer dans la page (commence à droite)
|
||||
</span>
|
||||
<span v-else>
|
||||
Affichage standard sans optimisation spéciale
|
||||
</span>
|
||||
<span v-if="doublePageMode === 'rotate'">Suggère de tourner l'appareil pour une meilleure lecture</span>
|
||||
<span v-else-if="doublePageMode === 'scroll'">Permet le défilement horizontal pour naviguer dans la page (commence à droite)</span>
|
||||
<span v-else>Affichage standard sans optimisation spéciale</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Seuil de détection -->
|
||||
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
||||
<label class="setting-label">
|
||||
Sensibilité de détection: {{ doublePageSettings.detectionThreshold.toFixed(1) }}
|
||||
@@ -160,14 +71,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<!-- Réinitialiser -->
|
||||
<div class="settings-section">
|
||||
<div class="setting-actions">
|
||||
<button @click="onResetPreferences" class="action-button reset">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Réinitialiser
|
||||
Réinitialiser les préférences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,21 +88,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
readingMode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
readingDirection: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
zoom: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
doublePageMode: {
|
||||
type: String,
|
||||
default: 'rotate'
|
||||
@@ -204,138 +103,38 @@
|
||||
detectionThreshold: 1.4
|
||||
})
|
||||
},
|
||||
// Visibilité contrôlée par le parent
|
||||
visible: {
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// Contrôle externe de l'ouverture (pour le bouton intégré)
|
||||
forceOpen: {
|
||||
type: Boolean,
|
||||
default: null // null = pas de contrôle externe, true/false = contrôle externe
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'toggleReadingMode',
|
||||
'toggleReadingDirection',
|
||||
'zoomIn',
|
||||
'zoomOut',
|
||||
'zoomChange',
|
||||
'toggleSettings',
|
||||
'doublePageModeChange',
|
||||
'doublePageAutoDetectChange',
|
||||
'detectionThresholdChange',
|
||||
'resetPreferences',
|
||||
'buttonClick' // Signaler l'interaction au parent
|
||||
]);
|
||||
|
||||
const isOpen = ref(false);
|
||||
const isMobile = computed(() => window.innerWidth < 768);
|
||||
const panelRef = ref(null);
|
||||
|
||||
// Computed pour gérer l'état d'ouverture (interne ou externe)
|
||||
const effectiveIsOpen = computed(() => {
|
||||
// Si forceOpen est défini (true/false), on l'utilise
|
||||
if (props.forceOpen !== null) {
|
||||
return props.forceOpen;
|
||||
}
|
||||
// Sinon, on utilise l'état interne
|
||||
return isOpen.value;
|
||||
});
|
||||
|
||||
const toggleSettings = () => {
|
||||
// Si on est en contrôle externe, ne pas permettre le toggle via le bouton flottant
|
||||
if (props.forceOpen !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = !isOpen.value;
|
||||
// Signaler l'interaction au parent
|
||||
emit('buttonClick');
|
||||
};
|
||||
|
||||
// Fonction pour fermer le panel (utilisée par les clics externes et internes)
|
||||
const closePanel = () => {
|
||||
if (props.forceOpen !== null) {
|
||||
// Mode externe : émettre l'événement pour que le parent gère la fermeture
|
||||
emit('buttonClick');
|
||||
} else {
|
||||
// Mode interne : fermer directement
|
||||
isOpen.value = false;
|
||||
emit('buttonClick');
|
||||
}
|
||||
};
|
||||
|
||||
// Gestion des clics en dehors du panel
|
||||
const handleClickOutside = (event) => {
|
||||
if (effectiveIsOpen.value && panelRef.value && !panelRef.value.contains(event.target)) {
|
||||
// Vérifier que le clic n'est pas sur le bouton de toggle
|
||||
const settingsButton = document.querySelector('.settings-toggle, .settings-button');
|
||||
if (settingsButton && settingsButton.contains(event.target)) {
|
||||
return; // Laisser le bouton gérer le toggle
|
||||
}
|
||||
|
||||
closePanel();
|
||||
if (props.open && panelRef.value && !panelRef.value.contains(event.target)) {
|
||||
emit('toggleSettings');
|
||||
}
|
||||
};
|
||||
|
||||
// Watcher pour empêcher la fermeture du bouton quand le panel est ouvert
|
||||
watch(
|
||||
() => effectiveIsOpen.value,
|
||||
(newIsOpen) => {
|
||||
if (newIsOpen || !newIsOpen) {
|
||||
// Signaler l'interaction à chaque changement
|
||||
emit('buttonClick');
|
||||
}
|
||||
}
|
||||
);
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside, true));
|
||||
onUnmounted(() => document.removeEventListener('click', handleClickOutside, true));
|
||||
|
||||
// Cycle de vie des event listeners
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside, true);
|
||||
});
|
||||
|
||||
// Méthodes des événements (toutes signalent l'interaction)
|
||||
const onToggleReadingMode = () => {
|
||||
emit('toggleReadingMode');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onToggleReadingDirection = () => {
|
||||
emit('toggleReadingDirection');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onZoomIn = () => {
|
||||
emit('zoomIn');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onZoomOut = () => {
|
||||
emit('zoomOut');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onZoomChange = (value) => {
|
||||
emit('zoomChange', parseFloat(value));
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDoublePageModeChange = (mode) => {
|
||||
emit('doublePageModeChange', mode);
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDoublePageAutoDetectChange = (enabled) => {
|
||||
emit('doublePageAutoDetectChange', enabled);
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDetectionThresholdChange = (threshold) => {
|
||||
emit('detectionThresholdChange', parseFloat(threshold));
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDoublePageModeChange = (mode) => emit('doublePageModeChange', mode);
|
||||
const onDoublePageAutoDetectChange = (enabled) => emit('doublePageAutoDetectChange', enabled);
|
||||
const onDetectionThresholdChange = (threshold) => emit('detectionThresholdChange', parseFloat(threshold));
|
||||
const onResetPreferences = () => {
|
||||
emit('resetPreferences');
|
||||
emit('buttonClick');
|
||||
isOpen.value = false;
|
||||
emit('toggleSettings');
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -344,25 +143,10 @@
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
@apply fixed top-20 right-4 z-50 w-12 h-12 bg-gray-800 hover:bg-gray-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
/* Masquer le bouton flottant si on est en contrôle externe */
|
||||
.settings-toggle[data-external-control="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-toggle.active {
|
||||
@apply bg-blue-600 hover:bg-blue-700;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
@apply fixed top-36 right-4 z-40 w-80 max-w-[calc(100vw-2rem)] bg-gray-800 rounded-lg shadow-xl border border-gray-700 max-h-[80vh] overflow-y-auto;
|
||||
@apply fixed top-20 right-4 z-40 w-80 max-w-[calc(100vw-2rem)] bg-gray-800 rounded-lg shadow-xl border border-gray-700 max-h-[80vh] overflow-y-auto;
|
||||
}
|
||||
|
||||
/* Responsive pour settings-panel */
|
||||
@media (max-width: 480px) {
|
||||
.settings-panel {
|
||||
width: 90vw;
|
||||
@@ -371,14 +155,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Position adaptative pour le contrôle externe (bouton intégré) */
|
||||
.settings-panel[data-external-control="true"] {
|
||||
@apply top-32 left-1/2 right-auto;
|
||||
transform: translateX(-50%);
|
||||
/* S'assurer qu'il ne couvre pas les contrôles */
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
@apply p-4 border-b border-gray-700 last:border-b-0;
|
||||
}
|
||||
@@ -387,44 +163,6 @@
|
||||
@apply text-white font-semibold text-lg mb-3 flex items-center;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.setting-button {
|
||||
@apply flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors duration-200 text-sm;
|
||||
}
|
||||
|
||||
.setting-button.active {
|
||||
@apply bg-blue-600 hover:bg-blue-700;
|
||||
}
|
||||
|
||||
/* Contrôles du zoom */
|
||||
.zoom-controls {
|
||||
@apply flex items-center gap-3 mb-2;
|
||||
}
|
||||
|
||||
.zoom-button {
|
||||
@apply w-8 h-8 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-white rounded flex items-center justify-center transition-colors;
|
||||
}
|
||||
|
||||
.zoom-display {
|
||||
@apply text-white font-mono text-sm min-w-[3rem] text-center;
|
||||
}
|
||||
|
||||
.zoom-slider {
|
||||
@apply w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer;
|
||||
}
|
||||
|
||||
.zoom-slider::-webkit-slider-thumb {
|
||||
@apply appearance-none w-4 h-4 bg-blue-600 rounded-full cursor-pointer;
|
||||
}
|
||||
|
||||
.zoom-slider::-moz-range-thumb {
|
||||
@apply w-4 h-4 bg-blue-600 rounded-full cursor-pointer border-none;
|
||||
}
|
||||
|
||||
/* Paramètres des doubles pages */
|
||||
.setting-item {
|
||||
@apply mb-4 last:mb-0;
|
||||
}
|
||||
@@ -457,7 +195,6 @@
|
||||
@apply text-gray-400 text-xs leading-relaxed;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.setting-actions {
|
||||
@apply flex gap-2;
|
||||
}
|
||||
@@ -470,23 +207,9 @@
|
||||
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.settings-panel {
|
||||
@apply right-2 w-72;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
@apply right-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pour les très petits écrans */
|
||||
@media (max-width: 480px) {
|
||||
.settings-toggle {
|
||||
right: 0.25rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<Toolbar :config="toolbarConfig">
|
||||
<template #center>
|
||||
<!-- Mode simple : navigation entre pages -->
|
||||
<div v-if="store.readingMode === 'single'" class="flex items-center gap-1">
|
||||
<button
|
||||
@click="store.previousPage()"
|
||||
:disabled="store.isFirstPage"
|
||||
class="nav-btn"
|
||||
title="Page précédente"
|
||||
>
|
||||
<ChevronLeftIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<span class="text-white text-sm w-16 text-center">
|
||||
{{ store.currentPage + 1 }} / {{ store.totalPages }}
|
||||
</span>
|
||||
<button
|
||||
@click="store.nextPage()"
|
||||
:disabled="store.isLastPage"
|
||||
class="nav-btn"
|
||||
title="Page suivante"
|
||||
>
|
||||
<ChevronRightIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mode scroll : navigation entre chapitres (ordre inversé en RTL) -->
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<button
|
||||
@click="leftChapterAction"
|
||||
:disabled="!canGoLeftChapter || store.isLoading"
|
||||
class="chapter-nav-btn"
|
||||
:title="store.readingDirection === 'rtl' ? 'Chapitre suivant' : 'Chapitre précédent'"
|
||||
>
|
||||
<ChevronDoubleLeftIcon class="h-4 w-4 flex-shrink-0" />
|
||||
<span class="text-xs">{{ store.readingDirection === 'rtl' ? 'Suivant' : 'Précédent' }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="rightChapterAction"
|
||||
:disabled="!canGoRightChapter || store.isLoading"
|
||||
class="chapter-nav-btn"
|
||||
:title="store.readingDirection === 'rtl' ? 'Chapitre précédent' : 'Chapitre suivant'"
|
||||
>
|
||||
<span class="text-xs">{{ store.readingDirection === 'rtl' ? 'Précédent' : 'Suivant' }}</span>
|
||||
<ChevronDoubleRightIcon class="h-4 w-4 flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ChevronDoubleLeftIcon,
|
||||
ChevronDoubleRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DocumentIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
ListBulletIcon,
|
||||
MinusIcon,
|
||||
PlusIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import { useReaderStore } from '../../application/store/readerStore';
|
||||
|
||||
const props = defineProps({
|
||||
chapterReaderRef: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const store = useReaderStore();
|
||||
const headerStore = useHeaderStore();
|
||||
const router = useRouter();
|
||||
|
||||
// Vue auto-unwrap les refs dans le template : chapterReaderRef est déjà l'instance
|
||||
const reader = computed(() => props.chapterReaderRef);
|
||||
|
||||
const goBack = () => {
|
||||
const mangaId = store.currentChapter?.mangaId;
|
||||
if (mangaId) {
|
||||
router.push({ name: 'manga-details', params: { id: mangaId } });
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReadingMode = () => reader.value?.toggleReadingMode();
|
||||
const toggleReadingDirection = () => reader.value?.toggleReadingDirection();
|
||||
const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||
const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||
|
||||
// En RTL, le bouton gauche (◄◄) avance dans l'histoire (chapitre suivant)
|
||||
const isRtl = computed(() => store.readingDirection === 'rtl');
|
||||
const leftChapterAction = () => isRtl.value ? store.goToNextChapter() : store.goToPreviousChapter();
|
||||
const rightChapterAction = () => isRtl.value ? store.goToPreviousChapter() : store.goToNextChapter();
|
||||
const canGoLeftChapter = computed(() => isRtl.value ? store.hasNextChapter : store.hasPreviousChapter);
|
||||
const canGoRightChapter = computed(() => isRtl.value ? store.hasPreviousChapter : store.hasNextChapter);
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: ArrowLeftIcon,
|
||||
label: 'Retour',
|
||||
onClick: goBack,
|
||||
},
|
||||
{
|
||||
type: 'label',
|
||||
text: store.currentChapter?.title || '',
|
||||
class: 'text-sm font-medium',
|
||||
},
|
||||
...(store.currentChapter?.number != null ? [{
|
||||
type: 'label',
|
||||
text: `Ch.${store.currentChapter.number}`,
|
||||
}] : []),
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: store.readingMode === 'single' ? ListBulletIcon : DocumentIcon,
|
||||
label: store.readingMode === 'single' ? 'Scroll' : 'Simple',
|
||||
active: store.readingMode === 'infinite',
|
||||
onClick: toggleReadingMode,
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
label: store.readingDirection.toUpperCase(),
|
||||
active: store.readingDirection === 'rtl',
|
||||
onClick: toggleReadingDirection,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'button',
|
||||
icon: MinusIcon,
|
||||
disabled: store.zoom <= 0.5,
|
||||
onClick: zoomOut,
|
||||
},
|
||||
{
|
||||
type: 'label',
|
||||
text: `${Math.round(store.zoom * 100)}%`,
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: PlusIcon,
|
||||
disabled: store.zoom >= 2,
|
||||
onClick: zoomIn,
|
||||
},
|
||||
...(store.readingMode === 'infinite' ? [
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'button',
|
||||
icon: headerStore.isReaderToolbarAutoHideEnabled ? EyeSlashIcon : EyeIcon,
|
||||
active: headerStore.isReaderToolbarAutoHideEnabled,
|
||||
title: headerStore.isReaderToolbarAutoHideEnabled ? 'Toolbar auto-masquée' : 'Toolbar toujours visible',
|
||||
onClick: () => headerStore.toggleReaderToolbarAutoHide(),
|
||||
},
|
||||
] : []),
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.nav-btn {
|
||||
@apply flex items-center justify-center w-7 h-7 rounded bg-gray-700 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-white;
|
||||
}
|
||||
|
||||
.chapter-nav-btn {
|
||||
@apply flex items-center justify-between gap-1 h-7 w-28 px-2 rounded bg-gray-700 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-white;
|
||||
}
|
||||
</style>
|
||||
@@ -5,10 +5,10 @@
|
||||
<!-- Zone de navigation gauche (invisible) -->
|
||||
<div
|
||||
class="navigation-zone left-zone"
|
||||
@click.stop="goToPrevious"
|
||||
@click.stop="onLeftZoneClick"
|
||||
@mouseenter="showLeftHint"
|
||||
@mouseleave="hideLeftHint"
|
||||
title="Page précédente"
|
||||
:title="isRtl ? 'Page suivante' : 'Page précédente'"
|
||||
></div>
|
||||
|
||||
<!-- Page centrale -->
|
||||
@@ -24,21 +24,21 @@
|
||||
<!-- Zone de navigation droite (invisible) -->
|
||||
<div
|
||||
class="navigation-zone right-zone"
|
||||
@click.stop="goToNext"
|
||||
@click.stop="onRightZoneClick"
|
||||
@mouseenter="showRightHint"
|
||||
@mouseleave="hideRightHint"
|
||||
title="Page suivante"
|
||||
:title="isRtl ? 'Page précédente' : 'Page suivante'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Indicateurs visuels de navigation -->
|
||||
<div class="navigation-hints">
|
||||
<div class="hint left-hint" v-if="canGoToPrevious && (showNavigationHints || showLeftHintHover)">
|
||||
<div class="hint left-hint" v-if="canGoLeft && (showNavigationHints || showLeftHintHover)">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hint right-hint" v-if="canGoToNext && (showNavigationHints || showRightHintHover)">
|
||||
<div class="hint right-hint" v-if="canGoRight && (showNavigationHints || showRightHintHover)">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@@ -81,14 +81,18 @@ const showLeftHintHover = ref(false);
|
||||
const showRightHintHover = ref(false);
|
||||
let hintTimeout = null;
|
||||
|
||||
// Computed pour vérifier les possibilités de navigation
|
||||
const canGoToPrevious = computed(() => {
|
||||
return !store.isFirstPage || store.hasPreviousChapter;
|
||||
});
|
||||
const isRtl = computed(() => store.readingDirection === 'rtl');
|
||||
|
||||
const canGoToNext = computed(() => {
|
||||
return !store.isLastPage || store.hasNextChapter;
|
||||
});
|
||||
// Computed pour vérifier les possibilités de navigation
|
||||
const canGoToPrevious = computed(() => !store.isFirstPage || store.hasPreviousChapter);
|
||||
const canGoToNext = computed(() => !store.isLastPage || store.hasNextChapter);
|
||||
|
||||
// En RTL, le côté gauche avance dans l'histoire (page suivante) et le droit recule
|
||||
const canGoLeft = computed(() => isRtl.value ? canGoToNext.value : canGoToPrevious.value);
|
||||
const canGoRight = computed(() => isRtl.value ? canGoToPrevious.value : canGoToNext.value);
|
||||
|
||||
const onLeftZoneClick = () => isRtl.value ? goToNext() : goToPrevious();
|
||||
const onRightZoneClick = () => isRtl.value ? goToPrevious() : goToNext();
|
||||
|
||||
// Navigation vers la page/chapitre précédent
|
||||
const goToPrevious = async () => {
|
||||
@@ -151,22 +155,20 @@ const hideRightHint = () => {
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.single-mode-reader {
|
||||
@apply relative w-full h-full flex items-center justify-center;
|
||||
/* Suppression des marges sur mobile */
|
||||
@apply p-0 sm:p-2;
|
||||
/* Ajouter des marges en haut et en bas pour l'espace des contrôles et paramètres */
|
||||
@apply py-8 sm:py-12;
|
||||
@apply relative w-full flex-1 flex flex-col min-h-0 overflow-hidden;
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.page-navigation-wrapper {
|
||||
@apply relative w-full h-full flex items-center justify-center cursor-pointer;
|
||||
/* overflow-auto : scrollbars quand l'image zoomée déborde */
|
||||
@apply relative w-full flex-1 min-h-0 overflow-auto cursor-pointer;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
@apply flex-1 h-full flex items-center justify-center;
|
||||
pointer-events: none; /* Empêche les clics sur l'image elle-même */
|
||||
/* Optimisation pour mobile */
|
||||
@apply p-0;
|
||||
/* min-h-full : centre l'image quand elle est plus petite que le conteneur */
|
||||
min-height: 100%;
|
||||
@apply flex items-center justify-center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.navigation-zone {
|
||||
|
||||
@@ -1,56 +1,31 @@
|
||||
<template>
|
||||
<div class="chapter-page">
|
||||
<div class="chapter-header">
|
||||
<!-- Bouton de retour -->
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<button
|
||||
@click="goBackToManga"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors duration-200"
|
||||
:disabled="!currentChapter?.mangaId"
|
||||
>
|
||||
<ArrowLeftIcon class="h-5 w-5" />
|
||||
<span class="text-sm font-medium">Retour au manga</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Titre du chapitre amélioré -->
|
||||
<div class="chapter-title-section">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-white leading-tight">
|
||||
{{ currentChapter?.title || 'Chargement...' }}
|
||||
</h1>
|
||||
<div class="chapter-meta mt-3">
|
||||
<span class="inline-flex items-center px-3 py-1 bg-blue-600 text-white text-sm font-semibold rounded-full">
|
||||
Chapitre {{ currentChapter?.number }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="toolbar-wrapper"
|
||||
:class="{ 'toolbar-hidden': !headerStore.shouldShowReaderToolbar }"
|
||||
>
|
||||
<div class="toolbar-slide">
|
||||
<ReaderToolbar :chapter-reader-ref="chapterReaderRef" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reader-container">
|
||||
<ChapterReader :chapter-id="chapterId" />
|
||||
<ChapterReader ref="chapterReaderRef" :chapter-id="chapterId" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowLeftIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useReaderStore } from '../../application/store/readerStore';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import ChapterReader from '../components/ChapterReader.vue';
|
||||
import ReaderToolbar from '../components/ReaderToolbar.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useReaderStore();
|
||||
const headerStore = useHeaderStore();
|
||||
|
||||
const chapterId = computed(() => route.params.chapterId);
|
||||
const currentChapter = computed(() => store.currentChapter);
|
||||
|
||||
const goBackToManga = () => {
|
||||
if (currentChapter.value?.mangaId) {
|
||||
router.push({ name: 'manga-details', params: { id: currentChapter.value.mangaId } });
|
||||
}
|
||||
};
|
||||
const chapterReaderRef = ref(null);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
@@ -58,19 +33,26 @@ import ChapterReader from '../components/ChapterReader.vue';
|
||||
@apply w-full h-full flex flex-col;
|
||||
}
|
||||
|
||||
.chapter-header {
|
||||
@apply p-6 bg-gradient-to-b from-gray-800 to-gray-900 border-b border-gray-700 shadow-lg;
|
||||
.toolbar-wrapper {
|
||||
@apply overflow-hidden;
|
||||
max-height: 5rem;
|
||||
transition: max-height 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.chapter-title-section {
|
||||
@apply space-y-2;
|
||||
.toolbar-wrapper.toolbar-hidden {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.chapter-meta {
|
||||
@apply flex flex-wrap items-center gap-3;
|
||||
.toolbar-slide {
|
||||
transform: translateY(0);
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.toolbar-hidden .toolbar-slide {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.reader-container {
|
||||
@apply flex-1 overflow-hidden;
|
||||
@apply flex-1 overflow-hidden min-h-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
const STORAGE_KEY = 'mangarr_preferences';
|
||||
|
||||
const defaultState = {
|
||||
theme: 'system',
|
||||
language: 'fr',
|
||||
defaultView: 'grid',
|
||||
itemsPerPage: 20,
|
||||
sortBy: 'title',
|
||||
readingDirection: 'ltr',
|
||||
readingMode: 'scroll',
|
||||
autoFullscreen: false,
|
||||
autoHideHeaderReader: true,
|
||||
toastDuration: 5000,
|
||||
};
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return { ...defaultState, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return { ...defaultState };
|
||||
}
|
||||
|
||||
let mediaQueryUnsubscribe = null;
|
||||
|
||||
export const useUserPreferencesStore = defineStore('userPreferences', {
|
||||
state: () => loadFromStorage(),
|
||||
|
||||
actions: {
|
||||
applyTheme() {
|
||||
// Nettoyer le listener précédent
|
||||
if (mediaQueryUnsubscribe) {
|
||||
mediaQueryUnsubscribe();
|
||||
mediaQueryUnsubscribe = null;
|
||||
}
|
||||
|
||||
const html = document.documentElement;
|
||||
|
||||
if (this.theme === 'dark') {
|
||||
html.classList.add('dark');
|
||||
} else if (this.theme === 'light') {
|
||||
html.classList.remove('dark');
|
||||
} else {
|
||||
// mode 'system'
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e) => {
|
||||
if (e.matches) {
|
||||
html.classList.add('dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
handler(mq);
|
||||
mq.addEventListener('change', handler);
|
||||
mediaQueryUnsubscribe = () => mq.removeEventListener('change', handler);
|
||||
}
|
||||
},
|
||||
|
||||
setTheme(theme) {
|
||||
this.theme = theme;
|
||||
this.persist();
|
||||
this.applyTheme();
|
||||
},
|
||||
|
||||
setLanguage(language) {
|
||||
this.language = language;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setDefaultView(view) {
|
||||
this.defaultView = view;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setItemsPerPage(count) {
|
||||
this.itemsPerPage = count;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setSortBy(sort) {
|
||||
this.sortBy = sort;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setReadingDirection(direction) {
|
||||
this.readingDirection = direction;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setReadingMode(mode) {
|
||||
this.readingMode = mode;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setAutoFullscreen(value) {
|
||||
this.autoFullscreen = value;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setAutoHideHeaderReader(value) {
|
||||
this.autoHideHeaderReader = value;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setToastDuration(duration) {
|
||||
this.toastDuration = duration;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
resetToDefaults() {
|
||||
Object.assign(this, defaultState);
|
||||
this.persist();
|
||||
this.applyTheme();
|
||||
},
|
||||
|
||||
persist() {
|
||||
try {
|
||||
const data = {
|
||||
theme: this.theme,
|
||||
language: this.language,
|
||||
defaultView: this.defaultView,
|
||||
itemsPerPage: this.itemsPerPage,
|
||||
sortBy: this.sortBy,
|
||||
readingDirection: this.readingDirection,
|
||||
readingMode: this.readingMode,
|
||||
autoFullscreen: this.autoFullscreen,
|
||||
autoHideHeaderReader: this.autoHideHeaderReader,
|
||||
toastDuration: this.toastDuration,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
@@ -71,6 +72,7 @@
|
||||
Configuration exportée !
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Back Navigation -->
|
||||
<div class="mb-6">
|
||||
@@ -180,6 +181,7 @@
|
||||
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('preferences.title') }}</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('preferences.subtitle') }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
@click="handleReset">
|
||||
{{ t('preferences.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Apparence -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.appearance') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Thème -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.theme.label') }}</label>
|
||||
<select
|
||||
:value="store.theme"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setTheme($event.target.value)">
|
||||
<option value="light">{{ t('preferences.theme.light') }}</option>
|
||||
<option value="dark">{{ t('preferences.theme.dark') }}</option>
|
||||
<option value="system">{{ t('preferences.theme.system') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Langue -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.language.label') }}</label>
|
||||
<select
|
||||
:value="store.language"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="handleLanguageChange($event.target.value)">
|
||||
<option value="fr">{{ t('preferences.language.fr') }}</option>
|
||||
<option value="en">{{ t('preferences.language.en') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Affichage collection -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.collection') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Vue par défaut -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.defaultView.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
:class="viewButtonClass('grid')"
|
||||
@click="store.setDefaultView('grid')">
|
||||
{{ t('preferences.defaultView.grid') }}
|
||||
</button>
|
||||
<button
|
||||
:class="viewButtonClass('list')"
|
||||
@click="store.setDefaultView('list')">
|
||||
{{ t('preferences.defaultView.list') }}
|
||||
</button>
|
||||
<button
|
||||
:class="viewButtonClass('table')"
|
||||
@click="store.setDefaultView('table')">
|
||||
{{ t('preferences.defaultView.table') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mangas par page -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.itemsPerPage.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="n in [12, 20, 40]"
|
||||
:key="n"
|
||||
:class="countButtonClass(n)"
|
||||
@click="store.setItemsPerPage(n)">
|
||||
{{ n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tri par défaut -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.sortBy.label') }}</label>
|
||||
<select
|
||||
:value="store.sortBy"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setSortBy($event.target.value)">
|
||||
<option value="title">{{ t('preferences.sortBy.title') }}</option>
|
||||
<option value="addedAt">{{ t('preferences.sortBy.addedAt') }}</option>
|
||||
<option value="progress">{{ t('preferences.sortBy.progress') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lecture -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.reading') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Direction de lecture -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingDirection.label') }}</label>
|
||||
<select
|
||||
:value="store.readingDirection"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setReadingDirection($event.target.value)">
|
||||
<option value="ltr">{{ t('preferences.readingDirection.ltr') }}</option>
|
||||
<option value="rtl">{{ t('preferences.readingDirection.rtl') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Mode d'affichage -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingMode.label') }}</label>
|
||||
<select
|
||||
:value="store.readingMode"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setReadingMode($event.target.value)">
|
||||
<option value="scroll">{{ t('preferences.readingMode.scroll') }}</option>
|
||||
<option value="single">{{ t('preferences.readingMode.single') }}</option>
|
||||
<option value="double">{{ t('preferences.readingMode.double') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Auto plein écran -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoFullscreen.label') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoFullscreen.description') }}</p>
|
||||
</div>
|
||||
<button
|
||||
:class="toggleClass(store.autoFullscreen)"
|
||||
role="switch"
|
||||
:aria-checked="store.autoFullscreen"
|
||||
@click="store.setAutoFullscreen(!store.autoFullscreen)">
|
||||
<span :class="toggleKnobClass(store.autoFullscreen)" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Auto-hide header -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoHideHeaderReader.label') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoHideHeaderReader.description') }}</p>
|
||||
</div>
|
||||
<button
|
||||
:class="toggleClass(store.autoHideHeaderReader)"
|
||||
role="switch"
|
||||
:aria-checked="store.autoHideHeaderReader"
|
||||
@click="store.setAutoHideHeaderReader(!store.autoHideHeaderReader)">
|
||||
<span :class="toggleKnobClass(store.autoHideHeaderReader)" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.notifications') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Durée des toasts -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.toastDuration.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="[val, label] in toastOptions"
|
||||
:key="val"
|
||||
:class="countButtonClass(val, store.toastDuration)"
|
||||
@click="store.setToastDuration(val)">
|
||||
{{ t(label) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUserPreferencesStore } from '../../application/store/userPreferencesStore';
|
||||
import { i18n } from '../../../../shared/i18n';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const store = useUserPreferencesStore();
|
||||
|
||||
const toastOptions = [
|
||||
[3000, 'preferences.toastDuration.3s'],
|
||||
[5000, 'preferences.toastDuration.5s'],
|
||||
[10000, 'preferences.toastDuration.10s'],
|
||||
];
|
||||
|
||||
function handleLanguageChange(lang) {
|
||||
store.setLanguage(lang);
|
||||
i18n.global.locale.value = lang;
|
||||
locale.value = lang;
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (confirm(t('preferences.resetConfirm'))) {
|
||||
store.resetToDefaults();
|
||||
i18n.global.locale.value = store.language;
|
||||
locale.value = store.language;
|
||||
}
|
||||
}
|
||||
|
||||
function viewButtonClass(view) {
|
||||
const active = store.defaultView === view;
|
||||
return [
|
||||
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
|
||||
active
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
];
|
||||
}
|
||||
|
||||
function countButtonClass(val, current = store.itemsPerPage) {
|
||||
const active = current === val;
|
||||
return [
|
||||
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
|
||||
active
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
];
|
||||
}
|
||||
|
||||
function toggleClass(active) {
|
||||
return [
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
active ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600',
|
||||
];
|
||||
}
|
||||
|
||||
function toggleKnobClass(active) {
|
||||
return [
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
active ? 'translate-x-6' : 'translate-x-1',
|
||||
];
|
||||
}
|
||||
</script>
|
||||
@@ -4,6 +4,9 @@ import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import '../../styles/app.scss';
|
||||
import { installVueQuery } from './shared/plugin/vueQuery';
|
||||
import { i18n } from './shared/i18n';
|
||||
import { useUserPreferencesStore } from './domain/setting/application/store/userPreferencesStore';
|
||||
|
||||
// Création du store
|
||||
const pinia = createPinia();
|
||||
|
||||
@@ -14,5 +17,12 @@ const app = createApp(App);
|
||||
app.use(router);
|
||||
app.use(pinia);
|
||||
app.use(installVueQuery);
|
||||
app.use(i18n);
|
||||
|
||||
// Appliquer le thème et la langue sauvegardés
|
||||
const prefs = useUserPreferencesStore();
|
||||
prefs.applyTheme();
|
||||
i18n.global.locale.value = prefs.language;
|
||||
|
||||
// Montage de l'application
|
||||
app.mount('#vue-app');
|
||||
|
||||
@@ -3,11 +3,13 @@ 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';
|
||||
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
|
||||
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
|
||||
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
|
||||
import Layout from '../shared/components/layout/Layout.vue';
|
||||
|
||||
// Placeholder component for new routes
|
||||
@@ -73,20 +75,13 @@ const routes = [
|
||||
{
|
||||
path: '/manga/discover',
|
||||
name: 'discover',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Découvrir' }
|
||||
component: DiscoverPage
|
||||
},
|
||||
{
|
||||
path: '/convert',
|
||||
name: 'convert',
|
||||
component: ConversionPage
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
name: 'calendar',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Calendrier' }
|
||||
},
|
||||
{
|
||||
path: '/activity',
|
||||
name: 'activity',
|
||||
@@ -129,8 +124,7 @@ const routes = [
|
||||
{
|
||||
path: '/settings/ui',
|
||||
name: 'settings-ui',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: "Paramètres de l'interface" }
|
||||
component: UserPreferencesPage
|
||||
},
|
||||
// Système
|
||||
{
|
||||
|
||||
@@ -15,20 +15,41 @@
|
||||
<Bars3Icon class="h-6 w-6" />
|
||||
</button>
|
||||
<div class="flex items-center flex-1">
|
||||
<router-link to="/" class="text-white text-2xl font-bold ml-4">
|
||||
Mangarr
|
||||
<router-link to="/" class="ml-4">
|
||||
<img src="/img/mangarr_logo.png" alt="Mangarr" class="h-10" />
|
||||
</router-link>
|
||||
<SearchBar />
|
||||
</div>
|
||||
<button
|
||||
@click="toggleDarkMode"
|
||||
class="mr-4 text-white p-2 hover:text-green-200 transition-colors"
|
||||
:title="isDark ? 'Passer en mode clair' : 'Passer en mode sombre'"
|
||||
>
|
||||
<SunIcon v-if="isDark" class="h-6 w-6" />
|
||||
<MoonIcon v-else class="h-6 w-6" />
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Bars3Icon } from '@heroicons/vue/24/outline';
|
||||
import { computed } from 'vue';
|
||||
import { Bars3Icon, SunIcon, MoonIcon } from '@heroicons/vue/24/outline';
|
||||
import { useHeaderStore } from '../../stores/headerStore';
|
||||
import { useUserPreferencesStore } from '../../../domain/setting/application/store/userPreferencesStore';
|
||||
import SearchBar from './SearchBar.vue';
|
||||
|
||||
const headerStore = useHeaderStore();
|
||||
const preferencesStore = useUserPreferencesStore();
|
||||
|
||||
const isDark = computed(() => {
|
||||
if (preferencesStore.theme === 'dark') return true;
|
||||
if (preferencesStore.theme === 'light') return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
|
||||
function toggleDarkMode() {
|
||||
preferencesStore.setTheme(isDark.value ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
defineProps({
|
||||
showMenuButton: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex">
|
||||
<div class="h-screen overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
|
||||
<Header
|
||||
:show-menu-button="isReaderMode"
|
||||
@menu-click="toggleSidebar"
|
||||
@@ -12,9 +12,10 @@
|
||||
@add-manga-click="$emit('add-manga-click', $event)" />
|
||||
|
||||
<main :class="[
|
||||
'flex-1 pt-16',
|
||||
'flex-1 flex flex-col overflow-hidden',
|
||||
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
|
||||
isReaderMode ? '' : 'md:ml-60'
|
||||
]">
|
||||
]" style="transition: margin-top 300ms ease-in-out;">
|
||||
<RouterView></RouterView>
|
||||
</main>
|
||||
</div>
|
||||
@@ -23,10 +24,12 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useHeaderStore } from '../../stores/headerStore';
|
||||
import Header from './Header.vue';
|
||||
import Sidebar from './Sidebar.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const headerStore = useHeaderStore();
|
||||
const isSidebarOpen = ref(false);
|
||||
|
||||
// Détecte si on est en mode Reader
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
ArrowDownTrayIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
BookOpenIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
Cog6ToothIcon,
|
||||
ComputerDesktopIcon,
|
||||
@@ -69,12 +68,6 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
||||
to: '/convert',
|
||||
id: 'convert'
|
||||
},
|
||||
{
|
||||
icon: CalendarIcon,
|
||||
text: 'Calendrier',
|
||||
to: '/calendar',
|
||||
id: 'calendar'
|
||||
},
|
||||
{
|
||||
icon: ClockIcon,
|
||||
text: 'Activité',
|
||||
|
||||
@@ -3,24 +3,25 @@
|
||||
class="border-l-4"
|
||||
:class="{
|
||||
'border-green-600': isActive,
|
||||
'hover:bg-gray-700 border-transparent': !isActive
|
||||
'border-transparent': !isActive
|
||||
}">
|
||||
<div class="flex w-full" @click="toggleExpanded">
|
||||
<div class="flex w-full">
|
||||
<RouterLink
|
||||
:to="to"
|
||||
class="flex-grow px-4 py-2 flex items-center"
|
||||
:class="{
|
||||
'text-green-600 bg-gray-800': isActive
|
||||
}">
|
||||
<div class="flex items-center flex-grow">
|
||||
<component :is="icon" class="w-5 h-5 mr-3" />
|
||||
<span class="px-2">{{ text }}</span>
|
||||
</div>
|
||||
<component
|
||||
v-if="subItems.length > 0"
|
||||
:is="expanded ? ChevronUpIcon : ChevronDownIcon"
|
||||
class="w-4 h-4" />
|
||||
:class="isActive
|
||||
? 'text-green-600 bg-gray-800'
|
||||
: 'hover:bg-gray-700 hover:text-white'">
|
||||
<component :is="icon" class="w-5 h-5 mr-3" />
|
||||
<span class="px-2">{{ text }}</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="subItems.length > 0"
|
||||
class="px-3 hover:bg-gray-700"
|
||||
:class="isActive ? 'text-green-600 bg-gray-800' : 'hover:text-white'"
|
||||
@click="toggleExpanded">
|
||||
<component :is="expanded ? ChevronUpIcon : ChevronDownIcon" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-if="subItems.length > 0" class="ml-8 mt-2 space-y-4" v-show="expanded">
|
||||
@@ -71,14 +72,14 @@
|
||||
|
||||
const isActive = computed(() => {
|
||||
if (!props.to) {
|
||||
return props.subItems?.some(subItem => route.path === subItem.to) || false;
|
||||
return props.subItems?.some(subItem => route.path.startsWith(subItem.to)) || false;
|
||||
}
|
||||
|
||||
if (props.to === '/') {
|
||||
return route.path === props.to || props.subItems.map(item => item.to).includes(route.path);
|
||||
return route.path === props.to || props.subItems.some(item => route.path.startsWith(item.to));
|
||||
}
|
||||
|
||||
return route.path.startsWith(props.to);
|
||||
return route.path.startsWith(props.to) || props.subItems.some(item => route.path.startsWith(item.to));
|
||||
});
|
||||
|
||||
const isRouteMatching = path => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<li>
|
||||
<RouterLink v-if="to" :to="to" class="block hover:text-green-600" role="menuitem">
|
||||
<RouterLink v-if="to" :to="to" class="block px-2 py-1 rounded hover:bg-gray-700 hover:text-white" role="menuitem">
|
||||
{{ text }}
|
||||
</RouterLink>
|
||||
<button v-else @click="$emit('click')" class="w-full text-left hover:text-green-600" role="menuitem">
|
||||
<button v-else @click="$emit('click')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-700 hover:text-white" role="menuitem">
|
||||
{{ text }}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<label :for="inputId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label :for="inputId" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
|
||||
:class="{ 'border-green-500 bg-green-50': isDragOver, 'hover:border-gray-400': !isDragOver }"
|
||||
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed "
|
||||
:class="{ 'border-green-500 bg-green-50 dark:bg-green-900/20': isDragOver, 'hover:border-gray-400': !isDragOver }"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
>
|
||||
<div class="space-y-1 text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<ArrowUpTrayIcon class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<label
|
||||
:for="inputId"
|
||||
class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"
|
||||
class="relative cursor-pointer font-medium text-green-600 hover:text-green-500"
|
||||
>
|
||||
<span>Sélectionner des fichiers</span>
|
||||
<input
|
||||
@@ -50,8 +38,8 @@
|
||||
</p>
|
||||
|
||||
<div v-if="selectedFiles.length > 0" class="mt-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Fichiers sélectionnés :</h4>
|
||||
<ul class="text-xs text-gray-600 space-y-1">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Fichiers sélectionnés :</h4>
|
||||
<ul class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
|
||||
@@ -64,6 +52,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowUpTrayIcon } from '@heroicons/vue/24/outline';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:class="[
|
||||
'max-w-md w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden',
|
||||
'max-w-md w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden',
|
||||
getNotificationClass(notification.type)
|
||||
]"
|
||||
>
|
||||
@@ -18,14 +18,14 @@
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<button
|
||||
@click="removeNotification(notification.id)"
|
||||
class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
class="bg-white dark:bg-gray-800 rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<XMarkIcon class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 pt-0.5 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 break-words">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 break-words">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Informations de pagination -->
|
||||
<div class="flex items-center text-sm text-gray-700">
|
||||
<div class="flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>
|
||||
Affichage de
|
||||
<span class="font-medium">{{ startItem }}</span>
|
||||
@@ -22,8 +22,8 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-2 py-2 text-sm font-medium rounded-md',
|
||||
hasPreviousPage
|
||||
? 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
|
||||
: 'text-gray-300 bg-gray-100 border border-gray-200 cursor-not-allowed'
|
||||
? 'text-gray-500 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
: 'text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 cursor-not-allowed'
|
||||
]">
|
||||
<span class="sr-only">Précédent</span>
|
||||
<ChevronLeftIcon class="h-5 w-5" />
|
||||
@@ -38,14 +38,14 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
|
||||
currentPage === 1
|
||||
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
]">
|
||||
1
|
||||
</button>
|
||||
|
||||
<!-- Points de suspension gauche -->
|
||||
<span v-if="showLeftDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700">
|
||||
<span v-if="showLeftDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
...
|
||||
</span>
|
||||
|
||||
@@ -57,14 +57,14 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
|
||||
currentPage === page
|
||||
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
]">
|
||||
{{ page }}
|
||||
</button>
|
||||
|
||||
<!-- Points de suspension droite -->
|
||||
<span v-if="showRightDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700">
|
||||
<span v-if="showRightDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
...
|
||||
</span>
|
||||
|
||||
@@ -75,8 +75,8 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
|
||||
currentPage === totalPages
|
||||
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
]">
|
||||
{{ totalPages }}
|
||||
</button>
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
<!-- Pagination mobile -->
|
||||
<div class="md:hidden flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ currentPage }} / {{ totalPages }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -96,8 +96,8 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-2 py-2 text-sm font-medium rounded-md',
|
||||
hasNextPage
|
||||
? 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
|
||||
: 'text-gray-300 bg-gray-100 border border-gray-200 cursor-not-allowed'
|
||||
? 'text-gray-500 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
: 'text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 cursor-not-allowed'
|
||||
]">
|
||||
<span class="sr-only">Suivant</span>
|
||||
<ChevronRightIcon class="h-5 w-5" />
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<!-- Left section -->
|
||||
<ToolbarSection :items="config.leftSection" />
|
||||
|
||||
<!-- Center section (optional slot) -->
|
||||
<slot name="center" />
|
||||
|
||||
<!-- Right section -->
|
||||
<ToolbarSection :items="config.rightSection" />
|
||||
</div>
|
||||
@@ -18,7 +21,6 @@
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: value => {
|
||||
// Vérifie que leftSection et rightSection sont des tableaux
|
||||
return Array.isArray(value.leftSection) && Array.isArray(value.rightSection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<button
|
||||
@click="$emit('click')"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'flex flex-col items-center justify-center min-h-12 sm:min-h-14 w-min px-2 sm:ml-4 ml-1 rounded group text-white',
|
||||
active
|
||||
? 'text-green-500' // Style actif
|
||||
: 'hover:text-green-500' // Effet de survol
|
||||
: 'hover:text-green-500', // Effet de survol
|
||||
disabled ? 'opacity-40 cursor-not-allowed' : ''
|
||||
]"
|
||||
:aria-label="label || 'Toolbar button'">
|
||||
<component v-if="icon" :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
@@ -30,6 +32,10 @@
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:active="item.active"
|
||||
:disabled="item.disabled"
|
||||
@click="item.onClick" />
|
||||
<ToolbarDropdown
|
||||
v-else-if="item.type === 'dropdown'"
|
||||
@@ -14,7 +15,9 @@
|
||||
:active="item.active"
|
||||
:items="item.items" />
|
||||
<Divider v-else-if="item.type === 'divider'" />
|
||||
<!-- Ajoutez d'autres types d'éléments ici si nécessaire -->
|
||||
<span
|
||||
v-else-if="item.type === 'label'"
|
||||
:class="['text-white px-1 select-none', item.class || 'text-xs']">{{ item.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -36,6 +39,7 @@
|
||||
item.type &&
|
||||
(item.type === 'button' ||
|
||||
item.type === 'divider' ||
|
||||
item.type === 'label' ||
|
||||
(item.type === 'dropdown' && Array.isArray(item.items)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref } from 'vue';
|
||||
import { useUserPreferencesStore } from '../../domain/setting/application/store/userPreferencesStore';
|
||||
|
||||
const notifications = ref([]);
|
||||
let nextId = 1;
|
||||
@@ -36,20 +37,24 @@ export function useNotifications() {
|
||||
notifications.value = [];
|
||||
};
|
||||
|
||||
const showSuccess = (message, duration = 4000) => {
|
||||
return addNotification(message, 'success', duration);
|
||||
const showSuccess = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'success', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
const showError = (message, duration = 6000) => {
|
||||
return addNotification(message, 'error', duration);
|
||||
const showError = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'error', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
const showWarning = (message, duration = 5000) => {
|
||||
return addNotification(message, 'warning', duration);
|
||||
const showWarning = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'warning', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
const showInfo = (message, duration = 4000) => {
|
||||
return addNotification(message, 'info', duration);
|
||||
const showInfo = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'info', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
10
assets/vue/app/shared/i18n/index.js
Normal file
10
assets/vue/app/shared/i18n/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import fr from './locales/fr.json';
|
||||
import en from './locales/en.json';
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'fr',
|
||||
fallbackLocale: 'fr',
|
||||
messages: { fr, en },
|
||||
});
|
||||
68
assets/vue/app/shared/i18n/locales/en.json
Normal file
68
assets/vue/app/shared/i18n/locales/en.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"nav": {
|
||||
"preferences": "Preferences"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"subtitle": "Customize the interface to your liking",
|
||||
"reset": "Reset",
|
||||
"resetConfirm": "Reset to default values?",
|
||||
"sections": {
|
||||
"appearance": "Appearance",
|
||||
"collection": "Collection display",
|
||||
"reading": "Reading",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"theme": {
|
||||
"label": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System (automatic)"
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"fr": "Français",
|
||||
"en": "English"
|
||||
},
|
||||
"defaultView": {
|
||||
"label": "Default view",
|
||||
"grid": "Grid",
|
||||
"list": "List",
|
||||
"table": "Table"
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"label": "Mangas per page"
|
||||
},
|
||||
"sortBy": {
|
||||
"label": "Default sort",
|
||||
"title": "Title",
|
||||
"addedAt": "Date added",
|
||||
"progress": "Progress"
|
||||
},
|
||||
"readingDirection": {
|
||||
"label": "Reading direction",
|
||||
"ltr": "Left → Right (western)",
|
||||
"rtl": "Right → Left (manga)"
|
||||
},
|
||||
"readingMode": {
|
||||
"label": "Display mode",
|
||||
"scroll": "Vertical scroll",
|
||||
"single": "Single page",
|
||||
"double": "Double page"
|
||||
},
|
||||
"autoFullscreen": {
|
||||
"label": "Auto fullscreen",
|
||||
"description": "Enter fullscreen when starting the reader"
|
||||
},
|
||||
"autoHideHeaderReader": {
|
||||
"label": "Auto-hide header",
|
||||
"description": "Hide the navigation bar in reading mode"
|
||||
},
|
||||
"toastDuration": {
|
||||
"label": "Notification duration",
|
||||
"3s": "3 seconds",
|
||||
"5s": "5 seconds",
|
||||
"10s": "10 seconds"
|
||||
}
|
||||
}
|
||||
}
|
||||
68
assets/vue/app/shared/i18n/locales/fr.json
Normal file
68
assets/vue/app/shared/i18n/locales/fr.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"nav": {
|
||||
"preferences": "Préférences"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Préférences",
|
||||
"subtitle": "Personnalisez l'interface selon vos goûts",
|
||||
"reset": "Réinitialiser",
|
||||
"resetConfirm": "Remettre les valeurs par défaut ?",
|
||||
"sections": {
|
||||
"appearance": "Apparence",
|
||||
"collection": "Affichage de la collection",
|
||||
"reading": "Lecture",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"theme": {
|
||||
"label": "Thème",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système (automatique)"
|
||||
},
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"fr": "Français",
|
||||
"en": "English"
|
||||
},
|
||||
"defaultView": {
|
||||
"label": "Vue par défaut",
|
||||
"grid": "Grille",
|
||||
"list": "Liste",
|
||||
"table": "Tableau"
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"label": "Mangas par page"
|
||||
},
|
||||
"sortBy": {
|
||||
"label": "Tri par défaut",
|
||||
"title": "Titre",
|
||||
"addedAt": "Date d'ajout",
|
||||
"progress": "Progression"
|
||||
},
|
||||
"readingDirection": {
|
||||
"label": "Direction de lecture",
|
||||
"ltr": "Gauche → Droite (occidental)",
|
||||
"rtl": "Droite → Gauche (manga)"
|
||||
},
|
||||
"readingMode": {
|
||||
"label": "Mode d'affichage",
|
||||
"scroll": "Défilement vertical",
|
||||
"single": "Page unique",
|
||||
"double": "Double page"
|
||||
},
|
||||
"autoFullscreen": {
|
||||
"label": "Plein écran automatique",
|
||||
"description": "Passer en plein écran au démarrage du lecteur"
|
||||
},
|
||||
"autoHideHeaderReader": {
|
||||
"label": "Masquer automatiquement l'en-tête",
|
||||
"description": "Masquer la barre de navigation en mode lecture"
|
||||
},
|
||||
"toastDuration": {
|
||||
"label": "Durée des notifications",
|
||||
"3s": "3 secondes",
|
||||
"5s": "5 secondes",
|
||||
"10s": "10 secondes"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,20 @@ export const useHeaderStore = defineStore('header', {
|
||||
state: () => ({
|
||||
isHeaderVisible: true,
|
||||
isAutoHideEnabled: false,
|
||||
isReaderToolbarVisible: true,
|
||||
isReaderToolbarAutoHideEnabled: false,
|
||||
lastScrollY: 0,
|
||||
scrollDirection: 'up'
|
||||
}),
|
||||
|
||||
getters: {
|
||||
shouldShowHeader: (state) => {
|
||||
// Si l'auto-hide n'est pas activé, toujours afficher le header
|
||||
if (!state.isAutoHideEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si l'auto-hide est activé, suivre la visibilité
|
||||
if (!state.isAutoHideEnabled) return true;
|
||||
return state.isHeaderVisible;
|
||||
},
|
||||
shouldShowReaderToolbar: (state) => {
|
||||
if (!state.isReaderToolbarAutoHideEnabled) return true;
|
||||
return state.isReaderToolbarVisible;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -27,35 +28,47 @@ export const useHeaderStore = defineStore('header', {
|
||||
|
||||
disableAutoHide() {
|
||||
this.isAutoHideEnabled = false;
|
||||
this.isHeaderVisible = true; // Toujours visible quand désactivé
|
||||
this.isHeaderVisible = true;
|
||||
},
|
||||
|
||||
updateScrollDirection(scrollY) {
|
||||
// Éviter les calculs inutiles si pas d'auto-hide
|
||||
if (!this.isAutoHideEnabled) {
|
||||
this.lastScrollY = scrollY;
|
||||
return;
|
||||
}
|
||||
enableReaderToolbarAutoHide() {
|
||||
this.isReaderToolbarAutoHideEnabled = true;
|
||||
this.isReaderToolbarVisible = true;
|
||||
},
|
||||
|
||||
// Détecter la direction du scroll avec un seuil pour éviter les micro-mouvements
|
||||
disableReaderToolbarAutoHide() {
|
||||
this.isReaderToolbarAutoHideEnabled = false;
|
||||
this.isReaderToolbarVisible = true;
|
||||
},
|
||||
|
||||
toggleReaderToolbarAutoHide() {
|
||||
if (this.isReaderToolbarAutoHideEnabled) {
|
||||
this.disableReaderToolbarAutoHide();
|
||||
this.disableAutoHide();
|
||||
} else {
|
||||
this.enableReaderToolbarAutoHide();
|
||||
this.enableAutoHide();
|
||||
}
|
||||
},
|
||||
|
||||
updateScrollDirection(scrollY) {
|
||||
const scrollDifference = Math.abs(scrollY - this.lastScrollY);
|
||||
|
||||
if (scrollDifference < 5) {
|
||||
// Mouvement trop petit, on ignore
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollY > this.lastScrollY && scrollY > 100) {
|
||||
// Scroll vers le bas et suffisamment de scroll
|
||||
if (this.scrollDirection !== 'down') {
|
||||
this.scrollDirection = 'down';
|
||||
this.isHeaderVisible = false;
|
||||
if (this.isAutoHideEnabled) this.isHeaderVisible = false;
|
||||
if (this.isReaderToolbarAutoHideEnabled) this.isReaderToolbarVisible = false;
|
||||
}
|
||||
} else if (scrollY < this.lastScrollY) {
|
||||
// Scroll vers le haut
|
||||
if (this.scrollDirection !== 'up') {
|
||||
this.scrollDirection = 'up';
|
||||
this.isHeaderVisible = true;
|
||||
if (this.isAutoHideEnabled) this.isHeaderVisible = true;
|
||||
if (this.isReaderToolbarAutoHideEnabled) this.isReaderToolbarVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,10 +126,10 @@ services:
|
||||
tags:
|
||||
- { name: messenger.message_handler, bus: command.bus }
|
||||
|
||||
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface:
|
||||
alias: App\Domain\Scraping\Infrastructure\Service\LocalImageStorage
|
||||
App\Domain\Shared\Domain\Contract\ImageStorageInterface:
|
||||
alias: App\Domain\Shared\Infrastructure\Service\ImageStorageManager
|
||||
|
||||
App\Domain\Scraping\Infrastructure\Service\LocalImageStorage:
|
||||
App\Domain\Shared\Infrastructure\Service\ImageStorageManager:
|
||||
arguments:
|
||||
$storagePath: '%kernel.project_dir%/public/images'
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
|
||||
public: true
|
||||
|
||||
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface:
|
||||
App\Domain\Shared\Domain\Contract\ImageStorageInterface:
|
||||
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage'
|
||||
public: true
|
||||
|
||||
|
||||
1965
package-lock.json
generated
1965
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@
|
||||
"react-router-dom": "^7.1.5",
|
||||
"sortablejs": "^1.15.2",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vuedraggable": "^2.24.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ use App\Domain\Manga\Application\Command\ImportChapter;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||
|
||||
readonly class ImportChapterHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private MangaPathManagerInterface $pathManager
|
||||
private ImageStorageInterface $imageStorage
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -39,11 +39,15 @@ readonly class ImportChapterHandler
|
||||
throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}");
|
||||
}
|
||||
|
||||
// 4. Save the CBZ file to storage using the path manager
|
||||
$cbzPath = $this->saveCbzFile($command, $manga, $existingChapter);
|
||||
// 4. Extract CBZ into individual images storage
|
||||
$pagesDirectory = $this->imageStorage->extractFromCbz(
|
||||
$existingChapter->getId(),
|
||||
$command->fileBinary
|
||||
);
|
||||
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
|
||||
|
||||
// 5. Update existing chapter with new path through the aggregate
|
||||
$manga->updateChapterPages($existingChapter, $cbzPath, $existingChapter->getPageCount());
|
||||
$manga->updateChapterPages($existingChapter, $pagesDirectory, $pageCount);
|
||||
$this->mangaRepository->save($manga);
|
||||
}
|
||||
|
||||
@@ -53,21 +57,4 @@ readonly class ImportChapterHandler
|
||||
|
||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
||||
}
|
||||
|
||||
private function saveCbzFile(ImportChapter $command, \App\Domain\Manga\Domain\Model\Manga $manga, \App\Domain\Manga\Domain\Model\Chapter $chapter): string
|
||||
{
|
||||
$volumeNumber = $chapter->getVolume() ?? 0;
|
||||
$cbzPath = $this->pathManager->buildChapterCbzPath(
|
||||
$manga->getTitle()->getValue(),
|
||||
(string)$manga->getPublicationYear(),
|
||||
$volumeNumber,
|
||||
(string)$command->chapterNumber
|
||||
);
|
||||
|
||||
if (!file_put_contents($cbzPath, $command->fileBinary)) {
|
||||
throw new \RuntimeException('Failed to save CBZ file');
|
||||
}
|
||||
|
||||
return $cbzPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
||||
use App\Domain\Manga\Application\Command\ImportVolume;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||
|
||||
readonly class ImportVolumeHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private MangaPathManagerInterface $pathManager
|
||||
private ImageStorageInterface $imageStorage
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -40,12 +40,14 @@ readonly class ImportVolumeHandler
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Save the CBZ file to storage using the path manager
|
||||
$cbzPath = $this->saveCbzFile($command, $manga);
|
||||
// 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
|
||||
$volumeDirectoryId = sprintf('volume_%s_%d', $command->mangaId, $command->volumeNumber);
|
||||
$pagesDirectory = $this->imageStorage->extractFromCbz($volumeDirectoryId, $command->fileBinary);
|
||||
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
|
||||
|
||||
// 5. Update all chapters with the volume path through the aggregate
|
||||
foreach ($chapters as $chapter) {
|
||||
$manga->updateChapterPages($chapter, $cbzPath, $chapter->getPageCount());
|
||||
$manga->updateChapterPages($chapter, $pagesDirectory, $pageCount);
|
||||
}
|
||||
$this->mangaRepository->save($manga);
|
||||
}
|
||||
@@ -56,19 +58,4 @@ readonly class ImportVolumeHandler
|
||||
|
||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
||||
}
|
||||
|
||||
private function saveCbzFile(ImportVolume $command, \App\Domain\Manga\Domain\Model\Manga $manga): string
|
||||
{
|
||||
$cbzPath = $this->pathManager->buildVolumeCbzPath(
|
||||
$manga->getTitle()->getValue(),
|
||||
(string)$manga->getPublicationYear(),
|
||||
$command->volumeNumber
|
||||
);
|
||||
|
||||
if (!file_put_contents($cbzPath, $command->fileBinary)) {
|
||||
throw new \RuntimeException('Failed to save CBZ file');
|
||||
}
|
||||
|
||||
return $cbzPath;
|
||||
}
|
||||
}
|
||||
|
||||
7
src/Domain/Manga/Application/Query/DiscoverManga.php
Normal file
7
src/Domain/Manga/Application/Query/DiscoverManga.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Application\Query;
|
||||
|
||||
readonly class DiscoverManga
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Query\DiscoverManga;
|
||||
use App\Domain\Manga\Application\Response\MangaSearchItem;
|
||||
use App\Domain\Manga\Application\Response\MangaSearchResponse;
|
||||
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
|
||||
readonly class DiscoverMangaHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private MangaProviderInterface $mangaProvider
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(DiscoverManga $query): MangaSearchResponse
|
||||
{
|
||||
$localMangas = $this->mangaRepository->findAll(page: 1, limit: 1000);
|
||||
|
||||
$ownedExternalIds = [];
|
||||
$mangasWithRating = [];
|
||||
foreach ($localMangas as $manga) {
|
||||
if (!$manga->getExternalId()) {
|
||||
continue;
|
||||
}
|
||||
$ownedExternalIds[] = $manga->getExternalId()->getValue();
|
||||
$mangasWithRating[] = $manga;
|
||||
}
|
||||
|
||||
usort($mangasWithRating, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0));
|
||||
$sourceIds = array_map(
|
||||
fn (Manga $m) => $m->getExternalId()->getValue(),
|
||||
array_slice($mangasWithRating, 0, 5)
|
||||
);
|
||||
|
||||
$collection = $this->mangaProvider->discover($sourceIds);
|
||||
|
||||
$recommendations = array_values(array_filter(
|
||||
$collection->getItems(),
|
||||
fn (Manga $m) => $m->getExternalId() === null
|
||||
|| !in_array($m->getExternalId()->getValue(), $ownedExternalIds, true)
|
||||
));
|
||||
|
||||
return new MangaSearchResponse(
|
||||
array_map(
|
||||
fn (Manga $manga, int $index) => new MangaSearchItem(
|
||||
id: $index,
|
||||
externalId: $manga->getExternalId()->getValue(),
|
||||
title: $manga->getTitle()->getValue(),
|
||||
slug: $manga->getSlug()->getValue(),
|
||||
description: $manga->getDescription(),
|
||||
author: $manga->getAuthor(),
|
||||
publicationYear: $manga->getPublicationYear(),
|
||||
genres: $manga->getGenres(),
|
||||
status: $manga->getStatus(),
|
||||
imageUrl: $manga->getImageUrl(),
|
||||
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
|
||||
rating: $manga->getRating()
|
||||
),
|
||||
$recommendations,
|
||||
array_keys($recommendations)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,18 +23,60 @@ readonly class GetMangaChaptersHandler
|
||||
throw new MangaNotFoundException();
|
||||
}
|
||||
|
||||
$chapters = $this->mangaRepository->findChapters(
|
||||
$allChapters = $this->mangaRepository->findAllChapters(
|
||||
mangaId: $query->mangaId,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
sortOrder: $query->sortOrder
|
||||
sortOrder: 'asc'
|
||||
);
|
||||
|
||||
$total = $this->mangaRepository->countChapters($query->mangaId);
|
||||
$grouped = $this->groupChapters($allChapters);
|
||||
|
||||
if ($query->sortOrder === 'desc') {
|
||||
usort($grouped, fn (ChapterResponse $a, ChapterResponse $b) => $b->number <=> $a->number);
|
||||
}
|
||||
|
||||
$total = count($grouped);
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
$paginatedChapters = array_slice($grouped, $offset, $query->limit);
|
||||
|
||||
return new ChapterListResponse(
|
||||
chapters: array_map(
|
||||
fn (Chapter $chapter) => new ChapterResponse(
|
||||
chapters: $paginatedChapters,
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit
|
||||
);
|
||||
}
|
||||
|
||||
/** @param Chapter[] $chapters */
|
||||
private function groupChapters(array $chapters): array
|
||||
{
|
||||
$result = [];
|
||||
$currentGroup = [];
|
||||
$currentPagesDir = null;
|
||||
$currentVolume = null;
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$pagesDir = $chapter->getPagesDirectory();
|
||||
$volume = $chapter->getVolume();
|
||||
|
||||
if ($pagesDir !== null && $volume !== null) {
|
||||
if ($pagesDir === $currentPagesDir && $volume === $currentVolume) {
|
||||
$currentGroup[] = $chapter;
|
||||
} else {
|
||||
if (!empty($currentGroup)) {
|
||||
$result[] = $this->buildVolumeGroupResponse($currentGroup);
|
||||
}
|
||||
$currentGroup = [$chapter];
|
||||
$currentPagesDir = $pagesDir;
|
||||
$currentVolume = $volume;
|
||||
}
|
||||
} else {
|
||||
if (!empty($currentGroup)) {
|
||||
$result[] = $this->buildVolumeGroupResponse($currentGroup);
|
||||
$currentGroup = [];
|
||||
$currentPagesDir = null;
|
||||
$currentVolume = null;
|
||||
}
|
||||
$result[] = new ChapterResponse(
|
||||
id: $chapter->getId(),
|
||||
number: $chapter->getNumber(),
|
||||
title: $chapter->getTitle(),
|
||||
@@ -42,12 +84,39 @@ readonly class GetMangaChaptersHandler
|
||||
isVisible: $chapter->isVisible(),
|
||||
pagesDirectory: $chapter->getPagesDirectory(),
|
||||
createdAt: $chapter->getCreatedAt()->format(\DateTimeInterface::RFC3339)
|
||||
),
|
||||
$chapters
|
||||
),
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($currentGroup)) {
|
||||
$result[] = $this->buildVolumeGroupResponse($currentGroup);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param Chapter[] $group */
|
||||
private function buildVolumeGroupResponse(array $group): ChapterResponse
|
||||
{
|
||||
$first = $group[0];
|
||||
$numbers = array_map(fn (Chapter $c) => $c->getNumber(), $group);
|
||||
$min = min($numbers);
|
||||
$max = max($numbers);
|
||||
|
||||
$fmt = fn (float $n) => $n == (int) $n ? (string) (int) $n : (string) $n;
|
||||
$range = count($group) > 1 ? $fmt($min) . '-' . $fmt($max) : $fmt($min);
|
||||
|
||||
return new ChapterResponse(
|
||||
id: $first->getId(),
|
||||
number: $first->getNumber(),
|
||||
title: $first->getTitle(),
|
||||
volume: $first->getVolume(),
|
||||
isVisible: $first->isVisible(),
|
||||
pagesDirectory: $first->getPagesDirectory(),
|
||||
createdAt: $first->getCreatedAt()->format(\DateTimeInterface::RFC3339),
|
||||
isVolumeGroup: true,
|
||||
volumeChaptersRange: $range,
|
||||
volumeChapterCount: count($group)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,21 @@ readonly class GetMangaListHandler
|
||||
|
||||
$total = $this->mangaRepository->count();
|
||||
|
||||
$chapterCounts = [];
|
||||
foreach ($mangas as $manga) {
|
||||
$id = $manga->getId()->getValue();
|
||||
$chapterCounts[$id] = [
|
||||
'total' => $this->mangaRepository->countChapters($id),
|
||||
'scraped' => $this->mangaRepository->countAvailableChapters($id),
|
||||
];
|
||||
}
|
||||
|
||||
return new MangaListResponse(
|
||||
mangas: $mangas,
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit
|
||||
limit: $query->limit,
|
||||
chapterCounts: $chapterCounts
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ readonly class ChapterResponse
|
||||
public ?int $volume,
|
||||
public bool $isVisible,
|
||||
public ?string $pagesDirectory,
|
||||
public string $createdAt
|
||||
public string $createdAt,
|
||||
public bool $isVolumeGroup = false,
|
||||
public ?string $volumeChaptersRange = null,
|
||||
public int $volumeChapterCount = 0,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ readonly class MangaListResponse
|
||||
public array $mangas,
|
||||
public int $total,
|
||||
public int $page,
|
||||
public int $limit
|
||||
public int $limit,
|
||||
public array $chapterCounts = []
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -93,4 +93,24 @@ interface MangadexClientInterface
|
||||
* }
|
||||
*/
|
||||
public function getManga(string $mangaId): array;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* data: array<array{
|
||||
* id: string,
|
||||
* attributes: array{
|
||||
* title: array<string, string>,
|
||||
* description: array<string, string>,
|
||||
* year: ?int,
|
||||
* status: string,
|
||||
* tags: array<array{attributes: array{name: array<string, string>}}>
|
||||
* },
|
||||
* relationships: array<array{
|
||||
* type: string,
|
||||
* attributes: array{name: string|null, fileName: string|null}
|
||||
* }>
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
public function getMangaRecommendations(string $mangaId): array;
|
||||
}
|
||||
|
||||
@@ -11,4 +11,9 @@ interface MangaProviderInterface
|
||||
public function search(string $title): MangaCollection;
|
||||
|
||||
public function findByExternalId(ExternalId $externalId): ?Manga;
|
||||
|
||||
/**
|
||||
* @param string[] $sourceExternalIds IDs MangaDex des manga sources
|
||||
*/
|
||||
public function discover(array $sourceExternalIds): MangaCollection;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,13 @@ interface MangaRepositoryInterface
|
||||
// --- Chapters (read) ---
|
||||
|
||||
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
|
||||
|
||||
/**
|
||||
* @return Chapter[]
|
||||
*/
|
||||
public function findAllChapters(string $mangaId, string $sortOrder = 'desc'): array;
|
||||
public function countChapters(string $mangaId): int;
|
||||
public function countAvailableChapters(string $mangaId): int;
|
||||
public function findChapterById(string $id): ?Chapter;
|
||||
public function findVisibleChapterById(string $id): ?Chapter;
|
||||
public function findChapterByMangaIdAndNumber(string $mangaId, float $chapterNumber): ?Chapter;
|
||||
|
||||
@@ -14,7 +14,10 @@ readonly class ChapterListItem
|
||||
public ?int $volume,
|
||||
public bool $isVisible,
|
||||
public bool $isAvailable,
|
||||
public string $createdAt
|
||||
public string $createdAt,
|
||||
public bool $isVolumeGroup = false,
|
||||
public ?string $volumeChaptersRange = null,
|
||||
public int $volumeChapterCount = 0,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ readonly class MangaListItem
|
||||
public string $status,
|
||||
public ?float $rating,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public bool $monitored = false,
|
||||
public int $chaptersTotal = 0,
|
||||
public int $chaptersScraped = 0,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\DiscoverMangaStateProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'MangaDiscover',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/manga-discover',
|
||||
output: MangaSearchCollection::class,
|
||||
provider: DiscoverMangaStateProvider::class
|
||||
)
|
||||
]
|
||||
)]
|
||||
class MangaDiscoverResource
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Domain\Manga\Application\Query\DiscoverManga;
|
||||
use App\Domain\Manga\Application\QueryHandler\DiscoverMangaHandler;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchItem;
|
||||
|
||||
readonly class DiscoverMangaStateProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(private DiscoverMangaHandler $handler)
|
||||
{
|
||||
}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): MangaSearchCollection
|
||||
{
|
||||
$response = $this->handler->handle(new DiscoverManga());
|
||||
|
||||
return new MangaSearchCollection(
|
||||
items: array_map(
|
||||
fn ($item) => new MangaSearchItem(
|
||||
externalId: $item->externalId,
|
||||
title: $item->title,
|
||||
slug: $item->slug,
|
||||
description: $item->description,
|
||||
author: $item->author,
|
||||
publicationYear: $item->publicationYear,
|
||||
genres: $item->genres,
|
||||
status: $item->status,
|
||||
imageUrl: $item->imageUrl,
|
||||
thumbnailUrl: $item->thumbnailUrl,
|
||||
rating: $item->rating
|
||||
),
|
||||
$response->items
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,10 @@ readonly class GetMangaChaptersStateProvider implements ProviderInterface
|
||||
volume: $chapter->volume,
|
||||
isVisible: $chapter->isVisible,
|
||||
isAvailable: $chapter->pagesDirectory !== null,
|
||||
createdAt: $chapter->createdAt
|
||||
createdAt: $chapter->createdAt,
|
||||
isVolumeGroup: $chapter->isVolumeGroup,
|
||||
volumeChaptersRange: $chapter->volumeChaptersRange,
|
||||
volumeChapterCount: $chapter->volumeChapterCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ readonly class GetMangaListStateProvider implements ProviderInterface
|
||||
|
||||
return new MangaCollection(
|
||||
items: array_map(
|
||||
fn (Manga $manga) => $this->createMangaListItem($manga),
|
||||
fn (Manga $manga) => $this->createMangaListItem(
|
||||
$manga,
|
||||
$response->chapterCounts[$manga->getId()->getValue()] ?? []
|
||||
),
|
||||
$response->mangas
|
||||
),
|
||||
total: $response->total,
|
||||
@@ -40,7 +43,7 @@ readonly class GetMangaListStateProvider implements ProviderInterface
|
||||
);
|
||||
}
|
||||
|
||||
private function createMangaListItem(Manga $manga): MangaListItem
|
||||
private function createMangaListItem(Manga $manga, array $counts = []): MangaListItem
|
||||
{
|
||||
return new MangaListItem(
|
||||
id: $manga->getId()->getValue(),
|
||||
@@ -54,7 +57,10 @@ readonly class GetMangaListStateProvider implements ProviderInterface
|
||||
genres: $manga->getGenres(),
|
||||
status: $manga->getStatus(),
|
||||
rating: $manga->getRating(),
|
||||
createdAt: $manga->getCreatedAt()
|
||||
createdAt: $manga->getCreatedAt(),
|
||||
monitored: $manga->getMonitoringStatus()->isEnabled(),
|
||||
chaptersTotal: $counts['total'] ?? 0,
|
||||
chaptersScraped: $counts['scraped'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,35 @@ class MangadexClient implements MangadexClientInterface
|
||||
]);
|
||||
}
|
||||
|
||||
public function getMangaRecommendations(string $mangaId): array
|
||||
{
|
||||
// L'endpoint retourne des objets manga_recommendation avec des relationships
|
||||
// vers les manga (sans détails). Il faut d'abord récupérer les IDs, puis
|
||||
// fetcher les manga en batch avec leurs détails complets.
|
||||
$recommendations = $this->get('/manga/' . $mangaId . '/recommendation');
|
||||
|
||||
$recommendedIds = [];
|
||||
foreach ($recommendations['data'] ?? [] as $item) {
|
||||
foreach ($item['relationships'] ?? [] as $rel) {
|
||||
if ($rel['type'] === 'manga' && $rel['id'] !== $mangaId) {
|
||||
$recommendedIds[] = $rel['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($recommendedIds)) {
|
||||
return ['data' => []];
|
||||
}
|
||||
|
||||
return $this->get('/manga', [
|
||||
'ids' => $recommendedIds,
|
||||
'includes' => ['cover_art', 'author'],
|
||||
'contentRating' => ['safe', 'suggestive', 'erotica'],
|
||||
'excludedTags' => self::EXCLUDED_TAGS,
|
||||
'limit' => count($recommendedIds),
|
||||
]);
|
||||
}
|
||||
|
||||
private function get(string $endpoint, array $params = []): array
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -185,6 +185,21 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
||||
);
|
||||
}
|
||||
|
||||
public function findAllChapters(string $mangaId, string $sortOrder = 'desc'): array
|
||||
{
|
||||
$queryBuilder = $this->entityManager->createQueryBuilder()
|
||||
->select('c')
|
||||
->from(EntityChapter::class, 'c')
|
||||
->where('c.manga = :mangaId')
|
||||
->orderBy('c.number', $sortOrder)
|
||||
->setParameter('mangaId', $mangaId);
|
||||
|
||||
return array_map(
|
||||
fn (EntityChapter $entity) => $this->toChapterDomain($entity),
|
||||
$queryBuilder->getQuery()->getResult()
|
||||
);
|
||||
}
|
||||
|
||||
public function countChapters(string $mangaId): int
|
||||
{
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
@@ -196,6 +211,18 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function countAvailableChapters(string $mangaId): int
|
||||
{
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(c.id)')
|
||||
->from(EntityChapter::class, 'c')
|
||||
->where('c.manga = :mangaId')
|
||||
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
|
||||
->setParameter('mangaId', $mangaId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function findByExternalId(ExternalId $externalId): ?DomainManga
|
||||
{
|
||||
$entity = $this->entityManager->getRepository(EntityManga::class)->findOneBy([
|
||||
|
||||
@@ -58,7 +58,12 @@ readonly class MangadexProvider implements MangaProviderInterface
|
||||
{
|
||||
try {
|
||||
$attributes = $result['attributes'];
|
||||
$title = $attributes['title']['en'] ?? null;
|
||||
$title = $attributes['title']['en']
|
||||
?? $attributes['title']['fr']
|
||||
?? $attributes['title']['ja-ro']
|
||||
?? $attributes['title']['ko-ro']
|
||||
?? $attributes['title']['zh-ro']
|
||||
?? (!empty($attributes['title']) ? reset($attributes['title']) : null);
|
||||
|
||||
if (!$title) {
|
||||
return null;
|
||||
@@ -77,7 +82,7 @@ readonly class MangadexProvider implements MangaProviderInterface
|
||||
}
|
||||
if ($relationship['type'] === 'cover_art') {
|
||||
$imageUrl = sprintf(
|
||||
'https://mangadex.org/covers/%s/%s',
|
||||
'https://uploads.mangadex.org/covers/%s/%s.512.jpg',
|
||||
$result['id'],
|
||||
$relationship['attributes']['fileName']
|
||||
);
|
||||
@@ -130,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 {
|
||||
|
||||
@@ -204,8 +204,9 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz
|
||||
}
|
||||
}
|
||||
|
||||
// Si on a trouvé un volume précédent et que le suivant est le même ou null, alors utilise le précédent
|
||||
if ($prevVolume !== null && ($nextVolume === null || $nextVolume === $prevVolume)) {
|
||||
// Priorité au volume précédent : le chapitre appartient à la fin du volume en cours
|
||||
// Couvre les cas : milieu de volume (prev=next), transition entre deux volumes (prev≠next)
|
||||
if ($prevVolume !== null) {
|
||||
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||
new ChapterId($currentChapter->getId()),
|
||||
$currentChapter->getMangaId(),
|
||||
@@ -218,8 +219,8 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz
|
||||
$currentChapter->getCreatedAt()
|
||||
);
|
||||
}
|
||||
// Si on a trouvé un volume suivant mais pas de précédent, utilise le suivant
|
||||
elseif ($nextVolume !== null && $prevVolume === null) {
|
||||
// Sinon utilise le volume suivant (chapitres en début de série)
|
||||
elseif ($nextVolume !== null) {
|
||||
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||
new ChapterId($currentChapter->getId()),
|
||||
$currentChapter->getMangaId(),
|
||||
|
||||
@@ -23,12 +23,13 @@ final readonly class GetChapterContextHandler
|
||||
$context = $this->chapterRepository->getChapterContext($chapterId);
|
||||
|
||||
return new ChapterContextResponse(
|
||||
$query->getChapterId(),
|
||||
$context->getChapterTitle(),
|
||||
$context->getNumber(),
|
||||
$context->getTotalPages(),
|
||||
$context->getPreviousChapterId()?->getValue(),
|
||||
$context->getNextChapterId()?->getValue(),
|
||||
id: $query->getChapterId(),
|
||||
mangaId: $context->getMangaId(),
|
||||
title: $context->getChapterTitle(),
|
||||
number: $context->getNumber(),
|
||||
totalPages: $context->getTotalPages(),
|
||||
previousChapterId: $context->getPreviousChapterId()?->getValue(),
|
||||
nextChapterId: $context->getNextChapterId()?->getValue(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ final readonly class ChapterContextResponse
|
||||
{
|
||||
public function __construct(
|
||||
private string $id,
|
||||
private string $mangaId,
|
||||
private string $title,
|
||||
private float $number,
|
||||
private int $totalPages,
|
||||
@@ -21,6 +22,11 @@ final readonly class ChapterContextResponse
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getMangaId(): string
|
||||
{
|
||||
return $this->mangaId;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
|
||||
@@ -12,6 +12,7 @@ readonly class ChapterContext
|
||||
private ChapterId $id,
|
||||
private ?ChapterId $previousChapterId,
|
||||
private ?ChapterId $nextChapterId,
|
||||
private string $mangaId,
|
||||
private string $mangaTitle,
|
||||
private float $number,
|
||||
private ?string $chapterTitle,
|
||||
@@ -39,6 +40,11 @@ readonly class ChapterContext
|
||||
return $this->nextChapterId;
|
||||
}
|
||||
|
||||
public function getMangaId(): string
|
||||
{
|
||||
return $this->mangaId;
|
||||
}
|
||||
|
||||
public function getMangaTitle(): string
|
||||
{
|
||||
return $this->mangaTitle;
|
||||
|
||||
@@ -27,6 +27,7 @@ final readonly class ChapterContextProvider implements ProviderInterface
|
||||
|
||||
return new ChapterContextResponse(
|
||||
id: $response->getId(),
|
||||
mangaId: $response->getMangaId(),
|
||||
title: $response->getTitle(),
|
||||
number: $response->getNumber(),
|
||||
totalPages: $response->getTotalPages(),
|
||||
|
||||
@@ -49,6 +49,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
id: $chapterId,
|
||||
previousChapterId: $this->getPreviousChapterId($chapterId),
|
||||
nextChapterId: $this->getNextChapterId($chapterId),
|
||||
mangaId: (string) $chapter->getManga()->getId(),
|
||||
mangaTitle: $chapter->getManga()->getTitle(),
|
||||
number: $chapter->getNumber(),
|
||||
chapterTitle: $chapter->getTitle(),
|
||||
@@ -153,7 +154,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
$pages[] = new Page(
|
||||
basename($files[$i]),
|
||||
new PageNumber($i + 1),
|
||||
sprintf('/images/pages/%s/%s', $chapterId->getValue(), basename($files[$i])),
|
||||
sprintf('/images/pages/%s/%s', basename($pagesDirectory), basename($files[$i])),
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
||||
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Scraping\Domain\Contract\Service;
|
||||
|
||||
interface ImageStorageInterface
|
||||
{
|
||||
/**
|
||||
* Copies images to permanent storage. Returns the pagesDirectory path.
|
||||
*
|
||||
* @param string $chapterId The chapter UUID used as directory name
|
||||
* @param string[] $localImagePaths Paths to the locally downloaded image files
|
||||
*
|
||||
* @return string Absolute path to the directory where images were stored
|
||||
*/
|
||||
public function storeChapterImages(string $chapterId, array $localImagePaths): string;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Scraping\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
||||
|
||||
readonly class LocalImageStorage implements ImageStorageInterface
|
||||
{
|
||||
public function __construct(private string $storagePath)
|
||||
{
|
||||
}
|
||||
|
||||
public function storeChapterImages(string $chapterId, array $localImagePaths): string
|
||||
{
|
||||
$targetDir = $this->storagePath . '/pages/' . $chapterId;
|
||||
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
sort($localImagePaths);
|
||||
|
||||
foreach ($localImagePaths as $index => $localPath) {
|
||||
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
|
||||
copy($localPath, $targetFile);
|
||||
}
|
||||
|
||||
return $targetDir;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user