Compare commits
151 Commits
4cd277aec7
...
feat/monit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2289156f57 | ||
|
|
f42b5a9cf5 | ||
| 214f470e77 | |||
|
|
345434c25d | ||
| 2868772f5c | |||
| a2469b6c07 | |||
|
|
926f938c45 | ||
| 5551d73962 | |||
| 395a0a16cb | |||
|
|
8e2e608ad9 | ||
| 0f80cb9fec | |||
| a3477629fb | |||
|
|
cde701986e | ||
| b921768aef | |||
|
|
5f0178f784 | ||
| 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 | |||
|
|
cc27fc4564 | ||
|
|
e1909b9804 | ||
|
|
07d3b56d1b | ||
|
|
ac19cc53ca | ||
|
|
15cb59e420 | ||
|
|
d4e456961a | ||
|
|
465a05c13b | ||
|
|
2ffe559832 | ||
|
|
5eb650df6f | ||
| b60a68cbd7 | |||
|
|
ec1ef8fe68 | ||
| 48d819ba72 | |||
| 156d2eea37 | |||
|
|
e5c319db79 | ||
|
|
41ca08f20e | ||
| 13653b4ced | |||
| e9b56b80e6 | |||
|
|
95f224d69a | ||
|
|
ff8b945014 | ||
|
|
2a8b6bc397 | ||
|
|
eb25d2c34e | ||
|
|
c981ce27c5 | ||
|
|
6f3efab0fc | ||
|
|
ed86c9074d | ||
|
|
1becbe9254 | ||
|
|
aea4e57b9e | ||
|
|
19395b4869 | ||
|
|
f418b36167 | ||
|
|
c085c3453a | ||
|
|
d299e0b9ae | ||
|
|
e78a6230b5 | ||
|
|
9d61e4231a | ||
|
|
027f795bc0 | ||
|
|
19f1633c7a | ||
|
|
751fb1e74b | ||
|
|
c60301d1ca | ||
|
|
944994b7d7 | ||
|
|
08e005a0d3 | ||
|
|
566b62450e | ||
|
|
16f87d5f06 | ||
|
|
78971a7e2b | ||
|
|
b1feb6a83f | ||
|
|
8b41626894 | ||
|
|
4e7a277d49 | ||
|
|
01428cbdeb | ||
|
|
5f5271e1b5 | ||
|
|
939f6da0c4 | ||
|
|
0756460fbc | ||
|
|
3941cb4b8f | ||
|
|
3507349167 | ||
| 487f400418 | |||
|
|
322c396165 | ||
| 6875ad4222 | |||
|
|
c311cfe80c | ||
|
|
d444f86315 | ||
|
|
7506a7a3c1 |
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:`
|
||||||
251
.claude/skills/vue-frontend/SKILL.md
Normal file
251
.claude/skills/vue-frontend/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
name: vue-frontend
|
||||||
|
description: Architecture Vue.js du projet Mangarr — structure DDD front (domain/application/infrastructure/presentation), patterns Pinia store, TanStack Query composables, API repositories, conventions de nommage. Utiliser quand on crée ou modifie un composant Vue, une page, un store Pinia, un composable, ou un repository API dans assets/vue/app/.
|
||||||
|
allowed-tools: Read, Grep, Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
# Architecture Vue.js — Mangarr Frontend
|
||||||
|
|
||||||
|
## Structure des dossiers
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/vue/app/
|
||||||
|
index.js # Point d'entrée : Vue + Pinia + Router + VueQuery
|
||||||
|
App.vue # Root : <router-view> + <NotificationToast>
|
||||||
|
router/index.js # Routes imbriquées sous Layout, base /vue/
|
||||||
|
domain/
|
||||||
|
{DomainName}/
|
||||||
|
domain/
|
||||||
|
entities/ # Classes entités JS
|
||||||
|
constants/ # Constantes du domaine
|
||||||
|
application/
|
||||||
|
store/ # Stores Pinia
|
||||||
|
infrastructure/
|
||||||
|
api/ # Clients HTTP (ApiXxxRepository)
|
||||||
|
presentation/
|
||||||
|
pages/ # Composants pleine page
|
||||||
|
components/ # Composants réutilisables
|
||||||
|
composables/ # Logique Vue (useXxx)
|
||||||
|
shared/
|
||||||
|
components/
|
||||||
|
layout/ # Layout, Header, Sidebar
|
||||||
|
ui/ # Composants UI génériques
|
||||||
|
composables/ # useNotifications, etc.
|
||||||
|
stores/ # headerStore, menuStore
|
||||||
|
plugin/ # vueQuery.js config
|
||||||
|
```
|
||||||
|
|
||||||
|
**Domaines existants :** `manga`, `reader`, `import`, `conversion`, `activity`, `setting`
|
||||||
|
|
||||||
|
## Conventions de nommage
|
||||||
|
|
||||||
|
| Couche | Pattern | Exemple |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| Entité | `PascalCase` | `Manga`, `ImportFile`, `Job` |
|
||||||
|
| Store Pinia | `use{Domain}Store()` | `useMangaStore()` |
|
||||||
|
| Composable | `use{Feature}()` | `useMangaDetails()`, `useNotifications()` |
|
||||||
|
| Repository API | `Api{Domain}Repository` | `ApiMangaRepository` |
|
||||||
|
| Page | `{Domain}{Action}.vue` | `MangaDetails.vue`, `NewImportPage.vue` |
|
||||||
|
| Composant | `{Domain}{Feature}.vue` | `MangaCard.vue`, `StatusBadge.vue` |
|
||||||
|
| Modal | `{Feature}Modal.vue` | `MangaDeleteModal.vue` |
|
||||||
|
|
||||||
|
## Pattern Store Pinia
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// application/store/xyzStore.js
|
||||||
|
export const useXyzStore = defineStore('xyz', {
|
||||||
|
state: () => ({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isReady: (state) => state.data && !state.isLoading,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async load() {
|
||||||
|
this.isLoading = true
|
||||||
|
try {
|
||||||
|
const repo = new ApiXyzRepository()
|
||||||
|
this.data = await repo.getAll()
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern Composable avec TanStack Query
|
||||||
|
|
||||||
|
Préférer TanStack Query pour les lectures (queries), le store Pinia pour les mutations et l'état global.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// presentation/composables/useXyzDetails.js
|
||||||
|
export function useXyzDetails(xyzId) {
|
||||||
|
const repo = new ApiXyzRepository()
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['xyz', xyzId],
|
||||||
|
queryFn: () => repo.getById(xyzId.value),
|
||||||
|
enabled: computed(() => !!xyzId.value),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutation
|
||||||
|
export function useXyzEdit() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data) => new ApiXyzRepository().edit(data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['xyz'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern Repository API
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// infrastructure/api/apiXyzRepository.js
|
||||||
|
export class ApiXyzRepository {
|
||||||
|
async getAll() {
|
||||||
|
const response = await fetch('/api/xyz')
|
||||||
|
if (!response.ok) throw new Error(await this.#extractError(response))
|
||||||
|
const data = await response.json()
|
||||||
|
return data.items.map(Xyz.fromApiData)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const response = await fetch(`/api/xyz/${id}`)
|
||||||
|
if (!response.ok) throw new Error(await this.#extractError(response))
|
||||||
|
return Xyz.fromApiData(await response.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(payload) {
|
||||||
|
const response = await fetch('/api/xyz', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(await this.#extractError(response))
|
||||||
|
return Xyz.fromApiData(await response.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
async #extractError(response) {
|
||||||
|
try {
|
||||||
|
const data = await response.json()
|
||||||
|
return data.error || data.detail || `HTTP ${response.status}`
|
||||||
|
} catch {
|
||||||
|
return `HTTP ${response.status}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern Entité
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// domain/entities/xyz.js
|
||||||
|
export class Xyz {
|
||||||
|
constructor({ id, name, status }) {
|
||||||
|
this.id = id
|
||||||
|
this.name = name
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromApiData(data) {
|
||||||
|
return new Xyz(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive() { return this.status === 'active' }
|
||||||
|
isCompleted() { return this.status === 'completed' }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern Page
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
<LoadingSpinner v-if="isLoading" />
|
||||||
|
<div v-else-if="error">{{ error }}</div>
|
||||||
|
<ChildComponent v-else :data="data" @action="handleAction" />
|
||||||
|
<FeatureModal :is-open="isModalOpen" @close="closeModal" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useFeatureComposable } from '../composables/useFeature'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { data, isLoading, error } = useFeatureComposable(
|
||||||
|
computed(() => route.params.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const isModalOpen = ref(false)
|
||||||
|
const closeModal = () => (isModalOpen.value = false)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Système de notifications (global)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { useNotifications } from '@/shared/composables/useNotifications'
|
||||||
|
|
||||||
|
const { showSuccess, showError, showWarning, showInfo } = useNotifications()
|
||||||
|
|
||||||
|
showSuccess('Manga ajouté avec succès')
|
||||||
|
showError('Erreur lors du chargement')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration VueQuery (shared/plugin/vueQuery.js)
|
||||||
|
|
||||||
|
- `staleTime`: 5 minutes
|
||||||
|
- `gcTime`: 10 minutes
|
||||||
|
- `retry`: 1
|
||||||
|
- `refetchOnWindowFocus`: true
|
||||||
|
|
||||||
|
## Upload de fichiers (FormData)
|
||||||
|
|
||||||
|
Ne pas définir `Content-Type` manuellement — le navigateur le gère automatiquement avec le boundary correct.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('mangaId', mangaId)
|
||||||
|
|
||||||
|
const response = await fetch('/api/xyz/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData, // pas de Content-Type header
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make npm-run # Build dev one-shot — vérifie qu'il n'y a pas d'erreur de compilation
|
||||||
|
make npm-watch # Watch + rebuild automatique pendant le développement
|
||||||
|
make npm-add p=pkg # Ajouter une dépendance npm
|
||||||
|
```
|
||||||
|
|
||||||
|
Après toute modification de composants Vue, stores ou repositories, lancer `make npm-run` pour valider le build.
|
||||||
|
|
||||||
|
## Règles à respecter
|
||||||
|
|
||||||
|
- **Domain** : entités JS pures, aucune dépendance Vue/fetch
|
||||||
|
- **Application** : stores Pinia uniquement, pas d'appels fetch directs (passer par Infrastructure)
|
||||||
|
- **Infrastructure** : repositories API, aucune logique Vue
|
||||||
|
- **Presentation** : composants + composables, import uniquement depuis Application et Infrastructure
|
||||||
|
- **Shared** : composants/composables transversaux, pas de dépendances vers les domaines
|
||||||
|
- Préférer `useQuery`/`useMutation` (TanStack) pour les données serveur, Pinia pour l'état UI global
|
||||||
|
- Un composable = une responsabilité, nommé `use{FeatureVerb}` (ex: `useMangaDelete`, `useMangaEdit`)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Deploy
|
name: Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -9,63 +9,34 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Install tools
|
- name: Setup SSH
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y docker.io curl jq
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
- name: Build production image
|
- name: Deploy via Deployer
|
||||||
run: |
|
|
||||||
docker build --target frankenphp_prod -t mangarr:latest .
|
|
||||||
|
|
||||||
- name: Redeploy via Portainer API
|
|
||||||
env:
|
env:
|
||||||
PORTAINER_USER: ${{ secrets.PORTAINER_USER }}
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
PORTAINER_PASSWORD: ${{ secrets.PORTAINER_PASSWORD }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
JWT=$(curl -s -X POST http://portainer:9000/api/auth \
|
# Créer le container sans le démarrer (évite le problème DinD avec les volumes)
|
||||||
-H "Content-Type: application/json" \
|
CONTAINER=$(docker create \
|
||||||
-d "{\"Username\":\"$PORTAINER_USER\",\"Password\":\"$PORTAINER_PASSWORD\"}" | jq -r '.jwt')
|
-e DEPLOY_HOST \
|
||||||
|
-e GITEA_TOKEN \
|
||||||
|
-w /app \
|
||||||
|
deployphp/deployer:v7 \
|
||||||
|
-f /app/deploy.php deploy production -vvv)
|
||||||
|
|
||||||
if [ -z "$JWT" ] || [ "$JWT" = "null" ]; then
|
# Copier les sources et les clés SSH dans le container
|
||||||
echo "Erreur: authentification Portainer echouee"
|
docker cp "$PWD/." "$CONTAINER:/app/"
|
||||||
exit 1
|
docker cp "$HOME/.ssh/." "$CONTAINER:/root/.ssh/"
|
||||||
fi
|
|
||||||
|
|
||||||
STACK_INFO=$(curl -s http://portainer:9000/api/stacks \
|
# Démarrer et attendre la fin
|
||||||
-H "Authorization: Bearer $JWT")
|
docker start -a "$CONTAINER"
|
||||||
STACK_ID=$(echo "$STACK_INFO" | jq '.[] | select(.Name=="mangarr") | .Id')
|
EXIT_CODE=$?
|
||||||
ENDPOINT_ID=$(echo "$STACK_INFO" | jq '.[] | select(.Name=="mangarr") | .EndpointId')
|
docker rm "$CONTAINER" || true
|
||||||
|
exit $EXIT_CODE
|
||||||
if [ -z "$STACK_ID" ] || [ "$STACK_ID" = "null" ]; then
|
|
||||||
echo "Erreur: stack mangarr non trouvee"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Stack ID: $STACK_ID, Endpoint ID: $ENDPOINT_ID"
|
|
||||||
|
|
||||||
STACK_FILE=$(curl -s "http://portainer:9000/api/stacks/$STACK_ID/file" \
|
|
||||||
-H "Authorization: Bearer $JWT" | jq -r '.StackFileContent')
|
|
||||||
STACK_ENV=$(curl -s "http://portainer:9000/api/stacks/$STACK_ID" \
|
|
||||||
-H "Authorization: Bearer $JWT" | jq '.Env')
|
|
||||||
|
|
||||||
HTTP_CODE=$(curl -s -o /tmp/deploy_result.json -w "%{http_code}" -X PUT \
|
|
||||||
"http://portainer:9000/api/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" \
|
|
||||||
-H "Authorization: Bearer $JWT" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"stackFileContent\":$(echo "$STACK_FILE" | jq -Rs .),\"env\":$STACK_ENV,\"prune\":true,\"pullImage\":false}")
|
|
||||||
|
|
||||||
echo "Portainer redeploy: HTTP $HTTP_CODE"
|
|
||||||
if [ "$HTTP_CODE" -ge 300 ]; then
|
|
||||||
cat /tmp/deploy_result.json
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Run migrations
|
|
||||||
run: |
|
|
||||||
echo "Attente du demarrage de Mangarr..."
|
|
||||||
sleep 15
|
|
||||||
docker exec mangarr php bin/console doctrine:migrations:migrate --no-interaction || echo "Rien a migrer"
|
|
||||||
docker exec mangarr php bin/console cache:clear --env=prod || true
|
|
||||||
echo "Deploy termine avec succes"
|
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -38,3 +38,11 @@ yarn-error.log
|
|||||||
/public/images/
|
/public/images/
|
||||||
src/Controller/TestController.php
|
src/Controller/TestController.php
|
||||||
.phpunit.cache/test-results
|
.phpunit.cache/test-results
|
||||||
|
/tests/Fixtures/pages/
|
||||||
|
|
||||||
|
# Claude Code — versionner les skills partagés, ignorer les fichiers perso
|
||||||
|
!.claude/
|
||||||
|
!.claude/skills/
|
||||||
|
!.claude/skills/**
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/projects/
|
||||||
|
|||||||
40
DONE.md
Normal file
40
DONE.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# DONE.md — Tâches terminées
|
||||||
|
|
||||||
|
## [UI] Passe sur le menu latéral (Sidebar) — 2026-03-14
|
||||||
|
|
||||||
|
> Branche : `style/sidebar-cleanup-and-ui-polish` | Commit : `d219ed1`
|
||||||
|
|
||||||
|
- [x] **`isActive` incorrect** : inclut désormais les sous-items dans le calcul (groupe Mangas actif sur `/import`)
|
||||||
|
- [x] **Double déclenchement toggle/navigation** : chevron déplacé dans un `<button>` séparé du `RouterLink`
|
||||||
|
- [x] **Parent items** (`MenuGroup.vue`) : ajout `hover:text-white` aligné avec le style SubMenuItem
|
||||||
|
- [x] **SubMenuItems** (`SubMenuItem.vue`) : ajout `hover:bg-gray-700` pour harmoniser avec le parent
|
||||||
|
- [x] **État actif vs hover** : logique couleur unifiée sur les deux niveaux
|
||||||
|
|
||||||
|
## [UI] Supprimer "Calendrier" du menu — 2026-03-14
|
||||||
|
|
||||||
|
> Branche : `style/sidebar-cleanup-and-ui-polish` | Commit : `d219ed1`
|
||||||
|
|
||||||
|
- [x] Retirer l'entrée "Calendrier" de la Sidebar
|
||||||
|
- [x] Supprimer la route Vue Router `/calendar`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [UI] Simplifier l'affichage table de la HomePage — 2026-03-14
|
||||||
|
|
||||||
|
> Branche : `style/simplifier-table-homepage` | Commit : `cc27fc4`
|
||||||
|
|
||||||
|
- [x] Supprimer le wrapper card (`bg-white shadow rounded-lg overflow-hidden`) — remplacer par un simple `border-t`
|
||||||
|
- [x] Lien du titre : passer le hover de bleu (`hover:text-blue-600`) à vert (`hover:text-green-500`)
|
||||||
|
- [x] Icône monitoring : remplacer `BellIcon` / `BellSlashIcon` par `BookmarkIcon` / `BookmarkSlashIcon`
|
||||||
|
- [x] Supprimer le padding du wrapper + `container mx-auto` pour tableau pleine largeur
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [UI] Restyling vue grille des mangas — 2026-03-14
|
||||||
|
|
||||||
|
> Branche : `style/restyling-manga-grid` | Commit : `9a4fb26`
|
||||||
|
|
||||||
|
- [x] **Réduire la taille des cards** : grille plus dense (cols-3/4/5/7/8 selon breakpoint, gap-2)
|
||||||
|
- [x] **Supprimer les arrondis** : retrait de `rounded-lg` et `hover:scale-105`
|
||||||
|
- [x] **Overlay icônes au survol** : gradient + 3 boutons (éditer, sources, rafraîchir) en bas à gauche de la cover, visibles au `group-hover`
|
||||||
|
- [x] MangaCard émet les événements, MangaGrid gère les modales (edit, sources, refresh)
|
||||||
18
Dockerfile
18
Dockerfile
@@ -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
|
||||||
@@ -68,6 +68,19 @@ ENTRYPOINT ["docker-entrypoint"]
|
|||||||
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
|
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
|
||||||
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
|
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
|
||||||
|
|
||||||
|
# Runtime FrankenPHP image (sans code baked-in)
|
||||||
|
# Le code vient du bind mount /srv/mangarr/current:/app (géré par Deployer)
|
||||||
|
# Builder une seule fois : docker build --target frankenphp_runtime -t mangarr:runtime .
|
||||||
|
FROM frankenphp_base AS frankenphp_runtime
|
||||||
|
|
||||||
|
ENV APP_ENV=prod
|
||||||
|
ENV FRANKENPHP_CONFIG="import worker.Caddyfile"
|
||||||
|
|
||||||
|
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||||
|
|
||||||
|
COPY --link frankenphp/conf.d/app.prod.ini $PHP_INI_DIR/conf.d/
|
||||||
|
COPY --link frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile
|
||||||
|
|
||||||
# Dev FrankenPHP image
|
# Dev FrankenPHP image
|
||||||
FROM frankenphp_base AS frankenphp_dev
|
FROM frankenphp_base AS frankenphp_dev
|
||||||
|
|
||||||
@@ -95,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 ./
|
||||||
|
|||||||
7
Makefile
7
Makefile
@@ -145,6 +145,13 @@ twig-extension: ## Create a new twig extension
|
|||||||
stimulus: ## Create a new stimulus controller
|
stimulus: ## Create a new stimulus controller
|
||||||
@$(SYMFONY) make:stimulus-controller
|
@$(SYMFONY) make:stimulus-controller
|
||||||
|
|
||||||
|
notify-test: ## Envoie les 4 types de notifications de test avec 2s d'intervalle
|
||||||
|
@for type in info success error warning; do \
|
||||||
|
$(SYMFONY) app:notify:test --type=$$type --message="Test $$type depuis Mangarr"; \
|
||||||
|
echo "[$$type] envoyé"; \
|
||||||
|
sleep 2; \
|
||||||
|
done
|
||||||
|
|
||||||
consume-commands: ## Consume commands messages
|
consume-commands: ## Consume commands messages
|
||||||
@$(SYMFONY) messenger:consume commands -vv
|
@$(SYMFONY) messenger:consume commands -vv
|
||||||
|
|
||||||
|
|||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
@import "tailwindcss/components";
|
@import "tailwindcss/components";
|
||||||
@import "tailwindcss/utilities";
|
@import "tailwindcss/utilities";
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
@@ -82,6 +87,33 @@ body {
|
|||||||
@apply bg-gray-700;
|
@apply bg-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Firefox uniquement — évite le conflit avec les pseudo-éléments webkit sur Chrome 121+ */
|
||||||
|
@supports (-moz-appearance: none) {
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #16a34a transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark * {
|
||||||
|
scrollbar-color: #16a34a #1f2937;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode — webkit track */
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Supprime les flèches de la scrollbar */
|
||||||
|
::-webkit-scrollbar-button:start:decrement,
|
||||||
|
::-webkit-scrollbar-button:end:increment,
|
||||||
|
::-webkit-scrollbar-button:start:increment,
|
||||||
|
::-webkit-scrollbar-button:end:decrement {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
///* Custom styles for the scrollbar buttons */
|
///* Custom styles for the scrollbar buttons */
|
||||||
//::-webkit-scrollbar-button {
|
//::-webkit-scrollbar-button {
|
||||||
// @apply bg-gray-700;
|
// @apply bg-gray-700;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
||||||
|
import { useMercureNotifications } from './shared/composables/useMercureNotifications';
|
||||||
|
|
||||||
|
useMercureNotifications();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -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: {
|
|
||||||
status: ['pending', 'in_progress'], // Par défaut, ne montrer que les actifs
|
|
||||||
sortBy: 'createdAt',
|
sortBy: 'createdAt',
|
||||||
sortOrder: 'DESC'
|
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({});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ export class Job {
|
|||||||
payload = {},
|
payload = {},
|
||||||
result = null,
|
result = null,
|
||||||
error = null,
|
error = 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,
|
||||||
|
maxAttempts = 1,
|
||||||
|
context = {}
|
||||||
}) {
|
}) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.type = type;
|
this.type = type;
|
||||||
@@ -16,9 +22,14 @@ export class Job {
|
|||||||
this.progress = progress;
|
this.progress = progress;
|
||||||
this.payload = payload;
|
this.payload = payload;
|
||||||
this.result = result;
|
this.result = result;
|
||||||
this.error = 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.maxAttempts = maxAttempts;
|
||||||
|
this.context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(data) {
|
static create(data) {
|
||||||
|
|||||||
@@ -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,7 +23,10 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
url += `&status=${status.join(',')}`;
|
url += `&status=${status.join(',')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Fetching jobs from URL:', url);
|
// Ajouter le filtre de type si fourni
|
||||||
|
if (type) {
|
||||||
|
url += `&type=${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
@@ -32,7 +35,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('API Response:', data);
|
|
||||||
|
|
||||||
// Gérer différents formats de réponse API
|
// Gérer différents formats de réponse API
|
||||||
let jobs, total, currentPage, limit_returned, hasNext, hasPrev;
|
let jobs, total, currentPage, limit_returned, hasNext, hasPrev;
|
||||||
@@ -63,15 +65,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
hasPrev = !!data.hasPreviousPage;
|
hasPrev = !!data.hasPreviousPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Processed data:', {
|
|
||||||
jobs: jobs.length,
|
|
||||||
total,
|
|
||||||
currentPage,
|
|
||||||
limit_returned,
|
|
||||||
hasNext,
|
|
||||||
hasPrev
|
|
||||||
});
|
|
||||||
|
|
||||||
return new JobCollection(
|
return new JobCollection(
|
||||||
jobs,
|
jobs,
|
||||||
total,
|
total,
|
||||||
@@ -81,7 +74,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
hasPrev
|
hasPrev
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API Error:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +94,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return Job.create(data);
|
return Job.create(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API Error:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +115,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API Error:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +148,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.deleted || 0;
|
return data.deleted || 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API Error:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<tr
|
<tr
|
||||||
class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out"
|
class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition duration-150 ease-in-out"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-yellow-50': job.status === 'pending',
|
'bg-yellow-50 dark:bg-yellow-900/20': job.status === 'pending',
|
||||||
'bg-blue-50': job.status === 'in_progress',
|
'bg-blue-50 dark:bg-blue-900/20': job.status === 'in_progress',
|
||||||
'bg-green-50': job.status === 'completed',
|
'bg-green-50 dark:bg-green-900/20': job.status === 'completed',
|
||||||
'bg-red-50': job.status === 'failed'
|
'bg-red-50 dark:bg-red-900/20': job.status === 'failed'
|
||||||
}">
|
}">
|
||||||
<td class="py-4 px-4 text-center">
|
<td class="py-4 px-4 text-center">
|
||||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" />
|
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" />
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 px-4 font-medium">{{ job.type }}</td>
|
<td class="py-4 px-4 font-medium">
|
||||||
|
<div>{{ jobTypeLabel }}</div>
|
||||||
|
<div v-if="job.context?.mangaTitle" class="text-xs text-gray-500 mt-0.5">
|
||||||
|
{{ job.context.mangaTitle }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="py-4 px-4">
|
<td class="py-4 px-4">
|
||||||
<span
|
<span
|
||||||
class="px-2 py-1 text-xs rounded-full"
|
class="px-2 py-1 text-xs rounded-full"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-yellow-100 text-yellow-800': job.status === 'pending',
|
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': job.status === 'pending',
|
||||||
'bg-blue-100 text-blue-800': job.status === 'in_progress',
|
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': job.status === 'in_progress',
|
||||||
'bg-green-100 text-green-800': job.status === 'completed',
|
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': job.status === 'completed',
|
||||||
'bg-red-100 text-red-800': job.status === 'failed'
|
'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300': job.status === 'failed'
|
||||||
}">
|
}">
|
||||||
{{ job.status }}
|
{{ job.status }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 px-4">
|
<td class="py-4 px-4">
|
||||||
<div v-if="job.error" class="text-sm text-red-600">
|
<div v-if="job.error" class="text-sm text-red-600 dark:text-red-400">
|
||||||
{{ job.error }}
|
{{ job.error }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm text-gray-600">
|
<div v-else-if="job.context?.mangaTitle || job.context?.chapterNumber !== undefined || job.context?.sourceId"
|
||||||
|
class="text-sm text-gray-700 dark:text-gray-300 space-y-0.5">
|
||||||
|
<div v-if="job.context.mangaTitle" class="font-medium">
|
||||||
|
{{ job.context.mangaTitle }}
|
||||||
|
</div>
|
||||||
|
<div v-if="job.context.chapterNumber !== undefined" class="text-gray-500 dark:text-gray-400">
|
||||||
|
Chapitre {{ job.context.chapterNumber }}
|
||||||
|
</div>
|
||||||
|
<div v-if="job.context.sourceId" class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Source : {{ job.context.sourceId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{{ formatDate(job.createdAt) }}
|
{{ formatDate(job.createdAt) }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 px-4">
|
<td class="py-4 px-4">
|
||||||
<div v-if="job.status === 'in_progress'" class="mt-2">
|
<div v-if="job.status === 'in_progress'" class="mt-2">
|
||||||
<div class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
<div class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||||
:style="{ width: `${job.progress}%` }"></div>
|
:style="{ width: `${job.progress}%` }"></div>
|
||||||
@@ -42,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||||
style="width: 100%"></div>
|
style="width: 100%"></div>
|
||||||
@@ -50,7 +67,7 @@
|
|||||||
100%
|
100%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 left-0 h-full bg-red-400 transition-all duration-300 ease-out"
|
class="absolute top-0 left-0 h-full bg-red-400 transition-all duration-300 ease-out"
|
||||||
style="width: 100%"></div>
|
style="width: 100%"></div>
|
||||||
@@ -58,14 +75,19 @@
|
|||||||
Erreur
|
Erreur
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
<div v-else class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 left-0 h-full bg-yellow-400 transition-all duration-300 ease-out"
|
class="absolute top-0 left-0 h-full bg-yellow-400 transition-all duration-300 ease-out"
|
||||||
style="width: 0%"></div>
|
style="width: 0%"></div>
|
||||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600">
|
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||||
En attente
|
En attente
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="job.maxAttempts > 1 || job.attempts > 0"
|
||||||
|
class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">
|
||||||
|
{{ job.attempts }} / {{ job.maxAttempts }} tentative{{ job.maxAttempts > 1 ? 's' : '' }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 px-4">
|
<td class="py-4 px-4">
|
||||||
<button
|
<button
|
||||||
@@ -80,7 +102,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { TrashIcon } from '@heroicons/vue/24/outline';
|
import { TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
import { defineEmits, defineProps } from 'vue';
|
import { computed, defineEmits, defineProps } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
job: {
|
job: {
|
||||||
@@ -91,6 +113,15 @@ import { defineEmits, defineProps } from 'vue';
|
|||||||
|
|
||||||
const emit = defineEmits(['delete']);
|
const emit = defineEmits(['delete']);
|
||||||
|
|
||||||
|
const JOB_TYPE_LABELS = {
|
||||||
|
scraping_job: 'Scraping',
|
||||||
|
conversion_job: 'Conversion',
|
||||||
|
};
|
||||||
|
|
||||||
|
const jobTypeLabel = computed(() =>
|
||||||
|
JOB_TYPE_LABELS[props.job.type] ?? props.job.type
|
||||||
|
);
|
||||||
|
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
|
|||||||
@@ -1,165 +1,143 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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 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>
|
||||||
|
|
||||||
<div v-else-if="activityStore.error" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
<!-- Error -->
|
||||||
<p>{{ activityStore.error }}</p>
|
<div v-else-if="activityStore.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">
|
||||||
|
<p class="text-red-800 dark:text-red-200">{{ activityStore.error }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="container mx-auto p-2">
|
<!-- Content -->
|
||||||
<!-- Debug pagination - À supprimer plus tard -->
|
<section v-else class="border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" v-if="true">
|
<!-- Empty -->
|
||||||
<strong>Debug Pagination:</strong>
|
<div v-if="activityStore.jobs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
|
||||||
Total: {{ activityStore.total }},
|
<ClockIcon class="w-12 h-12 mb-3" />
|
||||||
Limit: {{ activityStore.limit }},
|
<p class="text-base">Aucun job en cours ou en attente.</p>
|
||||||
Pages: {{ activityStore.totalPages }},
|
|
||||||
Page courante: {{ activityStore.currentPage }},
|
|
||||||
Condition: {{ activityStore.total > activityStore.limit }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
<!-- Table -->
|
||||||
<div class="overflow-x-auto">
|
<div v-else class="overflow-x-auto">
|
||||||
<table class="min-w-full bg-white">
|
<table class="min-w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-200 text-gray-800">
|
<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">
|
<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">
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="py-8 px-4 text-center text-gray-500">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<ClockIcon class="h-12 w-12 text-gray-300 mb-4" />
|
|
||||||
<p class="text-lg font-medium">Aucune activité trouvée</p>
|
|
||||||
<p class="text-sm">Aucune activité ne correspond aux filtres actuels.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<JobItem
|
<JobItem
|
||||||
v-for="job in activityStore.jobs"
|
v-for="job in activityStore.jobs"
|
||||||
:key="job.id"
|
:key="job.id"
|
||||||
:job="job"
|
:job="job"
|
||||||
@delete="deleteJob" />
|
@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' },
|
||||||
icon: FunnelIcon,
|
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
|
||||||
type: 'dropdown',
|
|
||||||
label: statusOptions[activeStatusIndex.value].label,
|
|
||||||
active: false,
|
|
||||||
items: statusOptions.map((option, index) => ({
|
|
||||||
label: option.label,
|
|
||||||
isSelected: index === activeStatusIndex.value,
|
|
||||||
onClick: () => setStatusFilter(index)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
rightSection: [
|
rightSection: [
|
||||||
{
|
{
|
||||||
icon: ArrowPathIcon,
|
type: 'dropdown',
|
||||||
type: 'button',
|
icon: BarsArrowDownIcon,
|
||||||
label: 'Rafraîchir',
|
label: 'Trier',
|
||||||
onClick: refreshJobs
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Plus récent',
|
||||||
|
isSelected: isSortSelected('createdAt', 'DESC'),
|
||||||
|
onClick: () => activityStore.updateSort('createdAt', 'DESC'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Plus ancien',
|
||||||
|
isSelected: isSortSelected('createdAt', 'ASC'),
|
||||||
|
onClick: () => activityStore.updateSort('createdAt', 'ASC'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Par type',
|
||||||
|
isSelected: isSortSelected('type', 'ASC'),
|
||||||
|
onClick: () => activityStore.updateSort('type', 'ASC'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Par statut',
|
||||||
|
isSelected: isSortSelected('status', 'ASC'),
|
||||||
|
onClick: () => activityStore.updateSort('status', 'ASC'),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: TrashIcon,
|
|
||||||
type: 'button',
|
type: 'button',
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: 'Rafraîchir',
|
||||||
|
disabled: loading.value,
|
||||||
|
onClick: () => activityStore.loadJobs(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: TrashIcon,
|
||||||
label: 'Supprimer visibles',
|
label: 'Supprimer visibles',
|
||||||
onClick: deleteVisibleJobs
|
disabled: loading.value || total.value === 0,
|
||||||
}
|
onClick: deleteVisibleJobs,
|
||||||
]
|
},
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadJobs();
|
activityStore.loadJobs();
|
||||||
|
activityStore.subscribeMercure();
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadJobs() {
|
onUnmounted(() => {
|
||||||
activityStore.loadJobs();
|
activityStore.unsubscribeMercure();
|
||||||
}
|
});
|
||||||
|
|
||||||
function refreshJobs() {
|
|
||||||
loadJobs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function changePage(page) {
|
function changePage(page) {
|
||||||
activityStore.goToPage(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) {
|
function deleteJob(id) {
|
||||||
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
||||||
activityStore.deleteJob(id);
|
activityStore.deleteJob(id);
|
||||||
@@ -167,13 +145,9 @@ import JobItem from '../components/JobItem.vue';
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteVisibleJobs() {
|
function deleteVisibleJobs() {
|
||||||
if (activityStore.jobs.length === 0) {
|
if (activityStore.jobs.length === 0) return;
|
||||||
return;
|
if (confirm('Voulez-vous vraiment supprimer tous les jobs visibles ?')) {
|
||||||
}
|
activityStore.deleteJobs({ status: ['pending', 'in_progress'] });
|
||||||
|
|
||||||
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é
|
||||||
|
|||||||
@@ -24,10 +24,10 @@
|
|||||||
|
|
||||||
<!-- Message de statut -->
|
<!-- Message de statut -->
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{{ statusMessage }}
|
{{ statusMessage }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="fileName" class="text-xs text-gray-500">
|
<p v-if="fileName" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ fileName }}
|
{{ fileName }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
|
|
||||||
<!-- Barre de progression -->
|
<!-- Barre de progression -->
|
||||||
<div v-if="showProgress" class="space-y-2">
|
<div v-if="showProgress" class="space-y-2">
|
||||||
<div class="flex justify-between text-xs text-gray-600">
|
<div class="flex justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||||
<span>Progression</span>
|
<span>Progression</span>
|
||||||
<span>{{ Math.round(progress) }}%</span>
|
<span>{{ Math.round(progress) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300 ease-out"
|
class="bg-blue-500 h-2 rounded-full transition-all duration-300 ease-out"
|
||||||
:style="{ width: `${progress}%` }"
|
:style="{ width: `${progress}%` }"
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Détails de la conversion -->
|
<!-- Détails de la conversion -->
|
||||||
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 space-y-1">
|
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
<div v-if="originalSize" class="flex justify-between">
|
<div v-if="originalSize" class="flex justify-between">
|
||||||
<span>Taille originale:</span>
|
<span>Taille originale:</span>
|
||||||
<span>{{ formatFileSize(originalSize) }}</span>
|
<span>{{ formatFileSize(originalSize) }}</span>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="canReset"
|
v-if="canReset"
|
||||||
@click="$emit('reset')"
|
@click="$emit('reset')"
|
||||||
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowPathIcon class="w-4 h-4" />
|
<ArrowPathIcon class="w-4 h-4" />
|
||||||
<span>Convertir un autre fichier</span>
|
<span>Convertir un autre fichier</span>
|
||||||
@@ -85,14 +85,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Message d'erreur détaillé -->
|
<!-- Message d'erreur détaillé -->
|
||||||
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 border border-red-200 rounded-md">
|
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 flex-shrink-0" />
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">
|
||||||
Erreur de conversion
|
Erreur de conversion
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1 text-sm text-red-700">
|
<p class="mt-1 text-sm text-red-700 dark:text-red-400">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'border-2 border-dashed rounded-lg p-8 text-center transition-all duration-200',
|
'border-2 border-dashed rounded-lg p-8 text-center transition-all duration-200',
|
||||||
isDragOver
|
isDragOver
|
||||||
? 'border-green-400 bg-green-50'
|
? 'border-green-400 bg-green-50 dark:bg-green-900/20'
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<!-- Zone d'upload -->
|
<!-- Zone d'upload -->
|
||||||
@@ -28,13 +28,13 @@
|
|||||||
|
|
||||||
<!-- Message principal -->
|
<!-- Message principal -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h3 class="text-lg font-medium text-gray-900">
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
{{ isDragOver ? 'Déposez votre fichier ici' : 'Sélectionnez un fichier CBR ou CBZ' }}
|
{{ isDragOver ? 'Déposez votre fichier ici' : 'Sélectionnez un fichier CBR ou CBZ' }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Glissez-déposez votre fichier ou cliquez pour le sélectionner
|
Glissez-déposez votre fichier ou cliquez pour le sélectionner
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-400">
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
Fichiers supportés: .cbr, .cbz (max. 150MB)
|
Fichiers supportés: .cbr, .cbz (max. 150MB)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,20 +63,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Informations du fichier sélectionné -->
|
<!-- Informations du fichier sélectionné -->
|
||||||
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 rounded-lg">
|
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<DocumentIcon class="w-8 h-8 text-gray-600" />
|
<DocumentIcon class="w-8 h-8 text-gray-600 dark:text-gray-400" />
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium text-gray-900 truncate">
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
{{ selectedFile.name }}
|
{{ selectedFile.name }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{{ formatFileSize(selectedFile.size) }}
|
{{ formatFileSize(selectedFile.size) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="clearFile"
|
@click="clearFile"
|
||||||
class="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
class="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
title="Supprimer le fichier"
|
title="Supprimer le fichier"
|
||||||
>
|
>
|
||||||
<XMarkIcon class="w-5 h-5" />
|
<XMarkIcon class="w-5 h-5" />
|
||||||
|
|||||||
@@ -1,67 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<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">
|
|
||||||
Convertir CBR en CBZ
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p class="text-lg text-gray-600">
|
|
||||||
Convertissez vos fichiers CBR (Comic Book RAR) en CBZ (Comic Book ZIP) pour une meilleure compatibilité.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zone principale -->
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="bg-white 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 -->
|
|
||||||
<div class="p-6 space-y-6">
|
|
||||||
<!-- Zone d'upload -->
|
<!-- Zone d'upload -->
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichier</h2>
|
||||||
<FileUploadArea
|
<FileUploadArea
|
||||||
:selected-file="conversionStore.currentFile"
|
:selected-file="conversionStore.currentFile"
|
||||||
:disabled="conversionStore.isProcessing"
|
:disabled="conversionStore.isProcessing"
|
||||||
@file-selected="handleFileSelected"
|
@file-selected="handleFileSelected"
|
||||||
@file-cleared="handleFileClear"
|
@file-cleared="handleFileClear"
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 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
|
|
||||||
@click="handleConvert"
|
|
||||||
:disabled="conversionStore.isProcessing"
|
|
||||||
:class="[
|
|
||||||
'flex items-center space-x-2 px-6 py-3 text-white font-medium rounded-lg transition-all duration-200',
|
|
||||||
conversionStore.isProcessing
|
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
|
||||||
: 'bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<ArrowPathIcon
|
|
||||||
:class="[
|
|
||||||
'w-5 h-5',
|
|
||||||
conversionStore.isProcessing && 'animate-spin'
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progression et résultat -->
|
|
||||||
<ConversionProgress
|
<ConversionProgress
|
||||||
v-if="showProgress"
|
|
||||||
:is-converting="conversionStore.isProcessing"
|
:is-converting="conversionStore.isProcessing"
|
||||||
:progress="conversionStore.conversionProgress"
|
:progress="conversionStore.conversionProgress"
|
||||||
:is-success="conversionStore.hasSucceeded"
|
:is-success="conversionStore.hasSucceeded"
|
||||||
@@ -73,128 +30,78 @@
|
|||||||
@download="handleDownload"
|
@download="handleDownload"
|
||||||
@reset="handleReset"
|
@reset="handleReset"
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Message d'information -->
|
<!-- Historique -->
|
||||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<section v-if="conversionStore.conversionCount > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
<div class="flex">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<InformationCircleIcon class="w-5 h-5 text-blue-500 flex-shrink-0" />
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Historique</h2>
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-blue-800">
|
|
||||||
À propos de la conversion
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-blue-700 space-y-1">
|
|
||||||
<p>• Les fichiers CBZ sont plus largement supportés par les lecteurs de bandes dessinées</p>
|
|
||||||
<p>• La compression ZIP permet généralement une meilleure accessibilité</p>
|
|
||||||
<p>• Aucune perte de qualité lors de la conversion</p>
|
|
||||||
<p>• Taille maximale supportée: 150MB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Historique des conversions -->
|
|
||||||
<div v-if="conversionStore.conversionCount > 0" class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900">
|
|
||||||
Historique des conversions
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
@click="handleClearHistory"
|
@click="conversionStore.clearHistory()"
|
||||||
class="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||||
>
|
>
|
||||||
Effacer l'historique
|
Effacer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
<div
|
||||||
v-for="(conversion, index) in conversionStore.conversionHistory"
|
v-for="(conversion, index) in conversionStore.conversionHistory"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center justify-between py-2 border-b border-gray-200 last:border-b-0"
|
class="flex items-center justify-between py-3"
|
||||||
>
|
>
|
||||||
<div class="flex-1">
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="text-sm text-gray-900 dark:text-gray-100">{{ conversion.originalName }}</p>
|
||||||
{{ conversion.originalName }}
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(conversion.timestamp) }}</p>
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
{{ formatDate(conversion.timestamp) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right text-sm">
|
||||||
<p class="text-sm text-gray-600">
|
<p class="text-gray-600 dark:text-gray-300">
|
||||||
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-green-600">
|
<p class="text-xs text-green-600">{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}</p>
|
||||||
{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Toast de notification -->
|
</div>
|
||||||
<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>
|
</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 {
|
|
||||||
name: 'ConversionPage',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
FileUploadArea,
|
|
||||||
ConversionProgress,
|
|
||||||
ArrowPathIcon,
|
|
||||||
ArchiveBoxIcon,
|
|
||||||
InformationCircleIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
XMarkIcon,
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
const conversionStore = useConversionStore();
|
const conversionStore = useConversionStore();
|
||||||
|
const { showSuccess, showError } = useNotifications();
|
||||||
|
|
||||||
// Computed properties
|
const showProgress = computed(() =>
|
||||||
const showProgress = computed(() => {
|
conversionStore.hasSelectedFile &&
|
||||||
return conversionStore.hasSelectedFile &&
|
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
|
||||||
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError);
|
);
|
||||||
});
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{ type: 'label', text: 'Conversion CBR → CBZ', class: 'text-sm font-medium' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
...(conversionStore.hasSelectedFile && !conversionStore.hasSucceeded ? [{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ',
|
||||||
|
onClick: handleConvert,
|
||||||
|
disabled: conversionStore.isProcessing,
|
||||||
|
}] : []),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
const handleFileSelected = (file) => {
|
const handleFileSelected = (file) => {
|
||||||
const success = conversionStore.selectFile(file);
|
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 handleFileClear = () => {
|
||||||
@@ -203,83 +110,40 @@ export default {
|
|||||||
|
|
||||||
const handleConvert = async () => {
|
const handleConvert = async () => {
|
||||||
if (!conversionStore.currentFile) return;
|
if (!conversionStore.currentFile) return;
|
||||||
|
|
||||||
const success = await conversionStore.convertCurrentFile();
|
const success = await conversionStore.convertCurrentFile();
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log('Conversion réussie');
|
showSuccess('Conversion réussie !');
|
||||||
} else {
|
} else {
|
||||||
console.error('Échec de la conversion');
|
showError(conversionStore.conversionError ?? 'Échec de la conversion');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => conversionStore.downloadConvertedFile();
|
||||||
conversionStore.downloadConvertedFile();
|
const handleReset = () => conversionStore.resetConversion();
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
conversionStore.resetConversion();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearHistory = () => {
|
|
||||||
conversionStore.clearHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Utility functions
|
|
||||||
const formatFileSize = (bytes) => {
|
const formatFileSize = (bytes) => {
|
||||||
if (bytes === 0) return '0 octets';
|
if (bytes === 0) return '0 octets';
|
||||||
|
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
|
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (isoString) => {
|
const formatDate = (isoString) =>
|
||||||
const date = new Date(isoString);
|
new Intl.DateTimeFormat('fr-FR', {
|
||||||
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) {
|
if (saving > 0) return `-${saving.toFixed(1)}%`;
|
||||||
return `-${saving.toFixed(1)}%`;
|
if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`;
|
||||||
} else if (saving < 0) {
|
|
||||||
return `+${Math.abs(saving).toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
return '0%';
|
return '0%';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lifecycle
|
onMounted(() => conversionStore.resetConversion());
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Styles spécifiques si nécessaires */
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,64 +1,75 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
<div class="py-3">
|
||||||
<div class="flex items-start space-x-4">
|
|
||||||
<!-- File Icon and Info -->
|
<!-- Row principal : icône, nom, statut, actions -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<DocumentIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File Details -->
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</p>
|
||||||
<h3 class="text-lg font-medium text-gray-900 truncate">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ file.filename }}
|
{{ file.getFormattedSize() }} · {{ file.getFileExtension().toUpperCase() }}
|
||||||
</h3>
|
<span v-if="file.isAnalyzed() && file.getExtractedChapterNumber()" class="ml-2 text-green-600 dark:text-green-400">
|
||||||
|
Ch. {{ file.getExtractedChapterNumber() }}
|
||||||
|
</span>
|
||||||
|
<span v-if="file.isAnalyzed() && file.getExtractedVolumeNumber()" class="ml-2 text-green-600 dark:text-green-400">
|
||||||
|
Vol. {{ file.getExtractedVolumeNumber() }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Status Badge -->
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<div class="flex-shrink-0 ml-4">
|
|
||||||
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="file.isReadyForImport()"
|
||||||
|
@click="$emit('import-file')"
|
||||||
|
:disabled="isImporting"
|
||||||
|
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUpTrayIcon class="w-3.5 h-3.5" />
|
||||||
|
Importer
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="file.hasError()"
|
||||||
|
@click="$emit('retry-file')"
|
||||||
|
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="$emit('remove-file')"
|
||||||
|
class="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-500 mt-1">
|
<!-- Message d'erreur -->
|
||||||
{{ file.getFormattedSize() }} • {{ file.getFileExtension().toUpperCase() }}
|
<div v-if="file.hasError()" class="mt-2 flex items-start gap-2 text-xs text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-3 py-2">
|
||||||
|
<ExclamationCircleIcon class="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
{{ file.errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aucun manga trouvé -->
|
||||||
|
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-2 flex items-start gap-2 text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-3 py-2">
|
||||||
|
<ExclamationTriangleIcon class="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
Aucun manga correspondant trouvé. Vérifiez le nom du fichier.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sélection du manga -->
|
||||||
|
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-3 space-y-3">
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||||
|
{{ file.getMatches().length }} correspondance(s)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Extracted Info -->
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||||
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
|
|
||||||
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 text-blue-700">
|
|
||||||
Chapitre {{ file.getExtractedChapterNumber() }}
|
|
||||||
</span>
|
|
||||||
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 text-purple-700">
|
|
||||||
Volume {{ file.getExtractedVolumeNumber() }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Display -->
|
|
||||||
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
|
||||||
<div class="flex">
|
|
||||||
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-red-800">Erreur</h3>
|
|
||||||
<div class="mt-2 text-sm text-red-700">{{ file.errorMessage }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manga Selection -->
|
|
||||||
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-3">
|
|
||||||
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Matches Grid -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
<MangaMatchCard
|
<MangaMatchCard
|
||||||
v-for="match in sortedMatches"
|
v-for="match in sortedMatches"
|
||||||
:key="match.id"
|
:key="match.id"
|
||||||
@@ -69,130 +80,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selected Manga Preview -->
|
<!-- Numéros de chapitre / volume -->
|
||||||
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 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">{{ file.selectedManga.title }}</p>
|
|
||||||
<p class="text-sm text-gray-500">{{ file.selectedManga.slug }}</p>
|
|
||||||
<p class="text-xs text-blue-600 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chapter/Volume Number Inputs -->
|
|
||||||
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
|
|
||||||
<!-- Chapter Number -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Chapitre</label>
|
||||||
Numéro de chapitre
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
:value="file.selectedChapterNumber ?? ''"
|
:value="file.selectedChapterNumber ?? ''"
|
||||||
@input="handleChapterNumberInput"
|
@input="handleChapterNumberInput"
|
||||||
:disabled="file.selectedVolumeNumber !== null"
|
:disabled="file.selectedVolumeNumber !== null"
|
||||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:text-gray-400"
|
||||||
placeholder="Ex: 1, 1.5, 2..."
|
placeholder="Ex: 1, 1.5..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Volume Number -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Volume</label>
|
||||||
Numéro de volume
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
:value="file.selectedVolumeNumber ?? ''"
|
:value="file.selectedVolumeNumber ?? ''"
|
||||||
@input="handleVolumeNumberInput"
|
@input="handleVolumeNumberInput"
|
||||||
:disabled="file.selectedChapterNumber !== null"
|
:disabled="file.selectedChapterNumber !== null"
|
||||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:text-gray-400"
|
||||||
placeholder="Ex: 1, 1.5, 2..."
|
placeholder="Ex: 1, 1.5..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No Matches Message -->
|
|
||||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
|
||||||
<div class="flex">
|
|
||||||
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-yellow-800">Aucun manga trouvé</h3>
|
|
||||||
<div class="mt-2 text-sm text-yellow-700">
|
|
||||||
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="mt-6 flex justify-between items-center">
|
|
||||||
<div class="flex space-x-3">
|
|
||||||
<!-- Import Button -->
|
|
||||||
<button
|
|
||||||
v-if="file.isReadyForImport()"
|
|
||||||
@click="$emit('import-file')"
|
|
||||||
:disabled="isImporting"
|
|
||||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md text-sm font-medium flex items-center"
|
|
||||||
>
|
|
||||||
<svg v-if="isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
{{ isImporting ? 'Import en cours...' : 'Importer' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Retry Button -->
|
|
||||||
<button
|
|
||||||
v-if="file.hasError()"
|
|
||||||
@click="$emit('retry-file')"
|
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
|
||||||
>
|
|
||||||
Réessayer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Remove Button -->
|
|
||||||
<button
|
|
||||||
@click="$emit('remove-file')"
|
|
||||||
class="text-red-600 hover:text-red-700 text-sm font-medium"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ArrowUpTrayIcon, DocumentIcon, ExclamationCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import MangaMatchCard from './MangaMatchCard.vue';
|
import MangaMatchCard from './MangaMatchCard.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
file: {
|
file: { type: Object, required: true },
|
||||||
type: Object,
|
isAnalyzing: { type: Boolean, default: false },
|
||||||
required: true
|
isImporting: { type: Boolean, default: false },
|
||||||
},
|
|
||||||
isAnalyzing: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
isImporting: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -201,28 +129,22 @@ const emit = defineEmits([
|
|||||||
'volume-number-selected',
|
'volume-number-selected',
|
||||||
'import-file',
|
'import-file',
|
||||||
'retry-file',
|
'retry-file',
|
||||||
'remove-file'
|
'remove-file',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Computed property to get sorted matches
|
const sortedMatches = computed(() =>
|
||||||
const sortedMatches = computed(() => {
|
[...props.file.getMatches()].sort((a, b) => b.matchScore - a.matchScore)
|
||||||
const matches = props.file.getMatches();
|
);
|
||||||
return matches.sort((a, b) => b.matchScore - a.matchScore);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleMangaSelection = (selectedManga) => {
|
const handleMangaSelection = (manga) => emit('manga-selected', manga);
|
||||||
emit('manga-selected', selectedManga);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChapterNumberInput = (event) => {
|
const handleChapterNumberInput = (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
const chapterNumber = value ? parseFloat(value) : null;
|
emit('chapter-number-selected', value ? parseFloat(value) : null);
|
||||||
emit('chapter-number-selected', chapterNumber);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVolumeNumberInput = (event) => {
|
const handleVolumeNumberInput = (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
const volumeNumber = value ? parseFloat(value) : null;
|
emit('volume-number-selected', value ? parseFloat(value) : null);
|
||||||
emit('volume-number-selected', volumeNumber);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,96 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white rounded-lg shadow-sm border 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 mb-4">
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="flex items-center justify-between">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<div class="flex items-center gap-3">
|
||||||
</svg>
|
<div class="flex items-center justify-center h-9 w-9 bg-green-100 dark:bg-green-900/40">
|
||||||
|
<CheckCircleIcon class="h-5 w-5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Import terminé</h3>
|
<div>
|
||||||
<p class="text-sm text-gray-500">
|
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">Import terminé</h3>
|
||||||
Voici le résumé de votre session d'import
|
<p class="text-xs text-gray-500 dark:text-gray-400">Voici le résumé de votre session d'import</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-6 text-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-green-600">{{ importedCount }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Importés</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-red-600">{{ errorCount }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Erreurs</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-gray-600 dark:text-gray-300">{{ totalCount }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Statistics -->
|
<!-- Fichiers importés -->
|
||||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
<section v-if="importedFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
<div class="text-center">
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||||
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
|
Importés ({{ importedFiles.length }})
|
||||||
<div class="text-sm text-gray-500">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 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">{{ file.filename }}</span>
|
|
||||||
<span v-if="file.selectedManga" class="ml-2 text-gray-500">
|
|
||||||
→ {{ file.selectedManga.title }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Error Files List -->
|
<!-- Fichiers en erreur -->
|
||||||
<div v-if="errorFiles.length > 0" class="mb-6">
|
<section v-if="errorFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
<h4 class="text-sm font-medium text-gray-900 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">{{ file.filename }}</div>
|
<div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div>
|
||||||
<div class="text-red-600 text-xs mt-1">{{ file.errorMessage }}</div>
|
<div class="text-red-600 dark:text-red-400 text-xs mt-0.5">{{ file.errorMessage }}</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-center space-x-4 pt-6 border-t">
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
@click="startNewImport"
|
@click="startNewImport"
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Nouvel import
|
Nouvel import
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="goToLibrary"
|
@click="goToLibrary"
|
||||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Aller à la bibliothèque
|
Aller à la bibliothèque
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||||
|
|||||||
@@ -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': isSelected,
|
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||||
'border-gray-200 hover:border-gray-300': !isSelected
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800'"
|
||||||
}"
|
|
||||||
@click="$emit('select-match', match)"
|
@click="$emit('select-match', match)"
|
||||||
>
|
>
|
||||||
<!-- Match Header with Score -->
|
<div class="flex gap-2.5">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<!-- Couverture -->
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-full"
|
|
||||||
:class="{
|
|
||||||
'bg-blue-500': isSelected,
|
|
||||||
'bg-gray-300': !isSelected
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
<span class="text-sm font-medium text-gray-700">Score: {{ match.matchScore }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="isSelected" class="text-blue-600">
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manga Thumbnail -->
|
|
||||||
<div class="flex space-x-3">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<img
|
<img
|
||||||
v-if="match.thumbnailUrl"
|
v-if="match.thumbnailUrl"
|
||||||
:src="match.thumbnailUrl"
|
:src="match.thumbnailUrl"
|
||||||
:alt="match.title"
|
:alt="match.title"
|
||||||
class="w-16 h-20 object-cover rounded border"
|
class="w-12 h-16 object-cover shrink-0"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="w-16 h-20 bg-gray-200 rounded border flex items-center justify-center"
|
class="w-12 h-16 bg-gray-100 dark:bg-gray-700 shrink-0 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<PhotoIcon class="w-6 h-6 text-gray-400" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manga Info -->
|
<!-- Infos -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0 flex flex-col justify-between py-0.5">
|
||||||
<h4 class="text-sm font-medium text-gray-900 truncate" :title="match.title">
|
<p class="text-xs font-medium text-gray-900 dark:text-gray-100 line-clamp-3 leading-snug" :title="match.title">
|
||||||
{{ match.title }}
|
{{ match.title }}
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-500 mt-1 truncate" :title="match.slug">
|
|
||||||
{{ match.slug }}
|
|
||||||
</p>
|
</p>
|
||||||
|
<div class="flex items-center justify-between mt-1">
|
||||||
<!-- Alternative Slugs -->
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ match.matchScore }}%</span>
|
||||||
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
|
<CheckCircleIcon v-if="isSelected" class="w-4 h-4 text-green-500 shrink-0" />
|
||||||
<p class="text-xs text-gray-400">Autres titres:</p>
|
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
|
||||||
<span
|
|
||||||
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
|
|
||||||
:key="altSlug"
|
|
||||||
class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"
|
|
||||||
>
|
|
||||||
{{ altSlug }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="match.alternativeSlugs.length > 2"
|
|
||||||
class="text-xs text-gray-400"
|
|
||||||
>
|
|
||||||
+{{ match.alternativeSlugs.length - 2 }} autres
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Score Bar -->
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
|
||||||
<span>Correspondance</span>
|
|
||||||
<span>{{ match.matchScore }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
class="h-2 rounded-full transition-all duration-300"
|
|
||||||
:class="{
|
|
||||||
'bg-blue-500': isSelected,
|
|
||||||
'bg-gray-400': !isSelected
|
|
||||||
}"
|
|
||||||
:style="{ width: match.matchScore + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</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,25 +46,25 @@ 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 text-blue-800`;
|
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
|
||||||
case 'analyzed':
|
case 'analyzed':
|
||||||
return `${baseClasses} bg-yellow-100 text-yellow-800`;
|
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
|
||||||
case 'importing':
|
case 'importing':
|
||||||
return `${baseClasses} bg-blue-100 text-blue-800`;
|
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||||
case 'imported':
|
case 'imported':
|
||||||
return `${baseClasses} bg-green-100 text-green-800`;
|
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||||
case 'error':
|
case 'error':
|
||||||
return `${baseClasses} bg-red-100 text-red-800`;
|
return `${baseClasses} bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300`;
|
||||||
default:
|
default:
|
||||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,77 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<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 mb-2">Import de Bibliothèque</h1>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress Bar (if files are being processed) -->
|
<div class="overflow-y-auto flex-1">
|
||||||
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8">
|
<div class="px-6 py-8">
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Progression</span>
|
|
||||||
<span class="text-sm text-gray-500">{{ store.progressPercentage }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
||||||
:style="{ width: store.progressPercentage + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
|
||||||
<span>{{ store.importedCount }} importés</span>
|
|
||||||
<span>{{ store.errorCount }} erreurs</span>
|
|
||||||
<span>{{ store.totalFiles }} total</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File Upload Zone -->
|
<!-- Zone de dépôt -->
|
||||||
<div v-if="!store.hasFiles || store.allFilesProcessed" class="mb-8">
|
<section v-if="!store.hasFiles" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichiers</h2>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
label="Importer des fichiers CBZ/CBR"
|
label="Importer des fichiers CBZ/CBR"
|
||||||
accept=".cbz,.cbr"
|
accept=".cbz,.cbr"
|
||||||
:multiple="true"
|
:multiple="true"
|
||||||
description="Formats CBZ ou CBR uniquement"
|
description="Formats CBZ ou CBR uniquement"
|
||||||
@files-selected="handleFilesSelected"
|
@files-selected="store.addFiles($event)"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Fichiers en cours -->
|
||||||
|
<template v-if="store.hasFiles && !store.allFilesProcessed">
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||||
|
{{ store.totalFiles }} fichier(s)
|
||||||
|
</h2>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ store.importedCount }}/{{ store.totalFiles }}
|
||||||
|
<span v-if="store.errorCount > 0" class="text-red-500 ml-1">· {{ store.errorCount }} erreur(s)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-1.5 mb-4">
|
||||||
|
<div
|
||||||
|
class="bg-green-600 h-1.5 transition-all duration-300"
|
||||||
|
:style="{ width: store.progressPercentage + '%' }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
<!-- Files List -->
|
|
||||||
<div v-if="store.hasFiles" class="space-y-6">
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="flex flex-wrap gap-4 mb-6">
|
|
||||||
<button
|
|
||||||
v-if="store.hasReadyFiles"
|
|
||||||
@click="importAllFiles"
|
|
||||||
:disabled="store.isLoading"
|
|
||||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md font-medium"
|
|
||||||
>
|
|
||||||
<LoadingSpinner v-if="store.isLoading" class="w-4 h-4 mr-2" />
|
|
||||||
Importer tous les fichiers prêts ({{ store.readyCount }})
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="store.analyzedFiles.length > 0"
|
|
||||||
@click="autoSelectMatches"
|
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium"
|
|
||||||
>
|
|
||||||
Sélection automatique
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="clearAllFiles"
|
|
||||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium"
|
|
||||||
>
|
|
||||||
Effacer tout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Files Grid -->
|
|
||||||
<div class="grid gap-6">
|
|
||||||
<FileImportCard
|
<FileImportCard
|
||||||
v-for="file in store.files"
|
v-for="file in store.files"
|
||||||
:key="file.id"
|
:key="file.id"
|
||||||
@@ -79,37 +43,61 @@
|
|||||||
:is-analyzing="store.analyzingFiles.has(file.id)"
|
:is-analyzing="store.analyzingFiles.has(file.id)"
|
||||||
:is-importing="store.importingFiles.has(file.id)"
|
:is-importing="store.importingFiles.has(file.id)"
|
||||||
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
|
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
|
||||||
@chapter-number-selected="(chapterNumber) => store.setFileChapterNumber(file.id, chapterNumber)"
|
@chapter-number-selected="(n) => store.setFileChapterNumber(file.id, n)"
|
||||||
@volume-number-selected="(volumeNumber) => store.setFileVolumeNumber(file.id, volumeNumber)"
|
@volume-number-selected="(n) => store.setFileVolumeNumber(file.id, n)"
|
||||||
@import-file="() => importSingleFile(file.id)"
|
@import-file="() => importSingleFile(file.id)"
|
||||||
@retry-file="() => retryFile(file.id)"
|
@retry-file="() => retryFile(file.id)"
|
||||||
@remove-file="() => store.removeFile(file.id)"
|
@remove-file="() => store.removeFile(file.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Results Summary (when all files are processed) -->
|
<!-- Résultats -->
|
||||||
<div v-if="store.allFilesProcessed" class="mt-8">
|
<ImportResults v-if="store.allFilesProcessed" />
|
||||||
<ImportResults />
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onUnmounted } from 'vue';
|
import { ArrowUpTrayIcon, SparklesIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, onUnmounted } from 'vue';
|
||||||
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
|
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
|
||||||
import LoadingSpinner from '../../../../shared/components/ui/LoadingSpinner.vue';
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||||
import FileImportCard from '../components/FileImportCard.vue';
|
import FileImportCard from '../components/FileImportCard.vue';
|
||||||
import ImportResults from '../components/ImportResults.vue';
|
import ImportResults from '../components/ImportResults.vue';
|
||||||
|
|
||||||
const store = useNewImportStore();
|
const store = useNewImportStore();
|
||||||
|
|
||||||
// === EVENT HANDLERS ===
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
const handleFilesSelected = (files) => {
|
{ type: 'label', text: 'Import de bibliothèque', class: 'text-sm font-medium' },
|
||||||
store.addFiles(files);
|
],
|
||||||
};
|
rightSection: [
|
||||||
|
...(store.analyzedFiles.length > 0 ? [{
|
||||||
|
type: 'button',
|
||||||
|
icon: SparklesIcon,
|
||||||
|
label: 'Sélection auto',
|
||||||
|
onClick: () => store.autoSelectBestMatches(),
|
||||||
|
}] : []),
|
||||||
|
...(store.hasReadyFiles ? [{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowUpTrayIcon,
|
||||||
|
label: `Importer (${store.readyCount})`,
|
||||||
|
onClick: importAllFiles,
|
||||||
|
disabled: store.isLoading,
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: TrashIcon,
|
||||||
|
label: 'Effacer',
|
||||||
|
onClick: () => store.clearFiles(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
const importAllFiles = async () => {
|
const importAllFiles = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -135,19 +123,6 @@ const retryFile = async (fileId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoSelectMatches = () => {
|
|
||||||
store.autoSelectBestMatches();
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAllFiles = () => {
|
|
||||||
if (confirm('Êtes-vous sûr de vouloir effacer tous les fichiers ?')) {
|
|
||||||
store.clearFiles();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// === LIFECYCLE ===
|
|
||||||
|
|
||||||
// Reset state when component unmounts
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
store.resetGlobalState();
|
store.resetGlobalState();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -5,32 +5,32 @@
|
|||||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="handleClose"></div>
|
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="handleClose"></div>
|
||||||
|
|
||||||
<!-- Modal avec style Material Design -->
|
<!-- Modal avec style Material Design -->
|
||||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100">
|
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100 dark:border-gray-700">
|
||||||
<!-- Header Material Design -->
|
<!-- Header Material Design -->
|
||||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
<FolderIcon class="h-5 w-5 text-green-600" />
|
<FolderIcon class="h-5 w-5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-medium text-gray-900 leading-6">
|
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100 leading-6">
|
||||||
Gérer les chapitres
|
Gérer les chapitres
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-600 mt-1">{{ manga?.title }}</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ manga?.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="handleClose"
|
@click="handleClose"
|
||||||
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
|
class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<XMarkIcon class="h-5 w-5 text-gray-600" />
|
<XMarkIcon class="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content avec style Material Design -->
|
<!-- Content avec style Material Design -->
|
||||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-8">
|
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-8">
|
||||||
<div v-if="isLoading" class="flex justify-center items-center h-32">
|
<div v-if="isLoading" class="flex justify-center items-center h-32">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="w-8 h-8 border-4 border-green-200 rounded-full"></div>
|
<div class="w-8 h-8 border-4 border-green-200 rounded-full"></div>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
<div v-else-if="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
||||||
<div class="w-5 h-5 bg-red-100 rounded-full flex items-center justify-center">
|
<div class="w-5 h-5 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
<XMarkIcon class="h-3 w-3 text-red-600" />
|
<XMarkIcon class="h-3 w-3 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<div v-else class="space-y-6">
|
<div v-else class="space-y-6">
|
||||||
<!-- Actions avec style Material Design -->
|
<!-- Actions avec style Material Design -->
|
||||||
<div class="flex items-center justify-between bg-gray-50 rounded-xl p-4">
|
<div class="flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<button
|
<button
|
||||||
@click="showCreateVolumeModal = true"
|
@click="showCreateVolumeModal = true"
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="showUnassignedChapters = !showUnassignedChapters"
|
@click="showUnassignedChapters = !showUnassignedChapters"
|
||||||
class="text-gray-600 hover:text-gray-800 text-sm font-medium hover:bg-gray-100 px-3 py-2 rounded-lg transition-colors duration-200"
|
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700 px-3 py-2 rounded-lg transition-colors duration-200"
|
||||||
>
|
>
|
||||||
{{ showUnassignedChapters ? 'Masquer' : 'Afficher' }} les chapitres non assignés
|
{{ showUnassignedChapters ? 'Masquer' : 'Afficher' }} les chapitres non assignés
|
||||||
</button>
|
</button>
|
||||||
@@ -88,17 +88,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-500 bg-white px-3 py-1.5 rounded-lg border border-gray-200">
|
<div class="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-700 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||||
{{ totalChapters }} chapitres, {{ volumes.length }} volumes
|
{{ totalChapters }} chapitres, {{ volumes.length }} volumes
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Arborescence avec style Material Design -->
|
<!-- Arborescence avec style Material Design -->
|
||||||
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden shadow-sm">
|
||||||
<!-- Chapitres non assignés -->
|
<!-- Chapitres non assignés -->
|
||||||
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
|
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700/50 dark:to-gray-700/30 border-b border-gray-200 dark:border-gray-600">
|
||||||
<div class="px-6 py-4">
|
<div class="px-6 py-4">
|
||||||
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center space-x-2">
|
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center space-x-2">
|
||||||
<DocumentIcon class="h-4 w-4 text-gray-500" />
|
<DocumentIcon class="h-4 w-4 text-gray-500" />
|
||||||
<span>Chapitres non assignés ({{ unassignedChapters.length }})</span>
|
<span>Chapitres non assignés ({{ unassignedChapters.length }})</span>
|
||||||
</h4>
|
</h4>
|
||||||
@@ -119,11 +119,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
||||||
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div v-if="!chapter.isEditing" class="flex items-center">
|
<div v-if="!chapter.isEditing" class="flex items-center">
|
||||||
<span
|
<span
|
||||||
class="text-sm text-gray-900 cursor-pointer hover:text-green-600 transition-colors duration-200"
|
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
||||||
@click="startEditingTitle(chapter)"
|
@click="startEditingTitle(chapter)"
|
||||||
>
|
>
|
||||||
{{ chapter.title || 'Sans titre' }}
|
{{ chapter.title || 'Sans titre' }}
|
||||||
@@ -173,22 +173,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Volumes avec style Material Design -->
|
<!-- Volumes avec style Material Design -->
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
<div
|
<div
|
||||||
v-for="volume in volumes"
|
v-for="volume in volumes"
|
||||||
:key="volume.number"
|
:key="volume.number"
|
||||||
class="bg-white"
|
class="bg-white dark:bg-gray-800"
|
||||||
>
|
>
|
||||||
<!-- En-tête du volume Material Design -->
|
<!-- En-tête du volume Material Design -->
|
||||||
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 border-b border-green-100">
|
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-b border-green-100 dark:border-green-900/30">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
<FolderIcon class="h-4 w-4 text-green-600" />
|
<FolderIcon class="h-4 w-4 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm font-semibold text-green-900">Volume {{ volume.number }}</span>
|
<span class="text-sm font-semibold text-green-900 dark:text-green-300">Volume {{ volume.number }}</span>
|
||||||
<span class="text-xs text-green-600 ml-2">({{ volume.chapters.length }} chapitres)</span>
|
<span class="text-xs text-green-600 dark:text-green-400 ml-2">({{ volume.chapters.length }} chapitres)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@@ -211,10 +211,10 @@
|
|||||||
|
|
||||||
<!-- Chapitres du volume -->
|
<!-- Chapitres du volume -->
|
||||||
<div v-if="volume.isExpanded" class="px-6 py-4">
|
<div v-if="volume.isExpanded" class="px-6 py-4">
|
||||||
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500">
|
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
<DocumentIcon class="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
<DocumentIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
|
||||||
<p class="text-sm">Aucun chapitre assigné à ce volume.</p>
|
<p class="text-sm">Aucun chapitre assigné à ce volume.</p>
|
||||||
<p class="text-xs text-gray-400 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
<div
|
<div
|
||||||
@@ -233,11 +233,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
||||||
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div v-if="!chapter.isEditing" class="flex items-center">
|
<div v-if="!chapter.isEditing" class="flex items-center">
|
||||||
<span
|
<span
|
||||||
class="text-sm text-gray-900 cursor-pointer hover:text-green-600 transition-colors duration-200"
|
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
||||||
@click="startEditingTitle(chapter)"
|
@click="startEditingTitle(chapter)"
|
||||||
>
|
>
|
||||||
{{ chapter.title || 'Sans titre' }}
|
{{ chapter.title || 'Sans titre' }}
|
||||||
@@ -291,12 +291,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer Material Design -->
|
<!-- Footer Material Design -->
|
||||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||||
<button
|
<button
|
||||||
@click="handleClose"
|
@click="handleClose"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
@@ -320,24 +320,24 @@
|
|||||||
<div v-if="showCreateVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
|
<div v-if="showCreateVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showCreateVolumeModal = false"></div>
|
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showCreateVolumeModal = false"></div>
|
||||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
<PlusIcon class="h-5 w-5 text-green-600" />
|
<PlusIcon class="h-5 w-5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900">Créer un nouveau volume</h3>
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Créer un nouveau volume</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Numéro du volume</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Numéro du volume</label>
|
||||||
<input
|
<input
|
||||||
v-model="newVolumeNumber"
|
v-model="newVolumeNumber"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
class="block w-full border border-gray-300 rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
class="block w-full border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-3 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
||||||
placeholder="Ex: 1"
|
placeholder="Ex: 1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,7 +351,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||||
<button
|
<button
|
||||||
@click="showCreateVolumeModal = false"
|
@click="showCreateVolumeModal = false"
|
||||||
@@ -376,8 +376,8 @@
|
|||||||
<div v-if="showAssignModal" class="fixed inset-0 z-60 overflow-y-auto">
|
<div v-if="showAssignModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showAssignModal = false"></div>
|
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showAssignModal = false"></div>
|
||||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
<DocumentIcon class="h-5 w-5 text-green-600" />
|
<DocumentIcon class="h-5 w-5 text-green-600" />
|
||||||
@@ -385,7 +385,7 @@
|
|||||||
<h3 class="text-lg font-medium text-gray-900">Assigner le chapitre {{ selectedChapter?.number }}</h3>
|
<h3 class="text-lg font-medium text-gray-900">Assigner le chapitre {{ selectedChapter?.number }}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Volume</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Volume</label>
|
||||||
@@ -401,7 +401,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||||
<button
|
<button
|
||||||
@click="showAssignModal = false"
|
@click="showAssignModal = false"
|
||||||
@@ -426,8 +426,8 @@
|
|||||||
<div v-if="showMoveToVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
|
<div v-if="showMoveToVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showMoveToVolumeModal = false"></div>
|
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showMoveToVolumeModal = false"></div>
|
||||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
<ArrowPathIcon class="h-5 w-5 text-green-600" />
|
<ArrowPathIcon class="h-5 w-5 text-green-600" />
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
<h3 class="text-lg font-medium text-gray-900">Déplacer {{ selectedChapters.length }} chapitre(s)</h3>
|
<h3 class="text-lg font-medium text-gray-900">Déplacer {{ selectedChapters.length }} chapitre(s)</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||||
<p class="text-sm text-green-800 font-medium">
|
<p class="text-sm text-green-800 font-medium">
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||||
<button
|
<button
|
||||||
@click="showMoveToVolumeModal = false"
|
@click="showMoveToVolumeModal = false"
|
||||||
@@ -491,7 +491,7 @@
|
|||||||
<h3 class="text-lg font-medium text-gray-900">Séparer le volume 00</h3>
|
<h3 class="text-lg font-medium text-gray-900">Séparer le volume 00</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||||
<p class="text-sm text-green-800 font-medium">
|
<p class="text-sm text-green-800 font-medium">
|
||||||
@@ -517,7 +517,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||||
<button
|
<button
|
||||||
@click="showSplitVolumeZeroModal = false"
|
@click="showSplitVolumeZeroModal = false"
|
||||||
|
|||||||
@@ -1,37 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="group relative bg-white dark:bg-gray-800 overflow-hidden shadow-sm">
|
||||||
|
<!-- Cover avec overlay -->
|
||||||
|
<div class="relative pb-[140%]">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||||
class="bg-white rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
|
class="absolute inset-0">
|
||||||
<div class="relative pb-[150%]">
|
|
||||||
<img
|
<img
|
||||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||||
:alt="manga.title"
|
:alt="manga.title"
|
||||||
class="absolute inset-0 w-full h-full object-cover bg-gray-100" />
|
class="w-full h-full object-cover bg-gray-100" />
|
||||||
</div>
|
|
||||||
<div class="p-2">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ manga.title }}</h3>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="text-sm text-gray-500">{{ manga.publicationYear }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-gray-500"> Added: {{ formatDate(manga.createdAt) }} </div>
|
|
||||||
</div>
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
|
<!-- Gradient + actions au survol -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||||
|
<div class="absolute bottom-2 left-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||||
|
title="Éditer"
|
||||||
|
@click="$emit('edit', manga)">
|
||||||
|
<PencilIcon class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||||
|
title="Sources préférées"
|
||||||
|
@click="$emit('sources', manga)">
|
||||||
|
<Cog6ToothIcon class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||||
|
title="Rafraîchir"
|
||||||
|
@click="$emit('refresh', manga)">
|
||||||
|
<ArrowPathIcon class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
manga: {
|
manga: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = dateString => {
|
defineEmits(['edit', 'sources', 'refresh']);
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<tr class="border-t hover:bg-green-100">
|
<tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20">
|
||||||
<td class="px-4 py-2" :class="{ 'text-green-500': 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">
|
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="chapter.isAvailable"
|
v-if="chapter.isAvailable"
|
||||||
|
class="hover:text-green-500 dark:hover:text-green-400"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'reader',
|
name: 'reader',
|
||||||
params: {
|
params: {
|
||||||
chapterId: chapter.id
|
chapterId: chapter.id
|
||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
{{ chapter.title || 'Sans titre' }}
|
<template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
|
||||||
|
Chapitres {{ chapter.volumeChaptersRange }}
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
|
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||||
|
<template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
|
||||||
|
Chapitres {{ 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,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-2 border-t">
|
<div class="p-2 border-t dark:border-gray-700">
|
||||||
<table class="min-w-full table-auto">
|
<table class="min-w-full table-auto">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr class="text-gray-700 dark:text-gray-300">
|
||||||
<th class="px-4 py-2 text-left">#</th>
|
<th class="px-4 py-2 text-left">#</th>
|
||||||
<th class="px-4 py-2 text-left">Titre</th>
|
<th class="px-4 py-2 text-left">Titre</th>
|
||||||
<th class="px-4 py-2 text-right">Actions</th>
|
<th class="px-4 py-2 text-right">Actions</th>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
leave-from="opacity-100"
|
leave-from="opacity-100"
|
||||||
leave-to="opacity-0"
|
leave-to="opacity-0"
|
||||||
>
|
>
|
||||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||||
@@ -24,15 +24,15 @@
|
|||||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
|
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||||
Supprimer le manga
|
Supprimer le manga
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||||
{{ error.message || 'Une erreur est survenue lors de la suppression.' }}
|
{{ error.message || 'Une erreur est survenue lors de la suppression.' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,19 +40,19 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
|
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
|
||||||
<span class="text-sm font-medium text-gray-900">Action irréversible</span>
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Action irréversible</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-600 mb-4">
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
Êtes-vous sûr de vouloir supprimer le manga <strong>"{{ manga?.title }}"</strong> ?
|
Êtes-vous sûr de vouloir supprimer le manga <strong>"{{ manga?.title }}"</strong> ?
|
||||||
</p>
|
</p>
|
||||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-md p-4">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
|
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<h3 class="text-sm font-medium text-yellow-800">
|
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">
|
||||||
Attention
|
Attention
|
||||||
</h3>
|
</h3>
|
||||||
<div class="mt-2 text-sm text-yellow-700">
|
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||||
<p>Cette action supprimera définitivement :</p>
|
<p>Cette action supprimera définitivement :</p>
|
||||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
<li>Le manga et toutes ses métadonnées</li>
|
<li>Le manga et toutes ses métadonnées</li>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<div class="mt-6 flex justify-end space-x-3">
|
<div class="mt-6 flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
leave-from="opacity-100"
|
leave-from="opacity-100"
|
||||||
leave-to="opacity-0"
|
leave-to="opacity-0"
|
||||||
>
|
>
|
||||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||||
@@ -24,15 +24,15 @@
|
|||||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
|
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||||
Edit Manga
|
Edit Manga
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||||
{{ error.message || 'Une erreur est survenue lors de la sauvegarde.' }}
|
{{ error.message || 'Une erreur est survenue lors de la sauvegarde.' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,49 +41,49 @@
|
|||||||
<!-- Titre et Slug -->
|
<!-- Titre et Slug -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Titre</label>
|
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre</label>
|
||||||
<input
|
<input
|
||||||
id="title"
|
id="title"
|
||||||
v-model="formData.title"
|
v-model="formData.title"
|
||||||
type="text"
|
type="text"
|
||||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
placeholder="Titre du manga"
|
placeholder="Titre du manga"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="slug" class="block text-sm font-medium text-gray-700 mb-2">Slug</label>
|
<label for="slug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slug</label>
|
||||||
<input
|
<input
|
||||||
id="slug"
|
id="slug"
|
||||||
:value="manga?.slug || ''"
|
:value="manga?.slug || ''"
|
||||||
type="text"
|
type="text"
|
||||||
disabled
|
disabled
|
||||||
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm text-gray-500"
|
class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-600 shadow-sm sm:text-sm text-gray-500 dark:text-gray-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Année de publication -->
|
<!-- Année de publication -->
|
||||||
<div>
|
<div>
|
||||||
<label for="publicationYear" class="block text-sm font-medium text-gray-700 mb-2">Année de publication</label>
|
<label for="publicationYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Année de publication</label>
|
||||||
<input
|
<input
|
||||||
id="publicationYear"
|
id="publicationYear"
|
||||||
v-model.number="formData.publicationYear"
|
v-model.number="formData.publicationYear"
|
||||||
type="number"
|
type="number"
|
||||||
min="1900"
|
min="1900"
|
||||||
:max="new Date().getFullYear()"
|
:max="new Date().getFullYear()"
|
||||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
placeholder="2023"
|
placeholder="2023"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div>
|
<div>
|
||||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
v-model="formData.description"
|
v-model="formData.description"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
placeholder="Description du manga"
|
placeholder="Description du manga"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,22 +91,22 @@
|
|||||||
<!-- Auteur et Statut -->
|
<!-- Auteur et Statut -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label for="author" class="block text-sm font-medium text-gray-700 mb-2">Auteur</label>
|
<label for="author" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Auteur</label>
|
||||||
<input
|
<input
|
||||||
id="author"
|
id="author"
|
||||||
v-model="formData.author"
|
v-model="formData.author"
|
||||||
type="text"
|
type="text"
|
||||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
placeholder="Auteur du manga"
|
placeholder="Auteur du manga"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-2">Statut</label>
|
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Statut</label>
|
||||||
<input
|
<input
|
||||||
id="status"
|
id="status"
|
||||||
v-model="formData.status"
|
v-model="formData.status"
|
||||||
type="text"
|
type="text"
|
||||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
placeholder="ongoing"
|
placeholder="ongoing"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
|
|
||||||
<!-- Note -->
|
<!-- Note -->
|
||||||
<div>
|
<div>
|
||||||
<label for="rating" class="block text-sm font-medium text-gray-700 mb-2">Note</label>
|
<label for="rating" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Note</label>
|
||||||
<input
|
<input
|
||||||
id="rating"
|
id="rating"
|
||||||
v-model.number="formData.rating"
|
v-model.number="formData.rating"
|
||||||
@@ -122,20 +122,20 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max="10"
|
max="10"
|
||||||
step="0.001"
|
step="0.001"
|
||||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
placeholder="9.541"
|
placeholder="9.541"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slugs alternatifs -->
|
<!-- Slugs alternatifs -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Slugs alternatifs</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slugs alternatifs</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-if="formData.alternativeSlugs.length > 0" class="flex flex-wrap gap-2">
|
<div v-if="formData.alternativeSlugs.length > 0" class="flex flex-wrap gap-2">
|
||||||
<span
|
<span
|
||||||
v-for="(slug, index) in formData.alternativeSlugs"
|
v-for="(slug, index) in formData.alternativeSlugs"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300"
|
||||||
>
|
>
|
||||||
{{ slug }}
|
{{ slug }}
|
||||||
<button
|
<button
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="newAlternativeSlug"
|
v-model="newAlternativeSlug"
|
||||||
type="text"
|
type="text"
|
||||||
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
placeholder="Nouveau slug alternatif"
|
placeholder="Nouveau slug alternatif"
|
||||||
@keyup.enter="addAlternativeSlug"
|
@keyup.enter="addAlternativeSlug"
|
||||||
/>
|
/>
|
||||||
@@ -175,19 +175,19 @@
|
|||||||
|
|
||||||
<!-- Genres -->
|
<!-- Genres -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Genres</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Genres</label>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-if="formData.genres.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
<div v-if="formData.genres.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
<span
|
<span
|
||||||
v-for="(genre, index) in formData.genres"
|
v-for="(genre, index) in formData.genres"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 text-gray-800"
|
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
|
||||||
>
|
>
|
||||||
{{ genre }}
|
{{ genre }}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="removeGenre(index)"
|
@click="removeGenre(index)"
|
||||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 hover:text-gray-600"
|
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
>
|
>
|
||||||
<XMarkIcon class="w-3 h-3" />
|
<XMarkIcon class="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="newGenre"
|
v-model="newGenre"
|
||||||
type="text"
|
type="text"
|
||||||
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
placeholder="Nouveau genre"
|
placeholder="Nouveau genre"
|
||||||
@keyup.enter="addGenre"
|
@keyup.enter="addGenre"
|
||||||
/>
|
/>
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
<div class="mt-8 flex justify-end space-x-3">
|
<div class="mt-8 flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
<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 { 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 MangaCard from './MangaCard.vue';
|
||||||
|
import MangaEditModal from './MangaEditModal.vue';
|
||||||
|
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
mangas: {
|
mangas: {
|
||||||
@@ -13,4 +43,54 @@
|
|||||||
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" />
|
|
||||||
<!-- TODO: Add placeholder image -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manga Info -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h3 class="text-lg leading-7 font-medium text-gray-900 dark:text-gray-100 truncate">{{
|
|
||||||
manga.title
|
|
||||||
}}</h3>
|
|
||||||
<p v-if="manga.publicationYear" class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{
|
|
||||||
manga.publicationYear
|
|
||||||
}}</p>
|
|
||||||
<p v-if="manga.description" class="text-sm text-gray-700 dark:text-gray-300 mt-2">
|
|
||||||
{{ truncateDescription(manga.description) }}
|
|
||||||
</p>
|
|
||||||
<p v-if="manga.createdAt" class="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
|
||||||
Added: {{ formatDate(manga.createdAt) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { defineEmits, defineProps } from 'vue';
|
|
||||||
|
|
||||||
const emit = defineEmits(['manga-click']);
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
mangas: {
|
|
||||||
type: Array,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = dateString => {
|
|
||||||
if (!dateString) return '';
|
|
||||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
|
||||||
try {
|
|
||||||
return new Date(dateString).toLocaleDateString(undefined, options);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error formatting date:', e);
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const truncateDescription = description => {
|
|
||||||
if (!description) return '';
|
|
||||||
return description.length > 500 ? description.slice(0, 500) + '...' : description;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Pour s'assurer que line-clamp fonctionne */
|
|
||||||
@supports (-webkit-line-clamp: 3) {
|
|
||||||
.line-clamp-3 {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-truncate {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div
|
||||||
|
v-for="manga in mangas"
|
||||||
|
:key="manga.id"
|
||||||
|
class="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors border-b border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Cover -->
|
||||||
|
<img
|
||||||
|
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||||
|
alt=""
|
||||||
|
class="h-36 w-24 object-cover flex-shrink-0 self-start"
|
||||||
|
referrerpolicy="no-referrer" />
|
||||||
|
|
||||||
|
<!-- Titre + méta + résumé -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-start gap-2 flex-wrap">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||||
|
class="text-2xl font-semibold text-gray-900 dark:text-gray-100 hover:text-green-500 dark:hover:text-green-400 transition-colors"
|
||||||
|
@click.stop>
|
||||||
|
{{ manga.title }}
|
||||||
|
</RouterLink>
|
||||||
|
<span
|
||||||
|
v-if="manga.status"
|
||||||
|
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
|
||||||
|
:class="statusClass(manga.status)">
|
||||||
|
{{ manga.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">
|
||||||
|
{{ manga.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions verticales -->
|
||||||
|
<div class="flex flex-col items-center justify-center gap-0.5 flex-shrink-0 self-stretch">
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title="Éditer"
|
||||||
|
@click.stop="openEdit(manga)">
|
||||||
|
<PencilIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title="Sources préférées"
|
||||||
|
@click.stop="openSources(manga)">
|
||||||
|
<Cog6ToothIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md transition-colors"
|
||||||
|
:class="refreshingId === manga.id
|
||||||
|
? 'text-blue-400 cursor-not-allowed'
|
||||||
|
: 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600'"
|
||||||
|
title="Rafraîchir"
|
||||||
|
:disabled="refreshingId === manga.id"
|
||||||
|
@click.stop="doRefresh(manga)">
|
||||||
|
<ArrowPathIcon
|
||||||
|
class="w-4 h-4"
|
||||||
|
:class="{ 'animate-spin': refreshingId === manga.id }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modales -->
|
||||||
|
<MangaEditModal
|
||||||
|
:is-open="isEditModalOpen"
|
||||||
|
:manga="selectedManga"
|
||||||
|
:is-saving="editIsLoading"
|
||||||
|
:error="editError"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@save="handleSaveEdit" />
|
||||||
|
|
||||||
|
<MangaPreferredSourcesModal
|
||||||
|
:is-open="isSourcesModalOpen"
|
||||||
|
:sources="preferredSources"
|
||||||
|
:is-loading="sourcesIsLoading"
|
||||||
|
:error="sourcesError"
|
||||||
|
:is-saving="sourcesIsSaving"
|
||||||
|
@close="isSourcesModalOpen = false"
|
||||||
|
@save="handleSaveSources" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||||
|
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||||
|
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||||
|
import MangaEditModal from './MangaEditModal.vue';
|
||||||
|
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['manga-click']);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mangas: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
} catch (e) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status) {
|
||||||
|
if (status === 'ongoing') return 'text-blue-600 bg-blue-50 dark:bg-blue-900/20';
|
||||||
|
if (status === 'completed') return 'text-green-600 bg-green-50 dark:bg-green-900/20';
|
||||||
|
return 'text-gray-500 bg-gray-100 dark:bg-gray-700';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selected manga ────────────────────────────────────────
|
||||||
|
const selectedManga = ref(null);
|
||||||
|
const isSourcesModalOpen = ref(false);
|
||||||
|
|
||||||
|
// ── Edit ──────────────────────────────────────────────────
|
||||||
|
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||||
|
|
||||||
|
function openEdit(manga) {
|
||||||
|
selectedManga.value = manga;
|
||||||
|
openEditModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(data) {
|
||||||
|
if (!selectedManga.value) return;
|
||||||
|
await editManga(selectedManga.value.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sources préférées ─────────────────────────────────────
|
||||||
|
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||||
|
const {
|
||||||
|
sources: preferredSources,
|
||||||
|
isLoading: sourcesIsLoading,
|
||||||
|
error: sourcesError,
|
||||||
|
isSaving: sourcesIsSaving,
|
||||||
|
savePreferredSources
|
||||||
|
} = useMangaPreferredSources(selectedMangaId);
|
||||||
|
|
||||||
|
function openSources(manga) {
|
||||||
|
selectedManga.value = manga;
|
||||||
|
isSourcesModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveSources(sourceIds) {
|
||||||
|
savePreferredSources(sourceIds);
|
||||||
|
isSourcesModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refresh ───────────────────────────────────────────────
|
||||||
|
const { refreshMetadata } = useMangaRefresh();
|
||||||
|
const refreshingId = ref(null);
|
||||||
|
|
||||||
|
async function doRefresh(manga) {
|
||||||
|
if (refreshingId.value) return;
|
||||||
|
refreshingId.value = manga.id;
|
||||||
|
try {
|
||||||
|
await refreshMetadata(manga.id);
|
||||||
|
} finally {
|
||||||
|
refreshingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="isLoading" class="text-gray-400 dark:text-gray-600 text-xs">…</span>
|
||||||
|
<span v-else-if="sources.length" class="text-gray-700 dark:text-gray-300 truncate max-w-xs block">{{ sources[0].name }}</span>
|
||||||
|
<span v-else class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, toRef } from 'vue';
|
||||||
|
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mangaId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mangaIdRef = toRef(props, 'mangaId');
|
||||||
|
const { sources, isLoading } = useMangaPreferredSources(mangaIdRef);
|
||||||
|
</script>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
leave-from="opacity-100"
|
leave-from="opacity-100"
|
||||||
leave-to="opacity-0"
|
leave-to="opacity-0"
|
||||||
>
|
>
|
||||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||||
@@ -24,17 +24,17 @@
|
|||||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||||
<div>
|
<div>
|
||||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
||||||
<Cog6ToothIcon class="h-6 w-6 text-blue-600" aria-hidden="true" />
|
<Cog6ToothIcon class="h-6 w-6 text-blue-600" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 text-center sm:mt-5">
|
<div class="mt-3 text-center sm:mt-5">
|
||||||
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900">
|
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||||
Sources préférées
|
Sources préférées
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Configurez l'ordre de priorité des sources pour ce manga. Glissez-déposez les sources pour les réorganiser.
|
Configurez l'ordre de priorité des sources pour ce manga. Glissez-déposez les sources pour les réorganiser.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,13 +47,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-else-if="error" class="mt-5 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
<div v-else-if="error" class="mt-5 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||||
{{ error.message || 'Une erreur est survenue lors du chargement des sources.' }}
|
{{ error.message || 'Une erreur est survenue lors du chargement des sources.' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sources list -->
|
<!-- Sources list -->
|
||||||
<div v-else class="mt-5">
|
<div v-else class="mt-5">
|
||||||
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500">
|
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
Aucune source disponible
|
Aucune source disponible
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
@@ -63,10 +63,10 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'group relative flex items-center p-4 rounded-lg border-2 transition-all duration-200 cursor-grab active:cursor-grabbing select-none',
|
'group relative flex items-center p-4 rounded-lg border-2 transition-all duration-200 cursor-grab active:cursor-grabbing select-none',
|
||||||
{
|
{
|
||||||
'bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-300 shadow-md': index === 0,
|
'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-300 dark:border-blue-700 shadow-md': index === 0,
|
||||||
'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300': index === 1,
|
'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-green-300 dark:border-green-700': index === 1,
|
||||||
'bg-gradient-to-r from-yellow-50 to-amber-50 border-yellow-300': index === 2,
|
'bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border-yellow-300 dark:border-yellow-700': index === 2,
|
||||||
'bg-gray-50 border-gray-200': index > 2,
|
'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600': index > 2,
|
||||||
'scale-105 shadow-lg border-blue-400': draggedIndex === index,
|
'scale-105 shadow-lg border-blue-400': draggedIndex === index,
|
||||||
'opacity-50': dragOverIndex === index && draggedIndex !== index,
|
'opacity-50': dragOverIndex === index && draggedIndex !== index,
|
||||||
'scale-95 active:scale-95': isPressed === index
|
'scale-95 active:scale-95': isPressed === index
|
||||||
@@ -102,10 +102,10 @@
|
|||||||
<div :class="[
|
<div :class="[
|
||||||
'flex items-center space-x-1 px-3 py-1 rounded-full text-xs font-semibold',
|
'flex items-center space-x-1 px-3 py-1 rounded-full text-xs font-semibold',
|
||||||
{
|
{
|
||||||
'bg-blue-100 text-blue-800': index === 0,
|
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': index === 0,
|
||||||
'bg-green-100 text-green-800': index === 1,
|
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': index === 1,
|
||||||
'bg-yellow-100 text-yellow-800': index === 2,
|
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': index === 2,
|
||||||
'bg-gray-100 text-gray-600': index > 2
|
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300': index > 2
|
||||||
}
|
}
|
||||||
]">
|
]">
|
||||||
<span v-if="index === 0">🥇 Priorité haute</span>
|
<span v-if="index === 0">🥇 Priorité haute</span>
|
||||||
@@ -117,14 +117,14 @@
|
|||||||
|
|
||||||
<!-- Informations de la source -->
|
<!-- Informations de la source -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-semibold text-gray-900 truncate">{{ source.name }}</div>
|
<div class="font-semibold text-gray-900 dark:text-gray-100 truncate">{{ source.name }}</div>
|
||||||
<div class="text-sm text-gray-600 truncate">
|
<div class="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||||
<a :href="source.baseUrl" target="_blank" class="hover:text-blue-600 hover:underline">{{ source.baseUrl }}</a>
|
<a :href="source.baseUrl" target="_blank" class="hover:text-blue-600 hover:underline">{{ source.baseUrl }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Indicateur de drag -->
|
<!-- Indicateur de drag -->
|
||||||
<div class="ml-4 text-gray-400 group-hover:text-gray-600 transition-colors duration-200">
|
<div class="ml-4 text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors duration-200">
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9h8M8 15h8" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9h8M8 15h8" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:col-start-1 sm:mt-0"
|
class="mt-3 inline-flex w-full justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:col-start-1 sm:mt-0"
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
<th class="w-10 px-4 py-3"></th>
|
||||||
|
<th class="py-3 pr-4 text-left font-medium">Titre</th>
|
||||||
|
<th class="py-3 pr-4 text-left font-medium w-44">Source préférée</th>
|
||||||
|
<th class="py-3 pr-4 text-left font-medium w-44">Chapitres</th>
|
||||||
|
<th class="py-3 px-4 text-right font-medium w-28">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<tr
|
||||||
|
v-for="manga in mangas"
|
||||||
|
:key="manga.id"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
|
||||||
|
|
||||||
|
<!-- Monitoring -->
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
:title="manga.monitored ? 'Monitoring actif — cliquer pour désactiver' : 'Monitoring inactif — cliquer pour activer'"
|
||||||
|
:class="manga.monitored
|
||||||
|
? 'text-green-500 hover:text-green-600'
|
||||||
|
: 'text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500'"
|
||||||
|
class="transition-colors"
|
||||||
|
@click="doToggleMonitoring(manga)">
|
||||||
|
<component
|
||||||
|
:is="manga.monitored ? BookmarkIcon : BookmarkSlashIcon"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Titre -->
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||||
|
class="font-medium text-gray-900 dark:text-gray-100 hover:text-green-500 dark:hover:text-green-400 transition-colors">
|
||||||
|
{{ manga.title }}
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Source préférée -->
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<MangaPreferredSourceCell :manga-id="manga.id" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Chapitres — barre de progression -->
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<div v-if="manga.chaptersTotal > 0">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
|
||||||
|
{{ manga.chaptersScraped }} / {{ manga.chaptersTotal }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{{ progressPercent(manga) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-100 dark:bg-gray-600 rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
class="h-1.5 rounded-full transition-all"
|
||||||
|
:class="progressPercent(manga) >= 100
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-blue-500'"
|
||||||
|
:style="{ width: progressPercent(manga) + '%' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-400 dark:text-gray-600 text-xs">—</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<div class="flex items-center justify-end gap-0.5">
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title="Éditer"
|
||||||
|
@click="openEdit(manga)">
|
||||||
|
<PencilIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title="Sources préférées"
|
||||||
|
@click="openSources(manga)">
|
||||||
|
<Cog6ToothIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-md transition-colors"
|
||||||
|
:class="refreshingId === manga.id
|
||||||
|
? 'text-blue-400 cursor-not-allowed'
|
||||||
|
: 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600'"
|
||||||
|
title="Rafraîchir"
|
||||||
|
:disabled="refreshingId === manga.id"
|
||||||
|
@click="doRefresh(manga)">
|
||||||
|
<ArrowPathIcon
|
||||||
|
class="w-4 h-4"
|
||||||
|
:class="{ 'animate-spin': refreshingId === manga.id }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modales -->
|
||||||
|
<MangaEditModal
|
||||||
|
:is-open="isEditModalOpen"
|
||||||
|
:manga="selectedManga"
|
||||||
|
:is-saving="editIsLoading"
|
||||||
|
:error="editError"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@save="handleSaveEdit" />
|
||||||
|
|
||||||
|
<MangaPreferredSourcesModal
|
||||||
|
:is-open="isSourcesModalOpen"
|
||||||
|
:sources="preferredSources"
|
||||||
|
:is-loading="sourcesIsLoading"
|
||||||
|
:error="sourcesError"
|
||||||
|
:is-saving="sourcesIsSaving"
|
||||||
|
@close="isSourcesModalOpen = false"
|
||||||
|
@save="handleSaveSources" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowPathIcon, BookmarkIcon, BookmarkSlashIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||||
|
import { useMangaMonitoring } from '../composables/useMangaMonitoring';
|
||||||
|
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||||
|
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||||
|
import MangaEditModal from './MangaEditModal.vue';
|
||||||
|
import MangaPreferredSourceCell from './MangaPreferredSourceCell.vue';
|
||||||
|
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mangas: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function progressPercent(manga) {
|
||||||
|
if (!manga.chaptersTotal) return 0;
|
||||||
|
return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Monitoring ────────────────────────────────────────────
|
||||||
|
const { toggleMonitoring } = useMangaMonitoring();
|
||||||
|
|
||||||
|
async function doToggleMonitoring(manga) {
|
||||||
|
await toggleMonitoring(manga.id, !manga.monitored);
|
||||||
|
manga.monitored = !manga.monitored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selected manga ────────────────────────────────────────
|
||||||
|
const selectedManga = ref(null);
|
||||||
|
const isSourcesModalOpen = ref(false);
|
||||||
|
|
||||||
|
// ── Edit ──────────────────────────────────────────────────
|
||||||
|
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||||
|
|
||||||
|
function openEdit(manga) {
|
||||||
|
selectedManga.value = manga;
|
||||||
|
openEditModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(data) {
|
||||||
|
if (!selectedManga.value) return;
|
||||||
|
await editManga(selectedManga.value.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sources préférées ─────────────────────────────────────
|
||||||
|
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||||
|
const {
|
||||||
|
sources: preferredSources,
|
||||||
|
isLoading: sourcesIsLoading,
|
||||||
|
error: sourcesError,
|
||||||
|
isSaving: sourcesIsSaving,
|
||||||
|
savePreferredSources
|
||||||
|
} = useMangaPreferredSources(selectedMangaId);
|
||||||
|
|
||||||
|
function openSources(manga) {
|
||||||
|
selectedManga.value = manga;
|
||||||
|
isSourcesModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveSources(sourceIds) {
|
||||||
|
savePreferredSources(sourceIds);
|
||||||
|
isSourcesModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refresh ───────────────────────────────────────────────
|
||||||
|
const { refreshMetadata } = useMangaRefresh();
|
||||||
|
const refreshingId = ref(null);
|
||||||
|
|
||||||
|
async function doRefresh(manga) {
|
||||||
|
if (refreshingId.value) return;
|
||||||
|
refreshingId.value = manga.id;
|
||||||
|
try {
|
||||||
|
await refreshMetadata(manga.id);
|
||||||
|
} finally {
|
||||||
|
refreshingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white rounded-sm shadow mb-2">
|
<div class="bg-white dark:bg-gray-800 rounded-sm shadow mb-2">
|
||||||
<!-- En-tête du volume -->
|
<!-- En-tête du volume -->
|
||||||
<div class="relative bg-white p-3 sm:p-4 rounded-t-sm">
|
<div class="relative bg-white dark:bg-gray-800 p-3 sm:p-4 rounded-t-sm">
|
||||||
<!-- Layout mobile/desktop -->
|
<!-- Layout mobile/desktop -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<!-- Partie gauche -->
|
<!-- Partie gauche -->
|
||||||
<div class="flex items-center space-x-1 sm:space-x-4 flex-1 min-w-0">
|
<div class="flex items-center space-x-1 sm:space-x-4 flex-1 min-w-0">
|
||||||
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 flex-shrink-0" />
|
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 dark:text-gray-400 flex-shrink-0" />
|
||||||
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
|
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0 dark:text-gray-100">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<MangaChapterList v-show="isOpen" :chapters="volume.chapters" :manga-slug="mangaSlug" :manga-id="mangaId" />
|
<MangaChapterList v-show="isOpen" :chapters="volume.chapters" :manga-slug="mangaSlug" :manga-id="mangaId" />
|
||||||
|
|
||||||
<!-- Chevron de fermeture -->
|
<!-- Chevron de fermeture -->
|
||||||
<div v-show="isOpen" class="flex justify-center p-2 bg-white rounded-b-sm">
|
<div v-show="isOpen" class="flex justify-center p-2 bg-white dark:bg-gray-800 rounded-b-sm">
|
||||||
<button @click="toggleVolume" class="w-8 h-8 flex items-center justify-center">
|
<button @click="toggleVolume" class="w-8 h-8 flex items-center justify-center">
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon
|
||||||
class="h-5 w-5 sm:h-6 sm:w-6 bg-gray-400 rounded-full p-1 text-white hover:bg-green-500 cursor-pointer"
|
class="h-5 w-5 sm:h-6 sm:w-6 bg-gray-400 rounded-full p-1 text-white hover:bg-green-500 cursor-pointer"
|
||||||
|
|||||||
@@ -1,80 +1,142 @@
|
|||||||
<template>
|
<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">
|
||||||
|
<div class="px-6 py-8">
|
||||||
|
|
||||||
|
<!-- Recherche -->
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Recherche</h2>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
@keyup.enter="performSearch"
|
@keyup.enter="performSearch"
|
||||||
placeholder="Rechercher un manga..."
|
placeholder="Rechercher un manga..."
|
||||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
|
||||||
<button
|
</section>
|
||||||
@click="performSearch"
|
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
|
||||||
Rechercher
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- État de chargement -->
|
<!-- État de chargement -->
|
||||||
<div v-if="loading" class="text-center py-8">
|
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||||
<p class="mt-4 text-gray-600">Recherche en cours...</p>
|
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
|
||||||
|
<span class="text-sm">Recherche en cours...</span>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Message d'erreur -->
|
<!-- Message d'erreur -->
|
||||||
<div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
<section v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||||
{{ error }}
|
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||||
|
</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>
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
<!-- Résultats de recherche -->
|
<div
|
||||||
<div class="max-w-full overflow-hidden">
|
v-for="manga in searchResults"
|
||||||
<MangaList v-if="searchResults.length > 0" :mangas="searchResults" @manga-click="openMangaModal" />
|
:key="manga.externalId"
|
||||||
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600">Aucun résultat trouvé</p>
|
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
|
||||||
</div>
|
@click="openMangaModal(manga)">
|
||||||
|
|
||||||
<!-- Modal de confirmation -->
|
|
||||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
|
||||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" />
|
|
||||||
|
|
||||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
|
||||||
<DialogPanel class="w-full max-w-lg bg-white rounded-xl shadow-xl p-6">
|
|
||||||
<DialogTitle class="text-lg mb-4"> Ajouter à la bibliothèque </DialogTitle>
|
|
||||||
|
|
||||||
<div v-if="selectedManga">
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<img
|
<img
|
||||||
:src="selectedManga.imageUrl || '/placeholder-cover.png'"
|
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||||
:alt="selectedManga.title"
|
alt=""
|
||||||
class="h-48 w-32 object-cover" />
|
class="h-36 w-24 object-cover flex-shrink-0"
|
||||||
|
referrerpolicy="no-referrer" />
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h4 class="text-lg">{{ selectedManga.title }}</h4>
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
|
||||||
<p class="mt-2">
|
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
|
||||||
{{ truncatedDescription }}
|
</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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
|
||||||
|
<span
|
||||||
|
v-for="genre in selectedManga.genres"
|
||||||
|
:key="genre"
|
||||||
|
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ genre }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<!-- Description -->
|
||||||
|
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||||
|
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||||
|
{{ selectedManga.description }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50">
|
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
|
||||||
Annuler
|
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>
|
||||||
@@ -83,32 +145,47 @@
|
|||||||
|
|
||||||
<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 hasSearched = ref(false);
|
||||||
const isModalOpen = ref(false);
|
const isModalOpen = ref(false);
|
||||||
const selectedManga = ref(null);
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
let debounceTimer = null;
|
||||||
|
watch(searchQuery, newVal => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
if (newVal.trim().length > 3) {
|
||||||
|
debounceTimer = setTimeout(performSearch, 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Effectuer la recherche au chargement si un paramètre q est présent
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const queryParam = route.query.q;
|
const queryParam = route.query.q;
|
||||||
if (queryParam) {
|
if (queryParam) {
|
||||||
@@ -117,8 +194,8 @@ import MangaList from '../components/MangaList.vue';
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nettoyer la recherche et les résultats lors du démontage du composant
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
searchQuery.value = '';
|
searchQuery.value = '';
|
||||||
mangaStore.clearSearchResults();
|
mangaStore.clearSearchResults();
|
||||||
});
|
});
|
||||||
@@ -127,6 +204,7 @@ import MangaList from '../components/MangaList.vue';
|
|||||||
if (!searchQuery.value.trim()) return;
|
if (!searchQuery.value.trim()) return;
|
||||||
try {
|
try {
|
||||||
await mangaStore.searchMangaDex(searchQuery.value);
|
await mangaStore.searchMangaDex(searchQuery.value);
|
||||||
|
hasSearched.value = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Erreur de recherche:', e);
|
console.error('Erreur de recherche:', e);
|
||||||
}
|
}
|
||||||
@@ -144,7 +222,6 @@ import MangaList from '../components/MangaList.vue';
|
|||||||
|
|
||||||
const addManga = async () => {
|
const addManga = async () => {
|
||||||
if (!selectedManga.value) return;
|
if (!selectedManga.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||||
router.push('/manga');
|
router.push('/manga');
|
||||||
|
|||||||
192
assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
Normal file
192
assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<div class="px-6 py-8">
|
||||||
|
|
||||||
|
<!-- État de chargement -->
|
||||||
|
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||||
|
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
|
||||||
|
<span class="text-sm">Chargement des recommandations...</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Message d'erreur -->
|
||||||
|
<section v-else-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Résultats -->
|
||||||
|
<section v-else-if="discoverResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Recommandations</h2>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ discoverResults.length }} manga(s)</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
|
<div
|
||||||
|
v-for="manga in discoverResults"
|
||||||
|
:key="manga.externalId"
|
||||||
|
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
|
||||||
|
@click="openMangaModal(manga)">
|
||||||
|
<img
|
||||||
|
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||||
|
alt=""
|
||||||
|
class="h-36 w-24 object-cover flex-shrink-0"
|
||||||
|
referrerpolicy="no-referrer" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
|
||||||
|
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Collection locale vide -->
|
||||||
|
<section v-else-if="!loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Ajoutez des manga pour obtenir des recommandations.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de détail -->
|
||||||
|
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||||
|
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
|
|
||||||
|
<!-- En-tête avec couverture -->
|
||||||
|
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<img
|
||||||
|
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
|
||||||
|
:alt="selectedManga.title"
|
||||||
|
class="h-64 w-44 object-cover flex-shrink-0"
|
||||||
|
referrerpolicy="no-referrer" />
|
||||||
|
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
|
||||||
|
{{ selectedManga.title }}
|
||||||
|
</DialogTitle>
|
||||||
|
<div class="mt-3 space-y-1.5">
|
||||||
|
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Publication</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Statut</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Note</span>
|
||||||
|
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
|
||||||
|
<span
|
||||||
|
v-for="genre in selectedManga.genres"
|
||||||
|
:key="genre"
|
||||||
|
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ genre }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||||
|
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||||
|
{{ selectedManga.description }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeModal"
|
||||||
|
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addManga"
|
||||||
|
:disabled="adding"
|
||||||
|
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
|
||||||
|
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
|
||||||
|
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||||
|
import { ArrowPathIcon, ArrowPathRoundedSquareIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const mangaStore = useMangaStore();
|
||||||
|
|
||||||
|
const isModalOpen = ref(false);
|
||||||
|
const selectedManga = ref(null);
|
||||||
|
|
||||||
|
const { discoverResults, loadingDiscover: loading, discoverError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||||
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{ type: 'label', text: 'Découvrir', class: 'text-sm font-medium' },
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
icon: ArrowPathRoundedSquareIcon,
|
||||||
|
label: 'Actualiser',
|
||||||
|
onClick: () => mangaStore.loadDiscoverRecommendations(),
|
||||||
|
disabled: loading.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mangaStore.loadDiscoverRecommendations();
|
||||||
|
});
|
||||||
|
|
||||||
|
const openMangaModal = manga => {
|
||||||
|
selectedManga.value = manga;
|
||||||
|
isModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isModalOpen.value = false;
|
||||||
|
selectedManga.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addManga = async () => {
|
||||||
|
if (!selectedManga.value) return;
|
||||||
|
try {
|
||||||
|
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||||
|
router.push('/manga');
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur d'ajout:", e);
|
||||||
|
} finally {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col h-full">
|
||||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
<div class="container mx-auto px-4">
|
<div class="overflow-y-auto flex-1">
|
||||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="collection?.items || []" />
|
<div class="w-full">
|
||||||
<MangaList
|
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
||||||
|
<MangaOverview
|
||||||
v-else-if="viewMode === 'list'"
|
v-else-if="viewMode === 'list'"
|
||||||
:mangas="collection?.items || []"
|
:mangas="pagedItems"
|
||||||
@manga-click="handleMangaClick" />
|
@manga-click="handleMangaClick" />
|
||||||
|
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" />
|
||||||
|
<Pagination
|
||||||
|
v-if="totalPages > 1"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-pages="totalPages"
|
||||||
|
:total="sortedCollection.length"
|
||||||
|
:limit="prefs.itemsPerPage"
|
||||||
|
:has-next-page="currentPage < totalPages"
|
||||||
|
:has-previous-page="currentPage > 1"
|
||||||
|
@page-change="currentPage = $event" />
|
||||||
<div
|
<div
|
||||||
v-if="isBackgroundLoading"
|
v-if="isBackgroundLoading"
|
||||||
class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
|
class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||||
@@ -14,6 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -26,15 +38,19 @@
|
|||||||
MagnifyingGlassIcon
|
MagnifyingGlassIcon
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||||
|
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
import 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';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const mangaStore = useMangaStore();
|
const mangaStore = useMangaStore();
|
||||||
|
const prefs = useUserPreferencesStore();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
collection,
|
collection,
|
||||||
@@ -43,7 +59,8 @@ import MangaList from '../components/MangaList.vue';
|
|||||||
isBackgroundLoadingCollection: isBackgroundLoading
|
isBackgroundLoadingCollection: isBackgroundLoading
|
||||||
} = storeToRefs(mangaStore);
|
} = storeToRefs(mangaStore);
|
||||||
|
|
||||||
const viewMode = ref('grid');
|
const viewMode = ref(prefs.defaultView);
|
||||||
|
const currentPage = ref(1);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
mangaStore.loadCollection();
|
mangaStore.loadCollection();
|
||||||
@@ -53,6 +70,27 @@ import MangaList from '../components/MangaList.vue';
|
|||||||
router.push({ name: 'manga-details', params: { id: manga.id } });
|
router.push({ name: 'manga-details', params: { id: manga.id } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sortedCollection = computed(() => {
|
||||||
|
const items = [...(collection.value?.items || [])];
|
||||||
|
if (prefs.sortBy === 'title') {
|
||||||
|
items.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
} else if (prefs.sortBy === 'addedAt') {
|
||||||
|
items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagedItems = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * prefs.itemsPerPage;
|
||||||
|
return sortedCollection.value.slice(start, start + prefs.itemsPerPage);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(sortedCollection.value.length / prefs.itemsPerPage));
|
||||||
|
|
||||||
|
watch(() => prefs.itemsPerPage, () => {
|
||||||
|
currentPage.value = 1;
|
||||||
|
});
|
||||||
|
|
||||||
const toolbarConfig = {
|
const toolbarConfig = {
|
||||||
leftSection: [
|
leftSection: [
|
||||||
{
|
{
|
||||||
@@ -71,8 +109,9 @@ import MangaList from '../components/MangaList.vue';
|
|||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
label: 'View',
|
label: 'View',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'List', onClick: () => (viewMode.value = 'list') },
|
{ label: 'Overview', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
|
||||||
{ label: 'Grid', onClick: () => (viewMode.value = 'grid') }
|
{ label: 'Grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
|
||||||
|
{ label: 'Table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,10 +119,9 @@ import MangaList from '../components/MangaList.vue';
|
|||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
label: 'Sort',
|
label: 'Sort',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Title', onClick: () => {} },
|
{ label: 'Title', onClick: () => prefs.setSortBy('title') },
|
||||||
{ label: 'Author', onClick: () => {} },
|
{ label: "Date d'ajout", onClick: () => prefs.setSortBy('addedAt') },
|
||||||
{ label: 'Status', onClick: () => {} },
|
{ label: 'Progression', onClick: () => prefs.setSortBy('progress') }
|
||||||
{ label: 'Year', onClick: () => {} }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||||
<!-- Notifications Toast -->
|
<!-- Notifications Toast -->
|
||||||
<NotificationToast />
|
<NotificationToast />
|
||||||
|
|
||||||
<div v-if="errorDetails" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mx-4 mt-4">
|
<Toolbar v-if="currentManga" :config="toolbarConfig" />
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
|
||||||
|
<div v-if="errorDetails" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded mx-4 mt-4">
|
||||||
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
|
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -11,9 +15,7 @@
|
|||||||
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
||||||
<MercureListener :manga-id="String(mangaId)" />
|
<MercureListener :manga-id="String(mangaId)" />
|
||||||
|
|
||||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 dark:text-gray-400 z-20">
|
||||||
|
|
||||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 z-20">
|
|
||||||
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
<div v-if="isLoadingVolumes" class="flex justify-center items-center h-32">
|
<div v-if="isLoadingVolumes" class="flex justify-center items-center h-32">
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="errorVolumes" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
<div v-else-if="errorVolumes" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||||
{{ errorVolumes.message || 'Une erreur est survenue lors du chargement des volumes.' }}
|
{{ errorVolumes.message || 'Une erreur est survenue lors du chargement des volumes.' }}
|
||||||
</div>
|
</div>
|
||||||
<MangaVolumeList
|
<MangaVolumeList
|
||||||
@@ -84,9 +86,11 @@
|
|||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="text-center text-gray-500 py-10 px-4">
|
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-10 px-4">
|
||||||
Aucun manga sélectionné ou trouvé.
|
Aucun manga sélectionné ou trouvé.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { useUserPreferencesStore } from '../../../setting/application/store/userPreferencesStore';
|
||||||
import { Chapter } from '../../domain/entities/Chapter';
|
import { Chapter } from '../../domain/entities/Chapter';
|
||||||
import { ApiChapterRepository } from '../../infrastructure/repository/ApiChapterRepository';
|
import { ApiChapterRepository } from '../../infrastructure/repository/ApiChapterRepository';
|
||||||
|
|
||||||
@@ -13,7 +14,6 @@ export const useReaderStore = defineStore('reader', {
|
|||||||
error: null,
|
error: null,
|
||||||
pages: [],
|
pages: [],
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
loadedPages: new Set(), // Garder une trace des pages déjà chargées
|
|
||||||
|
|
||||||
// Paramètres pour les doubles pages
|
// Paramètres pour les doubles pages
|
||||||
doublePageSettings: {
|
doublePageSettings: {
|
||||||
@@ -32,7 +32,6 @@ export const useReaderStore = defineStore('reader', {
|
|||||||
|
|
||||||
// Getters pour les doubles pages
|
// Getters pour les doubles pages
|
||||||
effectiveDoublePageMode: (state) => {
|
effectiveDoublePageMode: (state) => {
|
||||||
// Si la détection automatique est désactivée, retourner 'normal'
|
|
||||||
if (!state.doublePageSettings.autoDetect) {
|
if (!state.doublePageSettings.autoDetect) {
|
||||||
return 'normal';
|
return 'normal';
|
||||||
}
|
}
|
||||||
@@ -55,28 +54,20 @@ export const useReaderStore = defineStore('reader', {
|
|||||||
try {
|
try {
|
||||||
const repository = new ApiChapterRepository();
|
const repository = new ApiChapterRepository();
|
||||||
|
|
||||||
// Charger les informations du chapitre
|
const [chapterData, pagesData] = await Promise.all([
|
||||||
const chapterData = await repository.getChapter(chapterId);
|
repository.getChapter(chapterId),
|
||||||
|
repository.getChapterPages(chapterId, 1, 9999),
|
||||||
|
]);
|
||||||
|
|
||||||
this.currentChapter = Chapter.create(chapterData);
|
this.currentChapter = Chapter.create(chapterData);
|
||||||
|
this.pages = pagesData.pages.map(p => ({
|
||||||
// Charger la liste des pages
|
id: p.id,
|
||||||
const pagesData = await repository.getChapterPages(chapterId);
|
pageNumber: p.pageNumber,
|
||||||
|
url: p.url,
|
||||||
// Initialiser le tableau avec des placeholders
|
dimensions: p.dimensions,
|
||||||
this.pages = new Array(pagesData.totalItems).fill(null);
|
}));
|
||||||
this.totalPages = pagesData.totalItems;
|
this.totalPages = pagesData.totalItems;
|
||||||
this.loadedPages.clear();
|
|
||||||
|
|
||||||
// Charger la première page
|
|
||||||
if (this.totalPages > 0) {
|
|
||||||
this.currentPage = 0;
|
this.currentPage = 0;
|
||||||
await this.loadPageData(0);
|
|
||||||
|
|
||||||
// En mode infini, précharger les premières pages
|
|
||||||
if (this.readingMode === 'infinite') {
|
|
||||||
await this.preloadNextPages(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.message;
|
this.error = error.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -84,100 +75,28 @@ export const useReaderStore = defineStore('reader', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadPageData(pageIndex) {
|
handlePageVisible(pageIndex) {
|
||||||
if (!this.currentChapter || pageIndex < 0 || pageIndex >= this.totalPages) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si la page est déjà chargée, ne rien faire
|
|
||||||
if (this.loadedPages.has(pageIndex)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageNumber = pageIndex + 1; // Convertir en 1-based pour l'API
|
|
||||||
|
|
||||||
// Marquer la page comme en cours de chargement
|
|
||||||
const newPages = [...this.pages];
|
|
||||||
newPages[pageIndex] = { loading: true };
|
|
||||||
this.pages = newPages;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repository = new ApiChapterRepository();
|
|
||||||
const pageData = await repository.getChapterPage(this.currentChapter.id, pageNumber);
|
|
||||||
|
|
||||||
// Vérifier que les données sont valides
|
|
||||||
if (!pageData || !pageData.base64Content) {
|
|
||||||
throw new Error("Données de page invalides reçues de l'API");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre à jour la page
|
|
||||||
const updatedPages = [...this.pages];
|
|
||||||
updatedPages[pageIndex] = {
|
|
||||||
id: pageData.id,
|
|
||||||
pageNumber: pageData.pageNumber,
|
|
||||||
base64Content: pageData.base64Content,
|
|
||||||
mimeType: pageData.mimeType,
|
|
||||||
dimensions: pageData.dimensions
|
|
||||||
};
|
|
||||||
this.pages = updatedPages;
|
|
||||||
this.loadedPages.add(pageIndex);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Erreur lors du chargement de la page ${pageNumber}:`, error);
|
|
||||||
// Marquer la page comme en erreur
|
|
||||||
const errorPages = [...this.pages];
|
|
||||||
errorPages[pageIndex] = { error: error.message };
|
|
||||||
this.pages = errorPages;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async preloadNextPages(startIndex, count = 3) {
|
|
||||||
const promises = [];
|
|
||||||
for (let i = 1; i <= count; i++) {
|
|
||||||
const pageIndex = startIndex + i;
|
|
||||||
if (pageIndex < this.totalPages) {
|
|
||||||
promises.push(this.loadPageData(pageIndex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(promises);
|
|
||||||
},
|
|
||||||
|
|
||||||
async handlePageVisible(pageIndex) {
|
|
||||||
if (pageIndex !== this.currentPage) {
|
if (pageIndex !== this.currentPage) {
|
||||||
this.currentPage = pageIndex;
|
this.currentPage = pageIndex;
|
||||||
// Précharger les pages suivantes
|
|
||||||
if (this.readingMode === 'infinite') {
|
|
||||||
await this.preloadNextPages(pageIndex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async nextPage() {
|
nextPage() {
|
||||||
if (!this.isLastPage) {
|
if (!this.isLastPage) {
|
||||||
this.currentPage++;
|
this.currentPage++;
|
||||||
await this.loadPageData(this.currentPage);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async previousPage() {
|
previousPage() {
|
||||||
if (!this.isFirstPage) {
|
if (!this.isFirstPage) {
|
||||||
this.currentPage--;
|
this.currentPage--;
|
||||||
await this.loadPageData(this.currentPage);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async setReadingMode(mode) {
|
async setReadingMode(mode) {
|
||||||
if (mode === this.readingMode) return;
|
if (mode === this.readingMode) return;
|
||||||
|
|
||||||
this.readingMode = mode;
|
this.readingMode = mode;
|
||||||
this.savePreferences();
|
this.savePreferences();
|
||||||
|
|
||||||
// S'assurer que la page courante est chargée
|
|
||||||
await this.loadPageData(this.currentPage);
|
|
||||||
|
|
||||||
// Si on passe en mode infini, précharger les pages suivantes
|
|
||||||
if (mode === 'infinite') {
|
|
||||||
await this.preloadNextPages(this.currentPage);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setReadingDirection(direction) {
|
setReadingDirection(direction) {
|
||||||
@@ -190,7 +109,6 @@ export const useReaderStore = defineStore('reader', {
|
|||||||
this.savePreferences();
|
this.savePreferences();
|
||||||
},
|
},
|
||||||
|
|
||||||
// Nouvelles actions pour les doubles pages
|
|
||||||
setDoublePageMode(mode) {
|
setDoublePageMode(mode) {
|
||||||
if (['rotate', 'scroll', 'normal'].includes(mode)) {
|
if (['rotate', 'scroll', 'normal'].includes(mode)) {
|
||||||
this.doublePageSettings.mobileMode = mode;
|
this.doublePageSettings.mobileMode = mode;
|
||||||
@@ -225,16 +143,10 @@ export const useReaderStore = defineStore('reader', {
|
|||||||
async goToPreviousChapter() {
|
async goToPreviousChapter() {
|
||||||
if (this.currentChapter?.navigation?.previousChapter) {
|
if (this.currentChapter?.navigation?.previousChapter) {
|
||||||
await this.loadChapter(this.currentChapter.navigation.previousChapter);
|
await this.loadChapter(this.currentChapter.navigation.previousChapter);
|
||||||
// Aller à la dernière page du chapitre précédent
|
|
||||||
this.currentPage = Math.max(0, this.totalPages - 1);
|
this.currentPage = Math.max(0, this.totalPages - 1);
|
||||||
// S'assurer que la page est chargée
|
|
||||||
if (this.totalPages > 0) {
|
|
||||||
await this.loadPageData(this.currentPage);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Gestion de la persistance des préférences
|
|
||||||
savePreferences() {
|
savePreferences() {
|
||||||
try {
|
try {
|
||||||
const preferences = {
|
const preferences = {
|
||||||
@@ -252,10 +164,19 @@ export const useReaderStore = defineStore('reader', {
|
|||||||
loadPreferences() {
|
loadPreferences() {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('mangarr-reader-preferences');
|
const stored = localStorage.getItem('mangarr-reader-preferences');
|
||||||
|
if (!stored) {
|
||||||
|
const userPrefs = useUserPreferencesStore();
|
||||||
|
this.readingDirection = userPrefs.readingDirection;
|
||||||
|
const modeMap = { scroll: 'infinite', single: 'single', double: 'single' };
|
||||||
|
this.readingMode = modeMap[userPrefs.readingMode] ?? 'single';
|
||||||
|
if (userPrefs.readingMode === 'double') {
|
||||||
|
this.doublePageSettings.autoDetect = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const preferences = JSON.parse(stored);
|
const preferences = JSON.parse(stored);
|
||||||
|
|
||||||
// Appliquer les préférences sauvegardées
|
|
||||||
if (preferences.readingMode) this.readingMode = preferences.readingMode;
|
if (preferences.readingMode) this.readingMode = preferences.readingMode;
|
||||||
if (preferences.readingDirection) this.readingDirection = preferences.readingDirection;
|
if (preferences.readingDirection) this.readingDirection = preferences.readingDirection;
|
||||||
if (typeof preferences.zoom === 'number') this.zoom = preferences.zoom;
|
if (typeof preferences.zoom === 'number') this.zoom = preferences.zoom;
|
||||||
@@ -277,7 +198,6 @@ export const useReaderStore = defineStore('reader', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Réinitialiser les préférences
|
|
||||||
resetPreferences() {
|
resetPreferences() {
|
||||||
this.readingMode = 'single';
|
this.readingMode = 'single';
|
||||||
this.readingDirection = 'ltr';
|
this.readingDirection = 'ltr';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChapterPages(chapterId, page = 1, itemsPerPage = 20) {
|
async getChapterPages(chapterId, page = 1, itemsPerPage = 9999) {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/reader/chapter/${chapterId}/pages?page=${page}&itemsPerPage=${itemsPerPage}`
|
`/api/reader/chapter/${chapterId}/pages?page=${page}&itemsPerPage=${itemsPerPage}`
|
||||||
);
|
);
|
||||||
@@ -18,12 +18,4 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
|
|||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChapterPage(chapterId, pageNumber) {
|
|
||||||
const response = await fetch(`/api/reader/chapter/${chapterId}/page/${pageNumber}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch chapter page');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -65,10 +33,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||||
|
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({
|
||||||
@@ -84,128 +51,64 @@ import SingleModeReader from './SingleModeReader.vue';
|
|||||||
|
|
||||||
const store = useReaderStore();
|
const store = useReaderStore();
|
||||||
const headerStore = useHeaderStore();
|
const headerStore = useHeaderStore();
|
||||||
|
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');
|
||||||
|
|
||||||
// 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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleReadingDirection = () => {
|
const toggleReadingDirection = () => {
|
||||||
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
|
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
|
||||||
resetButtonsTimer();
|
store.setReadingDirection(newDir);
|
||||||
|
prefs.setReadingDirection(newDir);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 => {
|
||||||
@@ -217,28 +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);
|
||||||
|
|
||||||
// Afficher les boutons au démarrage
|
if (prefs.autoHideHeaderReader) {
|
||||||
showButtonsWithTimer();
|
headerStore.enableAutoHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.readingMode === 'infinite') {
|
||||||
|
headerStore.enableReaderToolbarAutoHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
|
||||||
|
document.documentElement.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {
|
||||||
@@ -250,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,23 +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?.loading" 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>
|
||||||
<div v-else-if="page?.error" class="error">
|
|
||||||
{{ page.error }}
|
|
||||||
</div>
|
|
||||||
<ReaderPage v-else-if="page?.base64Content" :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" />
|
|
||||||
</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 -->
|
||||||
@@ -32,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({
|
||||||
@@ -70,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
|
||||||
@@ -89,25 +94,47 @@ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fonction unique pour gérer la visibilité de tous les boutons flottants
|
// Fonction unique pour gérer la visibilité de tous les boutons flottants
|
||||||
@@ -172,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
|
||||||
@@ -192,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,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'
|
||||||
@@ -227,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'
|
||||||
@@ -243,7 +261,8 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
watch(
|
watch(
|
||||||
() => props.pages,
|
() => props.pages,
|
||||||
() => {
|
() => {
|
||||||
setupIntersectionObserver();
|
mountedPageIndices.clear();
|
||||||
|
setupObservers();
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
@@ -262,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) {
|
||||||
@@ -282,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();
|
||||||
@@ -307,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];
|
||||||
@@ -345,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,7 +1,10 @@
|
|||||||
<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.base64Content" class="error">Contenu de l'image manquant</div>
|
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
|
||||||
|
|
||||||
<!-- Affichage spécial pour les doubles pages sur mobile -->
|
<!-- Affichage spécial pour les doubles pages sur mobile -->
|
||||||
<div v-else-if="isDoublePage && isMobile && doublePageMode !== 'normal'" class="double-page-mobile">
|
<div v-else-if="isDoublePage && isMobile && doublePageMode !== 'normal'" class="double-page-mobile">
|
||||||
@@ -75,23 +78,33 @@ 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(() => {
|
||||||
if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
|
return props.pageData?.url ?? '';
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Détection des doubles pages basée sur le ratio largeur/hauteur et les dimensions API
|
// Détection des doubles pages basée sur le ratio largeur/hauteur et les dimensions API
|
||||||
@@ -106,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;
|
||||||
@@ -127,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) {
|
||||||
@@ -178,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) {
|
||||||
@@ -190,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 {
|
return {
|
||||||
width: `${maxWidth.value}px`,
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
width: 'auto',
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
maxWidth: '100%'
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode scroll : fixer la largeur, hauteur libre
|
||||||
|
const style = {
|
||||||
|
height: 'auto',
|
||||||
|
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
|
||||||
@@ -213,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
|
||||||
@@ -263,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"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors duration-200"
|
|
||||||
:disabled="!currentChapter?.mangaId"
|
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon class="h-5 w-5" />
|
<div class="toolbar-slide">
|
||||||
<span class="text-sm font-medium">Retour au manga</span>
|
<ReaderToolbar :chapter-reader-ref="chapterReaderRef" />
|
||||||
</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,142 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'mangarr_preferences';
|
||||||
|
|
||||||
|
const defaultState = {
|
||||||
|
theme: 'system',
|
||||||
|
language: 'fr',
|
||||||
|
defaultView: 'grid',
|
||||||
|
itemsPerPage: 20,
|
||||||
|
sortBy: 'title',
|
||||||
|
readingDirection: 'ltr',
|
||||||
|
readingMode: 'scroll',
|
||||||
|
autoFullscreen: false,
|
||||||
|
autoHideHeaderReader: true,
|
||||||
|
toastDuration: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
return { ...defaultState, ...JSON.parse(stored) };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
return { ...defaultState };
|
||||||
|
}
|
||||||
|
|
||||||
|
let mediaQueryUnsubscribe = null;
|
||||||
|
|
||||||
|
export const useUserPreferencesStore = defineStore('userPreferences', {
|
||||||
|
state: () => loadFromStorage(),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
applyTheme() {
|
||||||
|
// Nettoyer le listener précédent
|
||||||
|
if (mediaQueryUnsubscribe) {
|
||||||
|
mediaQueryUnsubscribe();
|
||||||
|
mediaQueryUnsubscribe = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = document.documentElement;
|
||||||
|
|
||||||
|
if (this.theme === 'dark') {
|
||||||
|
html.classList.add('dark');
|
||||||
|
} else if (this.theme === 'light') {
|
||||||
|
html.classList.remove('dark');
|
||||||
|
} else {
|
||||||
|
// mode 'system'
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = (e) => {
|
||||||
|
if (e.matches) {
|
||||||
|
html.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
html.classList.remove('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handler(mq);
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
mediaQueryUnsubscribe = () => mq.removeEventListener('change', handler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setTheme(theme) {
|
||||||
|
this.theme = theme;
|
||||||
|
this.persist();
|
||||||
|
this.applyTheme();
|
||||||
|
},
|
||||||
|
|
||||||
|
setLanguage(language) {
|
||||||
|
this.language = language;
|
||||||
|
this.persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
setDefaultView(view) {
|
||||||
|
this.defaultView = view;
|
||||||
|
this.persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
setItemsPerPage(count) {
|
||||||
|
this.itemsPerPage = count;
|
||||||
|
this.persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
setSortBy(sort) {
|
||||||
|
this.sortBy = sort;
|
||||||
|
this.persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
setReadingDirection(direction) {
|
||||||
|
this.readingDirection = direction;
|
||||||
|
this.persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
setReadingMode(mode) {
|
||||||
|
this.readingMode = mode;
|
||||||
|
this.persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
setAutoFullscreen(value) {
|
||||||
|
this.autoFullscreen = value;
|
||||||
|
this.persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
setAutoHideHeaderReader(value) {
|
||||||
|
this.autoHideHeaderReader = value;
|
||||||
|
this.persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
setToastDuration(duration) {
|
||||||
|
this.toastDuration = duration;
|
||||||
|
this.persist();
|
||||||
|
},
|
||||||
|
|
||||||
|
resetToDefaults() {
|
||||||
|
Object.assign(this, defaultState);
|
||||||
|
this.persist();
|
||||||
|
this.applyTheme();
|
||||||
|
},
|
||||||
|
|
||||||
|
persist() {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
theme: this.theme,
|
||||||
|
language: this.language,
|
||||||
|
defaultView: this.defaultView,
|
||||||
|
itemsPerPage: this.itemsPerPage,
|
||||||
|
sortBy: this.sortBy,
|
||||||
|
readingDirection: this.readingDirection,
|
||||||
|
readingMode: this.readingMode,
|
||||||
|
autoFullscreen: this.autoFullscreen,
|
||||||
|
autoHideHeaderReader: this.autoHideHeaderReader,
|
||||||
|
toastDuration: this.toastDuration,
|
||||||
|
};
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,37 +26,51 @@
|
|||||||
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 class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
|
||||||
|
<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 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder=".reading-content .page-break img" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<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">(let empty if vertical reader)</span>
|
Next Page Selector <span class="text-gray-500">(laisser vide si lecteur vertical)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="nextPageSelector"
|
id="nextPageSelector"
|
||||||
v-model="form.nextPageSelector"
|
v-model="form.nextPageSelector"
|
||||||
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=".next-page" />
|
placeholder=".next-page" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chapter Selector -->
|
|
||||||
<div>
|
<div>
|
||||||
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<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">(required for Javascript scraping)</span>
|
Chapter Selector <span class="text-gray-500">(requis pour le scraping Javascript)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="chapterSelector"
|
id="chapterSelector"
|
||||||
v-model="form.chapterSelector"
|
v-model="form.chapterSelector"
|
||||||
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=".chapter-selector" />
|
placeholder=".chapter-selector" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Scraping Type -->
|
<!-- Scraping Type + Token -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="scrapingType" 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">
|
||||||
Scraping Type
|
Scraping Type
|
||||||
@@ -88,13 +79,12 @@
|
|||||||
id="scrapingType"
|
id="scrapingType"
|
||||||
v-model="form.scrapingType"
|
v-model="form.scrapingType"
|
||||||
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">
|
||||||
<option value="html">HTML</option>
|
<option value="html">HTML</option>
|
||||||
<option value="javascript">Javascript</option>
|
<option value="javascript">Javascript</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Token (optionnel) -->
|
|
||||||
<div>
|
<div>
|
||||||
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Token
|
Token
|
||||||
@@ -103,78 +93,65 @@
|
|||||||
id="token"
|
id="token"
|
||||||
v-model="form.token"
|
v-model="form.token"
|
||||||
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="Optional authentication token" />
|
placeholder="Optional authentication token" />
|
||||||
</div>
|
</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 {
|
||||||
|
|||||||
@@ -1,48 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col h-full">
|
||||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
<div class="container mx-auto px-4 py-6">
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<div class="px-6 py-8">
|
||||||
<!-- 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">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<!-- Existing Sources -->
|
<!-- Existing Sources -->
|
||||||
<ContentSourceCard
|
<ContentSourceCard
|
||||||
v-for="source in sources"
|
v-for="source in sources"
|
||||||
@@ -54,32 +36,34 @@
|
|||||||
<!-- 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 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">
|
||||||
@@ -91,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>
|
||||||
@@ -107,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';
|
||||||
@@ -124,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);
|
||||||
@@ -136,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
|
||||||
@@ -188,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();
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col h-full">
|
||||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
<Toolbar :config="toolbarConfig" />
|
||||||
|
|
||||||
<div class="container mx-auto px-4 py-6">
|
|
||||||
<!-- Back Navigation -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<button
|
|
||||||
@click="goBack"
|
|
||||||
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>
|
|
||||||
|
|
||||||
|
<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">
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
|
<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 class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- 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 v-else-if="currentSourceError" 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">{{ currentSourceError }}</p>
|
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
|
||||||
@@ -27,18 +19,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<div v-else class="max-w-4xl mx-auto">
|
<div v-else>
|
||||||
<ContentSourceForm
|
<ContentSourceForm
|
||||||
|
ref="formRef"
|
||||||
:source="currentSource"
|
:source="currentSource"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
:error="saveError"
|
:error="saveError"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@test="handleTest" />
|
@test="handleTest" />
|
||||||
</div>
|
</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>
|
||||||
@@ -53,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>
|
||||||
|
|
||||||
@@ -64,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>
|
||||||
@@ -91,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>
|
||||||
@@ -106,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">
|
||||||
@@ -124,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>
|
||||||
@@ -137,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">
|
||||||
@@ -154,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>
|
||||||
@@ -165,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>
|
||||||
@@ -176,11 +171,21 @@
|
|||||||
</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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -188,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';
|
||||||
@@ -197,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();
|
||||||
@@ -212,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);
|
||||||
|
|
||||||
@@ -231,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) {
|
||||||
@@ -277,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,
|
||||||
@@ -321,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',
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8 max-w-3xl">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('preferences.title') }}</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('preferences.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
@click="handleReset">
|
||||||
|
{{ t('preferences.reset') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Apparence -->
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
|
{{ t('preferences.sections.appearance') }}
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<!-- Thème -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.theme.label') }}</label>
|
||||||
|
<select
|
||||||
|
:value="store.theme"
|
||||||
|
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
@change="store.setTheme($event.target.value)">
|
||||||
|
<option value="light">{{ t('preferences.theme.light') }}</option>
|
||||||
|
<option value="dark">{{ t('preferences.theme.dark') }}</option>
|
||||||
|
<option value="system">{{ t('preferences.theme.system') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Langue -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.language.label') }}</label>
|
||||||
|
<select
|
||||||
|
:value="store.language"
|
||||||
|
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
@change="handleLanguageChange($event.target.value)">
|
||||||
|
<option value="fr">{{ t('preferences.language.fr') }}</option>
|
||||||
|
<option value="en">{{ t('preferences.language.en') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Affichage collection -->
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
|
{{ t('preferences.sections.collection') }}
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<!-- Vue par défaut -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.defaultView.label') }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
:class="viewButtonClass('grid')"
|
||||||
|
@click="store.setDefaultView('grid')">
|
||||||
|
{{ t('preferences.defaultView.grid') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="viewButtonClass('list')"
|
||||||
|
@click="store.setDefaultView('list')">
|
||||||
|
{{ t('preferences.defaultView.list') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="viewButtonClass('table')"
|
||||||
|
@click="store.setDefaultView('table')">
|
||||||
|
{{ t('preferences.defaultView.table') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Mangas par page -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.itemsPerPage.label') }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="n in [12, 20, 40]"
|
||||||
|
:key="n"
|
||||||
|
:class="countButtonClass(n)"
|
||||||
|
@click="store.setItemsPerPage(n)">
|
||||||
|
{{ n }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tri par défaut -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.sortBy.label') }}</label>
|
||||||
|
<select
|
||||||
|
:value="store.sortBy"
|
||||||
|
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
@change="store.setSortBy($event.target.value)">
|
||||||
|
<option value="title">{{ t('preferences.sortBy.title') }}</option>
|
||||||
|
<option value="addedAt">{{ t('preferences.sortBy.addedAt') }}</option>
|
||||||
|
<option value="progress">{{ t('preferences.sortBy.progress') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Lecture -->
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
|
{{ t('preferences.sections.reading') }}
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<!-- Direction de lecture -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingDirection.label') }}</label>
|
||||||
|
<select
|
||||||
|
:value="store.readingDirection"
|
||||||
|
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
@change="store.setReadingDirection($event.target.value)">
|
||||||
|
<option value="ltr">{{ t('preferences.readingDirection.ltr') }}</option>
|
||||||
|
<option value="rtl">{{ t('preferences.readingDirection.rtl') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Mode d'affichage -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingMode.label') }}</label>
|
||||||
|
<select
|
||||||
|
:value="store.readingMode"
|
||||||
|
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
@change="store.setReadingMode($event.target.value)">
|
||||||
|
<option value="scroll">{{ t('preferences.readingMode.scroll') }}</option>
|
||||||
|
<option value="single">{{ t('preferences.readingMode.single') }}</option>
|
||||||
|
<option value="double">{{ t('preferences.readingMode.double') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Auto plein écran -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoFullscreen.label') }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoFullscreen.description') }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:class="toggleClass(store.autoFullscreen)"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="store.autoFullscreen"
|
||||||
|
@click="store.setAutoFullscreen(!store.autoFullscreen)">
|
||||||
|
<span :class="toggleKnobClass(store.autoFullscreen)" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Auto-hide header -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoHideHeaderReader.label') }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoHideHeaderReader.description') }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:class="toggleClass(store.autoHideHeaderReader)"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="store.autoHideHeaderReader"
|
||||||
|
@click="store.setAutoHideHeaderReader(!store.autoHideHeaderReader)">
|
||||||
|
<span :class="toggleKnobClass(store.autoHideHeaderReader)" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||||
|
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||||
|
{{ t('preferences.sections.notifications') }}
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<!-- Durée des toasts -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.toastDuration.label') }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="[val, label] in toastOptions"
|
||||||
|
:key="val"
|
||||||
|
:class="countButtonClass(val, store.toastDuration)"
|
||||||
|
@click="store.setToastDuration(val)">
|
||||||
|
{{ t(label) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useUserPreferencesStore } from '../../application/store/userPreferencesStore';
|
||||||
|
import { i18n } from '../../../../shared/i18n';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const store = useUserPreferencesStore();
|
||||||
|
|
||||||
|
const toastOptions = [
|
||||||
|
[3000, 'preferences.toastDuration.3s'],
|
||||||
|
[5000, 'preferences.toastDuration.5s'],
|
||||||
|
[10000, 'preferences.toastDuration.10s'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleLanguageChange(lang) {
|
||||||
|
store.setLanguage(lang);
|
||||||
|
i18n.global.locale.value = lang;
|
||||||
|
locale.value = lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
if (confirm(t('preferences.resetConfirm'))) {
|
||||||
|
store.resetToDefaults();
|
||||||
|
i18n.global.locale.value = store.language;
|
||||||
|
locale.value = store.language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewButtonClass(view) {
|
||||||
|
const active = store.defaultView === view;
|
||||||
|
return [
|
||||||
|
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function countButtonClass(val, current = store.itemsPerPage) {
|
||||||
|
const active = current === val;
|
||||||
|
return [
|
||||||
|
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleClass(active) {
|
||||||
|
return [
|
||||||
|
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||||
|
active ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleKnobClass(active) {
|
||||||
|
return [
|
||||||
|
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||||
|
active ? 'translate-x-6' : 'translate-x-1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user