Compare commits
81 Commits
style/simp
...
8e2e608ad9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e2e608ad9 | ||
| c610d22bd2 | |||
| ab2cf319ac | |||
|
|
69c6757cf8 | ||
|
|
21d8111734 | ||
|
|
5ed303612a | ||
| 4e30af6a16 | |||
|
|
5a0888eb28 | ||
| d7e6bf56d0 | |||
| 17d44f68e5 | |||
|
|
90d6feee2d | ||
| 0880a77546 | |||
|
|
9926da6730 | ||
| 4c80aa6b42 | |||
| c0307a9173 | |||
|
|
45f7e88024 | ||
| 507fac5b5e | |||
| 071e12a06c | |||
|
|
59f72339fa | ||
| 3963efa986 | |||
|
|
ca8791cc0d | ||
| c2b55e9018 | |||
|
|
07d1b2daed | ||
| a7e6879e83 | |||
|
|
fa035bfbfa | ||
|
|
ec4a8be934 | ||
| 8443120c2f | |||
| 7a8f749f3f | |||
|
|
670e3f5315 | ||
| 4398170989 | |||
|
|
fc4ab68e8b | ||
| 36f873aaca | |||
|
|
874003eb35 | ||
|
|
01474c264b | ||
|
|
795cbeccc3 | ||
| b0ce36096f | |||
|
|
da8a19cbcb | ||
|
|
367b361eef | ||
|
|
9c5ae4bf16 | ||
|
|
6b58e94fc3 | ||
| e78bc890ef | |||
| 47c33d549b | |||
|
|
814fe46ce5 | ||
| 1478b460ba | |||
|
|
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 |
142
.claude/skills/task-workflow/SKILL.md
Normal file
142
.claude/skills/task-workflow/SKILL.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
name: task-workflow
|
||||||
|
description: Workflow complet pour traiter une tâche du TASK.md — branche git, développement, tests, commit conventionnel, push, puis archivage dans DONE.md. Utiliser quand l'utilisateur veut implémenter une tâche listée dans TASK.md.
|
||||||
|
allowed-tools: Read, Bash, Edit, Write, Glob, Grep
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workflow de traitement d'une tâche (TASK.md → DONE.md)
|
||||||
|
|
||||||
|
Quand l'utilisateur demande de traiter une tâche du `TASK.md`, suivre **dans l'ordre** les étapes ci-dessous.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Étape 0 — Repartir d'une branche saine depuis `origin/main`
|
||||||
|
|
||||||
|
**IMPORTANT : toujours commencer par cette étape, sans exception.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout main
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensuite seulement créer la branche de travail (voir étape 2).
|
||||||
|
|
||||||
|
> Règle : ne jamais partir d'une branche de feature existante. Toujours tirer depuis `main` à jour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 1 — Lire et choisir la tâche
|
||||||
|
|
||||||
|
1. Lire `TASK.md` pour identifier la tâche à traiter (si non précisée, demander laquelle).
|
||||||
|
2. Extraire : le titre, les fichiers impactés, et la liste des sous-tâches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 2 — Créer une branche git
|
||||||
|
|
||||||
|
Nommer la branche d'après le type et le titre de la tâche :
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>/<slug-de-la-tache>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples de types : `feat`, `fix`, `style`, `refactor`, `test`, `chore`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b style/simplifier-table-homepage
|
||||||
|
```
|
||||||
|
|
||||||
|
Règle : **ne jamais committer directement sur `main`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 3 — Implémenter la tâche
|
||||||
|
|
||||||
|
- Lire tous les fichiers mentionnés dans la tâche avant de les modifier.
|
||||||
|
- Cocher mentalement chaque sous-tâche `[ ]` au fur et à mesure.
|
||||||
|
- Respecter les skills existants selon les fichiers touchés :
|
||||||
|
- Composant Vue → skill `vue-frontend`
|
||||||
|
- Domaine PHP → skills `ddd-core`, `hexagonal-arch`, `cqrs`, `api-platform`
|
||||||
|
- Tests → skill `testing-strategy`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 4 — Vérifier que tous les tests passent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
- Si des tests échouent, **corriger avant de continuer**.
|
||||||
|
- Ne pas passer à l'étape suivante tant que la suite n'est pas verte.
|
||||||
|
- Pour un test spécifique : `make test f="NomDeLaClasse"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 5 — Commit conventionnel
|
||||||
|
|
||||||
|
Format Conventional Commits :
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description courte en français>
|
||||||
|
|
||||||
|
[corps optionnel : explication du pourquoi]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types autorisés :** `feat`, `fix`, `style`, `refactor`, `test`, `chore`, `docs`
|
||||||
|
|
||||||
|
**Scope :** nom du domaine ou du composant impacté (ex: `manga-table`, `sidebar`, `homepage`)
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
```
|
||||||
|
style(manga-table): simplifier le wrapper card + hover vert sur le titre
|
||||||
|
fix(sidebar): séparer toggle et navigation sur MenuGroup
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add <fichiers modifiés>
|
||||||
|
git commit -m "style(manga-table): simplifier le wrapper card + hover vert sur le titre"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 6 — Push de la branche
|
||||||
|
|
||||||
|
**Demander confirmation à l'utilisateur avant de pusher.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin <nom-de-la-branche>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 7 — Archiver la tâche dans DONE.md
|
||||||
|
|
||||||
|
1. Retirer le bloc de la tâche de `TASK.md` (section complète, du titre `##` jusqu'au `---` suivant).
|
||||||
|
2. Ajouter la tâche dans `DONE.md` (créer le fichier s'il n'existe pas) avec la date et le sha du commit :
|
||||||
|
|
||||||
|
Format dans `DONE.md` :
|
||||||
|
```markdown
|
||||||
|
## [TYPE] Titre de la tâche — YYYY-MM-DD
|
||||||
|
|
||||||
|
> Branche : `<nom-de-la-branche>` | Commit : `<sha court>`
|
||||||
|
|
||||||
|
- [x] Sous-tâche 1
|
||||||
|
- [x] Sous-tâche 2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé du flux
|
||||||
|
|
||||||
|
```
|
||||||
|
fetch + checkout main + pull (branche saine)
|
||||||
|
→ branche git depuis main
|
||||||
|
→ TASK.md (choisir la tâche)
|
||||||
|
→ implémentation
|
||||||
|
→ make test (vert obligatoire)
|
||||||
|
→ conventional commit
|
||||||
|
→ push (après confirmation)
|
||||||
|
→ DONE.md
|
||||||
|
```
|
||||||
223
.claude/skills/ui-style/SKILL.md
Normal file
223
.claude/skills/ui-style/SKILL.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
---
|
||||||
|
name: ui-style
|
||||||
|
description: Design system et harmonisation UI de Mangarr — layout de page canonique (Toolbar + flex + sections border-t), palette Tailwind, patterns composants (boutons, badges, upload, progression). Utiliser quand on crée ou modifie une page Vue ou un composant UI.
|
||||||
|
allowed-tools: Read, Grep, Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design system Mangarr — Guide UI
|
||||||
|
|
||||||
|
Les pages de référence canoniques sont :
|
||||||
|
- `assets/vue/app/domain/manga/infrastructure/presentation/pages/NewImportPage.vue`
|
||||||
|
- `assets/vue/app/domain/conversion/infrastructure/presentation/pages/ConversionPage.vue`
|
||||||
|
|
||||||
|
En cas de doute, les lire pour vérifier le pattern en vigueur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Layout de page canonique
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<div class="px-6 py-8">
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
|
Titre section
|
||||||
|
</h2>
|
||||||
|
<!-- contenu -->
|
||||||
|
</section>
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
|
<!-- section suivante -->
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règles absolues :**
|
||||||
|
- `flex flex-col h-full` toujours à la racine du template
|
||||||
|
- `<Toolbar>` toujours en premier enfant direct de la racine
|
||||||
|
- `overflow-y-auto flex-1` pour le contenu scrollable
|
||||||
|
- `px-6 py-8` comme wrapper interne — **jamais** `container mx-auto`
|
||||||
|
- Chaque bloc logique = une `<section>` avec `border-t border-gray-200 dark:border-gray-700`
|
||||||
|
- **Jamais** de `<h1>` volant dans le contenu — le titre de page va dans `toolbarConfig.leftSection`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configuration Toolbar
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { SomeIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{ type: 'label', text: 'Titre de la page', class: 'text-sm font-medium' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: SomeIcon,
|
||||||
|
label: 'Action principale',
|
||||||
|
onClick: handler,
|
||||||
|
disabled: condition,
|
||||||
|
},
|
||||||
|
// Bouton conditionnel :
|
||||||
|
...(showAction ? [{
|
||||||
|
type: 'button',
|
||||||
|
icon: OtherIcon,
|
||||||
|
label: 'Action contextuelle',
|
||||||
|
onClick: otherHandler,
|
||||||
|
}] : []),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
- Icônes : Heroicons v24/outline (`@heroicons/vue/24/outline`)
|
||||||
|
- Boutons toolbar visibles uniquement si pertinents — utiliser le spread conditionnel
|
||||||
|
- `rightSection` peut être vide `[]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Headers de section
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Header simple -->
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
|
Titre
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Header avec info contextuelle à droite -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||||
|
Titre
|
||||||
|
</h2>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">info contextuelle</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Palette de couleurs
|
||||||
|
|
||||||
|
| Usage | Classes Tailwind |
|
||||||
|
|-------|-----------------|
|
||||||
|
| Primaire (action principale) | `bg-green-600 hover:bg-green-700` |
|
||||||
|
| Secondaire | `bg-blue-600 hover:bg-blue-700` |
|
||||||
|
| Danger | `bg-red-600 hover:bg-red-700` |
|
||||||
|
| Désactivé | `disabled:bg-gray-400 disabled:cursor-not-allowed` |
|
||||||
|
| Texte principal | `text-gray-900 dark:text-gray-100` |
|
||||||
|
| Texte secondaire | `text-gray-600 dark:text-gray-300` |
|
||||||
|
| Texte subtil | `text-gray-500 dark:text-gray-400` |
|
||||||
|
| Étiquette section | `text-gray-400 dark:text-gray-500` |
|
||||||
|
| Fond carte / panel | `bg-white dark:bg-gray-800` |
|
||||||
|
| Bordure | `border-gray-200 dark:border-gray-700` |
|
||||||
|
| Séparateur de liste | `divide-y divide-gray-100 dark:divide-gray-700/50` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Boutons
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Bouton action principale (submit, lancer, confirmer) -->
|
||||||
|
<button
|
||||||
|
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 rounded-md font-medium transition-colors"
|
||||||
|
:disabled="condition"
|
||||||
|
>
|
||||||
|
Label
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Bouton ghost / discret -->
|
||||||
|
<button class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||||
|
Label
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Barre de progression
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-1.5 mb-4">
|
||||||
|
<div
|
||||||
|
class="bg-green-600 h-1.5 transition-all duration-300"
|
||||||
|
:style="{ width: progress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important :** toujours `bg-green-600`, jamais `bg-blue-600` pour les barres de progression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Liste avec séparateurs
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center justify-between py-3"
|
||||||
|
>
|
||||||
|
<!-- contenu de l'item -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Zone de drop / upload de fichier
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
|
||||||
|
:class="isDragging
|
||||||
|
? 'border-green-500 bg-green-50 dark:bg-green-900/10'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'"
|
||||||
|
@dragover.prevent="isDragging = true"
|
||||||
|
@dragleave="isDragging = false"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<SomeIcon class="mx-auto h-8 w-8 text-gray-400 mb-3" />
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Message principal
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
Précision format/taille
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Pages non conformes à corriger
|
||||||
|
|
||||||
|
Les pages suivantes dévient encore du pattern canonique :
|
||||||
|
|
||||||
|
| Page | Chemin relatif | Déviations principales |
|
||||||
|
|------|---------------|----------------------|
|
||||||
|
| `HomePage.vue` | `domain/manga/.../pages/` | Pas de `px-6 py-8`, pas de sections `border-t` |
|
||||||
|
| `AddManga.vue` | `domain/manga/.../pages/` | Pas de Toolbar, pas de `flex flex-col h-full` |
|
||||||
|
| `ActivityPage.vue` | `domain/activity/.../pages/` | Pas de `flex flex-col`, pas de Toolbar intégré |
|
||||||
|
| `UserPreferencesPage.vue` | `domain/setting/.../pages/` | `h1` volant, pas de Toolbar |
|
||||||
|
| `ScrapperConfigurations.vue` | `domain/setting/.../pages/` | `h1` volant, `container mx-auto` |
|
||||||
|
| `ScrapperEdit.vue` | `domain/setting/.../pages/` | `container mx-auto` au lieu de `px-6 py-8` |
|
||||||
|
| `MangaDetails.vue` | `domain/manga/.../pages/` | Layout spécial (cover + chapitres), à traiter séparément |
|
||||||
|
| `ChapterPage.vue` | `domain/reader/.../pages/` | Layout lecteur spécialisé — **exception justifiée**, ne pas modifier |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Checklist avant de livrer une page
|
||||||
|
|
||||||
|
- [ ] Racine : `flex flex-col h-full`
|
||||||
|
- [ ] Premier enfant : `<Toolbar :config="toolbarConfig" />`
|
||||||
|
- [ ] Contenu scrollable : `overflow-y-auto flex-1`
|
||||||
|
- [ ] Wrapper interne : `px-6 py-8` (jamais `container mx-auto`)
|
||||||
|
- [ ] Blocs logiques : `<section class="border-t border-gray-200 dark:border-gray-700 pt-6">`
|
||||||
|
- [ ] Titre de page dans `toolbarConfig.leftSection`, pas de `<h1>` dans le contenu
|
||||||
|
- [ ] Headers de section : classes `text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider`
|
||||||
|
- [ ] Barres de progression : `bg-green-600` (pas `bg-blue-600`)
|
||||||
|
- [ ] Dark mode : chaque couleur a sa variante `dark:`
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -39,3 +39,10 @@ yarn-error.log
|
|||||||
src/Controller/TestController.php
|
src/Controller/TestController.php
|
||||||
.phpunit.cache/test-results
|
.phpunit.cache/test-results
|
||||||
/tests/Fixtures/pages/
|
/tests/Fixtures/pages/
|
||||||
|
|
||||||
|
# Claude Code — versionner les skills partagés, ignorer les fichiers perso
|
||||||
|
!.claude/
|
||||||
|
!.claude/skills/
|
||||||
|
!.claude/skills/**
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/projects/
|
||||||
|
|||||||
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)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#syntax=docker/dockerfile:1.4
|
#syntax=docker/dockerfile:1.4
|
||||||
|
|
||||||
# Versions
|
# Versions
|
||||||
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream
|
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
||||||
|
|
||||||
# The different stages of this Dockerfile are meant to be built into separate images
|
# The different stages of this Dockerfile are meant to be built into separate images
|
||||||
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
|
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
|
||||||
@@ -108,9 +108,6 @@ RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scri
|
|||||||
FROM node:22-alpine AS node_build
|
FROM node:22-alpine AS node_build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --link package.json package-lock.json ./
|
COPY --link package.json package-lock.json ./
|
||||||
COPY --from=composer_deps /app/vendor/symfony/ux-live-component/assets ./vendor/symfony/ux-live-component/assets
|
|
||||||
COPY --from=composer_deps /app/vendor/symfony/ux-react/assets ./vendor/symfony/ux-react/assets
|
|
||||||
COPY --from=composer_deps /app/vendor/symfony/ux-turbo/assets ./vendor/symfony/ux-turbo/assets
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY --link assets ./assets
|
COPY --link assets ./assets
|
||||||
COPY --link webpack.config.js ./
|
COPY --link webpack.config.js ./
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import './bootstrap.js';
|
|
||||||
|
|
||||||
import '@fortawesome/fontawesome-free/js/all.js';
|
|
||||||
/*
|
|
||||||
* Welcome to your app's main JavaScript file!
|
|
||||||
*
|
|
||||||
* We recommend including the built version of this JavaScript file
|
|
||||||
* (and its CSS file) in your base layout (base.html.twig).
|
|
||||||
*/
|
|
||||||
|
|
||||||
// any CSS you import will output into a single css file (app.css in this case)
|
|
||||||
import './styles/app.scss';
|
|
||||||
|
|
||||||
// start the Stimulus application
|
|
||||||
import './bootstrap';
|
|
||||||
|
|
||||||
// La ligne registerReactControllerComponents a déjà été commentée
|
|
||||||
35
assets/bootstrap.js
vendored
35
assets/bootstrap.js
vendored
@@ -1,35 +0,0 @@
|
|||||||
import { startStimulusApp } from '@symfony/stimulus-bridge';
|
|
||||||
|
|
||||||
// Registers Stimulus controllers from controllers.json and in the controllers/ directory
|
|
||||||
export const app = startStimulusApp(require.context(
|
|
||||||
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
|
|
||||||
true,
|
|
||||||
/\.[jt]sx?$/
|
|
||||||
));
|
|
||||||
|
|
||||||
// register any custom, 3rd party controllers here
|
|
||||||
// app.register('some_controller_name', SomeImportedController);
|
|
||||||
|
|
||||||
//DEBUG TURBO
|
|
||||||
// import * as Turbo from "@hotwired/turbo"
|
|
||||||
//
|
|
||||||
// Turbo.session.drive = false
|
|
||||||
// Turbo.start()
|
|
||||||
//
|
|
||||||
// // Écouteurs existants
|
|
||||||
// document.addEventListener("turbo:before-stream-render", (event) => {
|
|
||||||
// console.log("Before stream render", event.target);
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// document.addEventListener("turbo:stream-render", (event) => {
|
|
||||||
// console.log("Stream rendered", event.target);
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// // Nouvel écouteur pour les événements de création
|
|
||||||
// document.addEventListener("turbo:before-fetch-request", (event) => {
|
|
||||||
// console.log("Before fetch request", event.detail.fetchOptions);
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// document.addEventListener("turbo:before-fetch-response", (event) => {
|
|
||||||
// console.log("Before fetch response", event.detail.fetchResponse);
|
|
||||||
// });
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"controllers": {
|
|
||||||
"@symfony/ux-live-component": {
|
|
||||||
"live": {
|
|
||||||
"enabled": true,
|
|
||||||
"fetch": "eager",
|
|
||||||
"autoimport": {
|
|
||||||
"@symfony/ux-live-component/dist/live.min.css": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@symfony/ux-react": {
|
|
||||||
"react": {
|
|
||||||
"enabled": true,
|
|
||||||
"fetch": "eager"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@symfony/ux-turbo": {
|
|
||||||
"turbo-core": {
|
|
||||||
"enabled": true,
|
|
||||||
"fetch": "eager"
|
|
||||||
},
|
|
||||||
"mercure-turbo-stream": {
|
|
||||||
"enabled": true,
|
|
||||||
"fetch": "eager"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entrypoints": []
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import {Controller} from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
|
||||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
|
||||||
*/
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['activity']
|
|
||||||
|
|
||||||
// ...
|
|
||||||
async connect() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/activity/status`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
// Handle the response data as needed
|
|
||||||
this.activityTarget.innerHTML = data.length;
|
|
||||||
if (data.length > 0) {
|
|
||||||
this.activityTarget.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
|
||||||
const eventSource = new EventSource(`${mercureHubUrl}?topic=activity`, {withCredentials: true});
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.processing !== undefined && data.pending !== undefined) {
|
|
||||||
let totalActivities = data.processing.length + data.pending.length;
|
|
||||||
this.activityTarget.innerHTML = totalActivities;
|
|
||||||
if (totalActivities > 0) {
|
|
||||||
this.activityTarget.classList.remove('hidden');
|
|
||||||
}else if (totalActivities === 0) {
|
|
||||||
this.activityTarget.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource
|
|
||||||
.onerror = (event) => {
|
|
||||||
console.error('EventSource failed:', event);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// assets/controllers/addmanga_controller.js
|
|
||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static values = {
|
|
||||||
index: Number
|
|
||||||
}
|
|
||||||
|
|
||||||
openModal(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
const openEvent = new CustomEvent(`openAddMangaModal${this.indexValue}`)
|
|
||||||
document.dispatchEvent(openEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import {Controller} from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
|
||||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
|
||||||
*/
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['alert', 'icon', 'message']
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
window.addEventListener('alert:show', this.showAlert.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...
|
|
||||||
showAlert(event) {
|
|
||||||
const detail = event.detail;
|
|
||||||
const message = detail.message;
|
|
||||||
const level = detail.level;
|
|
||||||
|
|
||||||
let alertClass = "";
|
|
||||||
let iconClass = "";
|
|
||||||
switch (level) {
|
|
||||||
case 'success':
|
|
||||||
alertClass = "bg-green-500";
|
|
||||||
iconClass = "fa-circle-check";
|
|
||||||
break;
|
|
||||||
case 'warning':
|
|
||||||
alertClass = "bg-yellow-500";
|
|
||||||
iconClass = "fa-circle-exclamation";
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
alertClass = "bg-red-500";
|
|
||||||
iconClass = "fa-circle-xmark";
|
|
||||||
break;
|
|
||||||
case 'info':
|
|
||||||
default:
|
|
||||||
alertClass = "bg-blue-500";
|
|
||||||
iconClass = "fa-circle-info";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.messageTarget.innerHTML = message;
|
|
||||||
this.alertTarget.classList.add(alertClass);
|
|
||||||
this.iconTarget.classList.add(iconClass);
|
|
||||||
this.alertTarget.style.display = "block";
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.alertTarget.style.opacity = 0;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.alertTarget.style.display = 'none';
|
|
||||||
this.alertTarget.classList.remove(alertClass);
|
|
||||||
this.alertTarget.style.opacity = 1;
|
|
||||||
this.iconTarget.classList.remove(iconClass);
|
|
||||||
this.messageTarget.innerHTML = message;
|
|
||||||
}, 1000);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['progressBar', 'progressText']
|
|
||||||
static values = {
|
|
||||||
chapterId: Number
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.currentPage = 0;
|
|
||||||
this.totalPages = 0;
|
|
||||||
|
|
||||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
|
||||||
this.eventSource = new EventSource(`${mercureHubUrl}?topic=activity`, {withCredentials: true});
|
|
||||||
|
|
||||||
this.eventSource.onmessage = this.handleMessage.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
if (this.eventSource) {
|
|
||||||
this.eventSource.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMessage(event) {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.status === "scrapping.progress" && data.chapterId === this.chapterIdValue) {
|
|
||||||
this.handleProgressUpdate(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleProgressUpdate(data) {
|
|
||||||
this.currentPage = data.pageIndex;
|
|
||||||
this.totalPages = data.totalPages;
|
|
||||||
|
|
||||||
this.updateProgressBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateProgressBar() {
|
|
||||||
const progress = (this.currentPage / this.totalPages) * 100;
|
|
||||||
this.progressBarTarget.style.width = `${progress}%`;
|
|
||||||
this.progressTextTarget.textContent = `${this.currentPage} / ${this.totalPages}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import {Controller} from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
|
||||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
|
||||||
*/
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['container', 'template', 'item'];
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.index = this.itemTargets.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
add(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const template = this.templateTarget.innerHTML.replace(/__name__/g, this.index);
|
|
||||||
this.containerTarget.insertAdjacentHTML('beforeend', template);
|
|
||||||
this.index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.target.closest('.collection-item').remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['icon']
|
|
||||||
static values = {
|
|
||||||
url: String
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.defaultIconClass = this.iconTarget.classList.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async download(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// Change the icon to a loader
|
|
||||||
this.iconTarget.classList.remove("fa-download", "fa-search");
|
|
||||||
this.iconTarget.classList.add("fa-spinner", "fa-spin");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.urlValue, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentType = response.headers.get("Content-Type");
|
|
||||||
if (contentType && contentType.includes("application/json")) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.error) {
|
|
||||||
this.dispatchAlert(data.error, 'error');
|
|
||||||
} else if (data.success) {
|
|
||||||
this.dispatchAlert(data.success, 'success');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// C'est un fichier à télécharger
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.style.display = 'none';
|
|
||||||
a.href = url;
|
|
||||||
const contentDisposition = response.headers.get('Content-Disposition');
|
|
||||||
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
|
|
||||||
const matches = filenameRegex.exec(contentDisposition);
|
|
||||||
let filename = 'download';
|
|
||||||
if (matches != null && matches[1]) {
|
|
||||||
filename = matches[1].replace(/['"]/g, '');
|
|
||||||
}
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Revert the icon back to the original one
|
|
||||||
this.iconTarget.classList.value = this.defaultIconClass;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchAlert(message, level) {
|
|
||||||
const event = new CustomEvent('alert:show', {
|
|
||||||
detail: { message: message, level: level }
|
|
||||||
});
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// assets/controllers/dropdown_controller.js
|
|
||||||
import {Controller} from "@hotwired/stimulus"
|
|
||||||
import {useClickOutside} from "stimulus-use"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["button", "menu"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
useClickOutside(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(event) {
|
|
||||||
this.menuTarget.classList.toggle('hidden')
|
|
||||||
if (!this.menuTarget.classList.contains('hidden')) {
|
|
||||||
this.positionMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clickOutside(event) {
|
|
||||||
this.menuTarget.classList.add('hidden')
|
|
||||||
}
|
|
||||||
|
|
||||||
positionMenu() {
|
|
||||||
const buttonRect = this.buttonTarget.getBoundingClientRect()
|
|
||||||
const menuRect = this.menuTarget.getBoundingClientRect()
|
|
||||||
const spaceRight = window.innerWidth - buttonRect.right
|
|
||||||
const spaceBottom = window.innerHeight - buttonRect.bottom
|
|
||||||
|
|
||||||
if (spaceRight < menuRect.width && buttonRect.left > menuRect.width) {
|
|
||||||
this.menuTarget.style.left = 'auto'
|
|
||||||
this.menuTarget.style.right = '0'
|
|
||||||
} else {
|
|
||||||
this.menuTarget.style.left = '0'
|
|
||||||
this.menuTarget.style.right = 'auto'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spaceBottom < menuRect.height && buttonRect.top > menuRect.height) {
|
|
||||||
this.menuTarget.style.top = 'auto'
|
|
||||||
this.menuTarget.style.bottom = '100%'
|
|
||||||
} else {
|
|
||||||
this.menuTarget.style.top = '100%'
|
|
||||||
this.menuTarget.style.bottom = 'auto'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This is an example Stimulus controller!
|
|
||||||
*
|
|
||||||
* Any element with a data-controller="hello" attribute will cause
|
|
||||||
* this controller to be executed. The name "hello" comes from the filename:
|
|
||||||
* hello_controller.js -> "hello"
|
|
||||||
*
|
|
||||||
* Delete this file or adapt it for your use!
|
|
||||||
*/
|
|
||||||
export default class extends Controller {
|
|
||||||
connect() {
|
|
||||||
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["checkbox", "modal", "modalContent"]
|
|
||||||
|
|
||||||
toggleAllCheckboxes(event) {
|
|
||||||
this.checkboxTargets.forEach(checkbox => {
|
|
||||||
checkbox.checked = event.target.checked;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMangaInfo(event) {
|
|
||||||
const select = event.target;
|
|
||||||
const selectedOption = select.options[select.selectedIndex];
|
|
||||||
const mangaInfo = JSON.parse(selectedOption.dataset.mangaInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
showDetails(event) {
|
|
||||||
const fileId = event.currentTarget.dataset.fileId;
|
|
||||||
const select = document.querySelector(`select[name="manga_slug[${fileId}]"]`);
|
|
||||||
const mangaInfo = JSON.parse(select.options[select.selectedIndex].dataset.mangaInfo);
|
|
||||||
|
|
||||||
this.modalContentTarget.innerHTML = `
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">${mangaInfo.title}</h3>
|
|
||||||
<div class="mt-2">
|
|
||||||
<p><strong>Author:</strong> ${mangaInfo.author || 'N/A'}</p>
|
|
||||||
<p><strong>Publication Year:</strong> ${mangaInfo.publicationYear || 'N/A'}</p>
|
|
||||||
<p><strong>Genres:</strong> ${mangaInfo.genres ? mangaInfo.genres.join(', ') : 'N/A'}</p>
|
|
||||||
<p><strong>Description:</strong> ${this.truncate(mangaInfo.description || 'N/A', 200)}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
this.modalTarget.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModal() {
|
|
||||||
this.modalTarget.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmSelected(event) {
|
|
||||||
const selectedFiles = this.checkboxTargets.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value);
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
alert('Veuillez sélectionner au moins un fichier à importer.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
truncate(str, length) {
|
|
||||||
return str.length > length ? str.substring(0, length) + '...' : str;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// assets/controllers/loading_button_controller.js
|
|
||||||
import {Controller} from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["text", "loader"];
|
|
||||||
static values = {form: String};
|
|
||||||
|
|
||||||
startLoading(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.textTarget.classList.add("hidden");
|
|
||||||
this.loaderTarget.classList.remove("hidden");
|
|
||||||
this.element.disabled = true;
|
|
||||||
|
|
||||||
if (this.hasFormValue) {
|
|
||||||
const form = document.getElementById(this.formValue);
|
|
||||||
if (form) {
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// assets/controllers/menu_controller.js
|
|
||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["sidebar"]
|
|
||||||
|
|
||||||
toggleMenu() {
|
|
||||||
this.sidebarTarget.classList.toggle('-translate-x-full')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import {Controller} from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
|
||||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
|
||||||
*/
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
// ...
|
|
||||||
connect() {
|
|
||||||
const topic = this.data.get('topic');
|
|
||||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
|
||||||
const eventSource = new EventSource(`${mercureHubUrl}?topic=${topic}`, {withCredentials: true});
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
console.log('Received Mercure update:', data);
|
|
||||||
|
|
||||||
this.dispatchAlert(data.message, data.status);
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = (event) => {
|
|
||||||
console.error('EventSource failed:', event);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchAlert(message, level) {
|
|
||||||
const event = new CustomEvent('alert:show', {
|
|
||||||
detail: { message: message, level: level }
|
|
||||||
});
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// assets/controllers/modal_controller.js
|
|
||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["modal"]
|
|
||||||
static values = {
|
|
||||||
openTrigger: String,
|
|
||||||
closeTrigger: String
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
if (this.hasOpenTriggerValue) {
|
|
||||||
document.addEventListener(this.openTriggerValue, this.open.bind(this))
|
|
||||||
}
|
|
||||||
if (this.hasCloseTriggerValue) {
|
|
||||||
document.addEventListener(this.closeTriggerValue, this.close.bind(this))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
if (this.hasOpenTriggerValue) {
|
|
||||||
document.removeEventListener(this.openTriggerValue, this.open.bind(this))
|
|
||||||
}
|
|
||||||
if (this.hasCloseTriggerValue) {
|
|
||||||
document.removeEventListener(this.closeTriggerValue, this.close.bind(this))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
console.log("Opening modal...")
|
|
||||||
this.modalTarget.classList.remove('hidden')
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.modalTarget.classList.add('hidden')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// assets/controllers/preferred-sources_controller.js
|
|
||||||
|
|
||||||
import {Controller} from "@hotwired/stimulus"
|
|
||||||
import Sortable from 'sortablejs'
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["preferredList", "availableList"]
|
|
||||||
static values = {
|
|
||||||
mangaId: Number,
|
|
||||||
preferredSources: Array,
|
|
||||||
allSources: Array
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.initSortable()
|
|
||||||
}
|
|
||||||
|
|
||||||
initSortable() {
|
|
||||||
new Sortable(this.preferredListTarget, {
|
|
||||||
animation: 150,
|
|
||||||
ghostClass: 'bg-gray-300',
|
|
||||||
onEnd: this.handleDragEnd.bind(this)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDragEnd() {
|
|
||||||
this.updatePreferredSources()
|
|
||||||
}
|
|
||||||
|
|
||||||
addSource(event) {
|
|
||||||
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
|
|
||||||
if (!this.preferredSourcesValue.includes(sourceId)) {
|
|
||||||
this.preferredSourcesValue = [...this.preferredSourcesValue, sourceId]
|
|
||||||
this.updateLists()
|
|
||||||
this.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeSource(event) {
|
|
||||||
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
|
|
||||||
this.preferredSourcesValue = this.preferredSourcesValue.filter(id => id !== sourceId)
|
|
||||||
this.updateLists()
|
|
||||||
this.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePreferredSources() {
|
|
||||||
this.preferredSourcesValue = Array.from(this.preferredListTarget.children).map(li => parseInt(li.dataset.id))
|
|
||||||
this.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateLists() {
|
|
||||||
this.preferredListTarget.innerHTML = this.preferredSourcesValue
|
|
||||||
.map(id => this.allSourcesValue.find(s => s.id === id))
|
|
||||||
.map(source => this.sourceTemplate(source, true))
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
this.availableListTarget.innerHTML = this.allSourcesValue
|
|
||||||
.filter(source => !this.preferredSourcesValue.includes(source.id))
|
|
||||||
.map(source => this.sourceTemplate(source, false))
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
this.initSortable()
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceTemplate(source, isPreferred) {
|
|
||||||
return `
|
|
||||||
<li data-id="${source.id}" draggable="true" class="flex items-center justify-between p-2 bg-gray-100 rounded ${isPreferred ? 'cursor-move' : ''}">
|
|
||||||
<span>${source.name}</span>
|
|
||||||
<button type="button" data-action="preferred-sources#${isPreferred ? 'removeSource' : 'addSource'}" data-source-id="${source.id}" class="text-${isPreferred ? 'red' : 'green'}-500 hover:text-${isPreferred ? 'red' : 'green'}-700">
|
|
||||||
<i class="fas fa-${isPreferred ? 'times' : 'plus'}"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
async save() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/manga/${this.mangaIdValue}/preferred-sources`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
preferredSources: this.preferredSourcesValue
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log('Preferred sources saved successfully')
|
|
||||||
// Optionally show a success message
|
|
||||||
} else {
|
|
||||||
console.error('Error saving preferred sources')
|
|
||||||
// Optionally show an error message
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error)
|
|
||||||
// Optionally show an error message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['pageContainer', 'currentPage', 'chapterSelect', 'readingModeButton']
|
|
||||||
static values = {
|
|
||||||
mangaSlug: String,
|
|
||||||
chapterNumber: Number,
|
|
||||||
totalPages: Number,
|
|
||||||
currentPage: { type: Number, default: 1 },
|
|
||||||
readingMode: { type: String, default: 'horizontal' }
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.loadChapters();
|
|
||||||
this.loadPages();
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadChapters() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/chapters/${this.mangaSlugValue}`);
|
|
||||||
const chapters = await response.json();
|
|
||||||
|
|
||||||
this.chapterSelectTarget.innerHTML = chapters.map(chapter =>
|
|
||||||
`<option value="${chapter.number}" ${chapter.number === this.chapterNumberValue ? 'selected' : ''}>
|
|
||||||
Chapitre ${chapter.number}
|
|
||||||
</option>`
|
|
||||||
).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading chapters:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPages() {
|
|
||||||
this.pageContainerTarget.innerHTML = '';
|
|
||||||
if (this.readingModeValue === 'horizontal') {
|
|
||||||
await this.loadPage(this.currentPageValue);
|
|
||||||
} else {
|
|
||||||
for (let i = 1; i <= this.totalPagesValue; i++) {
|
|
||||||
await this.loadPage(i, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPage(pageNumber, isVertical = false) {
|
|
||||||
const response = await fetch(`/api/read/${this.mangaSlugValue}/${this.chapterNumberValue}/${pageNumber}`);
|
|
||||||
const pageContent = await response.text();
|
|
||||||
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = `data:image/jpeg;base64,${pageContent}`;
|
|
||||||
img.alt = `Page ${pageNumber}`;
|
|
||||||
img.classList.add('shadow-lg', 'w-full', 'h-auto');
|
|
||||||
|
|
||||||
if (this.readingModeValue === 'horizontal') {
|
|
||||||
img.classList.add('cursor-pointer');
|
|
||||||
img.dataset.action = 'click->reader#pageClick';
|
|
||||||
this.pageContainerTarget.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVertical) {
|
|
||||||
img.loading = 'lazy';
|
|
||||||
img.classList.add('mb-4');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pageContainerTarget.appendChild(img);
|
|
||||||
|
|
||||||
if (!isVertical) {
|
|
||||||
this.currentPageTarget.textContent = pageNumber;
|
|
||||||
this.currentPageValue = pageNumber;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pageClick(event) {
|
|
||||||
if (this.readingModeValue === 'horizontal') {
|
|
||||||
const pageWidth = event.target.offsetWidth;
|
|
||||||
const clickX = event.offsetX;
|
|
||||||
|
|
||||||
if (clickX < pageWidth / 2) {
|
|
||||||
this.previousPage();
|
|
||||||
} else {
|
|
||||||
this.nextPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previousPage() {
|
|
||||||
if (this.currentPageValue > 1) {
|
|
||||||
this.loadPage(this.currentPageValue - 1);
|
|
||||||
} else {
|
|
||||||
this.previousChapter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nextPage() {
|
|
||||||
if (this.currentPageValue < this.totalPagesValue) {
|
|
||||||
this.loadPage(this.currentPageValue + 1);
|
|
||||||
} else {
|
|
||||||
this.nextChapter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async previousChapter() {
|
|
||||||
const response = await fetch(`/api/previous-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`);
|
|
||||||
const previousChapter = await response.json();
|
|
||||||
if (previousChapter) {
|
|
||||||
window.location.href = `/read/${this.mangaSlugValue}/${previousChapter.number}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async nextChapter() {
|
|
||||||
const response = await fetch(`/api/next-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`);
|
|
||||||
const nextChapter = await response.json();
|
|
||||||
if (nextChapter) {
|
|
||||||
window.location.href = `/read/${this.mangaSlugValue}/${nextChapter.number}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
changeChapter(event) {
|
|
||||||
const selectedChapterNumber = event.target.value;
|
|
||||||
window.location.href = `/read/${this.mangaSlugValue}/${selectedChapterNumber}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleReadingMode() {
|
|
||||||
this.readingModeValue = this.readingModeValue === 'horizontal' ? 'vertical' : 'horizontal';
|
|
||||||
this.readingModeButtonTarget.textContent = this.readingModeValue === 'horizontal' ? 'Passer en mode vertical' : 'Passer en mode horizontal';
|
|
||||||
this.loadPages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['form', 'testForm', 'imageSelector', 'nextPageSelector', 'testResults', 'scrapingType']
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveConfiguration(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.formTarget.submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
async testConfiguration(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const formData = new FormData(this.formTarget);
|
|
||||||
const testFormData = new FormData(this.testFormTarget);
|
|
||||||
|
|
||||||
for (let [key, value] of formData.entries()) {
|
|
||||||
const cleanKey = key.replace(/^content_source\[(.+)]$/, '$1');
|
|
||||||
testFormData.append(`content_source[${cleanKey}]`, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.testFormTarget.action, {
|
|
||||||
method: 'POST',
|
|
||||||
body: testFormData
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.displayTestResults(result.data);
|
|
||||||
} else {
|
|
||||||
this.displayError(result.message, result.errors);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
this.displayError('An error occurred while testing the configuration');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
displayTestResults(data) {
|
|
||||||
let html = '<h3 class="text-xl font-semibold mb-4">Test Results</h3>';
|
|
||||||
html += '<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">';
|
|
||||||
data.forEach(page => {
|
|
||||||
html += `
|
|
||||||
<div class="border rounded-lg p-2 flex flex-col items-center">
|
|
||||||
<img src="${page.image_url}" alt="Page ${page.page_number}" class="w-full h-48 object-cover mb-2">
|
|
||||||
<p class="text-sm font-medium">Page ${page.page_number}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
this.testResultsTarget.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
displayError(message, errors = []) {
|
|
||||||
let errorHtml = `
|
|
||||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
|
||||||
<strong class="font-bold">Error:</strong>
|
|
||||||
<span class="block sm:inline">${message}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
errorHtml += '<ul class="list-disc list-inside mt-2">';
|
|
||||||
errors.forEach(error => {
|
|
||||||
errorHtml += `<li>${error}</li>`;
|
|
||||||
});
|
|
||||||
errorHtml += '</ul>';
|
|
||||||
}
|
|
||||||
|
|
||||||
errorHtml += '</div>';
|
|
||||||
this.testResultsTarget.innerHTML = errorHtml;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
|
||||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
|
||||||
*/
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
// ...
|
|
||||||
static targets = ["textarea", "submitButton"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
document.addEventListener('openImportModal', this.prepareImportModal.bind(this));
|
|
||||||
document.addEventListener('openExportModal', this.prepareExportModal.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
document.removeEventListener('openImportModal', this.prepareImportModal.bind(this));
|
|
||||||
document.removeEventListener('openExportModal', this.prepareExportModal.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
async prepareExportModal() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/settings/export_scrappers');
|
|
||||||
const data = await response.json();
|
|
||||||
this.textareaTarget.value = JSON.stringify(data, null, 2);
|
|
||||||
this.submitButtonTarget.textContent = 'Copy to Clipboard';
|
|
||||||
this.submitButtonTarget.dataset.action = 'scrapper-import#copyToClipboard';
|
|
||||||
this.openModal('Export Scrapper Configurations');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prepareImportModal() {
|
|
||||||
this.textareaTarget.value = '';
|
|
||||||
this.submitButtonTarget.textContent = 'Import';
|
|
||||||
this.submitButtonTarget.dataset.action = 'scrapper-import#submitImport';
|
|
||||||
this.openModal('Import Scrapper Configurations');
|
|
||||||
}
|
|
||||||
|
|
||||||
openModal(title) {
|
|
||||||
const event = new CustomEvent('openScrapperModal', { detail: { title: title } });
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
async submitImport() {
|
|
||||||
const jsonData = this.textareaTarget.value;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/settings/import_scrappers', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: jsonData
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log(result.message);
|
|
||||||
document.dispatchEvent(new CustomEvent('closeScrapperModal'));
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
console.error(result.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
copyToClipboard() {
|
|
||||||
navigator.clipboard.writeText(this.textareaTarget.value).then(() => {
|
|
||||||
console.log('Copied to clipboard');
|
|
||||||
document.dispatchEvent(new CustomEvent('closeScrapperModal'));
|
|
||||||
}, (err) => {
|
|
||||||
console.error('Could not copy text: ', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
|
||||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
|
||||||
*/
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['input']
|
|
||||||
|
|
||||||
clearSearch() {
|
|
||||||
this.inputTarget.value = '';
|
|
||||||
this.inputTarget.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import {Controller} from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
|
||||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
|
||||||
*/
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["body", "toggleIcon"]
|
|
||||||
static values = { open: Boolean }
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
if (!this.openValue) {
|
|
||||||
this.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
|
||||||
if (this.bodyTarget.style.display === "none") {
|
|
||||||
this.open()
|
|
||||||
} else {
|
|
||||||
this.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
this.bodyTarget.style.display = "block"
|
|
||||||
this.toggleIconTarget.classList.replace("fa-chevron-down", "fa-chevron-up")
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.bodyTarget.style.display = "none"
|
|
||||||
this.toggleIconTarget.classList.replace("fa-chevron-up", "fa-chevron-down")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
// assets/controllers/toolbar_controller.js
|
|
||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
import { visit } from "@hotwired/turbo"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["dropdown", "icon", "text"]
|
|
||||||
static values = {
|
|
||||||
currentSort: String,
|
|
||||||
currentOrder: String,
|
|
||||||
currentStatus: String,
|
|
||||||
mangaId: Number
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
window.addEventListener('alert:show', this.stopLoading.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
stopLoading(event) {
|
|
||||||
if(event.currentTarget.dataset !== undefined){
|
|
||||||
this.iconTarget.classList.remove('fa-spin');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshMetadata(event) {
|
|
||||||
const mangaId = event.currentTarget.dataset.mangaid;
|
|
||||||
const url = `/refresh_metadata`;
|
|
||||||
|
|
||||||
this.iconTarget.classList.add('fa-spin');
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ mangaId: mangaId })
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Network response was not ok');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
searchLastChapter() {
|
|
||||||
console.log("Searching last chapter...");
|
|
||||||
}
|
|
||||||
|
|
||||||
import() {
|
|
||||||
console.log("Importing...");
|
|
||||||
}
|
|
||||||
|
|
||||||
monitoring(event){
|
|
||||||
const mangaId = event.currentTarget.dataset.mangaid;
|
|
||||||
const currentTarget = event.currentTarget;
|
|
||||||
|
|
||||||
const url = `/toggle_monitored`;
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ mangaId: mangaId })
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Network response was not ok');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if(data.isMonitored === true){
|
|
||||||
currentTarget.classList.remove('text-white');
|
|
||||||
currentTarget.classList.add('text-green-500');
|
|
||||||
this.textTarget.innerHTML = "Monitored";
|
|
||||||
}else if(data.isMonitored === false){
|
|
||||||
currentTarget.classList.remove('text-green-500');
|
|
||||||
currentTarget.classList.add('text-white');
|
|
||||||
this.textTarget.innerHTML = "Monitoring";
|
|
||||||
}
|
|
||||||
// console.log(data.isMonitored);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
editMangas() {
|
|
||||||
console.log("Editing mangas...");
|
|
||||||
}
|
|
||||||
|
|
||||||
editManga() {
|
|
||||||
const event = new CustomEvent('openEditModal');
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
editPreferredSources() {
|
|
||||||
const event = new CustomEvent('openPreferredSourcesModal');
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
openImportModal() {
|
|
||||||
const importEvent = new CustomEvent('openImportModal');
|
|
||||||
document.dispatchEvent(importEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
openExportModal() {
|
|
||||||
const exportEvent = new CustomEvent('openExportModal');
|
|
||||||
document.dispatchEvent(exportEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteMangas() {
|
|
||||||
console.log("Deleting mangas...");
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteManga() {
|
|
||||||
const event = new CustomEvent('openDeleteModal');
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmDelete(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const url = `/manga/delete/${this.mangaIdValue}`;
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Network response was not ok');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
visit('/', {});
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
// Show error message to user
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showOptions() {
|
|
||||||
console.log("Showing options...");
|
|
||||||
}
|
|
||||||
|
|
||||||
expandAll() {
|
|
||||||
console.log("Expanding all...");
|
|
||||||
}
|
|
||||||
|
|
||||||
changeView(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const viewOption = event.currentTarget.dataset.view;
|
|
||||||
|
|
||||||
const url = new URL(window.location);
|
|
||||||
url.searchParams.set('view', viewOption);
|
|
||||||
|
|
||||||
window.location = url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
sort(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
const sortOption = event.currentTarget.dataset.sort;
|
|
||||||
let order = 'asc';
|
|
||||||
|
|
||||||
if (sortOption === this.currentSortValue && this.currentOrderValue === 'asc') {
|
|
||||||
order = 'desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(window.location);
|
|
||||||
url.searchParams.set('sort', sortOption);
|
|
||||||
url.searchParams.set('order', order);
|
|
||||||
|
|
||||||
window.location = url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
filter(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const filterOption = event.currentTarget.dataset.filter;
|
|
||||||
|
|
||||||
const url = new URL(window.location);
|
|
||||||
url.searchParams.set('status', filterOption);
|
|
||||||
|
|
||||||
// Réinitialiser la page à 1 si on utilise la pagination
|
|
||||||
// url.searchParams.set('page', '1');
|
|
||||||
|
|
||||||
window.location = url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { Job } from '../../domain/entities/job';
|
||||||
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
|
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
|
||||||
|
|
||||||
const jobRepository = new ApiJobRepository();
|
const jobRepository = new ApiJobRepository();
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = ['pending', 'in_progress'];
|
||||||
|
|
||||||
export const useActivityStore = defineStore('activity', {
|
export const useActivityStore = defineStore('activity', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
jobs: [],
|
jobs: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
mercureEventSource: null,
|
||||||
// Pagination
|
// Pagination
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
@@ -15,21 +19,15 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
hasPreviousPage: false,
|
hasPreviousPage: false,
|
||||||
// Filtres
|
// Tri
|
||||||
filter: {
|
sortBy: 'createdAt',
|
||||||
status: ['pending', 'in_progress'], // Par défaut, ne montrer que les actifs
|
sortOrder: 'DESC',
|
||||||
sortBy: 'createdAt',
|
|
||||||
sortOrder: 'DESC'
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
activeJobs: state => state.jobs.filter(job => job.isActive()),
|
activeJobs: state => state.jobs.filter(job => job.isActive()),
|
||||||
completedJobs: state => state.jobs.filter(job => job.isCompleted()),
|
|
||||||
failedJobs: state => state.jobs.filter(job => job.hasError()),
|
|
||||||
isLoading: state => state.loading,
|
isLoading: state => state.loading,
|
||||||
hasError: state => !!state.error,
|
hasError: state => !!state.error,
|
||||||
// Getters pour la pagination
|
|
||||||
paginationInfo: state => ({
|
paginationInfo: state => ({
|
||||||
currentPage: state.currentPage,
|
currentPage: state.currentPage,
|
||||||
totalPages: state.totalPages,
|
totalPages: state.totalPages,
|
||||||
@@ -41,44 +39,25 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
/**
|
|
||||||
* Charge la liste des jobs selon les filtres actuels
|
|
||||||
* @param {number} page - Numéro de page optionnel
|
|
||||||
*/
|
|
||||||
async loadJobs(page = null) {
|
async loadJobs(page = null) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const options = {
|
const jobCollection = await jobRepository.getJobs({
|
||||||
page: page || this.currentPage,
|
page: page || this.currentPage,
|
||||||
limit: this.limit,
|
limit: this.limit,
|
||||||
sortBy: this.filter.sortBy,
|
sortBy: this.sortBy,
|
||||||
sortOrder: this.filter.sortOrder,
|
sortOrder: this.sortOrder,
|
||||||
status: this.filter.status
|
status: ACTIVE_STATUSES,
|
||||||
};
|
});
|
||||||
|
|
||||||
const jobCollection = await jobRepository.getJobs(options);
|
|
||||||
|
|
||||||
// Mettre à jour les données
|
|
||||||
this.jobs = jobCollection.items;
|
this.jobs = jobCollection.items;
|
||||||
this.currentPage = jobCollection.page;
|
this.currentPage = jobCollection.page;
|
||||||
this.total = jobCollection.total;
|
this.total = jobCollection.total;
|
||||||
this.hasNextPage = jobCollection.hasNextPage;
|
this.hasNextPage = jobCollection.hasNextPage;
|
||||||
this.hasPreviousPage = jobCollection.hasPreviousPage;
|
this.hasPreviousPage = jobCollection.hasPreviousPage;
|
||||||
|
|
||||||
// Calculer le nombre total de pages
|
|
||||||
this.totalPages = Math.ceil(this.total / this.limit);
|
this.totalPages = Math.ceil(this.total / this.limit);
|
||||||
|
|
||||||
console.log('Store updated with:', {
|
|
||||||
jobs: this.jobs.length,
|
|
||||||
currentPage: this.currentPage,
|
|
||||||
total: this.total,
|
|
||||||
limit: this.limit,
|
|
||||||
totalPages: this.totalPages,
|
|
||||||
hasNextPage: this.hasNextPage,
|
|
||||||
hasPreviousPage: this.hasPreviousPage
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.message;
|
this.error = error.message;
|
||||||
console.error('Error loading jobs:', error);
|
console.error('Error loading jobs:', error);
|
||||||
@@ -87,10 +66,6 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Va à une page spécifique
|
|
||||||
* @param {number} page
|
|
||||||
*/
|
|
||||||
async goToPage(page) {
|
async goToPage(page) {
|
||||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||||
this.currentPage = page;
|
this.currentPage = page;
|
||||||
@@ -98,39 +73,26 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
async updateSort(sortBy, sortOrder) {
|
||||||
* Met à jour les filtres et recharge la liste
|
this.sortBy = sortBy;
|
||||||
* @param {Object} filter
|
this.sortOrder = sortOrder;
|
||||||
*/
|
this.currentPage = 1;
|
||||||
async updateFilter(filter) {
|
|
||||||
this.filter = { ...this.filter, ...filter };
|
|
||||||
this.currentPage = 1; // Retourner à la première page lors du changement de filtre
|
|
||||||
await this.loadJobs(1);
|
await this.loadJobs(1);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Met à jour la limite par page
|
|
||||||
* @param {number} limit
|
|
||||||
*/
|
|
||||||
async updateLimit(limit) {
|
async updateLimit(limit) {
|
||||||
this.limit = limit;
|
this.limit = limit;
|
||||||
this.currentPage = 1; // Retourner à la première page
|
this.currentPage = 1;
|
||||||
await this.loadJobs(1);
|
await this.loadJobs(1);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Supprime un job par son ID
|
|
||||||
* @param {string} id
|
|
||||||
*/
|
|
||||||
async deleteJob(id) {
|
async deleteJob(id) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await jobRepository.deleteJob(id);
|
await jobRepository.deleteJob(id);
|
||||||
// Supprimer le job de la liste locale
|
|
||||||
this.jobs = this.jobs.filter(job => job.id !== id);
|
this.jobs = this.jobs.filter(job => job.id !== id);
|
||||||
// Recharger la page courante pour avoir les bons totaux
|
|
||||||
await this.loadJobs(this.currentPage);
|
await this.loadJobs(this.currentPage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.message;
|
this.error = error.message;
|
||||||
@@ -140,17 +102,75 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
updateJobProgress(jobId, progress) {
|
||||||
* Supprime tous les jobs correspondant aux critères
|
const job = this.jobs.find(j => j.id === jobId);
|
||||||
* @param {Object} criteria
|
if (job) job.progress = progress;
|
||||||
*/
|
},
|
||||||
|
|
||||||
|
handleJobCreated(data) {
|
||||||
|
const alreadyExists = this.jobs.some(j => j.id === data.id);
|
||||||
|
if (alreadyExists) return;
|
||||||
|
|
||||||
|
const job = Job.create({
|
||||||
|
id: data.id,
|
||||||
|
type: data.type_job,
|
||||||
|
status: data.status,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
context: data.context,
|
||||||
|
attempts: data.attempts,
|
||||||
|
maxAttempts: data.maxAttempts,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.jobs.unshift(job);
|
||||||
|
this.total += 1;
|
||||||
|
this.totalPages = Math.ceil(this.total / this.limit);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleJobStatusChange(jobId, newStatus) {
|
||||||
|
const job = this.jobs.find(j => j.id === jobId);
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
if (newStatus === 'in_progress') {
|
||||||
|
job.status = 'in_progress';
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.jobs = this.jobs.filter(j => j.id !== jobId);
|
||||||
|
this.total = Math.max(0, this.total - 1);
|
||||||
|
this.totalPages = Math.ceil(this.total / this.limit);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeMercure() {
|
||||||
|
if (this.mercureEventSource) return;
|
||||||
|
const url = new URL('/.well-known/mercure', window.location.origin);
|
||||||
|
url.searchParams.append('topic', 'jobs/activity');
|
||||||
|
this.mercureEventSource = new EventSource(url.toString());
|
||||||
|
this.mercureEventSource.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'job.created') {
|
||||||
|
this.handleJobCreated(data);
|
||||||
|
} else if (data.type === 'job.progress_updated') {
|
||||||
|
this.updateJobProgress(data.jobId, data.progress);
|
||||||
|
} else if (data.type === 'job.status_changed') {
|
||||||
|
this.handleJobStatusChange(data.jobId, data.status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribeMercure() {
|
||||||
|
if (this.mercureEventSource) {
|
||||||
|
this.mercureEventSource.close();
|
||||||
|
this.mercureEventSource = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async deleteJobs(criteria = {}) {
|
async deleteJobs(criteria = {}) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deleted = await jobRepository.deleteJobs(criteria);
|
const deleted = await jobRepository.deleteJobs(criteria);
|
||||||
// Recharger la liste après suppression
|
|
||||||
await this.loadJobs(this.currentPage);
|
await this.loadJobs(this.currentPage);
|
||||||
return deleted;
|
return deleted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -160,26 +180,5 @@ export const useActivityStore = defineStore('activity', {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Supprime tous les jobs terminés
|
|
||||||
*/
|
|
||||||
async deleteCompletedJobs() {
|
|
||||||
return this.deleteJobs({ status: ['COMPLETED'] });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supprime tous les jobs en erreur
|
|
||||||
*/
|
|
||||||
async deleteFailedJobs() {
|
|
||||||
return this.deleteJobs({ status: ['ERROR'] });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supprime tous les jobs
|
|
||||||
*/
|
|
||||||
async deleteAllJobs() {
|
|
||||||
return this.deleteJobs({});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export class Job {
|
|||||||
failureReason = null,
|
failureReason = null,
|
||||||
createdAt = new Date().toISOString(),
|
createdAt = new Date().toISOString(),
|
||||||
updatedAt = new Date().toISOString(),
|
updatedAt = new Date().toISOString(),
|
||||||
|
startedAt = null,
|
||||||
|
completedAt = null,
|
||||||
attempts = 0,
|
attempts = 0,
|
||||||
maxAttempts = 1,
|
maxAttempts = 1,
|
||||||
context = {}
|
context = {}
|
||||||
@@ -23,6 +25,8 @@ export class Job {
|
|||||||
this.error = failureReason ?? error;
|
this.error = failureReason ?? error;
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
this.updatedAt = updatedAt;
|
this.updatedAt = updatedAt;
|
||||||
|
this.startedAt = startedAt;
|
||||||
|
this.completedAt = completedAt;
|
||||||
this.attempts = attempts;
|
this.attempts = attempts;
|
||||||
this.maxAttempts = maxAttempts;
|
this.maxAttempts = maxAttempts;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
* @returns {Promise<JobCollection>} Collection de jobs
|
* @returns {Promise<JobCollection>} Collection de jobs
|
||||||
*/
|
*/
|
||||||
async getJobs(options = {}) {
|
async getJobs(options = {}) {
|
||||||
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [] } = options;
|
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [], type = null } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
||||||
@@ -23,6 +23,11 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
url += `&status=${status.join(',')}`;
|
url += `&status=${status.join(',')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ajouter le filtre de type si fourni
|
||||||
|
if (type) {
|
||||||
|
url += `&type=${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,169 +1,153 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="overflow-y-auto h-full">
|
<div class="flex flex-col h-full">
|
||||||
<Toolbar :config="toolbarConfig" class="mb-6" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
|
<!-- Loading -->
|
||||||
</div>
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin h-10 w-10 border-b-2 border-indigo-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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">
|
<!-- Error -->
|
||||||
<p>{{ activityStore.error }}</p>
|
<div v-else-if="activityStore.error" class="px-6 py-8">
|
||||||
</div>
|
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4">
|
||||||
|
<p class="text-red-800 dark:text-red-200">{{ activityStore.error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="container mx-auto p-2">
|
<!-- Content -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
<section v-else class="border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="overflow-x-auto">
|
<!-- Empty -->
|
||||||
<table class="min-w-full bg-white dark:bg-gray-800">
|
<div v-if="activityStore.jobs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
|
||||||
|
<ClockIcon class="w-12 h-12 mb-3" />
|
||||||
|
<p class="text-base">Aucun job en cours ou en attente.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
|
<tr class="border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||||
<th class="w-1/12 py-3 px-4 text-left">
|
<th class="w-2/11 py-3 px-6 text-left">Type</th>
|
||||||
<input
|
<th class="w-2/11 py-3 px-4 text-left">Statut</th>
|
||||||
type="checkbox"
|
<th class="w-3/11 py-3 px-4 text-left">Informations</th>
|
||||||
class="form-checkbox h-5 w-5 text-green-600"
|
<th class="w-3/11 py-3 px-4 text-left">Progression</th>
|
||||||
@change="toggleSelectAll" />
|
<th class="w-1/11 py-3 px-4 text-left">Actions</th>
|
||||||
</th>
|
|
||||||
<th class="w-2/12 py-3 px-4 text-left">Type</th>
|
|
||||||
<th class="w-2/12 py-3 px-4 text-left">Statut</th>
|
|
||||||
<th class="w-3/12 py-3 px-4 text-left">Informations</th>
|
|
||||||
<th class="w-3/12 py-3 px-4 text-left">Progression</th>
|
|
||||||
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="text-gray-700 dark:text-gray-300">
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50 text-gray-700 dark:text-gray-300">
|
||||||
<template v-if="activityStore.jobs.length === 0">
|
<JobItem
|
||||||
<tr>
|
v-for="job in activityStore.jobs"
|
||||||
<td colspan="6" class="py-8 px-4 text-center text-gray-500">
|
:key="job.id"
|
||||||
<div class="flex flex-col items-center">
|
:job="job"
|
||||||
<ClockIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mb-4" />
|
@delete="deleteJob" />
|
||||||
<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>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<JobItem
|
|
||||||
v-for="job in activityStore.jobs"
|
|
||||||
:key="job.id"
|
|
||||||
:job="job"
|
|
||||||
@delete="deleteJob" />
|
|
||||||
</template>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="activityStore.total > activityStore.limit"
|
v-if="total > activityStore.limit"
|
||||||
:current-page="activityStore.currentPage"
|
:current-page="activityStore.currentPage"
|
||||||
:total-pages="activityStore.totalPages"
|
:total-pages="activityStore.totalPages"
|
||||||
:total="activityStore.total"
|
:total="total"
|
||||||
:limit="activityStore.limit"
|
:limit="activityStore.limit"
|
||||||
:has-next-page="activityStore.hasNextPage"
|
:has-next-page="activityStore.hasNextPage"
|
||||||
:has-previous-page="activityStore.hasPreviousPage"
|
:has-previous-page="activityStore.hasPreviousPage"
|
||||||
@page-change="changePage" />
|
@page-change="changePage" />
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ArrowPathIcon, ClockIcon, FunnelIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
import { ArrowPathIcon, BarsArrowDownIcon, ClockIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue';
|
||||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useActivityStore } from '../../application/store/activityStore';
|
import { useActivityStore } from '../../application/store/activityStore';
|
||||||
import JobItem from '../components/JobItem.vue';
|
import JobItem from '../components/JobItem.vue';
|
||||||
|
|
||||||
const activityStore = useActivityStore();
|
const activityStore = useActivityStore();
|
||||||
const selectedAll = ref(false);
|
|
||||||
|
|
||||||
// Statuts disponibles pour le filtre
|
const { sortBy, sortOrder, total, loading } = storeToRefs(activityStore);
|
||||||
const statusOptions = [
|
|
||||||
{ value: ['pending', 'in_progress'], label: 'Actifs' },
|
|
||||||
{ value: ['pending', 'in_progress', 'completed', 'failed'], label: 'Tous' },
|
|
||||||
{ value: ['completed'], label: 'Terminés' },
|
|
||||||
{ value: ['failed'], label: 'En erreur' },
|
|
||||||
{ value: ['pending'], label: 'En attente' },
|
|
||||||
{ value: ['in_progress'], label: 'En cours' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Index du statut actif (par défaut "Actifs")
|
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
|
||||||
const activeStatusIndex = ref(0);
|
|
||||||
|
|
||||||
// Configuration de la toolbar réactive
|
const toolbarConfig = computed(() => ({
|
||||||
const toolbarConfig = computed(() => ({
|
leftSection: [
|
||||||
leftSection: [
|
{ type: 'label', text: 'Activité', class: 'text-sm font-medium' },
|
||||||
{
|
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
|
||||||
icon: FunnelIcon,
|
],
|
||||||
type: 'dropdown',
|
rightSection: [
|
||||||
label: statusOptions[activeStatusIndex.value].label,
|
{
|
||||||
active: false,
|
type: 'dropdown',
|
||||||
items: statusOptions.map((option, index) => ({
|
icon: BarsArrowDownIcon,
|
||||||
label: option.label,
|
label: 'Trier',
|
||||||
isSelected: index === activeStatusIndex.value,
|
items: [
|
||||||
onClick: () => setStatusFilter(index)
|
{
|
||||||
}))
|
label: 'Plus récent',
|
||||||
}
|
isSelected: isSortSelected('createdAt', 'DESC'),
|
||||||
],
|
onClick: () => activityStore.updateSort('createdAt', 'DESC'),
|
||||||
rightSection: [
|
},
|
||||||
{
|
{
|
||||||
icon: ArrowPathIcon,
|
label: 'Plus ancien',
|
||||||
type: 'button',
|
isSelected: isSortSelected('createdAt', 'ASC'),
|
||||||
label: 'Rafraîchir',
|
onClick: () => activityStore.updateSort('createdAt', 'ASC'),
|
||||||
onClick: refreshJobs
|
},
|
||||||
},
|
{
|
||||||
{
|
label: 'Par type',
|
||||||
icon: TrashIcon,
|
isSelected: isSortSelected('type', 'ASC'),
|
||||||
type: 'button',
|
onClick: () => activityStore.updateSort('type', 'ASC'),
|
||||||
label: 'Supprimer visibles',
|
},
|
||||||
onClick: deleteVisibleJobs
|
{
|
||||||
}
|
label: 'Par statut',
|
||||||
]
|
isSelected: isSortSelected('status', 'ASC'),
|
||||||
}));
|
onClick: () => activityStore.updateSort('status', 'ASC'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: 'Rafraîchir',
|
||||||
|
disabled: loading.value,
|
||||||
|
onClick: () => activityStore.loadJobs(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: TrashIcon,
|
||||||
|
label: 'Supprimer visibles',
|
||||||
|
disabled: loading.value || total.value === 0,
|
||||||
|
onClick: deleteVisibleJobs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadJobs();
|
activityStore.loadJobs();
|
||||||
});
|
activityStore.subscribeMercure();
|
||||||
|
});
|
||||||
|
|
||||||
function loadJobs() {
|
onUnmounted(() => {
|
||||||
activityStore.loadJobs();
|
activityStore.unsubscribeMercure();
|
||||||
|
});
|
||||||
|
|
||||||
|
function changePage(page) {
|
||||||
|
activityStore.goToPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteJob(id) {
|
||||||
|
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
||||||
|
activityStore.deleteJob(id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshJobs() {
|
function deleteVisibleJobs() {
|
||||||
loadJobs();
|
if (activityStore.jobs.length === 0) return;
|
||||||
}
|
if (confirm('Voulez-vous vraiment supprimer tous les jobs visibles ?')) {
|
||||||
|
activityStore.deleteJobs({ status: ['pending', 'in_progress'] });
|
||||||
function changePage(page) {
|
|
||||||
activityStore.goToPage(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectAll() {
|
|
||||||
selectedAll.value = !selectedAll.value;
|
|
||||||
// La logique pour sélectionner tous les jobs serait ajoutée ici
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatusFilter(index) {
|
|
||||||
if (index >= 0 && index < statusOptions.length) {
|
|
||||||
activeStatusIndex.value = index;
|
|
||||||
activityStore.updateFilter({ status: statusOptions[index].value });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteJob(id) {
|
|
||||||
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
|
||||||
activityStore.deleteJob(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteVisibleJobs() {
|
|
||||||
if (activityStore.jobs.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabel = statusOptions[activeStatusIndex.value].label.toLowerCase();
|
|
||||||
if (confirm(`Voulez-vous vraiment supprimer tous les jobs ${statusLabel} ?`)) {
|
|
||||||
activityStore.deleteJobs({ status: activityStore.filter.status });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
|
|
||||||
// État de l'interface
|
// État de l'interface
|
||||||
isDragOver: false,
|
isDragOver: false,
|
||||||
showSuccessMessage: false,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -86,7 +85,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
this.clearError();
|
this.clearError();
|
||||||
this.conversionSuccess = false;
|
this.conversionSuccess = false;
|
||||||
this.convertedFile = null;
|
this.convertedFile = null;
|
||||||
this.showSuccessMessage = false;
|
|
||||||
|
|
||||||
// Stockage du fichier
|
// Stockage du fichier
|
||||||
this.currentFile = file;
|
this.currentFile = file;
|
||||||
@@ -125,7 +123,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
// Stockage du fichier converti
|
// Stockage du fichier converti
|
||||||
this.convertedFile = convertedFileBlob;
|
this.convertedFile = convertedFileBlob;
|
||||||
this.conversionSuccess = true;
|
this.conversionSuccess = true;
|
||||||
this.showSuccessMessage = true;
|
|
||||||
|
|
||||||
// Ajout à l'historique
|
// Ajout à l'historique
|
||||||
this.addToHistory({
|
this.addToHistory({
|
||||||
@@ -171,7 +168,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
this.currentFile = null;
|
this.currentFile = null;
|
||||||
this.convertedFile = null;
|
this.convertedFile = null;
|
||||||
this.conversionSuccess = false;
|
this.conversionSuccess = false;
|
||||||
this.showSuccessMessage = false;
|
|
||||||
this.conversionProgress = 0;
|
this.conversionProgress = 0;
|
||||||
this.clearError();
|
this.clearError();
|
||||||
},
|
},
|
||||||
@@ -183,7 +179,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
setError(message) {
|
setError(message) {
|
||||||
this.conversionError = message;
|
this.conversionError = message;
|
||||||
this.conversionSuccess = false;
|
this.conversionSuccess = false;
|
||||||
this.showSuccessMessage = false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,13 +188,6 @@ export const useConversionStore = defineStore('conversion', {
|
|||||||
this.conversionError = null;
|
this.conversionError = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache le message de succès
|
|
||||||
*/
|
|
||||||
hideSuccessMessage() {
|
|
||||||
this.showSuccessMessage = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gère l'état du drag and drop
|
* Gère l'état du drag and drop
|
||||||
* @param {boolean} isDragOver - Indique si un fichier est survolé
|
* @param {boolean} isDragOver - Indique si un fichier est survolé
|
||||||
|
|||||||
@@ -1,285 +1,149 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8 max-w-4xl">
|
<div class="flex flex-col h-full">
|
||||||
<!-- En-tête -->
|
<Toolbar :config="toolbarConfig" />
|
||||||
<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 dark:text-gray-100">
|
|
||||||
Convertir CBR en CBZ
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p class="text-lg text-gray-600 dark:text-gray-400">
|
|
||||||
Convertissez vos fichiers CBR (Comic Book RAR) en CBZ (Comic Book ZIP) pour une meilleure compatibilité.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zone principale -->
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden">
|
<div class="px-6 py-8">
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- Contenu de la carte -->
|
<!-- Zone d'upload -->
|
||||||
<div class="p-6 space-y-6">
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<!-- Zone d'upload -->
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichier</h2>
|
||||||
<FileUploadArea
|
<FileUploadArea
|
||||||
:selected-file="conversionStore.currentFile"
|
:selected-file="conversionStore.currentFile"
|
||||||
:disabled="conversionStore.isProcessing"
|
:disabled="conversionStore.isProcessing"
|
||||||
@file-selected="handleFileSelected"
|
@file-selected="handleFileSelected"
|
||||||
@file-cleared="handleFileClear"
|
@file-cleared="handleFileClear"
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Bouton de conversion -->
|
<!-- Progression -->
|
||||||
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="flex justify-center">
|
<section v-if="showProgress" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
<button
|
<ConversionProgress
|
||||||
@click="handleConvert"
|
:is-converting="conversionStore.isProcessing"
|
||||||
:disabled="conversionStore.isProcessing"
|
:progress="conversionStore.conversionProgress"
|
||||||
:class="[
|
:is-success="conversionStore.hasSucceeded"
|
||||||
'flex items-center space-x-2 px-6 py-3 text-white font-medium rounded-lg transition-all duration-200',
|
:has-error="conversionStore.hasError"
|
||||||
conversionStore.isProcessing
|
:error-message="conversionStore.conversionError"
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
:file-name="conversionStore.currentFileName"
|
||||||
: 'bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2'
|
:original-size="conversionStore.currentFile?.size || 0"
|
||||||
]"
|
:converted-size="conversionStore.convertedFile?.size || 0"
|
||||||
>
|
@download="handleDownload"
|
||||||
<ArrowPathIcon
|
@reset="handleReset"
|
||||||
:class="[
|
/>
|
||||||
'w-5 h-5',
|
</section>
|
||||||
conversionStore.isProcessing && 'animate-spin'
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progression et résultat -->
|
<!-- Historique -->
|
||||||
<ConversionProgress
|
<section v-if="conversionStore.conversionCount > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
v-if="showProgress"
|
<div class="flex items-center justify-between mb-3">
|
||||||
:is-converting="conversionStore.isProcessing"
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Historique</h2>
|
||||||
:progress="conversionStore.conversionProgress"
|
<button
|
||||||
:is-success="conversionStore.hasSucceeded"
|
@click="conversionStore.clearHistory()"
|
||||||
:has-error="conversionStore.hasError"
|
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||||
:error-message="conversionStore.conversionError"
|
>
|
||||||
:file-name="conversionStore.currentFileName"
|
Effacer
|
||||||
:original-size="conversionStore.currentFile?.size || 0"
|
</button>
|
||||||
:converted-size="conversionStore.convertedFile?.size || 0"
|
</div>
|
||||||
@download="handleDownload"
|
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
@reset="handleReset"
|
<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 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 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 dark:text-blue-300">
|
|
||||||
À propos de la conversion
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-400 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>
|
|
||||||
</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 dark:text-gray-100">
|
|
||||||
Historique des conversions
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
@click="handleClearHistory"
|
|
||||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
Effacer l'historique
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/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 dark:border-gray-600 last:border-b-0"
|
|
||||||
>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium 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">
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-green-600">
|
|
||||||
{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 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></div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import {
|
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
|
||||||
ArchiveBoxIcon,
|
|
||||||
ArrowPathIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
InformationCircleIcon,
|
|
||||||
XMarkIcon,
|
|
||||||
} from '@heroicons/vue/24/outline';
|
|
||||||
import { computed, onMounted } from 'vue';
|
import { computed, onMounted } from 'vue';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useConversionStore } from '../../application/store/conversionStore';
|
import { useConversionStore } from '../../application/store/conversionStore';
|
||||||
|
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||||
import ConversionProgress from '../components/ConversionProgress.vue';
|
import ConversionProgress from '../components/ConversionProgress.vue';
|
||||||
import FileUploadArea from '../components/FileUploadArea.vue';
|
import FileUploadArea from '../components/FileUploadArea.vue';
|
||||||
|
|
||||||
export default {
|
const conversionStore = useConversionStore();
|
||||||
name: 'ConversionPage',
|
const { showSuccess, showError } = useNotifications();
|
||||||
|
|
||||||
components: {
|
const showProgress = computed(() =>
|
||||||
FileUploadArea,
|
conversionStore.hasSelectedFile &&
|
||||||
ConversionProgress,
|
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
|
||||||
ArrowPathIcon,
|
);
|
||||||
ArchiveBoxIcon,
|
|
||||||
InformationCircleIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
XMarkIcon,
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
const toolbarConfig = computed(() => ({
|
||||||
const conversionStore = useConversionStore();
|
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 handleFileSelected = (file) => {
|
||||||
const showProgress = computed(() => {
|
conversionStore.selectFile(file);
|
||||||
return conversionStore.hasSelectedFile &&
|
};
|
||||||
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Event handlers
|
const handleFileClear = () => {
|
||||||
const handleFileSelected = (file) => {
|
conversionStore.resetConversion();
|
||||||
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 = () => {
|
const handleConvert = async () => {
|
||||||
conversionStore.resetConversion();
|
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 () => {
|
const handleDownload = () => conversionStore.downloadConvertedFile();
|
||||||
if (!conversionStore.currentFile) return;
|
const handleReset = () => conversionStore.resetConversion();
|
||||||
|
|
||||||
const success = await conversionStore.convertCurrentFile();
|
const formatFileSize = (bytes) => {
|
||||||
if (success) {
|
if (bytes === 0) return '0 octets';
|
||||||
console.log('Conversion réussie');
|
const k = 1024;
|
||||||
} else {
|
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
|
||||||
console.error('Échec de la conversion');
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
}
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = () => {
|
const formatDate = (isoString) =>
|
||||||
conversionStore.downloadConvertedFile();
|
new Intl.DateTimeFormat('fr-FR', {
|
||||||
};
|
|
||||||
|
|
||||||
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', {
|
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
}).format(date);
|
}).format(new Date(isoString));
|
||||||
};
|
|
||||||
|
|
||||||
const calculateSaving = (originalSize, convertedSize) => {
|
const calculateSaving = (originalSize, convertedSize) => {
|
||||||
if (!originalSize || !convertedSize) return '';
|
if (!originalSize || !convertedSize) return '';
|
||||||
|
const saving = ((originalSize - convertedSize) / originalSize) * 100;
|
||||||
const saving = ((originalSize - convertedSize) / originalSize) * 100;
|
if (saving > 0) return `-${saving.toFixed(1)}%`;
|
||||||
if (saving > 0) {
|
if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`;
|
||||||
return `-${saving.toFixed(1)}%`;
|
return '0%';
|
||||||
} 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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
onMounted(() => conversionStore.resetConversion());
|
||||||
/* Styles spécifiques si nécessaires */
|
</script>
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,228 +1,150 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6">
|
<div class="py-3">
|
||||||
<div class="flex items-start space-x-4">
|
|
||||||
<!-- File Icon and Info -->
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
|
||||||
<svg class="w-6 h-6 text-gray-600 dark:text-gray-400" 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>
|
|
||||||
|
|
||||||
<!-- File Details -->
|
<!-- Row principal : icône, nom, statut, actions -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 truncate">
|
<DocumentIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
{{ file.filename }}
|
</div>
|
||||||
</h3>
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
<!-- Status Badge -->
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</p>
|
||||||
<div class="flex-shrink-0 ml-4">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
{{ file.getFormattedSize() }} · {{ file.getFileExtension().toUpperCase() }}
|
||||||
</div>
|
<span v-if="file.isAnalyzed() && file.getExtractedChapterNumber()" class="ml-2 text-green-600 dark:text-green-400">
|
||||||
</div>
|
Ch. {{ file.getExtractedChapterNumber() }}
|
||||||
|
</span>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<span v-if="file.isAnalyzed() && file.getExtractedVolumeNumber()" class="ml-2 text-green-600 dark:text-green-400">
|
||||||
{{ file.getFormattedSize() }} • {{ file.getFileExtension().toUpperCase() }}
|
Vol. {{ file.getExtractedVolumeNumber() }}
|
||||||
</p>
|
</span>
|
||||||
|
</p>
|
||||||
<!-- Extracted Info -->
|
</div>
|
||||||
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
|
|
||||||
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
Chapitre {{ file.getExtractedChapterNumber() }}
|
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
||||||
</span>
|
|
||||||
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
<button
|
||||||
Volume {{ file.getExtractedVolumeNumber() }}
|
v-if="file.isReadyForImport()"
|
||||||
</span>
|
@click="$emit('import-file')"
|
||||||
</div>
|
: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"
|
||||||
<!-- Error Display -->
|
>
|
||||||
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
<ArrowUpTrayIcon class="w-3.5 h-3.5" />
|
||||||
<div class="flex">
|
Importer
|
||||||
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
</button>
|
||||||
<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>
|
<button
|
||||||
<div class="ml-3">
|
v-if="file.hasError()"
|
||||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">Erreur</h3>
|
@click="$emit('retry-file')"
|
||||||
<div class="mt-2 text-sm text-red-700 dark:text-red-400">{{ file.errorMessage }}</div>
|
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="$emit('remove-file')"
|
||||||
|
class="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manga Selection -->
|
<!-- Message d'erreur -->
|
||||||
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
|
<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">
|
||||||
<div>
|
<ExclamationCircleIcon class="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
{{ file.errorMessage }}
|
||||||
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
|
</div>
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Matches Grid -->
|
<!-- Aucun manga trouvé -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<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">
|
||||||
<MangaMatchCard
|
<ExclamationTriangleIcon class="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
v-for="match in sortedMatches"
|
Aucun manga correspondant trouvé. Vérifiez le nom du fichier.
|
||||||
:key="match.id"
|
</div>
|
||||||
:match="match"
|
|
||||||
:is-selected="file.selectedManga?.id === match.id"
|
<!-- Sélection du manga -->
|
||||||
@select-match="handleMangaSelection"
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Selected Manga Preview -->
|
<!-- Numéros de chapitre / volume -->
|
||||||
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
|
<div v-if="file.selectedManga" class="mt-3 grid grid-cols-2 gap-3">
|
||||||
<img
|
|
||||||
v-if="file.selectedManga.thumbnailUrl"
|
|
||||||
:src="file.selectedManga.thumbnailUrl"
|
|
||||||
:alt="file.selectedManga.title"
|
|
||||||
class="w-12 h-16 object-cover rounded"
|
|
||||||
/>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ file.selectedManga.title }}</p>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ file.selectedManga.slug }}</p>
|
|
||||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chapter/Volume Number Inputs -->
|
|
||||||
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
|
|
||||||
<!-- Chapter Number -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Chapitre</label>
|
||||||
Numéro de chapitre
|
<input
|
||||||
</label>
|
type="number"
|
||||||
<input
|
step="0.5"
|
||||||
type="number"
|
:value="file.selectedChapterNumber ?? ''"
|
||||||
step="0.5"
|
@input="handleChapterNumberInput"
|
||||||
:value="file.selectedChapterNumber ?? ''"
|
:disabled="file.selectedVolumeNumber !== null"
|
||||||
@input="handleChapterNumberInput"
|
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"
|
||||||
:disabled="file.selectedVolumeNumber !== null"
|
placeholder="Ex: 1, 1.5..."
|
||||||
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600"
|
/>
|
||||||
placeholder="Ex: 1, 1.5, 2..."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Volume Number -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Volume</label>
|
||||||
Numéro de volume
|
<input
|
||||||
</label>
|
type="number"
|
||||||
<input
|
step="0.5"
|
||||||
type="number"
|
:value="file.selectedVolumeNumber ?? ''"
|
||||||
step="0.5"
|
@input="handleVolumeNumberInput"
|
||||||
:value="file.selectedVolumeNumber ?? ''"
|
:disabled="file.selectedChapterNumber !== null"
|
||||||
@input="handleVolumeNumberInput"
|
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"
|
||||||
:disabled="file.selectedChapterNumber !== null"
|
placeholder="Ex: 1, 1.5..."
|
||||||
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600"
|
/>
|
||||||
placeholder="Ex: 1, 1.5, 2..."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No Matches Message -->
|
|
||||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
|
|
||||||
<div class="flex">
|
|
||||||
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Aucun manga trouvé</h3>
|
|
||||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
|
||||||
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="mt-6 flex justify-between items-center border-t dark:border-gray-700 pt-4">
|
|
||||||
<div class="flex space-x-3">
|
|
||||||
<!-- Import Button -->
|
|
||||||
<button
|
|
||||||
v-if="file.isReadyForImport()"
|
|
||||||
@click="$emit('import-file')"
|
|
||||||
:disabled="isImporting"
|
|
||||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md text-sm font-medium flex items-center"
|
|
||||||
>
|
|
||||||
<svg v-if="isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
{{ isImporting ? 'Import en cours...' : 'Importer' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Retry Button -->
|
|
||||||
<button
|
|
||||||
v-if="file.hasError()"
|
|
||||||
@click="$emit('retry-file')"
|
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
|
||||||
>
|
|
||||||
Réessayer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Remove Button -->
|
|
||||||
<button
|
|
||||||
@click="$emit('remove-file')"
|
|
||||||
class="text-red-600 hover:text-red-700 text-sm font-medium"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ArrowUpTrayIcon, DocumentIcon, ExclamationCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import MangaMatchCard from './MangaMatchCard.vue';
|
import MangaMatchCard from './MangaMatchCard.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
file: {
|
file: { type: Object, required: true },
|
||||||
type: Object,
|
isAnalyzing: { type: Boolean, default: false },
|
||||||
required: true
|
isImporting: { type: Boolean, default: false },
|
||||||
},
|
|
||||||
isAnalyzing: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
isImporting: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'manga-selected',
|
'manga-selected',
|
||||||
'chapter-number-selected',
|
'chapter-number-selected',
|
||||||
'volume-number-selected',
|
'volume-number-selected',
|
||||||
'import-file',
|
'import-file',
|
||||||
'retry-file',
|
'retry-file',
|
||||||
'remove-file'
|
'remove-file',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Computed property to get sorted matches
|
const sortedMatches = computed(() =>
|
||||||
const sortedMatches = computed(() => {
|
[...props.file.getMatches()].sort((a, b) => b.matchScore - a.matchScore)
|
||||||
const matches = props.file.getMatches();
|
);
|
||||||
return matches.sort((a, b) => b.matchScore - a.matchScore);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleMangaSelection = (selectedManga) => {
|
const handleMangaSelection = (manga) => emit('manga-selected', manga);
|
||||||
emit('manga-selected', selectedManga);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChapterNumberInput = (event) => {
|
const handleChapterNumberInput = (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
const chapterNumber = value ? parseFloat(value) : null;
|
emit('chapter-number-selected', value ? parseFloat(value) : null);
|
||||||
emit('chapter-number-selected', chapterNumber);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVolumeNumberInput = (event) => {
|
const handleVolumeNumberInput = (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
const volumeNumber = value ? parseFloat(value) : null;
|
emit('volume-number-selected', value ? parseFloat(value) : null);
|
||||||
emit('volume-number-selected', volumeNumber);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,96 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 p-6">
|
<div>
|
||||||
<div class="text-center mb-6">
|
<!-- En-tête -->
|
||||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/40 mb-4">
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="flex items-center justify-between">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<div class="flex items-center gap-3">
|
||||||
</svg>
|
<div class="flex items-center justify-center h-9 w-9 bg-green-100 dark:bg-green-900/40">
|
||||||
|
<CheckCircleIcon class="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">Import terminé</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Voici le résumé de votre session d'import</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-6 text-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-green-600">{{ importedCount }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Importés</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-red-600">{{ errorCount }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Erreurs</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-gray-600 dark:text-gray-300">{{ totalCount }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Import terminé</h3>
|
</section>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Voici le résumé de votre session d'import
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Statistics -->
|
<!-- Fichiers importés -->
|
||||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
<section v-if="importedFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
<div class="text-center">
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||||
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
|
Importés ({{ importedFiles.length }})
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Importés</div>
|
</h2>
|
||||||
</div>
|
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
<div class="text-center">
|
<div
|
||||||
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
|
|
||||||
<div class="text-sm text-gray-500">Erreurs</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-2xl font-bold text-gray-600">{{ totalCount }}</div>
|
|
||||||
<div class="text-sm text-gray-500">Total</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success Files List -->
|
|
||||||
<div v-if="importedFiles.length > 0" class="mb-6">
|
|
||||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
|
||||||
Fichiers importés avec succès ({{ importedFiles.length }})
|
|
||||||
</h4>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li
|
|
||||||
v-for="file in importedFiles"
|
v-for="file in importedFiles"
|
||||||
:key="file.id"
|
:key="file.id"
|
||||||
class="flex items-center text-sm"
|
class="flex items-center gap-2 py-2.5 text-sm"
|
||||||
>
|
>
|
||||||
<svg class="flex-shrink-0 h-4 w-4 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
<CheckCircleIcon class="flex-shrink-0 h-4 w-4 text-green-400" />
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
<span class="text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</span>
|
||||||
</svg>
|
<span v-if="file.selectedManga" class="text-gray-400 dark:text-gray-500 shrink-0">→ {{ file.selectedManga.title }}</span>
|
||||||
<span class="text-gray-900 dark:text-gray-100">{{ file.filename }}</span>
|
</div>
|
||||||
<span v-if="file.selectedManga" class="ml-2 text-gray-500 dark:text-gray-400">
|
</div>
|
||||||
→ {{ file.selectedManga.title }}
|
</section>
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Files List -->
|
<!-- Fichiers en erreur -->
|
||||||
<div v-if="errorFiles.length > 0" class="mb-6">
|
<section v-if="errorFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||||
Fichiers en erreur ({{ errorFiles.length }})
|
Erreurs ({{ errorFiles.length }})
|
||||||
</h4>
|
</h2>
|
||||||
<ul class="space-y-2">
|
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
<li
|
<div
|
||||||
v-for="file in errorFiles"
|
v-for="file in errorFiles"
|
||||||
:key="file.id"
|
:key="file.id"
|
||||||
class="flex items-start text-sm"
|
class="flex items-start gap-2 py-2.5 text-sm"
|
||||||
>
|
>
|
||||||
<svg class="flex-shrink-0 h-4 w-4 text-red-400 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
<XCircleIcon class="flex-shrink-0 h-4 w-4 text-red-400 mt-0.5" />
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div>
|
<div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div>
|
||||||
<div class="text-red-600 dark:text-red-400 text-xs mt-1">{{ file.errorMessage }}</div>
|
<div class="text-red-600 dark:text-red-400 text-xs mt-0.5">{{ file.errorMessage }}</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-center space-x-4 pt-6 border-t dark:border-gray-700">
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
<button
|
<div class="flex gap-3">
|
||||||
@click="startNewImport"
|
<button
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
@click="startNewImport"
|
||||||
>
|
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 text-sm font-medium"
|
||||||
Nouvel import
|
>
|
||||||
</button>
|
Nouvel import
|
||||||
<button
|
</button>
|
||||||
@click="goToLibrary"
|
<button
|
||||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
@click="goToLibrary"
|
||||||
>
|
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 text-sm font-medium"
|
||||||
Aller à la bibliothèque
|
>
|
||||||
</button>
|
Aller à la bibliothèque
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||||
|
|||||||
@@ -1,116 +1,47 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md"
|
class="border p-2.5 cursor-pointer transition-all duration-150"
|
||||||
:class="{
|
:class="isSelected
|
||||||
'border-blue-500 bg-blue-50 dark:bg-blue-900/20': isSelected,
|
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||||
'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-500': !isSelected
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800'"
|
||||||
}"
|
@click="$emit('select-match', match)"
|
||||||
@click="$emit('select-match', match)"
|
>
|
||||||
>
|
<div class="flex gap-2.5">
|
||||||
<!-- Match Header with Score -->
|
<!-- Couverture -->
|
||||||
<div class="flex items-center justify-between mb-3">
|
<img
|
||||||
<div class="flex items-center space-x-2">
|
v-if="match.thumbnailUrl"
|
||||||
<div
|
:src="match.thumbnailUrl"
|
||||||
class="w-3 h-3 rounded-full"
|
:alt="match.title"
|
||||||
:class="{
|
class="w-12 h-16 object-cover shrink-0"
|
||||||
'bg-blue-500': isSelected,
|
/>
|
||||||
'bg-gray-300': !isSelected
|
<div
|
||||||
}"
|
v-else
|
||||||
></div>
|
class="w-12 h-16 bg-gray-100 dark:bg-gray-700 shrink-0 flex items-center justify-center"
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Score: {{ match.matchScore }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="isSelected" class="text-blue-600">
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manga Thumbnail -->
|
|
||||||
<div class="flex space-x-3">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<img
|
|
||||||
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 dark:bg-gray-700 rounded border dark:border-gray-600 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<svg class="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<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 dark:text-gray-100 truncate" :title="match.title">
|
|
||||||
{{ match.title }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate" :title="match.slug">
|
|
||||||
{{ match.slug }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Alternative Slugs -->
|
|
||||||
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
|
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500">Autres titres:</p>
|
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
|
||||||
<span
|
|
||||||
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
|
|
||||||
:key="altSlug"
|
|
||||||
class="text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded"
|
|
||||||
>
|
>
|
||||||
{{ altSlug }}
|
<PhotoIcon class="w-6 h-6 text-gray-400" />
|
||||||
</span>
|
</div>
|
||||||
<span
|
|
||||||
v-if="match.alternativeSlugs.length > 2"
|
|
||||||
class="text-xs text-gray-400 dark:text-gray-500"
|
|
||||||
>
|
|
||||||
+{{ match.alternativeSlugs.length - 2 }} autres
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Score Bar -->
|
<!-- Infos -->
|
||||||
<div class="mt-3">
|
<div class="flex-1 min-w-0 flex flex-col justify-between py-0.5">
|
||||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
<p class="text-xs font-medium text-gray-900 dark:text-gray-100 line-clamp-3 leading-snug" :title="match.title">
|
||||||
<span>Correspondance</span>
|
{{ match.title }}
|
||||||
<span>{{ match.matchScore }}%</span>
|
</p>
|
||||||
</div>
|
<div class="flex items-center justify-between mt-1">
|
||||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ match.matchScore }}%</span>
|
||||||
<div
|
<CheckCircleIcon v-if="isSelected" class="w-4 h-4 text-green-500 shrink-0" />
|
||||||
class="h-2 rounded-full transition-all duration-300"
|
</div>
|
||||||
:class="{
|
</div>
|
||||||
'bg-blue-500': isSelected,
|
</div>
|
||||||
'bg-gray-400': !isSelected
|
|
||||||
}"
|
|
||||||
:style="{ width: match.matchScore + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { CheckCircleIcon, PhotoIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
match: {
|
match: { type: Object, required: true },
|
||||||
type: Object,
|
isSelected: { type: Boolean, default: false },
|
||||||
required: true
|
|
||||||
},
|
|
||||||
isSelected: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['select-match']);
|
const emit = defineEmits(['select-match']);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ const badgeText = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const badgeClasses = computed(() => {
|
const badgeClasses = computed(() => {
|
||||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 text-xs font-medium';
|
||||||
|
|
||||||
if (props.isImporting || props.isAnalyzing) {
|
if (props.isImporting || props.isAnalyzing) {
|
||||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300`;
|
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
@@ -58,7 +58,7 @@ const badgeClasses = computed(() => {
|
|||||||
case 'analyzed':
|
case 'analyzed':
|
||||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
|
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
|
||||||
case 'importing':
|
case 'importing':
|
||||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300`;
|
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||||
case 'imported':
|
case 'imported':
|
||||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||||
case 'error':
|
case 'error':
|
||||||
|
|||||||
@@ -1,115 +1,103 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<Toolbar :config="toolbarConfig" />
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">Import de Bibliothèque</h1>
|
<div class="overflow-y-auto flex-1">
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<div class="px-6 py-8">
|
||||||
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
|
|
||||||
</p>
|
<!-- Zone de dépôt -->
|
||||||
</div>
|
<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 dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Progression</span>
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ store.progressPercentage }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
||||||
:style="{ width: store.progressPercentage + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-2">
|
|
||||||
<span>{{ store.importedCount }} importés</span>
|
|
||||||
<span>{{ store.errorCount }} erreurs</span>
|
|
||||||
<span>{{ store.totalFiles }} total</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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></div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onUnmounted } from 'vue';
|
import { ArrowUpTrayIcon, SparklesIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, onUnmounted } from 'vue';
|
||||||
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
|
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
|
||||||
import LoadingSpinner from '../../../../shared/components/ui/LoadingSpinner.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||||
import FileImportCard from '../components/FileImportCard.vue';
|
import FileImportCard from '../components/FileImportCard.vue';
|
||||||
import ImportResults from '../components/ImportResults.vue';
|
import ImportResults from '../components/ImportResults.vue';
|
||||||
|
|
||||||
const store = useNewImportStore();
|
const store = useNewImportStore();
|
||||||
|
|
||||||
// === EVENT HANDLERS ===
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
const handleFilesSelected = (files) => {
|
{ type: 'label', text: 'Import de bibliothèque', class: 'text-sm font-medium' },
|
||||||
store.addFiles(files);
|
],
|
||||||
};
|
rightSection: [
|
||||||
|
...(store.analyzedFiles.length > 0 ? [{
|
||||||
|
type: 'button',
|
||||||
|
icon: SparklesIcon,
|
||||||
|
label: 'Sélection auto',
|
||||||
|
onClick: () => store.autoSelectBestMatches(),
|
||||||
|
}] : []),
|
||||||
|
...(store.hasReadyFiles ? [{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowUpTrayIcon,
|
||||||
|
label: `Importer (${store.readyCount})`,
|
||||||
|
onClick: importAllFiles,
|
||||||
|
disabled: store.isLoading,
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: TrashIcon,
|
||||||
|
label: 'Effacer',
|
||||||
|
onClick: () => store.clearFiles(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
const importAllFiles = async () => {
|
const importAllFiles = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -135,19 +123,6 @@ const retryFile = async (fileId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoSelectMatches = () => {
|
|
||||||
store.autoSelectBestMatches();
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAllFiles = () => {
|
|
||||||
if (confirm('Êtes-vous sûr de vouloir effacer tous les fichiers ?')) {
|
|
||||||
store.clearFiles();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// === LIFECYCLE ===
|
|
||||||
|
|
||||||
// Reset state when component unmounts
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
store.resetGlobalState();
|
store.resetGlobalState();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ export const useMangaStore = defineStore('manga', {
|
|||||||
|
|
||||||
// --- Add Manga State ---
|
// --- Add Manga State ---
|
||||||
addingManga: false,
|
addingManga: false,
|
||||||
addMangaError: null
|
addMangaError: null,
|
||||||
|
|
||||||
|
// --- Discover State ---
|
||||||
|
discoverResults: [],
|
||||||
|
loadingDiscover: false,
|
||||||
|
discoverError: null
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -170,6 +175,25 @@ export const useMangaStore = defineStore('manga', {
|
|||||||
this.loadingSearch = false;
|
this.loadingSearch = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Discover Actions ---
|
||||||
|
async loadDiscoverRecommendations() {
|
||||||
|
if (this.loadingDiscover) return;
|
||||||
|
|
||||||
|
this.loadingDiscover = true;
|
||||||
|
this.discoverError = null;
|
||||||
|
this.discoverResults = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await mangaRepository.discoverManga();
|
||||||
|
this.discoverResults = data.items || [];
|
||||||
|
} catch (error) {
|
||||||
|
this.discoverError = error.message;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.loadingDiscover = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// --- Add Manga Actions ---
|
// --- Add Manga Actions ---
|
||||||
async createFromMangaDex(externalId) {
|
async createFromMangaDex(externalId) {
|
||||||
if (this.addingManga) return;
|
if (this.addingManga) return;
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export class Manga {
|
|||||||
status = null,
|
status = null,
|
||||||
rating = null,
|
rating = null,
|
||||||
genres = [],
|
genres = [],
|
||||||
createdAt = new Date().toISOString()
|
createdAt = new Date().toISOString(),
|
||||||
|
monitored = false,
|
||||||
|
chaptersTotal = 0,
|
||||||
|
chaptersScraped = 0,
|
||||||
}) {
|
}) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.slug = slug;
|
this.slug = slug;
|
||||||
@@ -25,6 +28,9 @@ export class Manga {
|
|||||||
this.rating = rating;
|
this.rating = rating;
|
||||||
this.genres = genres;
|
this.genres = genres;
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
|
this.monitored = monitored;
|
||||||
|
this.chaptersTotal = chaptersTotal;
|
||||||
|
this.chaptersScraped = chaptersScraped;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(data) {
|
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) {
|
async createFromMangaDex(externalId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/mangas/create-from-mangadex', {
|
const response = await fetch('/api/mangas/create-from-mangadex', {
|
||||||
|
|||||||
@@ -1,37 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterLink
|
<div class="group relative bg-white dark:bg-gray-800 overflow-hidden shadow-sm">
|
||||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
<!-- Cover avec overlay -->
|
||||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
|
<div class="relative pb-[140%]">
|
||||||
<div class="relative pb-[150%]">
|
<RouterLink
|
||||||
<img
|
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
class="absolute inset-0">
|
||||||
:alt="manga.title"
|
<img
|
||||||
class="absolute inset-0 w-full h-full object-cover bg-gray-100" />
|
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||||
</div>
|
:alt="manga.title"
|
||||||
<div class="p-2">
|
class="w-full h-full object-cover bg-gray-100" />
|
||||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">{{ manga.title }}</h3>
|
</RouterLink>
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
|
<!-- 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>
|
||||||
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400"> Added: {{ formatDate(manga.createdAt) }} </div>
|
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||||
manga: {
|
import { RouterLink } from 'vue-router';
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = dateString => {
|
defineProps({
|
||||||
const date = new Date(dateString);
|
manga: {
|
||||||
return date.toLocaleDateString('en-US', {
|
type: Object,
|
||||||
month: 'short',
|
required: true
|
||||||
day: 'numeric',
|
}
|
||||||
year: 'numeric'
|
});
|
||||||
});
|
|
||||||
};
|
defineEmits(['edit', 'sources', 'refresh']);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20">
|
<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 }">
|
<td class="px-4 py-2 text-gray-900 dark:text-gray-100" :class="{ 'text-green-500 dark:text-green-400': chapter.isAvailable }">
|
||||||
{{ String(chapter.number).padStart(2, '0') }}
|
<template v-if="chapter.isVolumeGroup">{{ chapter.volumeChaptersRange }}</template>
|
||||||
|
<template v-else>{{ String(chapter.number).padStart(2, '0') }}</template>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
|
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
|
||||||
<router-link
|
<router-link
|
||||||
@@ -13,9 +14,17 @@
|
|||||||
chapterId: chapter.id
|
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>
|
</router-link>
|
||||||
<span v-else class="text-gray-500 dark:text-gray-400">{{ 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>
|
||||||
<td class="px-4 py-2 flex justify-end gap-2">
|
<td class="px-4 py-2 flex justify-end gap-2">
|
||||||
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">
|
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">
|
||||||
|
|||||||
@@ -1,16 +1,96 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6">
|
<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" />
|
<MangaCard
|
||||||
|
v-for="manga in mangas"
|
||||||
|
:key="manga.id"
|
||||||
|
:manga="manga"
|
||||||
|
@edit="openEdit"
|
||||||
|
@sources="openSources"
|
||||||
|
@refresh="doRefresh" />
|
||||||
</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" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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({
|
defineProps({
|
||||||
mangas: {
|
mangas: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true
|
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>
|
</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" referrerpolicy="no-referrer" />
|
|
||||||
<!-- 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>
|
||||||
@@ -1,80 +1,142 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Barre de recherche -->
|
<Toolbar :config="toolbarConfig" />
|
||||||
<div class="mb-8">
|
|
||||||
<div class="flex gap-4">
|
<div class="overflow-y-auto flex-1">
|
||||||
<input
|
<div class="px-6 py-8">
|
||||||
type="text"
|
|
||||||
v-model="searchQuery"
|
<!-- Recherche -->
|
||||||
@keyup.enter="performSearch"
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
placeholder="Rechercher un manga..."
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Recherche</h2>
|
||||||
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
|
<input
|
||||||
<button
|
type="text"
|
||||||
@click="performSearch"
|
v-model="searchQuery"
|
||||||
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">
|
@keyup.enter="performSearch"
|
||||||
Rechercher
|
placeholder="Rechercher un manga..."
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- État de chargement -->
|
<!-- Modal de détail -->
|
||||||
<div v-if="loading" class="text-center py-8">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
|
||||||
<p class="mt-4 text-gray-600 dark:text-gray-400">Recherche en cours...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Message d'erreur -->
|
|
||||||
<div v-if="error" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded relative mb-6">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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 dark:text-gray-400">Aucun résultat trouvé</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal de confirmation -->
|
|
||||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" aria-hidden="true" />
|
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
|
||||||
|
|
||||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||||
<DialogPanel class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6">
|
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
<DialogTitle class="text-lg mb-4 text-gray-900 dark:text-gray-100"> Ajouter à la bibliothèque </DialogTitle>
|
|
||||||
|
|
||||||
<div v-if="selectedManga">
|
<!-- En-tête avec couverture -->
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
|
||||||
<img
|
<img
|
||||||
:src="selectedManga.imageUrl || '/placeholder-cover.png'"
|
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
|
||||||
:alt="selectedManga.title"
|
:alt="selectedManga.title"
|
||||||
class="h-48 w-32 object-cover" />
|
class="h-64 w-44 object-cover flex-shrink-0"
|
||||||
<div class="flex-1 min-w-0">
|
referrerpolicy="no-referrer" />
|
||||||
<h4 class="text-lg text-gray-900 dark:text-gray-100">{{ selectedManga.title }}</h4>
|
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
|
||||||
<p class="mt-2 text-gray-700 dark:text-gray-300">
|
<div>
|
||||||
{{ truncatedDescription }}
|
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
|
||||||
</p>
|
{{ selectedManga.title }}
|
||||||
|
</DialogTitle>
|
||||||
|
<div class="mt-3 space-y-1.5">
|
||||||
|
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Publication</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Statut</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Note</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
|
||||||
|
<span
|
||||||
|
v-for="genre in selectedManga.genres"
|
||||||
|
:key="genre"
|
||||||
|
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ genre }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<!-- Description -->
|
||||||
|
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||||
|
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||||
|
{{ selectedManga.description }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 dark:bg-gray-800">
|
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="addManga"
|
@click="addManga"
|
||||||
:disabled="adding"
|
:disabled="adding"
|
||||||
class="px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center">
|
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
|
||||||
<span v-if="adding" class="mr-2">
|
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
|
||||||
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
|
||||||
</span>
|
|
||||||
{{ adding ? 'Ajout en cours...' : 'Ajouter' }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -82,76 +144,91 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||||
import { ArrowPathIcon } from '@heroicons/vue/24/solid';
|
import { ArrowPathIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useMangaStore } from '../../application/store/mangaStore';
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
import MangaList from '../components/MangaList.vue';
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const mangaStore = useMangaStore();
|
const mangaStore = useMangaStore();
|
||||||
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const isModalOpen = ref(false);
|
const hasSearched = ref(false);
|
||||||
const selectedManga = ref(null);
|
const isModalOpen = ref(false);
|
||||||
|
const selectedManga = ref(null);
|
||||||
|
|
||||||
// Récupération des états du store
|
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||||
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
|
|
||||||
|
|
||||||
const truncatedDescription = computed(() => {
|
const toolbarConfig = computed(() => ({
|
||||||
if (!selectedManga.value?.description) return '';
|
leftSection: [
|
||||||
return selectedManga.value.description.length > 500
|
{ type: 'label', text: 'Ajouter un manga', class: 'text-sm font-medium' },
|
||||||
? selectedManga.value.description.slice(0, 500) + '...'
|
],
|
||||||
: selectedManga.value.description;
|
rightSection: [
|
||||||
});
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: MagnifyingGlassIcon,
|
||||||
|
label: 'Rechercher',
|
||||||
|
onClick: performSearch,
|
||||||
|
disabled: !searchQuery.value.trim() || loading.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
// Effectuer la recherche au chargement si un paramètre q est présent
|
let debounceTimer = null;
|
||||||
onMounted(() => {
|
watch(searchQuery, newVal => {
|
||||||
const queryParam = route.query.q;
|
clearTimeout(debounceTimer);
|
||||||
if (queryParam) {
|
if (newVal.trim().length > 3) {
|
||||||
searchQuery.value = queryParam;
|
debounceTimer = setTimeout(performSearch, 500);
|
||||||
performSearch();
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Nettoyer la recherche et les résultats lors du démontage du composant
|
onMounted(() => {
|
||||||
onBeforeUnmount(() => {
|
const queryParam = route.query.q;
|
||||||
searchQuery.value = '';
|
if (queryParam) {
|
||||||
mangaStore.clearSearchResults();
|
searchQuery.value = queryParam;
|
||||||
});
|
performSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const performSearch = async () => {
|
onBeforeUnmount(() => {
|
||||||
if (!searchQuery.value.trim()) return;
|
clearTimeout(debounceTimer);
|
||||||
try {
|
searchQuery.value = '';
|
||||||
await mangaStore.searchMangaDex(searchQuery.value);
|
mangaStore.clearSearchResults();
|
||||||
} catch (e) {
|
});
|
||||||
console.error('Erreur de recherche:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openMangaModal = manga => {
|
const performSearch = async () => {
|
||||||
selectedManga.value = manga;
|
if (!searchQuery.value.trim()) return;
|
||||||
isModalOpen.value = true;
|
try {
|
||||||
};
|
await mangaStore.searchMangaDex(searchQuery.value);
|
||||||
|
hasSearched.value = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur de recherche:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const openMangaModal = manga => {
|
||||||
isModalOpen.value = false;
|
selectedManga.value = manga;
|
||||||
selectedManga.value = null;
|
isModalOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const addManga = async () => {
|
const closeModal = () => {
|
||||||
if (!selectedManga.value) return;
|
isModalOpen.value = false;
|
||||||
|
selectedManga.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
const addManga = async () => {
|
||||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
if (!selectedManga.value) return;
|
||||||
router.push('/manga');
|
try {
|
||||||
} catch (e) {
|
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||||
console.error("Erreur d'ajout:", e);
|
router.push('/manga');
|
||||||
} finally {
|
} catch (e) {
|
||||||
closeModal();
|
console.error("Erreur d'ajout:", e);
|
||||||
}
|
} finally {
|
||||||
};
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="overflow-y-auto flex-1">
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
||||||
<MangaList
|
<MangaOverview
|
||||||
v-else-if="viewMode === 'list'"
|
v-else-if="viewMode === 'list'"
|
||||||
:mangas="pagedItems"
|
:mangas="pagedItems"
|
||||||
@manga-click="handleMangaClick" />
|
@manga-click="handleMangaClick" />
|
||||||
@@ -45,7 +45,7 @@ import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
|||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useMangaStore } from '../../application/store/mangaStore';
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
import MangaGrid from '../components/MangaGrid.vue';
|
import MangaGrid from '../components/MangaGrid.vue';
|
||||||
import MangaList from '../components/MangaList.vue';
|
import MangaOverview from '../components/MangaOverview.vue';
|
||||||
import MangaTable from '../components/MangaTable.vue';
|
import MangaTable from '../components/MangaTable.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -9,19 +9,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="reader-content">
|
<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'">
|
<template v-if="store.readingMode === 'single'">
|
||||||
<SingleModeReader
|
<SingleModeReader
|
||||||
:page-data="store.currentPageData"
|
:page-data="store.currentPageData"
|
||||||
@@ -35,29 +22,10 @@
|
|||||||
:pages="store.pages"
|
:pages="store.pages"
|
||||||
:zoom="store.zoom"
|
:zoom="store.zoom"
|
||||||
:double-page-mode="store.effectiveDoublePageMode"
|
:double-page-mode="store.effectiveDoublePageMode"
|
||||||
|
:initial-page="store.currentPage"
|
||||||
@page-visible="store.handlePageVisible"
|
@page-visible="store.handlePageVisible"
|
||||||
@buttons-visibility-change="handleButtonsVisibilityChange"
|
|
||||||
ref="infiniteReaderRef" />
|
ref="infiniteReaderRef" />
|
||||||
</template>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -68,8 +36,6 @@ import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
|||||||
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||||
import { useReaderStore } from '../../application/store/readerStore';
|
import { useReaderStore } from '../../application/store/readerStore';
|
||||||
import InfiniteReader from './InfiniteReader.vue';
|
import InfiniteReader from './InfiniteReader.vue';
|
||||||
import ReaderControls from './ReaderControls.vue';
|
|
||||||
import ReaderSettings from './ReaderSettings.vue';
|
|
||||||
import SingleModeReader from './SingleModeReader.vue';
|
import SingleModeReader from './SingleModeReader.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -87,28 +53,20 @@ import SingleModeReader from './SingleModeReader.vue';
|
|||||||
const headerStore = useHeaderStore();
|
const headerStore = useHeaderStore();
|
||||||
const prefs = useUserPreferencesStore();
|
const prefs = useUserPreferencesStore();
|
||||||
|
|
||||||
// Référence vers InfiniteReader pour accéder à ses méthodes
|
|
||||||
const infiniteReaderRef = ref(null);
|
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
|
// Actions de l'interface lecteur
|
||||||
const toggleReadingMode = () => {
|
const toggleReadingMode = () => {
|
||||||
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
|
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
|
||||||
store.setReadingMode(newMode);
|
store.setReadingMode(newMode);
|
||||||
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
|
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
|
||||||
|
|
||||||
// Gérer la visibilité selon le mode
|
|
||||||
if (newMode === 'single') {
|
if (newMode === 'single') {
|
||||||
headerStore.disableAutoHide();
|
headerStore.disableAutoHide();
|
||||||
// En mode simple : toujours visible
|
headerStore.disableReaderToolbarAutoHide();
|
||||||
showFloatingButtons.value = true;
|
|
||||||
clearTimeout(localButtonsTimer); // Annuler tout timer local
|
|
||||||
} else {
|
} else {
|
||||||
// En mode infini : utiliser la logique d'InfiniteReader
|
headerStore.enableReaderToolbarAutoHide();
|
||||||
|
headerStore.enableAutoHide();
|
||||||
showButtonsWithTimer();
|
showButtonsWithTimer();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -117,100 +75,40 @@ import SingleModeReader from './SingleModeReader.vue';
|
|||||||
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
|
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
|
||||||
store.setReadingDirection(newDir);
|
store.setReadingDirection(newDir);
|
||||||
prefs.setReadingDirection(newDir);
|
prefs.setReadingDirection(newDir);
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoomIn = () => {
|
const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||||
store.setZoom(Math.min(store.zoom + 0.1, 2));
|
const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomOut = () => {
|
const handleZoomChange = (zoom) => store.setZoom(zoom);
|
||||||
store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
|
||||||
resetButtonsTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleZoomChange = (zoom) => {
|
const handleDoublePageModeChange = (mode) => store.setDoublePageMode(mode);
|
||||||
store.setZoom(zoom);
|
const handleDoublePageAutoDetectChange = (enabled) => store.setDoublePageAutoDetect(enabled);
|
||||||
resetButtonsTimer();
|
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 = () => {
|
const showButtonsWithTimer = () => {
|
||||||
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
||||||
// Mode infini : utiliser la logique d'InfiniteReader
|
|
||||||
infiniteReaderRef.value.showButtonsWithTimer();
|
infiniteReaderRef.value.showButtonsWithTimer();
|
||||||
} else {
|
|
||||||
// Mode simple : toujours visible, pas de timer
|
|
||||||
showFloatingButtons.value = true;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fonction centralisée pour réinitialiser le timer
|
|
||||||
const resetButtonsTimer = () => {
|
const resetButtonsTimer = () => {
|
||||||
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
||||||
// Mode infini : utiliser la logique d'InfiniteReader
|
|
||||||
infiniteReaderRef.value.resetButtonsTimer();
|
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 => {
|
const handleKeyPress = event => {
|
||||||
if (store.readingMode === 'single') {
|
if (store.readingMode === 'single') {
|
||||||
if (event.key === 'ArrowRight') {
|
if (event.key === 'ArrowRight') {
|
||||||
store.nextPage();
|
store.nextPage();
|
||||||
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
|
|
||||||
} else if (event.key === 'ArrowLeft') {
|
} else if (event.key === 'ArrowLeft') {
|
||||||
store.previousPage();
|
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(
|
watch(
|
||||||
() => props.chapterId,
|
() => props.chapterId,
|
||||||
newId => {
|
newId => {
|
||||||
@@ -222,38 +120,46 @@ import SingleModeReader from './SingleModeReader.vue';
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Charger les préférences sauvegardées
|
|
||||||
store.loadPreferences();
|
store.loadPreferences();
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyPress);
|
window.addEventListener('keydown', handleKeyPress);
|
||||||
|
|
||||||
// Auto-hide header si activé dans les préférences
|
|
||||||
if (prefs.autoHideHeaderReader) {
|
if (prefs.autoHideHeaderReader) {
|
||||||
headerStore.enableAutoHide();
|
headerStore.enableAutoHide();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fullscreen si activé dans les préférences
|
if (store.readingMode === 'infinite') {
|
||||||
|
headerStore.enableReaderToolbarAutoHide();
|
||||||
|
}
|
||||||
|
|
||||||
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
|
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
|
||||||
document.documentElement.requestFullscreen().catch(() => {});
|
document.documentElement.requestFullscreen().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Afficher les boutons au démarrage
|
|
||||||
showButtonsWithTimer();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', handleKeyPress);
|
window.removeEventListener('keydown', handleKeyPress);
|
||||||
// S'assurer que l'auto-hide est désactivé en quittant le lecteur
|
|
||||||
headerStore.disableAutoHide();
|
headerStore.disableAutoHide();
|
||||||
// Nettoyer le timer local
|
headerStore.disableReaderToolbarAutoHide();
|
||||||
clearTimeout(localButtonsTimer);
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggleReadingMode,
|
||||||
|
toggleReadingDirection,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
handleZoomChange,
|
||||||
|
handleDoublePageModeChange,
|
||||||
|
handleDoublePageAutoDetectChange,
|
||||||
|
handleDetectionThresholdChange,
|
||||||
|
handleResetPreferences,
|
||||||
|
resetButtonsTimer,
|
||||||
|
showButtonsWithTimer,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
.chapter-reader {
|
.chapter-reader {
|
||||||
@apply w-full h-full flex flex-col items-center justify-center bg-gray-900 text-white;
|
@apply w-full h-full flex flex-col bg-gray-900 text-white;
|
||||||
@apply p-0 sm:p-2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
@@ -265,8 +171,7 @@ import SingleModeReader from './SingleModeReader.vue';
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reader-content {
|
.reader-content {
|
||||||
@apply w-full h-full flex flex-col;
|
@apply w-full flex-1 flex flex-col min-h-0;
|
||||||
@apply p-0 sm:p-2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rtl {
|
.rtl {
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="infinite-reader" ref="containerRef">
|
<div class="infinite-reader" ref="containerRef">
|
||||||
<!-- Navigation en haut -->
|
<div v-for="(page, index) in pages" :key="index"
|
||||||
<div class="navigation-wrapper top">
|
class="page-wrapper" :data-page-index="index">
|
||||||
<ChapterNavigation position="top" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 v-if="!page?.url" class="loading">
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
</div>
|
</div>
|
||||||
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navigation en bas -->
|
<!-- Hors de la zone de rendu : placeholder dimensionné -->
|
||||||
<div class="navigation-wrapper bottom">
|
<div v-else-if="!mountedPageIndices.has(index)"
|
||||||
<ChapterNavigation position="bottom" />
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Bouton flottant pour revenir en haut -->
|
<!-- Bouton flottant pour revenir en haut -->
|
||||||
@@ -29,22 +35,22 @@
|
|||||||
<button
|
<button
|
||||||
v-show="showFloatingButtons"
|
v-show="showFloatingButtons"
|
||||||
@click="scrollToTop"
|
@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"
|
title="Revenir en haut"
|
||||||
type="button"
|
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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs hidden sm:inline">Haut</span>
|
||||||
</button>
|
</button>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||||
import ChapterNavigation from './ChapterNavigation.vue';
|
|
||||||
import ReaderPage from './ReaderPage.vue';
|
import ReaderPage from './ReaderPage.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -67,6 +73,8 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
const headerStore = useHeaderStore();
|
const headerStore = useHeaderStore();
|
||||||
const containerRef = ref(null);
|
const containerRef = ref(null);
|
||||||
const observer = ref(null);
|
const observer = ref(null);
|
||||||
|
const visibilityObserver = ref(null);
|
||||||
|
const mountedPageIndices = reactive(new Set());
|
||||||
const windowWidth = ref(window.innerWidth);
|
const windowWidth = ref(window.innerWidth);
|
||||||
|
|
||||||
// État unique pour tous les boutons flottants avec timer de 3 secondes
|
// État unique pour tous les boutons flottants avec timer de 3 secondes
|
||||||
@@ -86,24 +94,46 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupIntersectionObserver = () => {
|
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage, zoom inclus
|
||||||
if (observer.value) {
|
const getPlaceholderHeight = (page) => {
|
||||||
observer.value.disconnect();
|
const dims = page?.dimensions;
|
||||||
}
|
if (!dims?.width || !dims?.height) return Math.round(800 * props.zoom);
|
||||||
|
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 * props.zoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupObservers = () => {
|
||||||
|
observer.value?.disconnect();
|
||||||
|
visibilityObserver.value?.disconnect();
|
||||||
|
|
||||||
observer.value = new IntersectionObserver(observeIntersection, {
|
observer.value = new IntersectionObserver(observeIntersection, {
|
||||||
root: null,
|
root: containerRef.value,
|
||||||
threshold: 0.5
|
threshold: 0.5
|
||||||
});
|
});
|
||||||
|
|
||||||
nextTick(() => {
|
visibilityObserver.value = new IntersectionObserver(
|
||||||
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
|
(entries) => {
|
||||||
if (pageElements) {
|
entries.forEach(entry => {
|
||||||
pageElements.forEach((element, index) => {
|
const idx = parseInt(entry.target.getAttribute('data-page-index'));
|
||||||
element.setAttribute('data-page-index', index);
|
if (entry.isIntersecting) {
|
||||||
observer.value.observe(element);
|
mountedPageIndices.add(idx);
|
||||||
|
} else {
|
||||||
|
mountedPageIndices.delete(idx);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
{ root: containerRef.value, 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';
|
scrollDirection = 'up';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion du header auto-hide (seulement si largeur < 1200px)
|
// Gestion du header auto-hide (header : seulement si largeur < 1200px, toolbar : toujours)
|
||||||
if (windowWidth.value < 1200) {
|
headerStore.updateScrollDirection(scrollTop);
|
||||||
headerStore.updateScrollDirection(scrollTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gestion de la visibilité des boutons flottants (même condition pour tous)
|
// Gestion de la visibilité des boutons flottants (même condition pour tous)
|
||||||
// Afficher si on scroll et qu'on est à plus de 300px
|
// 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
|
// Fonction pour revenir en haut de la page
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
console.log('scrollToTop appelée'); // Debug
|
|
||||||
|
|
||||||
// Réinitialiser le timer lors du clic
|
// Réinitialiser le timer lors du clic
|
||||||
resetButtonsTimer();
|
resetButtonsTimer();
|
||||||
|
|
||||||
// Stratégie 1: Scroll sur le conteneur direct
|
// Stratégie 1: Scroll sur le conteneur direct
|
||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
|
|
||||||
|
|
||||||
if (containerRef.value.scrollTop > 0) {
|
if (containerRef.value.scrollTop > 0) {
|
||||||
containerRef.value.scrollTo({
|
containerRef.value.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
});
|
});
|
||||||
console.log('Scroll sur containerRef effectué'); // Debug
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +236,6 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
while (currentElement) {
|
while (currentElement) {
|
||||||
const styles = window.getComputedStyle(currentElement);
|
const styles = window.getComputedStyle(currentElement);
|
||||||
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
|
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
|
||||||
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
|
|
||||||
currentElement.scrollTo({
|
currentElement.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
@@ -224,7 +246,6 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stratégie 3: Scroll sur la fenêtre entière
|
// Stratégie 3: Scroll sur la fenêtre entière
|
||||||
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
|
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
@@ -240,7 +261,8 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
watch(
|
watch(
|
||||||
() => props.pages,
|
() => props.pages,
|
||||||
() => {
|
() => {
|
||||||
setupIntersectionObserver();
|
mountedPageIndices.clear();
|
||||||
|
setupObservers();
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
@@ -259,7 +281,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setupIntersectionObserver();
|
setupObservers();
|
||||||
|
|
||||||
// Activer l'auto-hide du header si la largeur < 1200px
|
// Activer l'auto-hide du header si la largeur < 1200px
|
||||||
if (windowWidth.value < 1200) {
|
if (windowWidth.value < 1200) {
|
||||||
@@ -279,9 +301,8 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (observer.value) {
|
observer.value?.disconnect();
|
||||||
observer.value.disconnect();
|
visibilityObserver.value?.disconnect();
|
||||||
}
|
|
||||||
|
|
||||||
// Désactiver l'auto-hide du header en quittant
|
// Désactiver l'auto-hide du header en quittant
|
||||||
headerStore.disableAutoHide();
|
headerStore.disableAutoHide();
|
||||||
@@ -304,19 +325,22 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
.infinite-reader {
|
.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 */
|
/* Réduction du padding sur mobile */
|
||||||
@apply py-2 sm:py-8;
|
@apply py-2 sm:py-8;
|
||||||
height: calc(100vh - 8rem);
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
@apply w-full flex justify-center min-h-[200px];
|
@apply w-full flex justify-center;
|
||||||
/* Réduction des marges sur mobile */
|
|
||||||
@apply mb-2 sm:mb-4 px-1 sm:px-4;
|
@apply mb-2 sm:mb-4 px-1 sm:px-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-placeholder {
|
||||||
|
@apply w-full;
|
||||||
|
max-width: 1200px;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
.error {
|
.error {
|
||||||
@apply flex items-center justify-center min-h-[400px];
|
@apply flex items-center justify-center min-h-[400px];
|
||||||
@@ -342,15 +366,4 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<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-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>
|
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
|
||||||
|
|
||||||
@@ -75,16 +78,29 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
||||||
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
||||||
|
},
|
||||||
|
windowWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = useReaderStore();
|
const store = useReaderStore();
|
||||||
|
|
||||||
|
// zoom via la propriété CSS `zoom` dans les deux modes (affecte le layout → pas de chevauchement en mode scroll)
|
||||||
|
const containerStyle = computed(() => {
|
||||||
|
return { zoom: props.zoom };
|
||||||
|
});
|
||||||
|
|
||||||
const imageRef = ref(null);
|
const imageRef = ref(null);
|
||||||
const scrollContainerRef = ref(null);
|
const scrollContainerRef = ref(null);
|
||||||
const naturalWidth = ref(0);
|
const naturalWidth = ref(0);
|
||||||
const naturalHeight = ref(0);
|
const naturalHeight = ref(0);
|
||||||
const windowWidth = ref(window.innerWidth);
|
const localWindowWidth = ref(window.innerWidth);
|
||||||
const isMobile = computed(() => windowWidth.value < 768);
|
const effectiveWindowWidth = computed(() =>
|
||||||
|
props.windowWidth !== null ? props.windowWidth : localWindowWidth.value
|
||||||
|
);
|
||||||
|
const isMobile = computed(() => effectiveWindowWidth.value < 768);
|
||||||
const imageLoaded = ref(false);
|
const imageLoaded = ref(false);
|
||||||
|
|
||||||
const imageSource = computed(() => {
|
const imageSource = computed(() => {
|
||||||
@@ -103,17 +119,13 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
// Utiliser d'abord les dimensions de l'API si disponibles
|
// Utiliser d'abord les dimensions de l'API si disponibles
|
||||||
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
||||||
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
|
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
|
||||||
const isDouble = ratio > threshold;
|
return ratio > threshold;
|
||||||
console.log(`API Dimensions - Page ${props.pageNumber}: ${props.pageData.dimensions.width}x${props.pageData.dimensions.height}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
|
|
||||||
return isDouble;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
|
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
|
||||||
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
|
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
|
||||||
const ratio = naturalWidth.value / naturalHeight.value;
|
const ratio = naturalWidth.value / naturalHeight.value;
|
||||||
const isDouble = ratio > threshold;
|
return ratio > threshold;
|
||||||
console.log(`Natural Dimensions - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
|
|
||||||
return isDouble;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -124,7 +136,6 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
naturalWidth.value = imageRef.value.naturalWidth;
|
naturalWidth.value = imageRef.value.naturalWidth;
|
||||||
naturalHeight.value = imageRef.value.naturalHeight;
|
naturalHeight.value = imageRef.value.naturalHeight;
|
||||||
imageLoaded.value = true;
|
imageLoaded.value = true;
|
||||||
console.log(`Image loaded - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}`);
|
|
||||||
|
|
||||||
// Positionner le scroll à droite si c'est le mode scroll
|
// Positionner le scroll à droite si c'est le mode scroll
|
||||||
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
|
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
|
||||||
@@ -175,7 +186,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
|
|
||||||
if (!width || !height) return null;
|
if (!width || !height) return null;
|
||||||
|
|
||||||
const availableWidth = windowWidth.value;
|
const availableWidth = effectiveWindowWidth.value;
|
||||||
|
|
||||||
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur
|
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur
|
||||||
if (availableWidth < 1200) {
|
if (availableWidth < 1200) {
|
||||||
@@ -187,13 +198,27 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imageStyle = computed(() => {
|
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 {
|
// Mode scroll : fixer la largeur, hauteur libre
|
||||||
width: `${maxWidth.value}px`,
|
const style = {
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
maxWidth: '100%'
|
maxWidth: '100%',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (maxWidth.value) {
|
||||||
|
style.width = `${maxWidth.value}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Styles spéciaux pour les doubles pages
|
// Styles spéciaux pour les doubles pages
|
||||||
@@ -210,7 +235,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
if (!width || !height) return {};
|
if (!width || !height) return {};
|
||||||
|
|
||||||
// En mode rotation : maximiser l'utilisation de l'espace
|
// En mode rotation : maximiser l'utilisation de l'espace
|
||||||
const availableWidth = windowWidth.value;
|
const availableWidth = effectiveWindowWidth.value;
|
||||||
const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles
|
const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles
|
||||||
|
|
||||||
// Après rotation, la largeur originale devient la hauteur affichée
|
// Après rotation, la largeur originale devient la hauteur affichée
|
||||||
@@ -260,36 +285,32 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gestion du redimensionnement de la fenêtre
|
let ownResizeHandler = null;
|
||||||
const handleResize = () => {
|
|
||||||
windowWidth.value = window.innerWidth;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (imageRef.value && imageRef.value.complete) {
|
if (props.windowWidth === null) {
|
||||||
handleImageLoad();
|
ownResizeHandler = () => { localWindowWidth.value = window.innerWidth; };
|
||||||
|
window.addEventListener('resize', ownResizeHandler, { passive: true });
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', handleResize);
|
if (imageRef.value?.complete) handleImageLoad();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', handleResize);
|
if (ownResizeHandler) window.removeEventListener('resize', ownResizeHandler);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
.page-container {
|
.page-container {
|
||||||
@apply flex-1 flex items-center justify-center overflow-hidden;
|
@apply flex items-center justify-center;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
/* Réduction des marges sur mobile */
|
|
||||||
@apply p-0 sm:p-2;
|
@apply p-0 sm:p-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-image {
|
.page-image {
|
||||||
@apply object-contain;
|
@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-width: 100%;
|
||||||
max-height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Styles pour les doubles pages sur mobile */
|
/* Styles pour les doubles pages sur mobile */
|
||||||
|
|||||||
@@ -1,29 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="reader-settings">
|
<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
|
<Transition
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
leave-active-class="transition-all duration-300 ease-in"
|
leave-active-class="transition-all duration-300 ease-in"
|
||||||
@@ -32,63 +8,9 @@
|
|||||||
leave-from-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-4 scale-95"
|
leave-to-class="opacity-0 translate-y-4 scale-95"
|
||||||
>
|
>
|
||||||
<div v-show="effectiveIsOpen" class="settings-panel" :data-external-control="forceOpen !== null" ref="panelRef">
|
<div v-show="open" class="settings-panel" 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>
|
|
||||||
|
|
||||||
<button
|
<!-- Paramètres des doubles pages (mobile uniquement) -->
|
||||||
@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 -->
|
|
||||||
<div class="settings-section" v-if="isMobile">
|
<div class="settings-section" v-if="isMobile">
|
||||||
<h3 class="section-title">
|
<h3 class="section-title">
|
||||||
<svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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)
|
Doubles pages (Mobile)
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Activation/désactivation -->
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<label class="setting-label">
|
<label class="setting-label">
|
||||||
<input
|
<input
|
||||||
@@ -113,7 +34,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mode d'affichage (si la détection automatique est activée) -->
|
|
||||||
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
||||||
<label class="setting-label">Mode d'affichage</label>
|
<label class="setting-label">Mode d'affichage</label>
|
||||||
<select
|
<select
|
||||||
@@ -125,22 +45,13 @@
|
|||||||
<option value="scroll">Défilement horizontal</option>
|
<option value="scroll">Défilement horizontal</option>
|
||||||
<option value="normal">Affichage normal</option>
|
<option value="normal">Affichage normal</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Descriptions des modes -->
|
|
||||||
<p class="setting-description">
|
<p class="setting-description">
|
||||||
<span v-if="doublePageMode === 'rotate'">
|
<span v-if="doublePageMode === 'rotate'">Suggère de tourner l'appareil pour une meilleure lecture</span>
|
||||||
Suggère de tourner l'appareil pour une meilleure lecture
|
<span v-else-if="doublePageMode === 'scroll'">Permet le défilement horizontal pour naviguer dans la page (commence à droite)</span>
|
||||||
</span>
|
<span v-else>Affichage standard sans optimisation spéciale</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Seuil de détection -->
|
|
||||||
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
||||||
<label class="setting-label">
|
<label class="setting-label">
|
||||||
Sensibilité de détection: {{ doublePageSettings.detectionThreshold.toFixed(1) }}
|
Sensibilité de détection: {{ doublePageSettings.detectionThreshold.toFixed(1) }}
|
||||||
@@ -160,14 +71,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Réinitialiser -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="setting-actions">
|
<div class="setting-actions">
|
||||||
<button @click="onResetPreferences" class="action-button reset">
|
<button @click="onResetPreferences" class="action-button reset">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
Réinitialiser
|
Réinitialiser les préférences
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,21 +88,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
readingMode: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
readingDirection: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
type: Number,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
doublePageMode: {
|
doublePageMode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'rotate'
|
default: 'rotate'
|
||||||
@@ -204,138 +103,38 @@
|
|||||||
detectionThreshold: 1.4
|
detectionThreshold: 1.4
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
// Visibilité contrôlée par le parent
|
open: {
|
||||||
visible: {
|
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: false
|
||||||
},
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'toggleReadingMode',
|
'toggleSettings',
|
||||||
'toggleReadingDirection',
|
|
||||||
'zoomIn',
|
|
||||||
'zoomOut',
|
|
||||||
'zoomChange',
|
|
||||||
'doublePageModeChange',
|
'doublePageModeChange',
|
||||||
'doublePageAutoDetectChange',
|
'doublePageAutoDetectChange',
|
||||||
'detectionThresholdChange',
|
'detectionThresholdChange',
|
||||||
'resetPreferences',
|
'resetPreferences',
|
||||||
'buttonClick' // Signaler l'interaction au parent
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isOpen = ref(false);
|
|
||||||
const isMobile = computed(() => window.innerWidth < 768);
|
const isMobile = computed(() => window.innerWidth < 768);
|
||||||
const panelRef = ref(null);
|
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) => {
|
const handleClickOutside = (event) => {
|
||||||
if (effectiveIsOpen.value && panelRef.value && !panelRef.value.contains(event.target)) {
|
if (props.open && panelRef.value && !panelRef.value.contains(event.target)) {
|
||||||
// Vérifier que le clic n'est pas sur le bouton de toggle
|
emit('toggleSettings');
|
||||||
const settingsButton = document.querySelector('.settings-toggle, .settings-button');
|
|
||||||
if (settingsButton && settingsButton.contains(event.target)) {
|
|
||||||
return; // Laisser le bouton gérer le toggle
|
|
||||||
}
|
|
||||||
|
|
||||||
closePanel();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watcher pour empêcher la fermeture du bouton quand le panel est ouvert
|
onMounted(() => document.addEventListener('click', handleClickOutside, true));
|
||||||
watch(
|
onUnmounted(() => document.removeEventListener('click', handleClickOutside, true));
|
||||||
() => effectiveIsOpen.value,
|
|
||||||
(newIsOpen) => {
|
|
||||||
if (newIsOpen || !newIsOpen) {
|
|
||||||
// Signaler l'interaction à chaque changement
|
|
||||||
emit('buttonClick');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cycle de vie des event listeners
|
const onDoublePageModeChange = (mode) => emit('doublePageModeChange', mode);
|
||||||
onMounted(() => {
|
const onDoublePageAutoDetectChange = (enabled) => emit('doublePageAutoDetectChange', enabled);
|
||||||
document.addEventListener('click', handleClickOutside, true);
|
const onDetectionThresholdChange = (threshold) => emit('detectionThresholdChange', parseFloat(threshold));
|
||||||
});
|
|
||||||
|
|
||||||
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 onResetPreferences = () => {
|
const onResetPreferences = () => {
|
||||||
emit('resetPreferences');
|
emit('resetPreferences');
|
||||||
emit('buttonClick');
|
emit('toggleSettings');
|
||||||
isOpen.value = false;
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -344,25 +143,10 @@
|
|||||||
@apply relative;
|
@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 {
|
.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) {
|
@media (max-width: 480px) {
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
width: 90vw;
|
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 {
|
.settings-section {
|
||||||
@apply p-4 border-b border-gray-700 last:border-b-0;
|
@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;
|
@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 {
|
.setting-item {
|
||||||
@apply mb-4 last:mb-0;
|
@apply mb-4 last:mb-0;
|
||||||
}
|
}
|
||||||
@@ -457,7 +195,6 @@
|
|||||||
@apply text-gray-400 text-xs leading-relaxed;
|
@apply text-gray-400 text-xs leading-relaxed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions */
|
|
||||||
.setting-actions {
|
.setting-actions {
|
||||||
@apply flex gap-2;
|
@apply flex gap-2;
|
||||||
}
|
}
|
||||||
@@ -470,23 +207,9 @@
|
|||||||
@apply bg-red-600 hover:bg-red-700 text-white;
|
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
@apply right-2 w-72;
|
@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>
|
</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) -->
|
<!-- Zone de navigation gauche (invisible) -->
|
||||||
<div
|
<div
|
||||||
class="navigation-zone left-zone"
|
class="navigation-zone left-zone"
|
||||||
@click.stop="goToPrevious"
|
@click.stop="onLeftZoneClick"
|
||||||
@mouseenter="showLeftHint"
|
@mouseenter="showLeftHint"
|
||||||
@mouseleave="hideLeftHint"
|
@mouseleave="hideLeftHint"
|
||||||
title="Page précédente"
|
:title="isRtl ? 'Page suivante' : 'Page précédente'"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Page centrale -->
|
<!-- Page centrale -->
|
||||||
@@ -24,21 +24,21 @@
|
|||||||
<!-- Zone de navigation droite (invisible) -->
|
<!-- Zone de navigation droite (invisible) -->
|
||||||
<div
|
<div
|
||||||
class="navigation-zone right-zone"
|
class="navigation-zone right-zone"
|
||||||
@click.stop="goToNext"
|
@click.stop="onRightZoneClick"
|
||||||
@mouseenter="showRightHint"
|
@mouseenter="showRightHint"
|
||||||
@mouseleave="hideRightHint"
|
@mouseleave="hideRightHint"
|
||||||
title="Page suivante"
|
:title="isRtl ? 'Page précédente' : 'Page suivante'"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Indicateurs visuels de navigation -->
|
<!-- Indicateurs visuels de navigation -->
|
||||||
<div class="navigation-hints">
|
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -81,14 +81,18 @@ const showLeftHintHover = ref(false);
|
|||||||
const showRightHintHover = ref(false);
|
const showRightHintHover = ref(false);
|
||||||
let hintTimeout = null;
|
let hintTimeout = null;
|
||||||
|
|
||||||
// Computed pour vérifier les possibilités de navigation
|
const isRtl = computed(() => store.readingDirection === 'rtl');
|
||||||
const canGoToPrevious = computed(() => {
|
|
||||||
return !store.isFirstPage || store.hasPreviousChapter;
|
|
||||||
});
|
|
||||||
|
|
||||||
const canGoToNext = computed(() => {
|
// Computed pour vérifier les possibilités de navigation
|
||||||
return !store.isLastPage || store.hasNextChapter;
|
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
|
// Navigation vers la page/chapitre précédent
|
||||||
const goToPrevious = async () => {
|
const goToPrevious = async () => {
|
||||||
@@ -151,22 +155,20 @@ const hideRightHint = () => {
|
|||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
.single-mode-reader {
|
.single-mode-reader {
|
||||||
@apply relative w-full h-full flex items-center justify-center;
|
@apply relative w-full flex-1 flex flex-col min-h-0 overflow-hidden;
|
||||||
/* Suppression des marges sur mobile */
|
@apply py-2;
|
||||||
@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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-navigation-wrapper {
|
.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 {
|
.page-content {
|
||||||
@apply flex-1 h-full flex items-center justify-center;
|
/* min-h-full : centre l'image quand elle est plus petite que le conteneur */
|
||||||
pointer-events: none; /* Empêche les clics sur l'image elle-même */
|
min-height: 100%;
|
||||||
/* Optimisation pour mobile */
|
@apply flex items-center justify-center;
|
||||||
@apply p-0;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigation-zone {
|
.navigation-zone {
|
||||||
|
|||||||
@@ -1,56 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chapter-page">
|
<div class="chapter-page">
|
||||||
<div class="chapter-header">
|
<div
|
||||||
<!-- Bouton de retour -->
|
class="toolbar-wrapper"
|
||||||
<div class="flex items-center gap-4 mb-4">
|
:class="{ 'toolbar-hidden': !headerStore.shouldShowReaderToolbar }"
|
||||||
<button
|
>
|
||||||
@click="goBackToManga"
|
<div class="toolbar-slide">
|
||||||
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"
|
<ReaderToolbar :chapter-reader-ref="chapterReaderRef" />
|
||||||
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="reader-container">
|
<div class="reader-container">
|
||||||
<ChapterReader :chapter-id="chapterId" />
|
<ChapterReader ref="chapterReaderRef" :chapter-id="chapterId" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ArrowLeftIcon } from '@heroicons/vue/24/outline';
|
import { computed, ref } from 'vue';
|
||||||
import { computed } from 'vue';
|
import { useRoute } from 'vue-router';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||||
import { useReaderStore } from '../../application/store/readerStore';
|
|
||||||
import ChapterReader from '../components/ChapterReader.vue';
|
import ChapterReader from '../components/ChapterReader.vue';
|
||||||
|
import ReaderToolbar from '../components/ReaderToolbar.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const headerStore = useHeaderStore();
|
||||||
const store = useReaderStore();
|
|
||||||
|
|
||||||
const chapterId = computed(() => route.params.chapterId);
|
const chapterId = computed(() => route.params.chapterId);
|
||||||
const currentChapter = computed(() => store.currentChapter);
|
const chapterReaderRef = ref(null);
|
||||||
|
|
||||||
const goBackToManga = () => {
|
|
||||||
if (currentChapter.value?.mangaId) {
|
|
||||||
router.push({ name: 'manga-details', params: { id: currentChapter.value.mangaId } });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
@@ -58,19 +33,26 @@ import ChapterReader from '../components/ChapterReader.vue';
|
|||||||
@apply w-full h-full flex flex-col;
|
@apply w-full h-full flex flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-header {
|
.toolbar-wrapper {
|
||||||
@apply p-6 bg-gradient-to-b from-gray-800 to-gray-900 border-b border-gray-700 shadow-lg;
|
@apply overflow-hidden;
|
||||||
|
max-height: 5rem;
|
||||||
|
transition: max-height 300ms ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-title-section {
|
.toolbar-wrapper.toolbar-hidden {
|
||||||
@apply space-y-2;
|
max-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-meta {
|
.toolbar-slide {
|
||||||
@apply flex flex-wrap items-center gap-3;
|
transform: translateY(0);
|
||||||
|
transition: transform 300ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-hidden .toolbar-slide {
|
||||||
|
transform: translateY(-100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-container {
|
.reader-container {
|
||||||
@apply flex-1 overflow-hidden;
|
@apply flex-1 overflow-hidden min-h-0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,7 +23,15 @@ export const useContentSourceStore = defineStore('contentSource', {
|
|||||||
importing: false,
|
importing: false,
|
||||||
exporting: false,
|
exporting: false,
|
||||||
importError: null,
|
importError: null,
|
||||||
exportError: null
|
exportError: null,
|
||||||
|
|
||||||
|
// Health check state
|
||||||
|
checkingHealth: false,
|
||||||
|
checkHealthError: null,
|
||||||
|
|
||||||
|
// Delete state
|
||||||
|
deleting: false,
|
||||||
|
deleteError: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -168,12 +176,64 @@ export const useContentSourceStore = defineStore('contentSource', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Delete a source
|
||||||
|
async deleteSource(id) {
|
||||||
|
if (this.deleting) return;
|
||||||
|
|
||||||
|
this.deleting = true;
|
||||||
|
this.deleteError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await contentSourceRepository.delete(id);
|
||||||
|
this.sources = this.sources.filter(source => source.id !== id);
|
||||||
|
if (this.currentSource && this.currentSource.id === id) {
|
||||||
|
this.currentSource = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.deleteError = error.message;
|
||||||
|
console.error('Erreur lors de la suppression de la source:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.deleting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Clear current source
|
// Clear current source
|
||||||
clearCurrentSource() {
|
clearCurrentSource() {
|
||||||
this.currentSource = null;
|
this.currentSource = null;
|
||||||
this.currentSourceError = null;
|
this.currentSourceError = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Check all scrapers health
|
||||||
|
async checkAllHealth() {
|
||||||
|
if (this.checkingHealth) return;
|
||||||
|
|
||||||
|
this.checkingHealth = true;
|
||||||
|
this.checkHealthError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await contentSourceRepository.checkAllHealth();
|
||||||
|
} catch (error) {
|
||||||
|
this.checkHealthError = error.message;
|
||||||
|
console.error('Erreur lors du health check:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.checkingHealth = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update health status of a single source (called from Mercure)
|
||||||
|
updateSourceHealth(sourceId, status, error = null) {
|
||||||
|
const index = this.sources.findIndex(s => s.id === sourceId);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.sources[index] = {
|
||||||
|
...this.sources[index],
|
||||||
|
healthStatus: status,
|
||||||
|
healthLastError: error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Clear errors
|
// Clear errors
|
||||||
clearErrors() {
|
clearErrors() {
|
||||||
this.sourcesError = null;
|
this.sourcesError = null;
|
||||||
@@ -181,6 +241,7 @@ export const useContentSourceStore = defineStore('contentSource', {
|
|||||||
this.saveError = null;
|
this.saveError = null;
|
||||||
this.importError = null;
|
this.importError = null;
|
||||||
this.exportError = null;
|
this.exportError = null;
|
||||||
|
this.checkHealthError = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const ScraperHealthStatus = {
|
||||||
|
UNKNOWN: 'unknown',
|
||||||
|
OK: 'ok',
|
||||||
|
KO: 'ko',
|
||||||
|
TESTING: 'testing',
|
||||||
|
};
|
||||||
@@ -82,6 +82,28 @@ export class ApiContentSourceRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déclenche le test de santé de tous les scrapers
|
||||||
|
*/
|
||||||
|
async checkAllHealth() {
|
||||||
|
try {
|
||||||
|
await this.apiClient.post('/scraping/check-all-health', {});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors du lancement du health check');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une source de contenu
|
||||||
|
*/
|
||||||
|
async delete(id) {
|
||||||
|
try {
|
||||||
|
await this.apiClient.delete(`/content-sources/${id}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors de la suppression de la source');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Teste une configuration de scraper
|
* Teste une configuration de scraper
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
@click="$emit('edit', source)"
|
@click="$emit('edit', source)"
|
||||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow duration-200 cursor-pointer">
|
class="bg-white dark:bg-gray-800 shadow-md border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow duration-200 cursor-pointer">
|
||||||
<!-- Header avec URL et icône externe -->
|
<!-- Header avec URL et icône externe -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate" :title="source.cleanBaseUrl">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate" :title="source.cleanBaseUrl">
|
||||||
@@ -20,16 +20,24 @@
|
|||||||
<!-- Badge type de scraping -->
|
<!-- Badge type de scraping -->
|
||||||
<span
|
<span
|
||||||
:class="getScrapingTypeBadgeClass(source.scrapingType)"
|
:class="getScrapingTypeBadgeClass(source.scrapingType)"
|
||||||
class="px-2 py-1 text-xs font-medium rounded-md">
|
class="px-2 py-1 text-xs font-medium">
|
||||||
{{ source.scrapingType?.toLowerCase() || 'N/A' }}
|
{{ source.scrapingType?.toLowerCase() || 'N/A' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Badge orientation basé sur les sélecteurs -->
|
<!-- Badge orientation basé sur les sélecteurs -->
|
||||||
<span
|
<span
|
||||||
:class="getOrientationBadgeClass(source)"
|
:class="getOrientationBadgeClass(source)"
|
||||||
class="px-2 py-1 text-xs font-medium rounded-md">
|
class="px-2 py-1 text-xs font-medium">
|
||||||
{{ getOrientation(source) }}
|
{{ getOrientation(source) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<!-- Badge health status -->
|
||||||
|
<span
|
||||||
|
:class="getHealthBadgeClass(source.healthStatus)"
|
||||||
|
class="px-2 py-1 text-xs font-medium"
|
||||||
|
:title="source.healthLastError || ''">
|
||||||
|
{{ getHealthLabel(source.healthStatus) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +47,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
|
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { ScraperHealthStatus } from '../../domain/model/ScraperHealthStatus';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
source: {
|
source: {
|
||||||
@@ -86,4 +95,26 @@ const getOrientationBadgeClass = (source) => {
|
|||||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getHealthLabel = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case ScraperHealthStatus.OK: return '✓ ok';
|
||||||
|
case ScraperHealthStatus.KO: return '✗ ko';
|
||||||
|
case ScraperHealthStatus.TESTING: return '⟳ test';
|
||||||
|
default: return '? unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHealthBadgeClass = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case ScraperHealthStatus.OK:
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||||
|
case ScraperHealthStatus.KO:
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||||
|
case ScraperHealthStatus.TESTING:
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<TransitionRoot as="template" :show="isOpen">
|
||||||
|
<Dialog as="div" class="relative z-50" @close="closeModal">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enter-to="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
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 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 dark:text-gray-100">
|
||||||
|
Supprimer la source de contenu
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<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 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning message -->
|
||||||
|
<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 dark:text-gray-100">Action irréversible</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Êtes-vous sûr de vouloir supprimer la source <strong>{{ source?.baseUrl }}</strong> ?
|
||||||
|
</p>
|
||||||
|
<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 dark:text-yellow-300">
|
||||||
|
Attention
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||||
|
<p>Cette source ne pourra plus être utilisée pour le scraping des chapitres.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="mt-6 flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
@click="confirmDelete"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
{{ isLoading ? 'Suppression...' : 'Supprimer définitivement' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TransitionRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
|
||||||
|
import { ArrowPathIcon, ExclamationTriangleIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'confirm']);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
emit('confirm');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,17 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
<div>
|
||||||
<!-- Header -->
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600 rounded-t-lg">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Cog6ToothIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ isEditing ? 'Edit Scrapper Configuration' : 'New Scrapper Configuration' }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-6">
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
<!-- Base URL -->
|
<!-- Base URL -->
|
||||||
<div>
|
<div>
|
||||||
<label for="baseUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="baseUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
@@ -22,25 +12,12 @@
|
|||||||
v-model="form.baseUrl"
|
v-model="form.baseUrl"
|
||||||
type="url"
|
type="url"
|
||||||
required
|
required
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="https://example.com" />
|
placeholder="https://example.com" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image Selector -->
|
|
||||||
<div>
|
|
||||||
<label for="imageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Image Selector
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="imageSelector"
|
|
||||||
v-model="form.imageSelector"
|
|
||||||
type="text"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder=".reading-content .page-break img" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chapter URL Format -->
|
<!-- Chapter URL Format -->
|
||||||
<div>
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<label for="chapterUrlFormat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="chapterUrlFormat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Chapter URL Format <span class="text-gray-500">({slug}, {chapterNumber})</span>
|
Chapter URL Format <span class="text-gray-500">({slug}, {chapterNumber})</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -49,132 +26,132 @@
|
|||||||
v-model="form.chapterUrlFormat"
|
v-model="form.chapterUrlFormat"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="https://example.com/manga/{slug}-{chapterNumber}/" />
|
placeholder="https://example.com/manga/{slug}-{chapterNumber}/" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Next Page Selector -->
|
<!-- Selectors -->
|
||||||
<div>
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
|
||||||
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<div>
|
||||||
Next Page Selector <span class="text-gray-500">(let empty if vertical reader)</span>
|
<label for="imageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
</label>
|
Image Selector
|
||||||
<input
|
</label>
|
||||||
id="nextPageSelector"
|
<input
|
||||||
v-model="form.nextPageSelector"
|
id="imageSelector"
|
||||||
type="text"
|
v-model="form.imageSelector"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
type="text"
|
||||||
placeholder=".next-page" />
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder=".reading-content .page-break img" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Next Page Selector <span class="text-gray-500">(laisser vide si lecteur vertical)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="nextPageSelector"
|
||||||
|
v-model="form.nextPageSelector"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder=".next-page" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Chapter Selector <span class="text-gray-500">(requis pour le scraping Javascript)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="chapterSelector"
|
||||||
|
v-model="form.chapterSelector"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder=".chapter-selector" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chapter Selector -->
|
<!-- Scraping Type + Token -->
|
||||||
<div>
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
|
||||||
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<div>
|
||||||
Chapter Selector <span class="text-gray-500">(required for Javascript scraping)</span>
|
<label for="scrapingType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
</label>
|
Scraping Type
|
||||||
<input
|
</label>
|
||||||
id="chapterSelector"
|
<select
|
||||||
v-model="form.chapterSelector"
|
id="scrapingType"
|
||||||
type="text"
|
v-model="form.scrapingType"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
required
|
||||||
placeholder=".chapter-selector" />
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white">
|
||||||
</div>
|
<option value="html">HTML</option>
|
||||||
|
<option value="javascript">Javascript</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Scraping Type -->
|
<div>
|
||||||
<div>
|
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<label for="scrapingType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
Token
|
||||||
Scraping Type
|
</label>
|
||||||
</label>
|
<input
|
||||||
<select
|
id="token"
|
||||||
id="scrapingType"
|
v-model="form.token"
|
||||||
v-model="form.scrapingType"
|
type="text"
|
||||||
required
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white">
|
placeholder="Optional authentication token" />
|
||||||
<option value="html">HTML</option>
|
</div>
|
||||||
<option value="javascript">Javascript</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Token (optionnel) -->
|
|
||||||
<div>
|
|
||||||
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Token
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="token"
|
|
||||||
v-model="form.token"
|
|
||||||
type="text"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
placeholder="Optional authentication token" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="saving"
|
|
||||||
class="px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center space-x-2">
|
|
||||||
<ArrowPathIcon v-if="saving" class="w-4 h-4 animate-spin" />
|
|
||||||
<span>{{ isEditing ? 'Update Configuration' : 'Create Configuration' }}</span>
|
|
||||||
<PencilSquareIcon v-if="!saving" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error message -->
|
<!-- Error message -->
|
||||||
<div v-if="error" class="text-red-600 dark:text-red-400 text-sm">
|
<div v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 text-red-600 dark:text-red-400 text-sm">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Test Configuration Section -->
|
<!-- Test Configuration Section -->
|
||||||
<div class="border-t border-gray-200 dark:border-gray-600 p-6 bg-gray-50 dark:bg-gray-700 rounded-b-lg">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
<div class="flex items-center space-x-2 mb-4">
|
<div class="flex items-center space-x-2 mb-6">
|
||||||
<WrenchScrewdriverIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
<WrenchScrewdriverIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Test Configuration</h3>
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Configuration de test (health check)</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="testMangaSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="testSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Manga Slug
|
Manga Slug <span class="text-gray-500">(enregistré)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="testMangaSlug"
|
id="testSlug"
|
||||||
v-model="testData.mangaSlug"
|
v-model="form.testSlug"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="manga-slug" />
|
placeholder="manga-slug" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="testChapterNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="testChapterNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Chapter Number
|
Numéro de chapitre <span class="text-gray-500">(enregistré)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="testChapterNumber"
|
id="testChapterNumber"
|
||||||
v-model="testData.chapterNumber"
|
v-model="form.testChapterNumber"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="1" />
|
placeholder="1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Preview de l'URL qui sera testée -->
|
<!-- Preview URL -->
|
||||||
<div v-if="generatedTestUrl" class="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md">
|
<div v-if="generatedTestUrl" class="mb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
<div class="text-sm text-blue-800 dark:text-blue-200">
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">URL qui sera testée</p>
|
||||||
<strong>URL qui sera testée :</strong>
|
<code class="text-xs text-gray-700 dark:text-gray-300 break-all">{{ generatedTestUrl }}</code>
|
||||||
<div class="mt-1 font-mono text-xs break-all">{{ generatedTestUrl }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="testConfiguration"
|
@click="testConfiguration"
|
||||||
:disabled="testing || !canTest"
|
:disabled="testing || !canTest"
|
||||||
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center justify-center space-x-2">
|
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200 flex items-center justify-center space-x-2">
|
||||||
<ArrowPathIcon v-if="testing" class="w-4 h-4 animate-spin" />
|
<ArrowPathIcon v-if="testing" class="w-4 h-4 animate-spin" />
|
||||||
<PlayIcon v-else class="w-4 h-4" />
|
<PlayIcon v-else class="w-4 h-4" />
|
||||||
<span>Test Configuration</span>
|
<span>Tester maintenant</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,8 +160,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
Cog6ToothIcon,
|
|
||||||
PencilSquareIcon,
|
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
WrenchScrewdriverIcon
|
WrenchScrewdriverIcon
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
@@ -216,12 +191,9 @@ const form = ref({
|
|||||||
nextPageSelector: '',
|
nextPageSelector: '',
|
||||||
chapterSelector: '',
|
chapterSelector: '',
|
||||||
scrapingType: 'html',
|
scrapingType: 'html',
|
||||||
token: ''
|
token: '',
|
||||||
});
|
testSlug: '',
|
||||||
|
testChapterNumber: '',
|
||||||
const testData = ref({
|
|
||||||
mangaSlug: '',
|
|
||||||
chapterNumber: ''
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const testing = ref(false);
|
const testing = ref(false);
|
||||||
@@ -229,20 +201,19 @@ const testing = ref(false);
|
|||||||
const canTest = computed(() => {
|
const canTest = computed(() => {
|
||||||
return form.value.baseUrl &&
|
return form.value.baseUrl &&
|
||||||
form.value.chapterUrlFormat &&
|
form.value.chapterUrlFormat &&
|
||||||
testData.value.mangaSlug &&
|
form.value.testSlug &&
|
||||||
testData.value.chapterNumber;
|
form.value.testChapterNumber;
|
||||||
});
|
});
|
||||||
|
|
||||||
const generatedTestUrl = computed(() => {
|
const generatedTestUrl = computed(() => {
|
||||||
if (!form.value.chapterUrlFormat || !testData.value.mangaSlug || !testData.value.chapterNumber) {
|
if (!form.value.chapterUrlFormat || !form.value.testSlug || !form.value.testChapterNumber) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return form.value.chapterUrlFormat
|
return form.value.chapterUrlFormat
|
||||||
.replace('{slug}', testData.value.mangaSlug)
|
.replace('{slug}', form.value.testSlug)
|
||||||
.replace('{chapterNumber}', testData.value.chapterNumber);
|
.replace('{chapterNumber}', form.value.testChapterNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize form with source data if editing, clear if creating new
|
|
||||||
watch(() => props.source, (newSource) => {
|
watch(() => props.source, (newSource) => {
|
||||||
if (newSource) {
|
if (newSource) {
|
||||||
form.value = {
|
form.value = {
|
||||||
@@ -252,10 +223,11 @@ watch(() => props.source, (newSource) => {
|
|||||||
nextPageSelector: newSource.nextPageSelector || '',
|
nextPageSelector: newSource.nextPageSelector || '',
|
||||||
chapterSelector: newSource.chapterSelector || '',
|
chapterSelector: newSource.chapterSelector || '',
|
||||||
scrapingType: (newSource.scrapingType || 'html').toLowerCase(),
|
scrapingType: (newSource.scrapingType || 'html').toLowerCase(),
|
||||||
token: newSource.token || ''
|
token: newSource.token || '',
|
||||||
|
testSlug: newSource.testSlug || '',
|
||||||
|
testChapterNumber: newSource.testChapterNumber ?? '',
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Reset form when no source (creating new)
|
|
||||||
form.value = {
|
form.value = {
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
imageSelector: '',
|
imageSelector: '',
|
||||||
@@ -263,23 +235,37 @@ watch(() => props.source, (newSource) => {
|
|||||||
nextPageSelector: '',
|
nextPageSelector: '',
|
||||||
chapterSelector: '',
|
chapterSelector: '',
|
||||||
scrapingType: 'html',
|
scrapingType: 'html',
|
||||||
token: ''
|
token: '',
|
||||||
|
testSlug: '',
|
||||||
|
testChapterNumber: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const buildPayload = (formData) => {
|
||||||
emit('submit', { ...form.value });
|
const data = { ...formData };
|
||||||
|
const raw = data.testChapterNumber;
|
||||||
|
data.testChapterNumber = (raw === '' || raw === null || raw === undefined)
|
||||||
|
? null
|
||||||
|
: parseFloat(raw);
|
||||||
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit('submit', buildPayload(form.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ submitForm: handleSubmit });
|
||||||
|
|
||||||
const testConfiguration = async () => {
|
const testConfiguration = async () => {
|
||||||
testing.value = true;
|
testing.value = true;
|
||||||
try {
|
try {
|
||||||
await emit('test', {
|
await emit('test', {
|
||||||
configuration: { ...form.value },
|
configuration: buildPayload(form.value),
|
||||||
testData: {
|
testData: {
|
||||||
...testData.value,
|
mangaSlug: form.value.testSlug,
|
||||||
testUrl: generatedTestUrl.value
|
chapterNumber: form.value.testChapterNumber,
|
||||||
|
testUrl: generatedTestUrl.value,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,72 +3,54 @@
|
|||||||
<Toolbar :config="toolbarConfig" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
<div class="overflow-y-auto flex-1">
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="container mx-auto px-4 py-6">
|
<div class="px-6 py-8">
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
|
||||||
Scrapper Configurations
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Gérez les configurations de scraping pour les différentes sources de manga
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loadingSources" class="flex justify-center py-12">
|
<div v-if="loadingSources" class="flex justify-center py-12">
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
<div class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-else-if="sourcesError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
|
<div v-else-if="sourcesError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-6">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
||||||
<p class="text-red-800 dark:text-red-200">{{ sourcesError }}</p>
|
<p class="text-red-800 dark:text-red-200">{{ sourcesError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="contentSourceStore.loadSources()"
|
@click="contentSourceStore.loadSources()"
|
||||||
class="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
|
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700">
|
||||||
Réessayer
|
Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug Info (temporary) -->
|
|
||||||
<div v-if="!loadingSources && !sourcesError && sources.length === 0" class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6">
|
|
||||||
<p class="text-blue-800 dark:text-blue-200">Aucune source trouvée. Rechargement en cours...</p>
|
|
||||||
<button
|
|
||||||
@click="contentSourceStore.loadSources()"
|
|
||||||
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
|
||||||
Actualiser
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sources Grid -->
|
<!-- Sources Grid -->
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<section v-else class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<!-- Existing Sources -->
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<ContentSourceCard
|
<!-- Existing Sources -->
|
||||||
v-for="source in sources"
|
<ContentSourceCard
|
||||||
:key="source.id"
|
v-for="source in sources"
|
||||||
:source="source"
|
:key="source.id"
|
||||||
@edit="editSource"
|
:source="source"
|
||||||
@open-link="openSourceLink" />
|
@edit="editSource"
|
||||||
|
@open-link="openSourceLink" />
|
||||||
|
|
||||||
<!-- Add New Configuration Card -->
|
<!-- Add New Configuration Card -->
|
||||||
<div
|
<div
|
||||||
@click="addNewSource"
|
@click="addNewSource"
|
||||||
class="bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer flex flex-col items-center justify-center h-full">
|
class="bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 p-6 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer flex flex-col items-center justify-center h-full">
|
||||||
<PlusIcon class="w-8 h-8 text-gray-400 dark:text-gray-500 mb-3" />
|
<PlusIcon class="w-8 h-8 text-gray-400 dark:text-gray-500 mb-3" />
|
||||||
<span class="text-lg font-medium text-gray-600 dark:text-gray-400 mb-2">
|
<span class="text-lg font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
Add New Configuration
|
Add New Configuration
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<!-- Import/Export Success Messages -->
|
<!-- Import/Export Success Messages -->
|
||||||
<div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
<div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
|
||||||
Configuration importée avec succès !
|
Configuration importée avec succès !
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showExportSuccess" class="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
<div v-if="showExportSuccess" class="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 shadow-lg">
|
||||||
Configuration exportée !
|
Configuration exportée !
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,12 +58,12 @@
|
|||||||
|
|
||||||
<!-- Import Modal -->
|
<!-- Import Modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
|
<div class="bg-white dark:bg-gray-800 shadow-xl w-full max-w-md">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-lg font-semibold mb-4">Importer des configurations</h3>
|
<h3 class="text-lg font-semibold mb-4">Importer des configurations</h3>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="importData"
|
v-model="importData"
|
||||||
class="w-full h-40 p-3 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
class="w-full h-40 p-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="Collez ici le JSON des configurations à importer..."></textarea>
|
placeholder="Collez ici le JSON des configurations à importer..."></textarea>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 mt-4">
|
<div class="flex justify-end space-x-3 mt-4">
|
||||||
@@ -93,7 +75,7 @@
|
|||||||
<button
|
<button
|
||||||
@click="handleImport"
|
@click="handleImport"
|
||||||
:disabled="importing || !importData.trim()"
|
:disabled="importing || !importData.trim()"
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md">
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white">
|
||||||
{{ importing ? 'Import...' : 'Importer' }}
|
{{ importing ? 'Import...' : 'Importer' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,10 +91,11 @@ import {
|
|||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
ArrowUpTrayIcon,
|
ArrowUpTrayIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
|
HeartIcon,
|
||||||
PlusIcon
|
PlusIcon
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
||||||
@@ -126,9 +109,13 @@ const {
|
|||||||
loadingSources,
|
loadingSources,
|
||||||
sourcesError,
|
sourcesError,
|
||||||
importing,
|
importing,
|
||||||
exporting
|
exporting,
|
||||||
|
checkingHealth,
|
||||||
} = storeToRefs(contentSourceStore);
|
} = storeToRefs(contentSourceStore);
|
||||||
|
|
||||||
|
// Mercure — écoute des mises à jour health
|
||||||
|
let mercureEventSource = null;
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const showImportModal = ref(false);
|
const showImportModal = ref(false);
|
||||||
const showExportSuccess = ref(false);
|
const showExportSuccess = ref(false);
|
||||||
@@ -138,40 +125,45 @@ const importData = ref('');
|
|||||||
// Load sources on mount and clear current source
|
// Load sources on mount and clear current source
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
contentSourceStore.clearCurrentSource(); // Clear any previously loaded source
|
contentSourceStore.clearCurrentSource();
|
||||||
contentSourceStore.clearErrors(); // Clear any previous errors
|
contentSourceStore.clearErrors();
|
||||||
await contentSourceStore.loadSources();
|
await contentSourceStore.loadSources();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement des sources:', error);
|
console.error('Erreur lors du chargement des sources:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Écoute Mercure pour les mises à jour de health status
|
||||||
|
const url = new URL('/.well-known/mercure', window.location.href);
|
||||||
|
sources.value.forEach(source => {
|
||||||
|
url.searchParams.append('topic', `scrapers/health/${source.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
mercureEventSource = new EventSource(url.toString());
|
||||||
|
mercureEventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
contentSourceStore.updateSourceHealth(data.sourceId, data.status, data.error);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur parsing Mercure event:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
mercureEventSource?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toolbar configuration
|
// Toolbar configuration
|
||||||
const toolbarConfig = computed(() => ({
|
const toolbarConfig = computed(() => ({
|
||||||
leftSection: [
|
leftSection: [
|
||||||
{
|
{ type: 'label', text: 'Scrapers', class: 'text-sm font-medium' },
|
||||||
icon: ArrowPathIcon,
|
|
||||||
label: 'Actualiser',
|
|
||||||
type: 'button',
|
|
||||||
onClick: () => contentSourceStore.loadSources(),
|
|
||||||
active: loadingSources.value
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
rightSection: [
|
rightSection: [
|
||||||
{
|
{ type: 'button', icon: ArrowPathIcon, label: 'Actualiser', onClick: () => contentSourceStore.loadSources(), disabled: loadingSources.value },
|
||||||
icon: ArrowDownTrayIcon,
|
{ type: 'button', icon: HeartIcon, label: 'Tester tous', onClick: handleCheckAllHealth, disabled: checkingHealth.value },
|
||||||
label: 'Exporter',
|
{ type: 'button', icon: ArrowDownTrayIcon, label: 'Exporter', onClick: handleExport, disabled: exporting.value },
|
||||||
type: 'button',
|
{ type: 'button', icon: ArrowUpTrayIcon, label: 'Importer', onClick: () => showImportModal.value = true },
|
||||||
onClick: handleExport,
|
],
|
||||||
disabled: exporting.value
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ArrowUpTrayIcon,
|
|
||||||
label: 'Importer',
|
|
||||||
type: 'button',
|
|
||||||
onClick: () => showImportModal.value = true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -190,6 +182,14 @@ const openSourceLink = (url) => {
|
|||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function handleCheckAllHealth() {
|
||||||
|
try {
|
||||||
|
await contentSourceStore.checkAllHealth();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du health check:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
try {
|
try {
|
||||||
const exportData = await contentSourceStore.exportSources();
|
const exportData = await contentSourceStore.exportSources();
|
||||||
|
|||||||
@@ -3,43 +3,36 @@
|
|||||||
<Toolbar :config="toolbarConfig" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
<div class="overflow-y-auto flex-1">
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="container mx-auto px-4 py-6">
|
<div class="px-6 py-8">
|
||||||
<!-- Back Navigation -->
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<div class="mb-6">
|
<!-- Loading State -->
|
||||||
<button
|
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
|
||||||
@click="goBack"
|
<div class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
|
||||||
<ArrowLeftIcon class="w-5 h-5" />
|
|
||||||
<span>Retour aux configurations</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
|
||||||
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
|
||||||
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Error State -->
|
||||||
<div v-else class="max-w-4xl mx-auto">
|
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-6">
|
||||||
<ContentSourceForm
|
<div class="flex items-center">
|
||||||
:source="currentSource"
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
||||||
:saving="saving"
|
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
|
||||||
:error="saveError"
|
</div>
|
||||||
@submit="handleSubmit"
|
</div>
|
||||||
@test="handleTest" />
|
|
||||||
</div>
|
<!-- Form -->
|
||||||
|
<div v-else>
|
||||||
|
<ContentSourceForm
|
||||||
|
ref="formRef"
|
||||||
|
:source="currentSource"
|
||||||
|
:saving="saving"
|
||||||
|
:error="saveError"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@test="handleTest" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Test Results Modal -->
|
<!-- Test Results Modal -->
|
||||||
<div v-if="showTestResults" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div v-if="showTestResults" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden">
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-600">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-600">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h3 class="text-lg font-semibold">Résultats du test</h3>
|
<h3 class="text-lg font-semibold">Résultats du test</h3>
|
||||||
@@ -54,7 +47,7 @@
|
|||||||
<div class="p-6 overflow-y-auto">
|
<div class="p-6 overflow-y-auto">
|
||||||
<!-- Loading state during test -->
|
<!-- Loading state during test -->
|
||||||
<div v-if="testingConfiguration" class="flex items-center justify-center py-8">
|
<div v-if="testingConfiguration" class="flex items-center justify-center py-8">
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
<div class="animate-spin h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||||
<span class="text-gray-600">Test en cours...</span>
|
<span class="text-gray-600">Test en cours...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,7 +58,7 @@
|
|||||||
<span class="font-medium">Test réussi !</span>
|
<span class="font-medium">Test réussi !</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4">
|
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 p-4">
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
|
<span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
|
||||||
@@ -92,10 +85,11 @@
|
|||||||
<img
|
<img
|
||||||
:src="imageUrl"
|
:src="imageUrl"
|
||||||
:alt="`Image ${index + 1}`"
|
:alt="`Image ${index + 1}`"
|
||||||
class="w-full h-32 object-cover rounded border border-gray-200 dark:border-gray-600"
|
class="w-full h-32 object-cover border border-gray-200 dark:border-gray-600"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
@load="handleImageLoad" />
|
@load="handleImageLoad" />
|
||||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity rounded flex items-center justify-center">
|
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity flex items-center justify-center">
|
||||||
<span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium">
|
<span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium">
|
||||||
Page {{ index + 1 }}
|
Page {{ index + 1 }}
|
||||||
</span>
|
</span>
|
||||||
@@ -107,7 +101,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
|
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 p-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ExclamationTriangleIcon class="w-5 h-5 text-yellow-400 mr-2" />
|
<ExclamationTriangleIcon class="w-5 h-5 text-yellow-400 mr-2" />
|
||||||
<p class="text-yellow-800 dark:text-yellow-200">
|
<p class="text-yellow-800 dark:text-yellow-200">
|
||||||
@@ -125,7 +119,7 @@
|
|||||||
<span class="font-medium">Test échoué</span>
|
<span class="font-medium">Test échoué</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-4">
|
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-4">
|
||||||
<div class="text-sm text-red-800 dark:text-red-200">
|
<div class="text-sm text-red-800 dark:text-red-200">
|
||||||
<div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div>
|
<div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div>
|
||||||
<div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div>
|
<div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div>
|
||||||
@@ -138,14 +132,14 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(error, index) in testResults.errors"
|
v-for="(error, index) in testResults.errors"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4 rounded">
|
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4">
|
||||||
<div class="flex items-start">
|
<div class="flex items-start">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400" />
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 flex-1">
|
<div class="ml-3 flex-1">
|
||||||
<div class="flex items-center mb-1">
|
<div class="flex items-center mb-1">
|
||||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
|
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
|
||||||
{{ formatErrorType(error.type) }}
|
{{ formatErrorType(error.type) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-medium text-red-800 dark:text-red-200">
|
<span class="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
@@ -155,7 +149,7 @@
|
|||||||
<p class="text-sm text-red-700 dark:text-red-300 mb-2">
|
<p class="text-sm text-red-700 dark:text-red-300 mb-2">
|
||||||
{{ error.message }}
|
{{ error.message }}
|
||||||
</p>
|
</p>
|
||||||
<div class="bg-red-50 dark:bg-red-900 rounded p-2">
|
<div class="bg-red-50 dark:bg-red-900 p-2">
|
||||||
<p class="text-xs text-red-600 dark:text-red-400">
|
<p class="text-xs text-red-600 dark:text-red-400">
|
||||||
<strong>Suggestion :</strong> {{ error.suggestion }}
|
<strong>Suggestion :</strong> {{ error.suggestion }}
|
||||||
</p>
|
</p>
|
||||||
@@ -166,7 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Generic Error -->
|
<!-- Generic Error -->
|
||||||
<div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded p-3">
|
<div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-3">
|
||||||
<code class="text-sm text-red-800 dark:text-red-200">
|
<code class="text-sm text-red-800 dark:text-red-200">
|
||||||
{{ testResults.error }}
|
{{ testResults.error }}
|
||||||
</code>
|
</code>
|
||||||
@@ -177,11 +171,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Success Message -->
|
<!-- Success Message -->
|
||||||
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
|
||||||
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
|
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Modal -->
|
||||||
|
<ContentSourceDeleteModal
|
||||||
|
:is-open="isDeleteModalOpen"
|
||||||
|
:source="currentSource"
|
||||||
|
:is-loading="isDeleting"
|
||||||
|
:error="deleteError"
|
||||||
|
@close="isDeleteModalOpen = false"
|
||||||
|
@confirm="confirmDeleteSource" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -190,6 +193,8 @@ import {
|
|||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
TrashIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
XMarkIcon
|
XMarkIcon
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
@@ -199,6 +204,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
||||||
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
|
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
|
||||||
|
import ContentSourceDeleteModal from '../components/ContentSourceDeleteModal.vue';
|
||||||
import ContentSourceForm from '../components/ContentSourceForm.vue';
|
import ContentSourceForm from '../components/ContentSourceForm.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -214,11 +220,17 @@ const {
|
|||||||
saveError
|
saveError
|
||||||
} = storeToRefs(contentSourceStore);
|
} = storeToRefs(contentSourceStore);
|
||||||
|
|
||||||
|
// Form ref
|
||||||
|
const formRef = ref(null);
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const showTestResults = ref(false);
|
const showTestResults = ref(false);
|
||||||
const showSuccessMessage = ref(false);
|
const showSuccessMessage = ref(false);
|
||||||
const testResults = ref({});
|
const testResults = ref({});
|
||||||
const testingConfiguration = ref(false);
|
const testingConfiguration = ref(false);
|
||||||
|
const isDeleteModalOpen = ref(false);
|
||||||
|
const isDeleting = ref(false);
|
||||||
|
const deleteError = ref(null);
|
||||||
|
|
||||||
const isEditing = computed(() => !!route.params.id);
|
const isEditing = computed(() => !!route.params.id);
|
||||||
|
|
||||||
@@ -233,16 +245,19 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Toolbar configuration
|
// Toolbar configuration
|
||||||
const toolbarConfig = {
|
const toolbarConfig = computed(() => ({
|
||||||
leftSection: [],
|
leftSection: [
|
||||||
rightSection: []
|
{ type: 'button', icon: ArrowLeftIcon, label: 'Retour', onClick: () => router.push({ name: 'scrapper-configurations' }) },
|
||||||
};
|
{ type: 'divider' },
|
||||||
|
{ type: 'label', text: isEditing.value ? 'Modifier la configuration' : 'Nouvelle configuration', class: 'text-sm font-medium' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
...(isEditing.value ? [{ type: 'button', icon: TrashIcon, label: 'Supprimer', onClick: () => { isDeleteModalOpen.value = true; }, class: 'text-red-600 hover:text-red-700' }, { type: 'divider' }] : []),
|
||||||
|
{ type: 'button', icon: PencilSquareIcon, label: isEditing.value ? 'Mettre à jour' : 'Créer', onClick: () => formRef.value?.submitForm(), disabled: saving.value },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const goBack = () => {
|
|
||||||
router.push({ name: 'scrapper-configurations' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (formData) => {
|
const handleSubmit = async (formData) => {
|
||||||
try {
|
try {
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
@@ -279,6 +294,11 @@ const handleTest = async ({ configuration, testData }) => {
|
|||||||
testResults.value = {};
|
testResults.value = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Persister testSlug + testChapterNumber avant de lancer le test
|
||||||
|
if (isEditing.value) {
|
||||||
|
await contentSourceStore.updateSource(route.params.id, configuration);
|
||||||
|
}
|
||||||
|
|
||||||
// Préparer les données selon le format de l'API
|
// Préparer les données selon le format de l'API
|
||||||
const testConfiguration = {
|
const testConfiguration = {
|
||||||
baseUrl: configuration.baseUrl,
|
baseUrl: configuration.baseUrl,
|
||||||
@@ -323,6 +343,21 @@ const handleImageLoad = (event) => {
|
|||||||
event.target.style.display = 'block';
|
event.target.style.display = 'block';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmDeleteSource = async () => {
|
||||||
|
isDeleting.value = true;
|
||||||
|
deleteError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await contentSourceStore.deleteSource(route.params.id);
|
||||||
|
isDeleteModalOpen.value = false;
|
||||||
|
await router.push({ name: 'scrapper-configurations' });
|
||||||
|
} catch (error) {
|
||||||
|
deleteError.value = error.message;
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatErrorType = (type) => {
|
const formatErrorType = (type) => {
|
||||||
const typeMap = {
|
const typeMap = {
|
||||||
'selector_error': 'Erreur sélecteur',
|
'selector_error': 'Erreur sélecteur',
|
||||||
|
|||||||
@@ -64,6 +64,11 @@
|
|||||||
@click="store.setDefaultView('list')">
|
@click="store.setDefaultView('list')">
|
||||||
{{ t('preferences.defaultView.list') }}
|
{{ t('preferences.defaultView.list') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:class="viewButtonClass('table')"
|
||||||
|
@click="store.setDefaultView('table')">
|
||||||
|
{{ t('preferences.defaultView.table') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mangas par page -->
|
<!-- Mangas par page -->
|
||||||
|
|||||||
110
assets/vue/app/domain/system/application/store/logsStore.js
Normal file
110
assets/vue/app/domain/system/application/store/logsStore.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ApiJobRepository } from '../../../activity/infrastructure/api/ApiJobRepository';
|
||||||
|
|
||||||
|
const jobRepository = new ApiJobRepository();
|
||||||
|
|
||||||
|
// Statuts disponibles par filtre
|
||||||
|
const STATUS_MAP = {
|
||||||
|
failed: ['failed'],
|
||||||
|
completed: ['completed'],
|
||||||
|
all: ['failed', 'completed'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLogsStore = defineStore('logs', {
|
||||||
|
state: () => ({
|
||||||
|
logs: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 0,
|
||||||
|
total: 0,
|
||||||
|
limit: 50,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPreviousPage: false,
|
||||||
|
sortBy: 'createdAt',
|
||||||
|
sortOrder: 'DESC',
|
||||||
|
statusFilter: 'failed', // 'failed' | 'completed' | 'all'
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isLoading: state => state.loading,
|
||||||
|
hasError: state => !!state.error,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async loadLogs(page = null) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const collection = await jobRepository.getJobs({
|
||||||
|
page: page || this.currentPage,
|
||||||
|
limit: this.limit,
|
||||||
|
sortBy: this.sortBy,
|
||||||
|
sortOrder: this.sortOrder,
|
||||||
|
status: STATUS_MAP[this.statusFilter],
|
||||||
|
type: 'scraping_job',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logs = collection.items;
|
||||||
|
this.currentPage = collection.page;
|
||||||
|
this.total = collection.total;
|
||||||
|
this.hasNextPage = collection.hasNextPage;
|
||||||
|
this.hasPreviousPage = collection.hasPreviousPage;
|
||||||
|
this.totalPages = Math.ceil(this.total / this.limit);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async goToPage(page) {
|
||||||
|
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||||
|
this.currentPage = page;
|
||||||
|
await this.loadLogs(page);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSort(sortBy, sortOrder) {
|
||||||
|
this.sortBy = sortBy;
|
||||||
|
this.sortOrder = sortOrder;
|
||||||
|
this.currentPage = 1;
|
||||||
|
await this.loadLogs(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setStatusFilter(filter) {
|
||||||
|
this.statusFilter = filter;
|
||||||
|
this.currentPage = 1;
|
||||||
|
await this.loadLogs(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteLog(id) {
|
||||||
|
try {
|
||||||
|
await jobRepository.deleteJob(id);
|
||||||
|
this.logs = this.logs.filter(log => log.id !== id);
|
||||||
|
this.total = Math.max(0, this.total - 1);
|
||||||
|
this.totalPages = Math.ceil(this.total / this.limit);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAllLogs() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jobRepository.deleteJobs({
|
||||||
|
status: STATUS_MAP[this.statusFilter].join(','),
|
||||||
|
type: 'scraping_job',
|
||||||
|
});
|
||||||
|
await this.loadLogs(1);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ApiStatusRepository } from '../../infrastructure/api/ApiStatusRepository';
|
||||||
|
|
||||||
|
const statusRepository = new ApiStatusRepository();
|
||||||
|
|
||||||
|
export const useStatusStore = defineStore('system-status', {
|
||||||
|
state: () => ({
|
||||||
|
status: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async loadStatus() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
this.status = await statusRepository.getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message ?? 'Erreur lors du chargement du statut système';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export class ApiStatusRepository {
|
||||||
|
async getStatus() {
|
||||||
|
const response = await fetch('/api/system/status', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<StatusCard title="Chapitres" :icon="DocumentTextIcon">
|
||||||
|
<div class="flex items-baseline gap-2 mb-3">
|
||||||
|
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalChapters }}</span>
|
||||||
|
<span class="text-sm text-gray-500">total</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1 flex justify-between text-xs text-gray-500">
|
||||||
|
<span>{{ status.downloadedChapters }} téléchargés</span>
|
||||||
|
<span>{{ downloadedPercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full bg-green-500 rounded-full transition-all"
|
||||||
|
:style="{ width: downloadedPercent + '%' }" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">{{ status.pendingChapters }} en attente</p>
|
||||||
|
</StatusCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { DocumentTextIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import StatusCard from './StatusCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadedPercent = computed(() => {
|
||||||
|
if (!props.status.totalChapters) return 0;
|
||||||
|
return Math.round((props.status.downloadedChapters / props.status.totalChapters) * 100);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<StatusCard title="Jobs" :icon="CpuChipIcon">
|
||||||
|
<!-- Onglets -->
|
||||||
|
<div class="flex gap-1 mb-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="activeTab === tab.key
|
||||||
|
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'">
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu -->
|
||||||
|
<template v-if="activeTab === 'global'">
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Metric label="Total" :value="status.totalJobs" />
|
||||||
|
<Metric label="En cours" :value="status.inProgressJobs" color="blue" />
|
||||||
|
<Metric label="Terminés" :value="status.completedJobs" color="green" />
|
||||||
|
<Metric label="En attente" :value="status.pendingJobs" color="yellow" />
|
||||||
|
<Metric label="Échoués" :value="status.failedJobs" color="red" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeTab === '24h'">
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Metric label="Total" :value="status.totalJobsLast24h" />
|
||||||
|
<Metric label="Terminés" :value="status.completedJobsLast24h" color="green" />
|
||||||
|
<Metric label="Échoués" :value="status.failedJobsLast24h" color="red" />
|
||||||
|
<div class="col-span-2">
|
||||||
|
<p class="text-xs text-gray-500 mb-1">Taux de succès</p>
|
||||||
|
<span class="text-xl font-bold" :class="rateColor(status.successRateLast24h)">
|
||||||
|
{{ status.successRateLast24h }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Metric label="Total" :value="status.totalJobsLast7d" />
|
||||||
|
<Metric label="Terminés" :value="status.completedJobsLast7d" color="green" />
|
||||||
|
<Metric label="Échoués" :value="status.failedJobsLast7d" color="red" />
|
||||||
|
<div class="col-span-2">
|
||||||
|
<p class="text-xs text-gray-500 mb-1">Taux de succès</p>
|
||||||
|
<span class="text-xl font-bold" :class="rateColor(status.successRateLast7d)">
|
||||||
|
{{ status.successRateLast7d }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</StatusCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { CpuChipIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import StatusCard from './StatusCard.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
status: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTab = ref('global');
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'global', label: 'Global' },
|
||||||
|
{ key: '24h', label: '24h' },
|
||||||
|
{ key: '7j', label: '7 jours' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function rateColor(rate) {
|
||||||
|
if (rate >= 80) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (rate >= 50) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Metric = {
|
||||||
|
props: {
|
||||||
|
label: String,
|
||||||
|
value: Number,
|
||||||
|
color: { type: String, default: 'gray' },
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500">{{ label }}</p>
|
||||||
|
<p class="text-lg font-semibold"
|
||||||
|
:class="{
|
||||||
|
'text-gray-900 dark:text-white': color === 'gray',
|
||||||
|
'text-green-600 dark:text-green-400': color === 'green',
|
||||||
|
'text-red-600 dark:text-red-400': color === 'red',
|
||||||
|
'text-yellow-600 dark:text-yellow-400': color === 'yellow',
|
||||||
|
'text-blue-600 dark:text-blue-400': color === 'blue',
|
||||||
|
}">{{ value }}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
131
assets/vue/app/domain/system/presentation/components/LogItem.vue
Normal file
131
assets/vue/app/domain/system/presentation/components/LogItem.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 py-4 px-6">
|
||||||
|
<!-- Ligne 1 : Titre manga + chapitre + badge statut + date + bouton supprimer -->
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex items-baseline gap-2 min-w-0">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{{ log.context?.mangaTitle ?? 'Manga inconnu' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 text-sm shrink-0">•</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400 shrink-0">
|
||||||
|
Chapitre {{ log.context?.chapterNumber ?? '?' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'px-1.5 py-0.5 text-xs font-medium shrink-0',
|
||||||
|
log.status === 'completed'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
|
]">
|
||||||
|
{{ log.status === 'completed' ? 'Terminé' : 'Échec' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ formattedDate }}</span>
|
||||||
|
<button
|
||||||
|
@click="$emit('delete', log.id)"
|
||||||
|
class="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors"
|
||||||
|
title="Supprimer ce log">
|
||||||
|
<TrashIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ligne 2 : Source + slug + durée -->
|
||||||
|
<div class="flex items-center justify-between mt-1 gap-4">
|
||||||
|
<div class="flex items-center gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<!-- Domaine de la source (lien vers la page d'édition) -->
|
||||||
|
<RouterLink
|
||||||
|
v-if="source"
|
||||||
|
:to="{ name: 'scrapper-edit', params: { id: source.id } }"
|
||||||
|
class="flex items-center gap-1 hover:text-blue-500 dark:hover:text-blue-400 transition-colors shrink-0">
|
||||||
|
<GlobeAltIcon class="w-3.5 h-3.5" />
|
||||||
|
<span class="font-mono">{{ cleanDomain }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else class="font-mono shrink-0">
|
||||||
|
ID {{ log.context?.sourceId ?? '-' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Badge type de scraping -->
|
||||||
|
<span
|
||||||
|
v-if="source?.scrapingType"
|
||||||
|
:class="[
|
||||||
|
'px-1.5 py-0.5 text-xs font-medium shrink-0',
|
||||||
|
source.scrapingType === 'Javascript'
|
||||||
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
|
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
|
]">
|
||||||
|
{{ source.scrapingType }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Slug utilisé -->
|
||||||
|
<span v-if="log.context?.slug" class="truncate text-gray-400 dark:text-gray-500">
|
||||||
|
slug : <span class="font-mono">{{ log.context.slug }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span v-if="duration !== null" class="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||||
|
{{ duration }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ligne 3 : Message d'erreur -->
|
||||||
|
<div v-if="log.error" class="mt-2">
|
||||||
|
<p
|
||||||
|
:class="[
|
||||||
|
'text-sm font-mono text-red-600 dark:text-red-400',
|
||||||
|
!expanded && isLong ? 'line-clamp-1' : ''
|
||||||
|
]">
|
||||||
|
↳ {{ log.error }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="isLong"
|
||||||
|
@click="expanded = !expanded"
|
||||||
|
class="mt-1 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
{{ expanded ? 'voir moins' : 'voir plus' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { GlobeAltIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
log: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['delete']);
|
||||||
|
|
||||||
|
const expanded = ref(false);
|
||||||
|
|
||||||
|
const isLong = computed(() => props.log.error && props.log.error.length > 120);
|
||||||
|
|
||||||
|
const cleanDomain = computed(() => {
|
||||||
|
if (!props.source?.baseUrl) return null;
|
||||||
|
return props.source.baseUrl.replace(/^(https?:\/\/)?(www\.)?/, '').replace(/\/+$/, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!props.log.createdAt) return '';
|
||||||
|
const d = new Date(props.log.createdAt);
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = computed(() => {
|
||||||
|
if (!props.log.startedAt || !props.log.completedAt) return null;
|
||||||
|
const ms = new Date(props.log.completedAt) - new Date(props.log.startedAt);
|
||||||
|
if (ms < 0) return null;
|
||||||
|
return `${(ms / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 })}s`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<StatusCard title="Mangas" :icon="BookOpenIcon">
|
||||||
|
<div class="flex items-baseline gap-2 mb-3">
|
||||||
|
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalMangas }}</span>
|
||||||
|
<span class="text-sm text-gray-500">total</span>
|
||||||
|
<span class="ml-auto text-sm text-blue-600 dark:text-blue-400">{{ status.monitoredMangas }} suivis</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="(count, label) in status.mangasByStatus"
|
||||||
|
:key="label"
|
||||||
|
class="px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ label }}: {{ count }}
|
||||||
|
</span>
|
||||||
|
<span v-if="!hasStatuses" class="text-xs text-gray-400">Aucun statut disponible</span>
|
||||||
|
</div>
|
||||||
|
</StatusCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { BookOpenIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import StatusCard from './StatusCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasStatuses = computed(() => Object.keys(props.status.mangasByStatus ?? {}).length > 0);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<StatusCard title="Sources" :icon="GlobeAltIcon">
|
||||||
|
<div class="flex items-baseline gap-2 mb-3">
|
||||||
|
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalSources }}</span>
|
||||||
|
<span class="text-sm text-gray-500">sources configurées</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="(count, health) in status.sourcesByHealth"
|
||||||
|
:key="health"
|
||||||
|
class="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
:class="healthBadgeClass(health)">
|
||||||
|
{{ health }}: {{ count }}
|
||||||
|
</span>
|
||||||
|
<span v-if="!hasSources" class="text-xs text-gray-400">Aucune source</span>
|
||||||
|
</div>
|
||||||
|
</StatusCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { GlobeAltIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import StatusCard from './StatusCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasSources = computed(() => Object.keys(props.status.sourcesByHealth ?? {}).length > 0);
|
||||||
|
|
||||||
|
function healthBadgeClass(health) {
|
||||||
|
switch (health) {
|
||||||
|
case 'healthy': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'unhealthy': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<component :is="icon" v-if="icon" class="w-5 h-5 text-blue-500 shrink-0" />
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ title }}</h3>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: [Object, Function],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<StatusCard title="Stockage" :icon="CircleStackIcon">
|
||||||
|
<div class="flex items-baseline gap-2 mb-3">
|
||||||
|
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.storageUsedHuman }}</span>
|
||||||
|
<span class="text-sm text-gray-500">utilisés</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1 flex justify-between text-xs text-gray-500">
|
||||||
|
<span>{{ status.storageFreeHuman }} libres</span>
|
||||||
|
<span>{{ usedPercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all"
|
||||||
|
:class="usedPercent > 90 ? 'bg-red-500' : 'bg-blue-500'"
|
||||||
|
:style="{ width: usedPercent + '%' }" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">Total : {{ status.storageTotalHuman }}</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-400 truncate" :title="status.storagePath">{{ status.storagePath }}</p>
|
||||||
|
</StatusCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { CircleStackIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import StatusCard from './StatusCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const diskUsedBytes = computed(() => props.status.storageTotalBytes - props.status.storageFreeBytes);
|
||||||
|
|
||||||
|
const usedPercent = computed(() => {
|
||||||
|
if (!props.status.storageTotalBytes) return 0;
|
||||||
|
return Math.round((diskUsedBytes.value / props.status.storageTotalBytes) * 100);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<StatusCard title="Informations système" :icon="ServerIcon">
|
||||||
|
<dl class="space-y-2">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<dt class="text-gray-500">Version PHP</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ status.phpVersion }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<dt class="text-gray-500">Généré le</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ formattedDate }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</StatusCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { ServerIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import StatusCard from './StatusCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!props.status.generatedAt) return '';
|
||||||
|
return new Date(props.status.generatedAt).toLocaleString('fr-FR');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
165
assets/vue/app/domain/system/presentation/pages/LogsPage.vue
Normal file
165
assets/vue/app/domain/system/presentation/pages/LogsPage.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="isLoading" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin h-10 w-10 border-b-2 border-blue-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="hasError" class="px-6 py-8">
|
||||||
|
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ExclamationCircleIcon class="w-5 h-5 text-red-400 mr-2 shrink-0" />
|
||||||
|
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="logsStore.loadLogs()"
|
||||||
|
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else-if="!isLoading && logs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
|
||||||
|
<ExclamationCircleIcon class="w-12 h-12 mb-3" />
|
||||||
|
<p class="text-base">Aucune erreur de scraping</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<template v-else>
|
||||||
|
<LogItem
|
||||||
|
v-for="log in logs"
|
||||||
|
:key="log.id"
|
||||||
|
:log="log"
|
||||||
|
:source="getSource(log)"
|
||||||
|
@delete="handleDelete" />
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<Pagination
|
||||||
|
v-if="totalPages > 1"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-pages="totalPages"
|
||||||
|
:total="total"
|
||||||
|
:limit="limit"
|
||||||
|
:has-next-page="hasNextPage"
|
||||||
|
:has-previous-page="hasPreviousPage"
|
||||||
|
@page-change="logsStore.goToPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowPathIcon, ExclamationCircleIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { BarsArrowDownIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
|
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||||
|
import { useContentSourceStore } from '../../../setting/application/store/contentSourceStore';
|
||||||
|
import { useLogsStore } from '../../application/store/logsStore';
|
||||||
|
import LogItem from '../components/LogItem.vue';
|
||||||
|
|
||||||
|
const logsStore = useLogsStore();
|
||||||
|
const contentSourceStore = useContentSourceStore();
|
||||||
|
const { sources } = storeToRefs(contentSourceStore);
|
||||||
|
|
||||||
|
const {
|
||||||
|
logs,
|
||||||
|
loading: isLoading,
|
||||||
|
error,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
hasNextPage,
|
||||||
|
hasPreviousPage,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
statusFilter,
|
||||||
|
} = storeToRefs(logsStore);
|
||||||
|
|
||||||
|
const hasError = computed(() => !!error.value);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
logsStore.loadLogs();
|
||||||
|
contentSourceStore.loadSources();
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSource(log) {
|
||||||
|
const sourceId = log.context?.sourceId;
|
||||||
|
if (!sourceId) return null;
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
return sources.value.find(s => s.id == sourceId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
|
||||||
|
|
||||||
|
const STATUS_FILTERS = [
|
||||||
|
{ key: 'failed', label: 'Échecs' },
|
||||||
|
{ key: 'completed', label: 'Terminés' },
|
||||||
|
{ key: 'all', label: 'Tous' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{ type: 'label', text: 'Logs', class: 'text-sm font-medium' },
|
||||||
|
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
...STATUS_FILTERS.map(f => ({
|
||||||
|
type: 'button',
|
||||||
|
label: f.label,
|
||||||
|
active: statusFilter.value === f.key,
|
||||||
|
onClick: () => logsStore.setStatusFilter(f.key),
|
||||||
|
})),
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
type: 'dropdown',
|
||||||
|
icon: BarsArrowDownIcon,
|
||||||
|
label: 'Trier',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Plus récent',
|
||||||
|
isSelected: isSortSelected('createdAt', 'DESC'),
|
||||||
|
onClick: () => logsStore.updateSort('createdAt', 'DESC'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Plus ancien',
|
||||||
|
isSelected: isSortSelected('createdAt', 'ASC'),
|
||||||
|
onClick: () => logsStore.updateSort('createdAt', 'ASC'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: 'Rafraîchir',
|
||||||
|
disabled: isLoading.value,
|
||||||
|
onClick: () => logsStore.loadLogs(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: TrashIcon,
|
||||||
|
label: 'Tout supprimer',
|
||||||
|
disabled: isLoading.value || total.value === 0,
|
||||||
|
onClick: handleDeleteAll,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
await logsStore.deleteLog(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAll() {
|
||||||
|
if (!confirm('Supprimer tous les logs d\'erreur ? Cette action est irréversible.')) return;
|
||||||
|
await logsStore.deleteAllLogs();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin h-10 w-10 border-b-2 border-blue-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="px-6 py-8">
|
||||||
|
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 rounded">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ExclamationCircleIcon class="w-5 h-5 text-red-400 mr-2 shrink-0" />
|
||||||
|
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="statusStore.loadStatus()"
|
||||||
|
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Données -->
|
||||||
|
<div v-else-if="status" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4">
|
||||||
|
<MangasStatusCard :status="status" />
|
||||||
|
<ChaptersStatusCard :status="status" />
|
||||||
|
<JobsStatusCard :status="status" />
|
||||||
|
<StorageStatusCard :status="status" />
|
||||||
|
<SourcesStatusCard :status="status" />
|
||||||
|
<SystemInfoCard :status="status" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowPathIcon, ExclamationCircleIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
|
import { useStatusStore } from '../../application/store/statusStore';
|
||||||
|
import ChaptersStatusCard from '../components/ChaptersStatusCard.vue';
|
||||||
|
import JobsStatusCard from '../components/JobsStatusCard.vue';
|
||||||
|
import MangasStatusCard from '../components/MangasStatusCard.vue';
|
||||||
|
import SourcesStatusCard from '../components/SourcesStatusCard.vue';
|
||||||
|
import StorageStatusCard from '../components/StorageStatusCard.vue';
|
||||||
|
import SystemInfoCard from '../components/SystemInfoCard.vue';
|
||||||
|
|
||||||
|
const statusStore = useStatusStore();
|
||||||
|
const { status, loading, error } = storeToRefs(statusStore);
|
||||||
|
|
||||||
|
onMounted(() => statusStore.loadStatus());
|
||||||
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{ type: 'label', text: 'Statut système', class: 'text-sm font-medium' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: 'Rafraîchir',
|
||||||
|
disabled: loading.value,
|
||||||
|
onClick: () => statusStore.loadStatus(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
@@ -3,30 +3,17 @@ import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue
|
|||||||
import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue';
|
import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue';
|
||||||
import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue';
|
import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue';
|
||||||
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
|
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
|
||||||
|
import DiscoverPage from '../domain/manga/presentation/pages/DiscoverPage.vue';
|
||||||
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
|
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
|
||||||
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
||||||
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
||||||
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
|
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
|
||||||
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
|
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
|
||||||
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
|
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
|
||||||
|
import LogsPage from '../domain/system/presentation/pages/LogsPage.vue';
|
||||||
|
import StatusPage from '../domain/system/presentation/pages/StatusPage.vue';
|
||||||
import Layout from '../shared/components/layout/Layout.vue';
|
import Layout from '../shared/components/layout/Layout.vue';
|
||||||
|
|
||||||
// Placeholder component for new routes
|
|
||||||
const PlaceholderComponent = {
|
|
||||||
props: {
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
template: `
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<h1 class="text-2xl font-bold mb-4">{{ title }}</h1>
|
|
||||||
<p class="text-gray-600">Cette fonctionnalité sera bientôt disponible.</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -64,30 +51,16 @@ const routes = [
|
|||||||
name: 'import',
|
name: 'import',
|
||||||
component: NewImportPage
|
component: NewImportPage
|
||||||
},
|
},
|
||||||
// Pages placeholder avec chargement différé
|
|
||||||
{
|
|
||||||
path: '/manga/import',
|
|
||||||
name: 'manga-import',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Import de bibliothèque' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/manga/discover',
|
path: '/manga/discover',
|
||||||
name: 'discover',
|
name: 'discover',
|
||||||
component: PlaceholderComponent,
|
component: DiscoverPage
|
||||||
props: { title: 'Découvrir' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/convert',
|
path: '/convert',
|
||||||
name: 'convert',
|
name: 'convert',
|
||||||
component: ConversionPage
|
component: ConversionPage
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/calendar',
|
|
||||||
name: 'calendar',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Calendrier' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/activity',
|
path: '/activity',
|
||||||
name: 'activity',
|
name: 'activity',
|
||||||
@@ -96,21 +69,7 @@ const routes = [
|
|||||||
// Paramètres
|
// Paramètres
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: 'settings',
|
redirect: '/settings/scrappers',
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Paramètres' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/settings/general',
|
|
||||||
name: 'settings-general',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Paramètres généraux' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/settings/folders',
|
|
||||||
name: 'settings-folders',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Gestion des dossiers' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings/scrappers',
|
path: '/settings/scrappers',
|
||||||
@@ -135,34 +94,18 @@ const routes = [
|
|||||||
// Système
|
// Système
|
||||||
{
|
{
|
||||||
path: '/system',
|
path: '/system',
|
||||||
name: 'system',
|
redirect: '/system/status',
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Système' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/status',
|
path: '/system/status',
|
||||||
name: 'system-status',
|
name: 'system-status',
|
||||||
component: PlaceholderComponent,
|
component: StatusPage,
|
||||||
props: { title: 'Status du système' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/system/backup',
|
|
||||||
name: 'system-backup',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Sauvegarde' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/logs',
|
path: '/system/logs',
|
||||||
name: 'system-logs',
|
name: 'system-logs',
|
||||||
component: PlaceholderComponent,
|
component: LogsPage,
|
||||||
props: { title: 'Journaux système' }
|
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/system/updates',
|
|
||||||
name: 'system-updates',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
props: { title: 'Mises à jour' }
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,20 +15,41 @@
|
|||||||
<Bars3Icon class="h-6 w-6" />
|
<Bars3Icon class="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center flex-1">
|
<div class="flex items-center flex-1">
|
||||||
<router-link to="/" class="text-white text-2xl font-bold ml-4">
|
<router-link to="/" class="ml-4">
|
||||||
Mangarr
|
<img src="/img/mangarr_logo.png" alt="Mangarr" class="h-10" />
|
||||||
</router-link>
|
</router-link>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</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>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useHeaderStore } from '../../stores/headerStore';
|
||||||
|
import { useUserPreferencesStore } from '../../../domain/setting/application/store/userPreferencesStore';
|
||||||
import SearchBar from './SearchBar.vue';
|
import SearchBar from './SearchBar.vue';
|
||||||
|
|
||||||
const headerStore = useHeaderStore();
|
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({
|
defineProps({
|
||||||
showMenuButton: {
|
showMenuButton: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
|
<div class="h-[100dvh] overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
|
||||||
<Header
|
<Header
|
||||||
:show-menu-button="isReaderMode"
|
:show-menu-button="isReaderMode"
|
||||||
@menu-click="toggleSidebar"
|
@menu-click="toggleSidebar"
|
||||||
@@ -12,10 +12,11 @@
|
|||||||
@add-manga-click="$emit('add-manga-click', $event)" />
|
@add-manga-click="$emit('add-manga-click', $event)" />
|
||||||
|
|
||||||
<main :class="[
|
<main :class="[
|
||||||
'flex-1 mt-16 flex flex-col overflow-hidden',
|
'flex-1 flex flex-col overflow-hidden',
|
||||||
|
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
|
||||||
isReaderMode ? '' : 'md:ml-60'
|
isReaderMode ? '' : 'md:ml-60'
|
||||||
]">
|
]" style="transition: margin-top 300ms ease-in-out;">
|
||||||
<RouterView></RouterView>
|
<RouterView class="flex-1 min-h-0"></RouterView>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -23,10 +24,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useHeaderStore } from '../../stores/headerStore';
|
||||||
import Header from './Header.vue';
|
import Header from './Header.vue';
|
||||||
import Sidebar from './Sidebar.vue';
|
import Sidebar from './Sidebar.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const headerStore = useHeaderStore();
|
||||||
const isSidebarOpen = ref(false);
|
const isSidebarOpen = ref(false);
|
||||||
|
|
||||||
// Détecte si on est en mode Reader
|
// Détecte si on est en mode Reader
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
ArrowsRightLeftIcon,
|
ArrowsRightLeftIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
CalendarIcon,
|
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
ComputerDesktopIcon,
|
ComputerDesktopIcon,
|
||||||
@@ -69,12 +68,6 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
|||||||
to: '/convert',
|
to: '/convert',
|
||||||
id: 'convert'
|
id: 'convert'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: CalendarIcon,
|
|
||||||
text: 'Calendrier',
|
|
||||||
to: '/calendar',
|
|
||||||
id: 'calendar'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: ClockIcon,
|
icon: ClockIcon,
|
||||||
text: 'Activité',
|
text: 'Activité',
|
||||||
@@ -85,11 +78,9 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
|||||||
{
|
{
|
||||||
icon: Cog6ToothIcon,
|
icon: Cog6ToothIcon,
|
||||||
text: 'Paramètres',
|
text: 'Paramètres',
|
||||||
to: '/settings',
|
to: '/settings/scrappers',
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
subItems: [
|
subItems: [
|
||||||
{ icon: null, text: 'Général', to: '/settings/general' },
|
|
||||||
{ icon: null, text: 'Dossiers', to: '/settings/folders' },
|
|
||||||
{ icon: null, text: 'Scrappers', to: '/settings/scrappers' },
|
{ icon: null, text: 'Scrappers', to: '/settings/scrappers' },
|
||||||
{ icon: null, text: 'UI', to: '/settings/ui' }
|
{ icon: null, text: 'UI', to: '/settings/ui' }
|
||||||
]
|
]
|
||||||
@@ -97,13 +88,11 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
|||||||
{
|
{
|
||||||
icon: ComputerDesktopIcon,
|
icon: ComputerDesktopIcon,
|
||||||
text: 'Système',
|
text: 'Système',
|
||||||
to: '/system',
|
to: '/system/status',
|
||||||
id: 'system',
|
id: 'system',
|
||||||
subItems: [
|
subItems: [
|
||||||
{ icon: null, text: 'Status', to: '/system/status' },
|
{ icon: null, text: 'Status', to: '/system/status' },
|
||||||
{ icon: null, text: 'Backup', to: '/system/backup' },
|
|
||||||
{ icon: null, text: 'Logs', to: '/system/logs' },
|
{ icon: null, text: 'Logs', to: '/system/logs' },
|
||||||
{ icon: null, text: 'Updates', to: '/system/updates' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,24 +3,25 @@
|
|||||||
class="border-l-4"
|
class="border-l-4"
|
||||||
:class="{
|
:class="{
|
||||||
'border-green-600': isActive,
|
'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
|
<RouterLink
|
||||||
:to="to"
|
:to="to"
|
||||||
class="flex-grow px-4 py-2 flex items-center"
|
class="flex-grow px-4 py-2 flex items-center"
|
||||||
:class="{
|
:class="isActive
|
||||||
'text-green-600 bg-gray-800': isActive
|
? 'text-green-600 bg-gray-800'
|
||||||
}">
|
: 'hover:bg-gray-700 hover:text-white'">
|
||||||
<div class="flex items-center flex-grow">
|
<component :is="icon" class="w-5 h-5 mr-3" />
|
||||||
<component :is="icon" class="w-5 h-5 mr-3" />
|
<span class="px-2">{{ text }}</span>
|
||||||
<span class="px-2">{{ text }}</span>
|
|
||||||
</div>
|
|
||||||
<component
|
|
||||||
v-if="subItems.length > 0"
|
|
||||||
:is="expanded ? ChevronUpIcon : ChevronDownIcon"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
</RouterLink>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ul v-if="subItems.length > 0" class="ml-8 mt-2 space-y-4" v-show="expanded">
|
<ul v-if="subItems.length > 0" class="ml-8 mt-2 space-y-4" v-show="expanded">
|
||||||
@@ -71,14 +72,14 @@
|
|||||||
|
|
||||||
const isActive = computed(() => {
|
const isActive = computed(() => {
|
||||||
if (!props.to) {
|
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 === '/') {
|
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 => {
|
const isRouteMatching = path => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<li>
|
<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 }}
|
{{ text }}
|
||||||
</RouterLink>
|
</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 }}
|
{{ text }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,35 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="file-upload">
|
<div class="file-upload">
|
||||||
<label :for="inputId" class="block text-sm font-medium text-gray-700 mb-2">
|
<label :for="inputId" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
|
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed "
|
||||||
:class="{ 'border-green-500 bg-green-50': isDragOver, 'hover:border-gray-400': !isDragOver }"
|
:class="{ 'border-green-500 bg-green-50 dark:bg-green-900/20': isDragOver, 'hover:border-gray-400': !isDragOver }"
|
||||||
@drop.prevent="handleDrop"
|
@drop.prevent="handleDrop"
|
||||||
@dragover.prevent="isDragOver = true"
|
@dragover.prevent="isDragOver = true"
|
||||||
@dragleave.prevent="isDragOver = false"
|
@dragleave.prevent="isDragOver = false"
|
||||||
>
|
>
|
||||||
<div class="space-y-1 text-center">
|
<div class="space-y-1 text-center">
|
||||||
<svg
|
<ArrowUpTrayIcon class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||||
class="mx-auto h-12 w-12 text-gray-400"
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 48 48"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="flex text-sm text-gray-600">
|
<div class="flex text-sm text-gray-600">
|
||||||
<label
|
<label
|
||||||
:for="inputId"
|
:for="inputId"
|
||||||
class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"
|
class="relative cursor-pointer font-medium text-green-600 hover:text-green-500"
|
||||||
>
|
>
|
||||||
<span>Sélectionner des fichiers</span>
|
<span>Sélectionner des fichiers</span>
|
||||||
<input
|
<input
|
||||||
@@ -50,8 +38,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="selectedFiles.length > 0" class="mt-4">
|
<div v-if="selectedFiles.length > 0" class="mt-4">
|
||||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Fichiers sélectionnés :</h4>
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Fichiers sélectionnés :</h4>
|
||||||
<ul class="text-xs text-gray-600 space-y-1">
|
<ul class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||||
<li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
|
<li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
|
||||||
<span class="truncate">{{ file.name }}</span>
|
<span class="truncate">{{ file.name }}</span>
|
||||||
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
|
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
|
||||||
@@ -64,6 +52,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ArrowUpTrayIcon } from '@heroicons/vue/24/outline';
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<!-- Left section -->
|
<!-- Left section -->
|
||||||
<ToolbarSection :items="config.leftSection" />
|
<ToolbarSection :items="config.leftSection" />
|
||||||
|
|
||||||
|
<!-- Center section (optional slot) -->
|
||||||
|
<slot name="center" />
|
||||||
|
|
||||||
<!-- Right section -->
|
<!-- Right section -->
|
||||||
<ToolbarSection :items="config.rightSection" />
|
<ToolbarSection :items="config.rightSection" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
@click="$emit('click')"
|
@click="$emit('click')"
|
||||||
|
:disabled="disabled"
|
||||||
:class="[
|
: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',
|
'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
|
active
|
||||||
? 'text-green-500' // Style actif
|
? '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'">
|
:aria-label="label || 'Toolbar button'">
|
||||||
<component v-if="icon" :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" />
|
<component v-if="icon" :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
@@ -30,6 +32,10 @@
|
|||||||
active: {
|
active: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
:icon="item.icon"
|
:icon="item.icon"
|
||||||
:label="item.label"
|
:label="item.label"
|
||||||
:active="item.active"
|
:active="item.active"
|
||||||
|
:disabled="item.disabled"
|
||||||
@click="item.onClick" />
|
@click="item.onClick" />
|
||||||
<ToolbarDropdown
|
<ToolbarDropdown
|
||||||
v-else-if="item.type === 'dropdown'"
|
v-else-if="item.type === 'dropdown'"
|
||||||
@@ -14,7 +15,9 @@
|
|||||||
:active="item.active"
|
:active="item.active"
|
||||||
:items="item.items" />
|
:items="item.items" />
|
||||||
<Divider v-else-if="item.type === 'divider'" />
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
item.type &&
|
item.type &&
|
||||||
(item.type === 'button' ||
|
(item.type === 'button' ||
|
||||||
item.type === 'divider' ||
|
item.type === 'divider' ||
|
||||||
|
item.type === 'label' ||
|
||||||
(item.type === 'dropdown' && Array.isArray(item.items)))
|
(item.type === 'dropdown' && Array.isArray(item.items)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"defaultView": {
|
"defaultView": {
|
||||||
"label": "Default view",
|
"label": "Default view",
|
||||||
"grid": "Grid",
|
"grid": "Grid",
|
||||||
"list": "List"
|
"list": "List",
|
||||||
|
"table": "Table"
|
||||||
},
|
},
|
||||||
"itemsPerPage": {
|
"itemsPerPage": {
|
||||||
"label": "Mangas per page"
|
"label": "Mangas per page"
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"defaultView": {
|
"defaultView": {
|
||||||
"label": "Vue par défaut",
|
"label": "Vue par défaut",
|
||||||
"grid": "Grille",
|
"grid": "Grille",
|
||||||
"list": "Liste"
|
"list": "Liste",
|
||||||
|
"table": "Tableau"
|
||||||
},
|
},
|
||||||
"itemsPerPage": {
|
"itemsPerPage": {
|
||||||
"label": "Mangas par page"
|
"label": "Mangas par page"
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ export const useHeaderStore = defineStore('header', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
isHeaderVisible: true,
|
isHeaderVisible: true,
|
||||||
isAutoHideEnabled: false,
|
isAutoHideEnabled: false,
|
||||||
|
isReaderToolbarVisible: true,
|
||||||
|
isReaderToolbarAutoHideEnabled: false,
|
||||||
lastScrollY: 0,
|
lastScrollY: 0,
|
||||||
scrollDirection: 'up'
|
scrollDirection: 'up'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
shouldShowHeader: (state) => {
|
shouldShowHeader: (state) => {
|
||||||
// Si l'auto-hide n'est pas activé, toujours afficher le header
|
if (!state.isAutoHideEnabled) return true;
|
||||||
if (!state.isAutoHideEnabled) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si l'auto-hide est activé, suivre la visibilité
|
|
||||||
return state.isHeaderVisible;
|
return state.isHeaderVisible;
|
||||||
|
},
|
||||||
|
shouldShowReaderToolbar: (state) => {
|
||||||
|
if (!state.isReaderToolbarAutoHideEnabled) return true;
|
||||||
|
return state.isReaderToolbarVisible;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -27,35 +28,47 @@ export const useHeaderStore = defineStore('header', {
|
|||||||
|
|
||||||
disableAutoHide() {
|
disableAutoHide() {
|
||||||
this.isAutoHideEnabled = false;
|
this.isAutoHideEnabled = false;
|
||||||
this.isHeaderVisible = true; // Toujours visible quand désactivé
|
this.isHeaderVisible = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateScrollDirection(scrollY) {
|
enableReaderToolbarAutoHide() {
|
||||||
// Éviter les calculs inutiles si pas d'auto-hide
|
this.isReaderToolbarAutoHideEnabled = true;
|
||||||
if (!this.isAutoHideEnabled) {
|
this.isReaderToolbarVisible = true;
|
||||||
this.lastScrollY = scrollY;
|
},
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
const scrollDifference = Math.abs(scrollY - this.lastScrollY);
|
||||||
|
|
||||||
if (scrollDifference < 5) {
|
if (scrollDifference < 5) {
|
||||||
// Mouvement trop petit, on ignore
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollY > this.lastScrollY && scrollY > 100) {
|
if (scrollY > this.lastScrollY && scrollY > 100) {
|
||||||
// Scroll vers le bas et suffisamment de scroll
|
|
||||||
if (this.scrollDirection !== 'down') {
|
if (this.scrollDirection !== 'down') {
|
||||||
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) {
|
} else if (scrollY < this.lastScrollY) {
|
||||||
// Scroll vers le haut
|
|
||||||
if (this.scrollDirection !== 'up') {
|
if (this.scrollDirection !== 'up') {
|
||||||
this.scrollDirection = 'up';
|
this.scrollDirection = 'up';
|
||||||
this.isHeaderVisible = true;
|
if (this.isAutoHideEnabled) this.isHeaderVisible = true;
|
||||||
|
if (this.isReaderToolbarAutoHideEnabled) this.isReaderToolbarVisible = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,56 +3,55 @@
|
|||||||
"type": "project",
|
"type": "project",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "A minimal Symfony project recommended to create bare bones applications",
|
"description": "A minimal Symfony project recommended to create bare bones applications",
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3.1",
|
"php": ">=8.4.0",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-curl": "*",
|
"ext-curl": "*",
|
||||||
"ext-gd": "*",
|
"ext-gd": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"api-platform/core": "^3.2",
|
"api-platform/core": "^4.0",
|
||||||
"doctrine/dbal": "^3",
|
"doctrine/dbal": "^4",
|
||||||
"doctrine/doctrine-bundle": "^2.11",
|
"doctrine/doctrine-bundle": "^3.0",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||||
"doctrine/orm": "^2.17",
|
"doctrine/orm": "^3.0",
|
||||||
"guzzlehttp/guzzle": "^7.8",
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
"intervention/image": "^3.7",
|
"intervention/image": "^3.7",
|
||||||
"nelmio/cors-bundle": "^2.4",
|
"nelmio/cors-bundle": "^2.4",
|
||||||
"phpdocumentor/reflection-docblock": "^5.3",
|
"phpdocumentor/reflection-docblock": "^5.3",
|
||||||
"phpstan/phpdoc-parser": "^1.25",
|
"phpstan/phpdoc-parser": "^1.25",
|
||||||
"ramsey/uuid": "^4.7",
|
"ramsey/uuid": "^4.7",
|
||||||
"runtime/frankenphp-symfony": "^0.2.0",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/asset": "7.0.*",
|
"symfony/console": "8.0.*",
|
||||||
"symfony/console": "7.0.*",
|
"symfony/css-selector": "8.0.*",
|
||||||
"symfony/css-selector": "7.0.*",
|
"symfony/doctrine-messenger": "8.0.*",
|
||||||
"symfony/doctrine-messenger": "7.0.*",
|
"symfony/dotenv": "8.0.*",
|
||||||
"symfony/dotenv": "7.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/expression-language": "7.0.*",
|
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/form": "7.0.*",
|
"symfony/form": "8.0.*",
|
||||||
"symfony/framework-bundle": "7.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/http-client": "7.0.*",
|
"symfony/http-client": "8.0.*",
|
||||||
"symfony/mercure-bundle": "^0.3.9",
|
"symfony/mercure-bundle": "^0.4",
|
||||||
"symfony/messenger": "7.0.*",
|
"symfony/messenger": "8.0.*",
|
||||||
"symfony/mime": "7.0.*",
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/monolog-bundle": "^3.10",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
"symfony/panther": "^2.1",
|
"symfony/panther": "^2.1",
|
||||||
"symfony/property-access": "7.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "7.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
"symfony/runtime": "7.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
"symfony/scheduler": "7.0.*",
|
"symfony/scheduler": "8.0.*",
|
||||||
"symfony/security-bundle": "7.0.*",
|
"symfony/security-bundle": "8.0.*",
|
||||||
"symfony/serializer": "7.0.*",
|
"symfony/serializer": "8.0.*",
|
||||||
"symfony/stimulus-bundle": "^2.17",
|
"symfony/stimulus-bundle": "^2.17",
|
||||||
"symfony/twig-bundle": "7.0.*",
|
"symfony/twig-bundle": "8.0.*",
|
||||||
"symfony/ux-live-component": "^2.17",
|
"symfony/ux-live-component": "^2.17",
|
||||||
"symfony/ux-react": "^2.23",
|
"symfony/ux-react": "^2.23",
|
||||||
"symfony/ux-turbo": "^2.18",
|
"symfony/ux-turbo": "^2.18",
|
||||||
"symfony/validator": "7.0.*",
|
"symfony/validator": "8.0.*",
|
||||||
"symfony/webpack-encore-bundle": "^2.1",
|
"symfony/webpack-encore-bundle": "^2.1",
|
||||||
"symfony/yaml": "7.0.*",
|
"symfony/yaml": "8.0.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/twig": "^2.12|^3.0",
|
"twig/twig": "^2.12|^3.0",
|
||||||
"vich/uploader-bundle": "^2.7"
|
"vich/uploader-bundle": "^2.7"
|
||||||
@@ -103,7 +102,7 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.0.*",
|
"require": "8.0.*",
|
||||||
"docker": true
|
"docker": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,18 +110,18 @@
|
|||||||
"dama/doctrine-test-bundle": "^8.2",
|
"dama/doctrine-test-bundle": "^8.2",
|
||||||
"dbrekelmans/bdi": "^1.3",
|
"dbrekelmans/bdi": "^1.3",
|
||||||
"deployer/deployer": "^7.5",
|
"deployer/deployer": "^7.5",
|
||||||
"doctrine/doctrine-fixtures-bundle": "^3.5",
|
"doctrine/doctrine-fixtures-bundle": "^4.0",
|
||||||
"friendsofphp/php-cs-fixer": "^3.48",
|
"friendsofphp/php-cs-fixer": "^3.48",
|
||||||
"mtdowling/jmespath.php": "^2.7",
|
"mtdowling/jmespath.php": "^2.7",
|
||||||
"phparkitect/phparkitect": "^0.3.33",
|
"phparkitect/phparkitect": "^0.8",
|
||||||
"phpmd/phpmd": "^2.15",
|
"phpmd/phpmd": "3.x-dev",
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5",
|
||||||
"symfony/browser-kit": "7.0.*",
|
"symfony/browser-kit": "8.0.*",
|
||||||
"symfony/maker-bundle": "^1.52",
|
"symfony/maker-bundle": "^1.52",
|
||||||
"symfony/phpunit-bridge": "^7.0",
|
"symfony/phpunit-bridge": "^8.0",
|
||||||
"symfony/stopwatch": "7.0.*",
|
"symfony/stopwatch": "8.0.*",
|
||||||
"symfony/web-profiler-bundle": "7.0.*",
|
"symfony/web-profiler-bundle": "8.0.*",
|
||||||
"zenstruck/browser": "^1.8",
|
"zenstruck/browser": "^1.8",
|
||||||
"zenstruck/foundry": "^1.36"
|
"zenstruck/foundry": "^2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3796
composer.lock
generated
3796
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,6 @@ api_platform:
|
|||||||
extra_properties:
|
extra_properties:
|
||||||
standard_put: true
|
standard_put: true
|
||||||
rfc_7807_compliant_errors: true
|
rfc_7807_compliant_errors: true
|
||||||
event_listeners_backward_compatibility_layer: false
|
|
||||||
keep_legacy_inflector: false
|
|
||||||
mapping:
|
mapping:
|
||||||
paths:
|
paths:
|
||||||
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
|
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
|
||||||
@@ -34,5 +32,6 @@ api_platform:
|
|||||||
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
|
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
|
||||||
- '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource'
|
- '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource'
|
||||||
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
|
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
|
||||||
|
- '%kernel.project_dir%/src/Domain/System/Infrastructure/ApiPlatform/Resource'
|
||||||
patch_formats:
|
patch_formats:
|
||||||
json: ['application/merge-patch+json']
|
json: ['application/merge-patch+json']
|
||||||
|
|||||||
11
config/packages/csrf.yaml
Normal file
11
config/packages/csrf.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Enable stateless CSRF protection for forms and logins/logouts
|
||||||
|
framework:
|
||||||
|
form:
|
||||||
|
csrf_protection:
|
||||||
|
token_id: submit
|
||||||
|
|
||||||
|
csrf_protection:
|
||||||
|
stateless_token_ids:
|
||||||
|
- submit
|
||||||
|
- authenticate
|
||||||
|
- logout
|
||||||
@@ -3,7 +3,6 @@ doctrine:
|
|||||||
connections:
|
connections:
|
||||||
default:
|
default:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
use_savepoints: true
|
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
|
|
||||||
# IMPORTANT: You MUST configure your server version,
|
# IMPORTANT: You MUST configure your server version,
|
||||||
@@ -11,9 +10,6 @@ doctrine:
|
|||||||
#server_version: '16'
|
#server_version: '16'
|
||||||
|
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: true
|
|
||||||
enable_lazy_ghost_objects: true
|
|
||||||
report_fields_where_declared: true
|
|
||||||
validate_xml_mapping: true
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
@@ -40,7 +36,6 @@ when@test:
|
|||||||
dbal:
|
dbal:
|
||||||
connections:
|
connections:
|
||||||
default:
|
default:
|
||||||
use_savepoints: true
|
|
||||||
# "TEST_TOKEN" is typically set by ParaTest
|
# "TEST_TOKEN" is typically set by ParaTest
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ framework:
|
|||||||
command.bus:
|
command.bus:
|
||||||
middleware:
|
middleware:
|
||||||
- validation
|
- validation
|
||||||
- doctrine_transaction
|
|
||||||
event.bus:
|
event.bus:
|
||||||
default_middleware: allow_no_handlers
|
default_middleware: allow_no_handlers
|
||||||
|
|
||||||
@@ -38,10 +37,6 @@ framework:
|
|||||||
'App\Domain\Shared\Domain\Event\VolumeImported': events
|
'App\Domain\Shared\Domain\Event\VolumeImported': events
|
||||||
'App\Domain\Shared\Domain\Event\ChapterScraped': events
|
'App\Domain\Shared\Domain\Event\ChapterScraped': events
|
||||||
|
|
||||||
# Legacy messages (à garder si nécessaire)
|
|
||||||
'App\Message\DownloadChapter': commands
|
|
||||||
'App\Message\RefreshMetadata': commands
|
|
||||||
'App\Message\RefreshAndDownloadChapters': commands
|
|
||||||
|
|
||||||
# when@test:
|
# when@test:
|
||||||
# framework:
|
# framework:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user