Compare commits
211 Commits
f09f744a9b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 810e18c26c | |||
|
|
1905581214 | ||
| c0ab40eacd | |||
|
|
e214e1ea46 | ||
| 1f1efd1b16 | |||
|
|
41c1fc5e2e | ||
| 848efd3327 | |||
| 65eef59999 | |||
|
|
e525c9b7bd | ||
| 8d8389377d | |||
| a9c5769c8e | |||
|
|
969f4569f5 | ||
| 13eac6954d | |||
|
|
7e6bacd934 | ||
| d1279c90cc | |||
| a0729d2e6e | |||
|
|
f47d1a245f | ||
| 78cc83d465 | |||
| 7204ea7754 | |||
|
|
f42b5a9cf5 | ||
| 5edd28309f | |||
|
|
3f08e1c899 | ||
| 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 | ||
| 4cd277aec7 | |||
|
|
640d1cec82 | ||
| 02760effe6 | |||
|
|
b52b27189d | ||
|
|
ff451855a7 | ||
|
|
2c051351a8 | ||
|
|
a4b3d8a5f1 | ||
|
|
c50f1638ee | ||
|
|
dae215dd3d | ||
|
|
b5a832fbbc | ||
|
|
f75b535426 | ||
|
|
74e321bc50 | ||
|
|
20f1211d5b | ||
|
|
eafcc58d84 | ||
|
|
c18f3653b8 | ||
|
|
ec8a45a500 | ||
|
|
889646afda | ||
|
|
af84deadd2 | ||
|
|
4d18c45af1 | ||
|
|
8d261a9de3 | ||
|
|
8bebde2f58 | ||
|
|
5a3e68fa2a | ||
|
|
c03cad6028 | ||
|
|
03b0e5a34f | ||
|
|
d8f8984192 | ||
|
|
58f68541f4 | ||
|
|
f472e250eb | ||
|
|
89b074113c | ||
|
|
134b4679ae | ||
|
|
fb6a61d5b6 | ||
|
|
21a87a3eb3 | ||
|
|
ffceda606f | ||
|
|
b05bd98f63 | ||
|
|
9e7f7b4cfc | ||
|
|
50b33f53d7 | ||
|
|
3170a7c60e | ||
|
|
fbe9619224 | ||
|
|
8d14676656 | ||
|
|
bec1572fcb | ||
|
|
f1eb97f156 |
208
.claude/skills/api-platform/SKILL.md
Normal file
208
.claude/skills/api-platform/SKILL.md
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
name: api-platform
|
||||
description: Conventions API Platform du projet Mangarr — brancher un State Processor sur une Command, un State Provider sur une Query, nommage des Resources, gestion des DTOs. Utiliser quand on crée ou modifie une Resource, un State Processor/Provider, ou un DTO API Platform.
|
||||
allowed-tools: Read, Grep, Glob
|
||||
---
|
||||
|
||||
# API Platform — Mangarr
|
||||
|
||||
Tout le code API Platform vit dans `Infrastructure/ApiPlatform/` du domaine concerné.
|
||||
|
||||
```
|
||||
Infrastructure/ApiPlatform/
|
||||
Resource/
|
||||
{FeatureName}Resource.php ← classe vide avec attribut #[ApiResource]
|
||||
State/
|
||||
Processor/
|
||||
{DoSomething}Processor.php ← implémente ProcessorInterface → Command
|
||||
Provider/
|
||||
{GetSomething}StateProvider.php ← implémente ProviderInterface → Query
|
||||
Dto/
|
||||
{Name}.php ← données entrantes ou sortantes
|
||||
Controller/
|
||||
{Action}Controller.php ← uniquement pour cas non-standards
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resource
|
||||
|
||||
Classe **vide** — elle ne contient que l'attribut `#[ApiResource]`. Aucune logique.
|
||||
|
||||
```php
|
||||
// Infrastructure/ApiPlatform/Resource/MangaResource.php
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaDetail;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaStateProvider;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\CreateMangaProcessor;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'Manga',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/mangas/by-id/{id}',
|
||||
provider: GetMangaStateProvider::class,
|
||||
output: MangaDetail::class,
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/mangas',
|
||||
input: CreateMangaDto::class,
|
||||
processor: CreateMangaProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/mangas/{id}',
|
||||
provider: DeleteMangaProvider::class, // ← provider requis pour Delete
|
||||
processor: DeleteMangaProcessor::class,
|
||||
),
|
||||
]
|
||||
)]
|
||||
class MangaResource {}
|
||||
```
|
||||
|
||||
**Règles Resource :**
|
||||
- `shortName` = nom du concept métier (ex: `'Manga'`, `'Chapter'`).
|
||||
- `uriTemplate` explicite (pas de génération automatique depuis le nom de classe).
|
||||
- `output` = DTO de sortie, `input` = DTO d'entrée.
|
||||
- `provider` et `processor` référencés par `::class`.
|
||||
|
||||
---
|
||||
|
||||
## State Processor → Command (écriture)
|
||||
|
||||
```php
|
||||
// Infrastructure/ApiPlatform/State/Processor/{DoSomething}Processor.php
|
||||
namespace App\Domain\{Domain}\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Domain\{Domain}\Application\Command\{DoSomething};
|
||||
use App\Domain\{Domain}\Application\CommandHandler\{DoSomething}Handler;
|
||||
use App\Domain\{Domain}\Domain\Exception\{Something}NotFoundException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
readonly class {DoSomething}Processor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private {DoSomething}Handler $handler,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
|
||||
{
|
||||
try {
|
||||
$this->handler->handle(new {DoSomething}(
|
||||
$uriVariables['id'] ?? $data->someField,
|
||||
));
|
||||
} catch ({Something}NotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Règles Processor :**
|
||||
- Injecte le **Handler concret** (pas une interface, car l'Infrastructure peut dépendre de l'Application).
|
||||
- Traduit les Domain Exceptions en HTTP Exceptions Symfony (`NotFoundHttpException`, `UnprocessableEntityHttpException`…).
|
||||
- Retourne `void` pour les opérations sans corps de réponse, ou le DTO de sortie si nécessaire.
|
||||
|
||||
---
|
||||
|
||||
## State Provider → Query (lecture)
|
||||
|
||||
```php
|
||||
// Infrastructure/ApiPlatform/State/Provider/{GetSomething}StateProvider.php
|
||||
namespace App\Domain\{Domain}\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Domain\{Domain}\Application\Query\{GetSomething};
|
||||
use App\Domain\{Domain}\Application\QueryHandler\{GetSomething}Handler;
|
||||
use App\Domain\{Domain}\Infrastructure\ApiPlatform\Dto\{Something}Detail;
|
||||
|
||||
readonly class {GetSomething}StateProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private {GetSomething}Handler $handler,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): {Something}Detail
|
||||
{
|
||||
$query = new {GetSomething}($uriVariables['id']);
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
return new {Something}Detail(
|
||||
id: $response->id,
|
||||
title: $response->title,
|
||||
// mapper Response → DTO ici
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Règles Provider :**
|
||||
- Construit la Query depuis `$uriVariables` et/ou `$context['filters']`.
|
||||
- Mappe le `Response` (Application) vers un **DTO** (Infrastructure) — ne jamais retourner un Response directement.
|
||||
- Pour les collections, retourner un tableau ou un objet `Paginator`.
|
||||
|
||||
---
|
||||
|
||||
## DTOs
|
||||
|
||||
Les DTOs sont des classes de données spécifiques à la couche HTTP. Ils ne doivent pas contenir de logique.
|
||||
|
||||
```php
|
||||
// Infrastructure/ApiPlatform/Dto/MangaDetail.php
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
|
||||
|
||||
readonly class MangaDetail
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $title,
|
||||
public string $slug,
|
||||
public ?string $imageUrl,
|
||||
// ...
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
**Règles DTO :**
|
||||
- `readonly class`.
|
||||
- Uniquement des types PHP natifs.
|
||||
- **DTO d'entrée** : contient les champs que le client envoie (`input:`).
|
||||
- **DTO de sortie** : contient les champs que l'API retourne (`output:`).
|
||||
- Ne jamais réutiliser un `Response` Application comme DTO API Platform (couches séparées).
|
||||
|
||||
---
|
||||
|
||||
## Conventions de nommage
|
||||
|
||||
| Fichier | Pattern de nom | Exemple |
|
||||
|------------------------------------|----------------------------------------|------------------------------------|
|
||||
| Resource (opération GET) | `{Concept}Resource.php` | `MangaResource.php` |
|
||||
| Resource (opération spécifique) | `{Action}{Concept}Resource.php` | `CreateMangaResource.php` |
|
||||
| Processor | `{DoSomething}Processor.php` | `CreateMangaProcessor.php` |
|
||||
| Provider (GET item) | `Get{Concept}StateProvider.php` | `GetMangaStateProvider.php` |
|
||||
| Provider (GET collection) | `Get{Concept}ListStateProvider.php` | `GetMangaListStateProvider.php` |
|
||||
| Provider (Delete, nécessite item) | `Delete{Concept}Provider.php` | `DeleteMangaProvider.php` |
|
||||
| DTO de sortie (item) | `{Concept}Detail.php` | `MangaDetail.php` |
|
||||
| DTO de sortie (liste) | `{Concept}ListItem.php` | `MangaListItem.php` |
|
||||
| DTO de sortie (collection) | `{Concept}Collection.php` | `MangaCollection.php` |
|
||||
|
||||
---
|
||||
|
||||
## Flux complet : une opération POST
|
||||
|
||||
```
|
||||
HTTP POST /mangas
|
||||
→ CreateMangaResource (#[ApiResource] avec processor:)
|
||||
→ CreateMangaProcessor::process($data, ...)
|
||||
→ CreateMangaFromMangadexHandler::handle(new CreateMangaFromMangadex(...))
|
||||
→ Domain : Manga::__construct(...) + invariants
|
||||
→ MangaRepositoryInterface::save($manga)
|
||||
→ MessageBus::dispatch(new MangaCreated(...))
|
||||
```
|
||||
208
.claude/skills/cqrs/SKILL.md
Normal file
208
.claude/skills/cqrs/SKILL.md
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
name: cqrs
|
||||
description: Patterns CQRS du projet Mangarr — templates Command/CommandHandler et Query/QueryHandler, enregistrement dans Symfony Messenger, séparation read/write model. Utiliser quand on crée ou modifie un use case (Command ou Query) et son handler.
|
||||
allowed-tools: Read, Grep, Glob
|
||||
---
|
||||
|
||||
# CQRS — Mangarr
|
||||
|
||||
## Principe
|
||||
|
||||
- **Command** : intention de modifier l'état. Retourne `void`.
|
||||
- **Query** : lecture seule. Retourne un `Response` objet (jamais une entité Doctrine).
|
||||
- Les handlers vivent dans `Application/`, jamais dans `Infrastructure/` directement.
|
||||
- Les handlers sont `readonly class` et reçoivent leurs dépendances via le constructeur (autowiring).
|
||||
|
||||
---
|
||||
|
||||
## Template Command
|
||||
|
||||
```php
|
||||
// src/Domain/{Domain}/Application/Command/{DoSomething}.php
|
||||
namespace App\Domain\{Domain}\Application\Command;
|
||||
|
||||
readonly class {DoSomething}
|
||||
{
|
||||
public function __construct(
|
||||
public string $someId,
|
||||
public string $someValue,
|
||||
// scalaires ou tableaux uniquement — pas d'objets du Domain
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
## Template CommandHandler
|
||||
|
||||
```php
|
||||
// src/Domain/{Domain}/Application/CommandHandler/{DoSomething}Handler.php
|
||||
namespace App\Domain\{Domain}\Application\CommandHandler;
|
||||
|
||||
use App\Domain\{Domain}\Application\Command\{DoSomething};
|
||||
use App\Domain\{Domain}\Domain\Contract\Repository\{Name}RepositoryInterface;
|
||||
use App\Domain\{Domain}\Domain\Event\{SomethingHappened};
|
||||
use App\Domain\{Domain}\Domain\Model\{Aggregate};
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
readonly class {DoSomething}Handler
|
||||
{
|
||||
public function __construct(
|
||||
private {Name}RepositoryInterface $repository,
|
||||
// autres interfaces Domain uniquement
|
||||
private MessageBusInterface $messageBus, // si event à dispatcher
|
||||
) {}
|
||||
|
||||
public function handle({DoSomething} $command): void
|
||||
{
|
||||
// 1. Reconstruire/créer l'Aggregate via Value Objects
|
||||
$aggregate = new {Aggregate}(
|
||||
new {AggregateId}(Uuid::uuid4()->toString()),
|
||||
// ...
|
||||
);
|
||||
|
||||
// 2. Appeler la méthode métier (invariants dans le Domain)
|
||||
$aggregate->doSomething();
|
||||
|
||||
// 3. Persister via l'interface repository
|
||||
$this->repository->save($aggregate);
|
||||
|
||||
// 4. Dispatcher un Domain Event si nécessaire
|
||||
$this->messageBus->dispatch(new {SomethingHappened}($aggregate->getId()->getValue()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Règles CommandHandler :**
|
||||
- N'injecte que des interfaces définies dans `Domain/Contract/` (jamais une classe concrète Infrastructure).
|
||||
- Exception autorisée : `MessageBusInterface` de Symfony Messenger.
|
||||
- Ne retourne jamais de données (`void`).
|
||||
|
||||
---
|
||||
|
||||
## Template Query
|
||||
|
||||
```php
|
||||
// src/Domain/{Domain}/Application/Query/{GetSomething}.php
|
||||
namespace App\Domain\{Domain}\Application\Query;
|
||||
|
||||
readonly class {GetSomething}
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
// critères de filtrage en scalaires
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
## Template QueryHandler
|
||||
|
||||
```php
|
||||
// src/Domain/{Domain}/Application/QueryHandler/{GetSomething}Handler.php
|
||||
namespace App\Domain\{Domain}\Application\QueryHandler;
|
||||
|
||||
use App\Domain\{Domain}\Application\Query\{GetSomething};
|
||||
use App\Domain\{Domain}\Application\Response\{Something}Response;
|
||||
use App\Domain\{Domain}\Domain\Contract\Repository\{Name}RepositoryInterface;
|
||||
use App\Domain\{Domain}\Domain\Exception\{Something}NotFoundException;
|
||||
|
||||
readonly class {GetSomething}Handler
|
||||
{
|
||||
public function __construct(
|
||||
private {Name}RepositoryInterface $repository,
|
||||
) {}
|
||||
|
||||
public function handle({GetSomething} $query): {Something}Response
|
||||
{
|
||||
$aggregate = $this->repository->findById($query->id);
|
||||
|
||||
if (!$aggregate) {
|
||||
throw new {Something}NotFoundException();
|
||||
}
|
||||
|
||||
return new {Something}Response(
|
||||
id: $aggregate->getId()->getValue(),
|
||||
// mapper les Value Objects vers scalaires ici
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Template Response
|
||||
|
||||
```php
|
||||
// src/Domain/{Domain}/Application/Response/{Something}Response.php
|
||||
namespace App\Domain\{Domain}\Application\Response;
|
||||
|
||||
readonly class {Something}Response
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $title,
|
||||
// scalaires et tableaux uniquement — jamais d'objets Domain
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
**Règles Response :**
|
||||
- `readonly class`.
|
||||
- Uniquement des types PHP natifs (`string`, `int`, `float`, `bool`, `array`, `?string`…).
|
||||
- C'est le **read model** — il ne sert qu'à transporter des données vers l'Infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Enregistrement dans Symfony Messenger
|
||||
|
||||
### Command via bus synchrone
|
||||
|
||||
Les handlers sont auto-découverts par autowiring. Aucune configuration supplémentaire pour les CommandHandlers Application purs.
|
||||
|
||||
Pour les handlers Symfony Messenger (traitement asynchrone ou via `command.bus`), créer un wrapper dans `Infrastructure/CommandHandler/` :
|
||||
|
||||
```php
|
||||
// src/Domain/{Domain}/Infrastructure/CommandHandler/Symfony{DoSomething}Handler.php
|
||||
namespace App\Domain\{Domain}\Infrastructure\CommandHandler;
|
||||
|
||||
use App\Domain\{Domain}\Application\Command\{DoSomething};
|
||||
use App\Domain\{Domain}\Application\CommandHandler\{DoSomething}Handler;
|
||||
|
||||
readonly class Symfony{DoSomething}Handler
|
||||
{
|
||||
public function __construct(
|
||||
private {DoSomething}Handler $handler,
|
||||
) {}
|
||||
|
||||
public function __invoke({DoSomething} $command): void
|
||||
{
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Déclarer dans `config/services.yaml` :
|
||||
```yaml
|
||||
App\Domain\{Domain}\Infrastructure\CommandHandler\Symfony{DoSomething}Handler:
|
||||
tags:
|
||||
- { name: messenger.message_handler, bus: command.bus }
|
||||
```
|
||||
|
||||
### Buses disponibles
|
||||
|
||||
| Bus / Transport | Usage |
|
||||
|------------------|------------------------------------|
|
||||
| `command.bus` | Commands synchrones ou async |
|
||||
| `events` | Domain Events (async) |
|
||||
| `commands` | Messages async (ex: scraping) |
|
||||
| `async` | Scheduler (tâches planifiées) |
|
||||
|
||||
---
|
||||
|
||||
## Séparation Read Model / Write Model
|
||||
|
||||
| Write Model | Read Model |
|
||||
|--------------------------------------|-------------------------------------------|
|
||||
| `Domain/Model/{Aggregate}.php` | `Application/Response/{Name}Response.php` |
|
||||
| Contient les invariants métier | Contient uniquement des données aplaties |
|
||||
| Manipulé par les CommandHandlers | Retourné par les QueryHandlers |
|
||||
| Persisté via `RepositoryInterface` | Jamais persisté directement |
|
||||
|
||||
Ne jamais retourner un Aggregate depuis un QueryHandler — toujours mapper vers une Response.
|
||||
144
.claude/skills/ddd-core/SKILL.md
Normal file
144
.claude/skills/ddd-core/SKILL.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
name: ddd-core
|
||||
description: Règles DDD du projet Mangarr — Aggregates, Value Objects immutables, Domain Events, invariants. Utiliser quand on crée ou modifie un Model, Value Object, Event ou Exception dans src/Domain/*/Domain/.
|
||||
allowed-tools: Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Règles DDD — Couche Domain
|
||||
|
||||
## Emplacement
|
||||
|
||||
```
|
||||
src/Domain/{DomainName}/Domain/
|
||||
Model/
|
||||
{AggregateName}.php
|
||||
ValueObject/
|
||||
{VoName}.php
|
||||
Event/
|
||||
{SomethingHappened}.php
|
||||
Exception/
|
||||
{Something}Exception.php
|
||||
Contract/
|
||||
Repository/
|
||||
{Name}RepositoryInterface.php
|
||||
Service/
|
||||
{Name}Interface.php
|
||||
Client/
|
||||
{Name}ClientInterface.php
|
||||
```
|
||||
|
||||
## Aggregates
|
||||
|
||||
- Classe normale (pas `readonly`), propriétés `private`.
|
||||
- Le constructeur prend des **Value Objects**, jamais des scalaires bruts pour les identifiants et concepts métier.
|
||||
- **Aucune annotation Doctrine** dans le Model — c'est la responsabilité du Repository (Infrastructure).
|
||||
- Les méthodes métier protègent les invariants et lèvent des **Domain Exceptions** (jamais des exceptions génériques).
|
||||
- Les setters publics sont interdits. Exposer des méthodes métier explicites (`updateImageUrls()`, `enableMonitoring()`, etc.).
|
||||
|
||||
```php
|
||||
// ✅ Correct
|
||||
class Manga
|
||||
{
|
||||
public function __construct(
|
||||
private MangaId $id,
|
||||
private MangaTitle $title,
|
||||
private MangaSlug $slug,
|
||||
// ...
|
||||
) {}
|
||||
|
||||
public function updateImageUrls(ImageUrls $imageUrls): void
|
||||
{
|
||||
$this->imageUrls = $imageUrls;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Interdit
|
||||
class Manga
|
||||
{
|
||||
public string $title; // propriété publique
|
||||
#[ORM\Column] // annotation Doctrine dans le Domain
|
||||
private string $title;
|
||||
public function setTitle(string $title): void {} // setter générique
|
||||
}
|
||||
```
|
||||
|
||||
## Value Objects
|
||||
|
||||
- Toujours `readonly class`.
|
||||
- Valider dans le constructeur, lever une **Domain Exception** si invalide.
|
||||
- Exposer `getValue()` pour récupérer la valeur primitive.
|
||||
- Jamais de dépendance externe (pas de Symfony, pas de Doctrine).
|
||||
|
||||
```php
|
||||
readonly class MangaTitle
|
||||
{
|
||||
public function __construct(public readonly string $value)
|
||||
{
|
||||
if (empty(trim($value))) {
|
||||
throw new InvalidMangaTitleException('Title cannot be empty');
|
||||
}
|
||||
}
|
||||
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Valeurs composées (ex: chemins d'images) → Value Object avec plusieurs propriétés :
|
||||
|
||||
```php
|
||||
readonly class ImageUrls
|
||||
{
|
||||
public function __construct(
|
||||
private string $full,
|
||||
private string $thumbnail,
|
||||
) {}
|
||||
|
||||
public function getFull(): string { return $this->full; }
|
||||
public function getThumbnail(): string { return $this->thumbnail; }
|
||||
}
|
||||
```
|
||||
|
||||
## Domain Events
|
||||
|
||||
- Nommés au **passé** : `MangaCreated`, `ChapterImported`, `MonitoringEnabled`.
|
||||
- `readonly class`, transportent uniquement des scalaires (pas d'objets du Domain).
|
||||
- Placés dans `Domain/Event/`.
|
||||
- Dispatchés depuis le **CommandHandler** (Application), jamais depuis le Domain lui-même.
|
||||
- Le bus utilisé est `MessageBusInterface` de Symfony Messenger (autorisé dans Application, pas dans Domain).
|
||||
|
||||
```php
|
||||
readonly class MangaCreated
|
||||
{
|
||||
public function __construct(
|
||||
public string $mangaId,
|
||||
public string $externalId,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
## Domain Exceptions
|
||||
|
||||
- Étendent `DomainException` ou `\RuntimeException` selon le cas.
|
||||
- Nommées avec le suffixe `Exception` ou `NotFoundException`.
|
||||
- Localisées dans `Domain/Exception/`.
|
||||
|
||||
```php
|
||||
class MangaNotFoundException extends \DomainException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Manga not found');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Règles d'invariants PHPArkitect (enforced automatiquement)
|
||||
|
||||
- `App\Domain\{X}\Domain` → **aucune dépendance** en dehors de son propre namespace.
|
||||
- Exceptions autorisées : `DateTimeImmutable`, `RuntimeException`, `Exception`, `DomainException`, `InvalidArgumentException`, `Throwable`, `Symfony\Component\HttpKernel\Exception`.
|
||||
- `Ramsey\Uuid` et `Symfony\Component\Messenger` : autorisés uniquement en **Application**, pas en Domain.
|
||||
|
||||
Vérification : `make phparkitect`
|
||||
139
.claude/skills/hexagonal-arch/SKILL.md
Normal file
139
.claude/skills/hexagonal-arch/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: hexagonal-arch
|
||||
description: Architecture hexagonale du projet Mangarr — structure exacte des dossiers, règles d'import strictes par couche, nommage ports (interfaces) vs adapters (implémentations). Utiliser quand on crée un nouveau domaine, un nouveau fichier, ou qu'on vérifie les dépendances entre couches.
|
||||
allowed-tools: Read, Grep, Glob, Bash
|
||||
---
|
||||
|
||||
# Architecture Hexagonale — Mangarr
|
||||
|
||||
## Structure canonique d'un domaine
|
||||
|
||||
```
|
||||
src/Domain/{DomainName}/
|
||||
Domain/ ← NOYAU pur, 0 dépendance framework
|
||||
Model/
|
||||
{Aggregate}.php
|
||||
ValueObject/
|
||||
{VoName}.php
|
||||
Event/
|
||||
{SomethingHappened}.php
|
||||
Exception/
|
||||
{Something}Exception.php
|
||||
Contract/ ← PORTS (interfaces only)
|
||||
Repository/
|
||||
{Name}RepositoryInterface.php
|
||||
Service/
|
||||
{Name}Interface.php
|
||||
Client/
|
||||
{Name}ClientInterface.php
|
||||
|
||||
Application/ ← Use cases, orchestre le Domain
|
||||
Command/
|
||||
{DoSomething}.php
|
||||
CommandHandler/
|
||||
{DoSomething}Handler.php
|
||||
Query/
|
||||
{GetSomething}.php
|
||||
QueryHandler/
|
||||
{GetSomething}Handler.php
|
||||
Response/
|
||||
{Something}Response.php
|
||||
EventListener/
|
||||
{SomethingHappened}EventListener.php
|
||||
|
||||
Infrastructure/ ← ADAPTERS (implémentations concrètes)
|
||||
Persistence/
|
||||
Repository/
|
||||
{Name}Repository.php ← implémente Domain/Contract/Repository/
|
||||
ApiPlatform/
|
||||
Resource/
|
||||
{FeatureName}Resource.php
|
||||
State/
|
||||
Processor/
|
||||
{DoSomething}Processor.php
|
||||
Provider/
|
||||
{GetSomething}StateProvider.php
|
||||
Dto/
|
||||
{Name}.php
|
||||
Service/
|
||||
{ServiceName}.php ← implémente Domain/Contract/Service/
|
||||
Client/
|
||||
{ClientName}.php ← implémente Domain/Contract/Client/
|
||||
CommandHandler/ ← handlers Symfony Messenger (wrappent l'Application)
|
||||
Symfony{DoSomething}Handler.php
|
||||
```
|
||||
|
||||
## Domaines du projet
|
||||
|
||||
| Domaine | Responsabilité |
|
||||
|-------------|-----------------------------------------------------|
|
||||
| `Manga` | Catalogue mangas, chapitres, métadonnées |
|
||||
| `Scraping` | Téléchargement de chapitres depuis les sources |
|
||||
| `Conversion`| Conversion de formats (CBR→CBZ, génération CBZ) |
|
||||
| `Reader` | Lecture de chapitres |
|
||||
| `Setting` | Configuration applicative |
|
||||
| `Shared` | Contrats transverses (`EventDispatcherInterface`, `MangaPathManagerInterface`, etc.) |
|
||||
|
||||
## Règles d'import strictes
|
||||
|
||||
### Domain (noyau)
|
||||
```
|
||||
✅ Peut importer : son propre namespace uniquement
|
||||
+ exceptions PHP standard
|
||||
❌ Interdit : Symfony\*, Doctrine\*, Ramsey\Uuid, tout autre domaine
|
||||
```
|
||||
|
||||
### Application
|
||||
```
|
||||
✅ Peut importer : son propre Domain (App\Domain\{X}\Domain\*)
|
||||
App\Domain\Shared\Domain\Contract\*
|
||||
Symfony\Component\Messenger\*
|
||||
Ramsey\Uuid\*
|
||||
❌ Interdit : son propre Infrastructure (App\Domain\{X}\Infrastructure\*)
|
||||
Doctrine\*, tout autre domaine
|
||||
```
|
||||
|
||||
### Infrastructure
|
||||
```
|
||||
✅ Peut importer : tout (Symfony, Doctrine, API Platform, etc.)
|
||||
son Application et son Domain
|
||||
❌ Convention : ne pas contenir de logique métier (déléguer à Application)
|
||||
```
|
||||
|
||||
## Ports vs Adapters — nommage
|
||||
|
||||
| Concept | Localisation | Suffixe | Exemple |
|
||||
|-----------|--------------------------------------|-----------------|---------------------------------|
|
||||
| Port | `Domain/Contract/Repository/` | `Interface` | `MangaRepositoryInterface` |
|
||||
| Port | `Domain/Contract/Service/` | `Interface` | `ImageProcessorInterface` |
|
||||
| Port | `Domain/Contract/Client/` | `Interface` | `MangadexClientInterface` |
|
||||
| Adapter | `Infrastructure/Persistence/` | `Repository` | `LegacyChapterRepository` |
|
||||
| Adapter | `Infrastructure/Service/` | *(nom libre)* | `ImageProcessor` |
|
||||
| Adapter | `Infrastructure/Client/` | `Client` | `MangadexClient` |
|
||||
|
||||
Le binding port → adapter se déclare dans `config/services.yaml` :
|
||||
```yaml
|
||||
App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface:
|
||||
alias: App\Domain\Manga\Infrastructure\Persistence\Repository\LegacyMangaRepository
|
||||
```
|
||||
|
||||
## Shared Domain
|
||||
|
||||
Les contrats transverses vivent dans `src/Domain/Shared/Domain/Contract/` :
|
||||
- `CommandInterface`, `QueryInterface`, `ResponseInterface` — marqueurs
|
||||
- `CommandHandlerInterface`, `QueryHandlerInterface` — handlers génériques
|
||||
- `EventDispatcherInterface` — dispatch d'événements domain
|
||||
- `MangaPathManagerInterface` — gestion des chemins de fichiers
|
||||
- `FileUploadInterface`, `NotificationInterface` — services transverses
|
||||
|
||||
`App\Domain\Shared` **ne dépend de personne** (règle PHPArkitect).
|
||||
|
||||
## Checklist avant de créer un fichier
|
||||
|
||||
1. Dans quelle couche va ce fichier ? (Domain / Application / Infrastructure)
|
||||
2. Ce fichier va-t-il importer quelque chose d'interdit pour cette couche ?
|
||||
3. Si c'est une implémentation concrète → existe-t-il déjà une interface (port) dans `Domain/Contract/` ?
|
||||
4. Si c'est une nouvelle interface → est-elle dans `Domain/Contract/` et non dans Infrastructure ?
|
||||
5. Le binding alias est-il déclaré dans `config/services.yaml` ?
|
||||
|
||||
Vérification automatique : `make phparkitect`
|
||||
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
|
||||
```
|
||||
258
.claude/skills/testing-strategy/SKILL.md
Normal file
258
.claude/skills/testing-strategy/SKILL.md
Normal file
@@ -0,0 +1,258 @@
|
||||
---
|
||||
name: testing-strategy
|
||||
description: Stratégie de tests du projet Mangarr — pyramide adaptée à l'archi DDD/Hexa. Tests unitaires purs sur le Domain/Application (sans framework), adapters InMemory, tests fonctionnels API. Utiliser quand on crée ou modifie des tests, ou qu'on discute de la couverture à implémenter.
|
||||
allowed-tools: Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Stratégie de tests — Mangarr
|
||||
|
||||
## Pyramide
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Tests Fonctionnels (API) │ ← peu nombreux, coûteux
|
||||
│ tests/Functional/ │ zenstruck/browser + BrowserKit
|
||||
├─────────────────────────────┤
|
||||
│ Tests d'Intégration │ ← adapters Doctrine, clients HTTP
|
||||
│ tests/Domain/*/Adapter/ │ zenstruck/foundry + DAMA
|
||||
├─────────────────────────────┤
|
||||
│ Tests Unitaires (Domain) │ ← majorité, rapides, sans framework
|
||||
│ tests/Domain/*/Application/ │ PHPUnit pur, InMemory adapters
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Tests Unitaires — Application Layer (CommandHandlers, QueryHandlers)
|
||||
|
||||
**Localisation :** `tests/Domain/{Domain}/Application/CommandHandler/` et `QueryHandler/`
|
||||
|
||||
**Principe :** Aucune dépendance au framework. On injecte des **adapters InMemory** à la place des vraies implémentations Infrastructure.
|
||||
|
||||
### Structure d'un test CommandHandler
|
||||
|
||||
```php
|
||||
// tests/Domain/Manga/Application/CommandHandler/CreateMangaHandlerTest.php
|
||||
namespace App\Tests\Domain\Manga\Application\CommandHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Command\CreateManga;
|
||||
use App\Domain\Manga\Application\CommandHandler\CreateMangaHandler;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor;
|
||||
use App\Tests\Shared\Adapter\InMemoryMessageBus;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CreateMangaHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryMangaRepository $repository;
|
||||
private CreateMangaHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryMangaRepository();
|
||||
$this->handler = new CreateMangaHandler(
|
||||
$this->repository,
|
||||
new InMemoryImageProcessor(),
|
||||
new InMemoryMessageBus(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testHandleSuccess(): void
|
||||
{
|
||||
// Arrange
|
||||
$command = new CreateManga(
|
||||
title: 'One Piece',
|
||||
slug: 'one-piece',
|
||||
// ...
|
||||
);
|
||||
|
||||
// Act
|
||||
$this->handler->handle($command);
|
||||
|
||||
// Assert
|
||||
$saved = $this->repository->findAll()[0];
|
||||
$this->assertEquals('One Piece', $saved->getTitle()->getValue());
|
||||
}
|
||||
|
||||
public function testThrowsWhenInvalid(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->handler->handle(new CreateManga(title: '', /* ... */));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Structure d'un test QueryHandler
|
||||
|
||||
```php
|
||||
// tests/Domain/Manga/Application/QueryHandler/GetMangaByIdHandlerTest.php
|
||||
class GetMangaByIdHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryMangaRepository $repository;
|
||||
private GetMangaByIdHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryMangaRepository();
|
||||
$this->handler = new GetMangaByIdHandler($this->repository);
|
||||
}
|
||||
|
||||
public function testThrowsWhenNotFound(): void
|
||||
{
|
||||
$this->expectException(MangaNotFoundException::class);
|
||||
$this->handler->handle(new GetMangaById('non-existent'));
|
||||
}
|
||||
|
||||
public function testReturnsMappedResponse(): void
|
||||
{
|
||||
// Arrange — construire l'Aggregate directement avec Value Objects
|
||||
$manga = new Manga(
|
||||
id: new MangaId('123'),
|
||||
title: new MangaTitle('One Piece'),
|
||||
// ...
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// Act
|
||||
$response = $this->handler->handle(new GetMangaById('123'));
|
||||
|
||||
// Assert — vérifier les scalaires du Response
|
||||
$this->assertEquals('123', $response->id);
|
||||
$this->assertEquals('One Piece', $response->title);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->repository->clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Adapters InMemory
|
||||
|
||||
**Localisation :** `tests/Domain/{Domain}/Adapter/`
|
||||
|
||||
Chaque interface de `Domain/Contract/` a son adapter InMemory dans les tests. Ces adapters stockent les données en mémoire (`array`).
|
||||
|
||||
### Structure d'un InMemory Repository
|
||||
|
||||
```php
|
||||
// tests/Domain/Manga/Adapter/InMemoryMangaRepository.php
|
||||
namespace App\Tests\Domain\Manga\Adapter;
|
||||
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
|
||||
class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
{
|
||||
/** @var array<string, Manga> */
|
||||
private array $mangas = [];
|
||||
|
||||
public function save(Manga $manga): void
|
||||
{
|
||||
$this->mangas[$manga->getId()->getValue()] = $manga;
|
||||
}
|
||||
|
||||
public function findById(string $id): ?Manga
|
||||
{
|
||||
return $this->mangas[$id] ?? null;
|
||||
}
|
||||
|
||||
public function findAll(): array
|
||||
{
|
||||
return array_values($this->mangas);
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->mangas = [];
|
||||
}
|
||||
// ... implémenter toutes les méthodes de l'interface
|
||||
}
|
||||
```
|
||||
|
||||
### Adapters InMemory disponibles (existants)
|
||||
|
||||
| Adapter | Interface implémentée |
|
||||
|----------------------------------|------------------------------------------|
|
||||
| `InMemoryMangaRepository` | `MangaRepositoryInterface` |
|
||||
| `InMemoryImageProcessor` | `ImageProcessorInterface` |
|
||||
| `InMemoryMangadexClient` | `MangadexClientInterface` |
|
||||
| `InMemoryMangaProvider` | `MangaProviderInterface` |
|
||||
| `InMemoryPathManager` | `MangaPathManagerInterface` |
|
||||
| `InMemoryMessageBus` | `MessageBusInterface` |
|
||||
|
||||
Quand on crée une nouvelle interface dans `Domain/Contract/`, **créer l'adapter InMemory correspondant** avant d'écrire les tests.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tests Fonctionnels API
|
||||
|
||||
**Localisation :** `tests/Functional/`
|
||||
|
||||
Utilisent `zenstruck/browser` + `BrowserKitBrowser` avec le conteneur Symfony complet. Les données sont gérées par `zenstruck/foundry` (Factories) et `DAMA\DoctrineTestBundle` (rollback automatique après chaque test).
|
||||
|
||||
```php
|
||||
// tests/Functional/SomeEndpointTest.php
|
||||
namespace App\Tests\Functional;
|
||||
|
||||
use Zenstruck\Browser\Test\HasBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
class SomeEndpointTest extends WebTestCase
|
||||
{
|
||||
use HasBrowser;
|
||||
|
||||
public function testGetManga(): void
|
||||
{
|
||||
$this->browser()
|
||||
->get('/api/mangas/by-id/some-uuid')
|
||||
->assertStatus(200)
|
||||
->assertJson()
|
||||
->assertJsonMatches('title', 'One Piece');
|
||||
}
|
||||
|
||||
public function testCreateManga(): void
|
||||
{
|
||||
$this->browser()
|
||||
->post('/api/mangas', [
|
||||
'json' => ['externalId' => 'abc-123'],
|
||||
])
|
||||
->assertStatus(204);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commandes
|
||||
|
||||
```bash
|
||||
make test # tous les tests
|
||||
make test f="CreateMangaHandlerTest" # un test par nom de classe
|
||||
make test c="--group unit" # par groupe
|
||||
make test c="--stop-on-failure" # s'arrêter au premier échec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist par feature
|
||||
|
||||
Quand on implémente une nouvelle feature, les tests à écrire dans l'ordre :
|
||||
|
||||
1. **Test du CommandHandler/QueryHandler** (unitaire, `TestCase` pur)
|
||||
- Cas nominal (happy path)
|
||||
- Cas d'erreur (not found, invalide…)
|
||||
- Vérification que le repository est bien appelé
|
||||
|
||||
2. **Test de la Value Object** si une nouvelle VO est créée
|
||||
- Validation des invariants (cas invalides)
|
||||
- `getValue()` retourne la bonne valeur
|
||||
|
||||
3. **Test fonctionnel de l'endpoint** (si API Platform)
|
||||
- Codes HTTP corrects (200, 201, 204, 404…)
|
||||
- Structure JSON de la réponse
|
||||
|
||||
Ne pas tester les Processors/Providers API Platform en unitaire (trop de couplage framework) — les couvrir via les tests fonctionnels.
|
||||
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`)
|
||||
@@ -30,3 +30,4 @@ vendor/
|
||||
.env.local
|
||||
.env.local.php
|
||||
.env.test
|
||||
.env
|
||||
|
||||
18
.env.example
18
.env.example
@@ -22,3 +22,21 @@ POSTGRES_VERSION=16
|
||||
DATABASE_URL="postgresql://%env(resolve:POSTGRES_USER)%:%env(resolve:POSTGRES_PASSWORD)%@%env(resolve:POSTGRES_HOST)%/%env(resolve:POSTGRES_DB)%?serverVersion=%env(resolve:POSTGRES_VERSION)%&charset=utf8"
|
||||
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> symfony/mercure-bundle ###
|
||||
# See https://symfony.com/doc/current/mercure.html#configuration
|
||||
# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
|
||||
MERCURE_URL=http://mercure/.well-known/mercure
|
||||
# The public URL of the Mercure hub, used by the browser to connect
|
||||
MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure
|
||||
# The secret used to sign the JWTs
|
||||
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
||||
###< symfony/mercure-bundle ###
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
|
||||
###> symfony/messenger ###
|
||||
# Choose one of the transports below
|
||||
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
|
||||
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
|
||||
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
42
.gitea/workflows/deploy.yml
Normal file
42
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
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: Deploy via Deployer
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
# Créer le container sans le démarrer (évite le problème DinD avec les volumes)
|
||||
CONTAINER=$(docker create \
|
||||
-e DEPLOY_HOST \
|
||||
-e GITEA_TOKEN \
|
||||
-w /app \
|
||||
deployphp/deployer:v7 \
|
||||
-f /app/deploy.php deploy production -vvv)
|
||||
|
||||
# Copier les sources et les clés SSH dans le container
|
||||
docker cp "$PWD/." "$CONTAINER:/app/"
|
||||
docker cp "$HOME/.ssh/." "$CONTAINER:/root/.ssh/"
|
||||
|
||||
# Démarrer et attendre la fin
|
||||
docker start -a "$CONTAINER"
|
||||
EXIT_CODE=$?
|
||||
docker rm "$CONTAINER" || true
|
||||
exit $EXIT_CODE
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -38,3 +38,11 @@ yarn-error.log
|
||||
/public/images/
|
||||
src/Controller/TestController.php
|
||||
.phpunit.cache/test-results
|
||||
/tests/Fixtures/pages/
|
||||
|
||||
# Claude Code — versionner les skills partagés, ignorer les fichiers perso
|
||||
!.claude/
|
||||
!.claude/skills/
|
||||
!.claude/skills/**
|
||||
.claude/settings.local.json
|
||||
.claude/projects/
|
||||
|
||||
25
.vscode/settings.json
vendored
25
.vscode/settings.json
vendored
@@ -1,4 +1,25 @@
|
||||
{
|
||||
"symfony-vscode.shellExecutable": "/bin/bash",
|
||||
"symfony-vscode.shellCommand": "docker exec mangarr-php-1 /bin/sh -c 'cd / && php \"$@\"' -- "
|
||||
}
|
||||
"symfony-vscode.shellCommand": "docker exec mangarr-php-1 /bin/sh -c 'cd / && php \"$@\"' -- ",
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#2f7c47",
|
||||
"activityBar.background": "#2f7c47",
|
||||
"activityBar.foreground": "#e7e7e7",
|
||||
"activityBar.inactiveForeground": "#e7e7e799",
|
||||
"activityBarBadge.background": "#422c74",
|
||||
"activityBarBadge.foreground": "#e7e7e7",
|
||||
"commandCenter.border": "#e7e7e799",
|
||||
"sash.hoverBorder": "#2f7c47",
|
||||
"statusBar.background": "#215732",
|
||||
"statusBar.foreground": "#e7e7e7",
|
||||
"statusBarItem.hoverBackground": "#2f7c47",
|
||||
"statusBarItem.remoteBackground": "#215732",
|
||||
"statusBarItem.remoteForeground": "#e7e7e7",
|
||||
"titleBar.activeBackground": "#215732",
|
||||
"titleBar.activeForeground": "#e7e7e7",
|
||||
"titleBar.inactiveBackground": "#21573299",
|
||||
"titleBar.inactiveForeground": "#e7e7e799",
|
||||
"activityBar.activeBorder": "#422c74"
|
||||
},
|
||||
"peacock.color": "#215732"
|
||||
}
|
||||
|
||||
110
CLAUDE.md
Normal file
110
CLAUDE.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
Mangarr is a Symfony 7.0 manga management/reader application. It scrapes manga chapters from various sources, stores them as CBZ files, and provides a reader interface. It runs on FrankenPHP inside Docker.
|
||||
|
||||
## Common Commands
|
||||
|
||||
All commands run via Docker through the Makefile. Use `make help` to see all available targets.
|
||||
|
||||
```bash
|
||||
make start # Start Docker containers
|
||||
make stop # Stop containers
|
||||
make install # Build images, start containers, install deps (first-time setup)
|
||||
make logs # Follow container logs
|
||||
make sh # Shell into the PHP container
|
||||
```
|
||||
|
||||
**PHP / Symfony:**
|
||||
```bash
|
||||
make sf c="about" # Run any Symfony console command
|
||||
make cc # Clear cache
|
||||
make vendor # Install Composer dependencies
|
||||
make migration-migrate # Run pending migrations
|
||||
make fixtures-load # Load fixtures (drops and recreates data)
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
```bash
|
||||
make test # Run all tests
|
||||
make test f="ScrapeChapterHandlerTest" # Run a specific test by class name
|
||||
make test c="--group e2e" # Pass phpunit options
|
||||
```
|
||||
|
||||
**Code Quality:**
|
||||
```bash
|
||||
make phpcs # Fix code style (PSR-12 via php-cs-fixer)
|
||||
make phpmd # Run PHP Mess Detector
|
||||
make quality # Run both phpmd and phpcs
|
||||
make phparkitect # Check architectural rules
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
make npm-run # Build assets once (dev)
|
||||
make npm-watch # Watch and rebuild assets
|
||||
make npm-add p=pkg # Add an npm dependency
|
||||
```
|
||||
|
||||
**Messenger workers** (run in separate terminals):
|
||||
```bash
|
||||
make consume-commands # Process command.bus messages
|
||||
make consume-events # Process domain events
|
||||
make consume-schedule # Process scheduled tasks
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The project uses **Domain-Driven Design** with strict layer separation enforced by PHPArkitect (`phparkitect.php`).
|
||||
|
||||
### Domain Structure
|
||||
|
||||
```
|
||||
src/Domain/
|
||||
{DomainName}/
|
||||
Domain/ # Pure domain: Models, Contracts (interfaces), Events, Exceptions
|
||||
Application/ # Use cases: Commands, Queries, CommandHandlers, QueryHandlers, Responses
|
||||
Infrastructure/ # Framework: Persistence, API Platform State, Clients, Services
|
||||
Shared/ # Cross-domain contracts and infrastructure (MangaPathManagerInterface, EventDispatcherInterface, etc.)
|
||||
```
|
||||
|
||||
**Business domains:** `Manga`, `Reader`, `Scraping`, `Conversion`, `Setting` (+ `Shared`)
|
||||
|
||||
**Architectural rules enforced:**
|
||||
- `Domain` layer has no outside dependencies (only std exceptions)
|
||||
- `Application` layer may depend on its own Domain + `App\Domain\Shared\Domain\Contract` + `Symfony\Messenger` + `Ramsey\Uuid`; never on Infrastructure
|
||||
- `Shared` depends on nothing outside itself
|
||||
|
||||
### Outside Domain (`src/`)
|
||||
|
||||
- `src/Entity/` — Doctrine ORM entities (legacy, used by repositories)
|
||||
- `src/Controller/` — Symfony HTTP controllers
|
||||
- `src/ApiResource/` — API Platform resource definitions + `OpenApiFactoryDecorator`
|
||||
- `src/Service/` — Legacy services (being migrated into Domain)
|
||||
- `src/Message/` + `src/MessageHandler/` — Legacy Messenger messages (outside DDD)
|
||||
|
||||
### Frontend
|
||||
|
||||
- `assets/controllers/` — Stimulus controllers (one per UI interaction)
|
||||
- `assets/vue/app/` — Vue.js SPA mounted at `/vue/*`
|
||||
- Tailwind CSS via PostCSS, bundled with Webpack Encore
|
||||
- Mercure for real-time updates (queue status, download progress)
|
||||
|
||||
### Key Infrastructure
|
||||
|
||||
- **Database:** PostgreSQL 16 via Doctrine ORM; Adminer on port 8080
|
||||
- **Scraping:** `scrapers.json` defines per-site CSS selectors; `HtmlScraper` and `JavascriptScraper` (Panther) strategies
|
||||
- **File storage:** CBZ files stored at `MANGA_DATA_PATH` (default `~/Mangas`); images at `IMAGE_DATA_PATH`
|
||||
- **External API:** MangaDex client for metadata (`MANGADEX_CLIENT_ID/SECRET/USERNAME/PASSWORD` env vars)
|
||||
- **Messenger buses:** `command.bus` (sync commands), `events` transport, `commands` transport, `async` transport (scheduler)
|
||||
|
||||
### Adding a New Domain Feature
|
||||
|
||||
1. Define contracts (interfaces) in `Domain/{Name}/Domain/Contract/`
|
||||
2. Write Command/Query + Handler in `Domain/{Name}/Application/`
|
||||
3. Implement interfaces in `Domain/{Name}/Infrastructure/`
|
||||
4. Register infrastructure aliases in `config/services.yaml`
|
||||
5. Run `make phparkitect` to validate layer boundaries
|
||||
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)
|
||||
40
Dockerfile
40
Dockerfile
@@ -1,7 +1,7 @@
|
||||
#syntax=docker/dockerfile:1.4
|
||||
|
||||
# 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
|
||||
# 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
|
||||
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
|
||||
FROM frankenphp_base AS frankenphp_dev
|
||||
|
||||
@@ -85,6 +98,23 @@ COPY --link frankenphp/conf.d/app.dev.ini $PHP_INI_DIR/conf.d/
|
||||
|
||||
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ]
|
||||
|
||||
# Composer dependencies (needed for Symfony UX assets referenced in package.json)
|
||||
FROM composer:2 AS composer_deps
|
||||
WORKDIR /app
|
||||
COPY --link composer.* symfony.* ./
|
||||
RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress --ignore-platform-reqs
|
||||
|
||||
# Stage Node.js pour compiler les assets (Webpack Encore)
|
||||
FROM node:22-alpine AS node_build
|
||||
WORKDIR /app
|
||||
COPY --link package.json package-lock.json ./
|
||||
RUN npm install
|
||||
COPY --link assets ./assets
|
||||
COPY --link webpack.config.js ./
|
||||
COPY --link tailwind.config.js postcss.config.js ./
|
||||
COPY --link templates ./templates
|
||||
RUN npm run build
|
||||
|
||||
# Prod FrankenPHP image
|
||||
FROM frankenphp_base AS frankenphp_prod
|
||||
|
||||
@@ -103,11 +133,15 @@ RUN set -eux; \
|
||||
|
||||
# copy sources
|
||||
COPY --link . ./
|
||||
RUN rm -Rf frankenphp/
|
||||
RUN rm -Rf frankenphp/ && \
|
||||
test -f .env || cp .env.example .env
|
||||
|
||||
# Copier les assets compilés depuis le stage Node.js
|
||||
COPY --from=node_build /app/public/build ./public/build
|
||||
|
||||
RUN set -eux; \
|
||||
mkdir -p var/cache var/log; \
|
||||
composer dump-autoload --classmap-authoritative --no-dev; \
|
||||
composer dump-env prod; \
|
||||
composer run-script --no-dev post-install-cmd; \
|
||||
DATABASE_URL="postgresql://dummy:dummy@dummy:5432/dummy?serverVersion=15&charset=utf8" composer run-script --no-dev post-install-cmd; \
|
||||
chmod +x bin/console; sync;
|
||||
|
||||
14
Makefile
14
Makefile
@@ -145,8 +145,18 @@ twig-extension: ## Create a new twig extension
|
||||
stimulus: ## Create a new stimulus controller
|
||||
@$(SYMFONY) make:stimulus-controller
|
||||
|
||||
consume:
|
||||
@$(SYMFONY) messenger:consume commands events -vv
|
||||
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
|
||||
@$(SYMFONY) messenger:consume commands -vv
|
||||
|
||||
consume-events: ## Consume events messages
|
||||
@$(SYMFONY) messenger:consume events -vv
|
||||
|
||||
consume-schedule: ## Consume schedule messages
|
||||
@$(SYMFONY) messenger:consume async -vv scheduler_default
|
||||
|
||||
@@ -13,7 +13,7 @@ Avant de commencer, assurez-vous que les outils suivants sont installés sur vot
|
||||
Pour mettre en place le projet, suivez ces étapes :
|
||||
|
||||
1. Clonez le dépôt du projet :
|
||||
```git clone git@bitbucket.org:tkm_rd/tkm-symfony.git```
|
||||
```git clone git@git.homelab.nestor-server.fr:2222/colgora/Mangarr.git```
|
||||
|
||||
2. Copiez le fichier `.env.example` en `.env` :
|
||||
```cp .env.example .env```
|
||||
|
||||
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/utilities";
|
||||
|
||||
html, body {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: white;
|
||||
}
|
||||
@@ -82,6 +87,33 @@ body {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
|
||||
/* Firefox uniquement — évite le conflit avec les pseudo-éléments webkit sur Chrome 121+ */
|
||||
@supports (-moz-appearance: none) {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #16a34a transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: #16a34a #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode — webkit track */
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
/* Supprime les flèches de la scrollbar */
|
||||
::-webkit-scrollbar-button:start:decrement,
|
||||
::-webkit-scrollbar-button:end:increment,
|
||||
::-webkit-scrollbar-button:start:increment,
|
||||
::-webkit-scrollbar-button:end:decrement {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
///* Custom styles for the scrollbar buttons */
|
||||
//::-webkit-scrollbar-button {
|
||||
// @apply bg-gray-700;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
<NotificationToast />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App'
|
||||
}
|
||||
<script setup>
|
||||
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
||||
import { useMercureNotifications } from './shared/composables/useMercureNotifications';
|
||||
|
||||
useMercureNotifications();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -18,4 +20,4 @@ export default {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { Job } from '../../domain/entities/job';
|
||||
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
|
||||
|
||||
const jobRepository = new ApiJobRepository();
|
||||
|
||||
const ACTIVE_STATUSES = ['pending', 'in_progress'];
|
||||
|
||||
export const useActivityStore = defineStore('activity', {
|
||||
state: () => ({
|
||||
jobs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
mercureEventSource: null,
|
||||
// Pagination
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
@@ -15,21 +19,15 @@ export const useActivityStore = defineStore('activity', {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
// Filtres
|
||||
filter: {
|
||||
status: ['pending', 'in_progress'], // Par défaut, ne montrer que les actifs
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC'
|
||||
}
|
||||
// Tri
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
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,
|
||||
hasError: state => !!state.error,
|
||||
// Getters pour la pagination
|
||||
paginationInfo: state => ({
|
||||
currentPage: state.currentPage,
|
||||
totalPages: state.totalPages,
|
||||
@@ -41,44 +39,25 @@ export const useActivityStore = defineStore('activity', {
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Charge la liste des jobs selon les filtres actuels
|
||||
* @param {number} page - Numéro de page optionnel
|
||||
*/
|
||||
async loadJobs(page = null) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const options = {
|
||||
const jobCollection = await jobRepository.getJobs({
|
||||
page: page || this.currentPage,
|
||||
limit: this.limit,
|
||||
sortBy: this.filter.sortBy,
|
||||
sortOrder: this.filter.sortOrder,
|
||||
status: this.filter.status
|
||||
};
|
||||
sortBy: this.sortBy,
|
||||
sortOrder: this.sortOrder,
|
||||
status: ACTIVE_STATUSES,
|
||||
});
|
||||
|
||||
const jobCollection = await jobRepository.getJobs(options);
|
||||
|
||||
// Mettre à jour les données
|
||||
this.jobs = jobCollection.items;
|
||||
this.currentPage = jobCollection.page;
|
||||
this.total = jobCollection.total;
|
||||
this.hasNextPage = jobCollection.hasNextPage;
|
||||
this.hasPreviousPage = jobCollection.hasPreviousPage;
|
||||
|
||||
// Calculer le nombre total de pages
|
||||
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) {
|
||||
this.error = error.message;
|
||||
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) {
|
||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
@@ -98,39 +73,26 @@ export const useActivityStore = defineStore('activity', {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Met à jour les filtres et recharge la liste
|
||||
* @param {Object} filter
|
||||
*/
|
||||
async updateFilter(filter) {
|
||||
this.filter = { ...this.filter, ...filter };
|
||||
this.currentPage = 1; // Retourner à la première page lors du changement de filtre
|
||||
async updateSort(sortBy, sortOrder) {
|
||||
this.sortBy = sortBy;
|
||||
this.sortOrder = sortOrder;
|
||||
this.currentPage = 1;
|
||||
await this.loadJobs(1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Met à jour la limite par page
|
||||
* @param {number} limit
|
||||
*/
|
||||
async updateLimit(limit) {
|
||||
this.limit = limit;
|
||||
this.currentPage = 1; // Retourner à la première page
|
||||
this.currentPage = 1;
|
||||
await this.loadJobs(1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime un job par son ID
|
||||
* @param {string} id
|
||||
*/
|
||||
async deleteJob(id) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
await jobRepository.deleteJob(id);
|
||||
// Supprimer le job de la liste locale
|
||||
this.jobs = this.jobs.filter(job => job.id !== id);
|
||||
// Recharger la page courante pour avoir les bons totaux
|
||||
await this.loadJobs(this.currentPage);
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
@@ -140,17 +102,75 @@ export const useActivityStore = defineStore('activity', {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs correspondant aux critères
|
||||
* @param {Object} criteria
|
||||
*/
|
||||
updateJobProgress(jobId, progress) {
|
||||
const job = this.jobs.find(j => j.id === jobId);
|
||||
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 = {}) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const deleted = await jobRepository.deleteJobs(criteria);
|
||||
// Recharger la liste après suppression
|
||||
await this.loadJobs(this.currentPage);
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
@@ -160,26 +180,5 @@ export const useActivityStore = defineStore('activity', {
|
||||
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 = {},
|
||||
result = null,
|
||||
error = null,
|
||||
failureReason = null,
|
||||
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.type = type;
|
||||
@@ -16,9 +22,14 @@ export class Job {
|
||||
this.progress = progress;
|
||||
this.payload = payload;
|
||||
this.result = result;
|
||||
this.error = error;
|
||||
this.error = failureReason ?? error;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
this.startedAt = startedAt;
|
||||
this.completedAt = completedAt;
|
||||
this.attempts = attempts;
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
static create(data) {
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
* @returns {Promise<JobCollection>} Collection de jobs
|
||||
*/
|
||||
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 {
|
||||
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(',')}`;
|
||||
}
|
||||
|
||||
console.log('Fetching jobs from URL:', url);
|
||||
// Ajouter le filtre de type si fourni
|
||||
if (type) {
|
||||
url += `&type=${type}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
@@ -32,7 +35,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('API Response:', data);
|
||||
|
||||
// Gérer différents formats de réponse API
|
||||
let jobs, total, currentPage, limit_returned, hasNext, hasPrev;
|
||||
@@ -63,15 +65,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
hasPrev = !!data.hasPreviousPage;
|
||||
}
|
||||
|
||||
console.log('Processed data:', {
|
||||
jobs: jobs.length,
|
||||
total,
|
||||
currentPage,
|
||||
limit_returned,
|
||||
hasNext,
|
||||
hasPrev
|
||||
});
|
||||
|
||||
return new JobCollection(
|
||||
jobs,
|
||||
total,
|
||||
@@ -81,7 +74,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
hasPrev
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -102,7 +94,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
const data = await response.json();
|
||||
return Job.create(data);
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -124,7 +115,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -158,7 +148,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
const data = await response.json();
|
||||
return data.deleted || 0;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,56 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out"
|
||||
class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition duration-150 ease-in-out"
|
||||
:class="{
|
||||
'bg-yellow-50': job.status === 'pending',
|
||||
'bg-blue-50': job.status === 'in_progress',
|
||||
'bg-green-50': job.status === 'completed',
|
||||
'bg-red-50': job.status === 'failed'
|
||||
'bg-yellow-50 dark:bg-yellow-900/20': job.status === 'pending',
|
||||
'bg-blue-50 dark:bg-blue-900/20': job.status === 'in_progress',
|
||||
'bg-green-50 dark:bg-green-900/20': job.status === 'completed',
|
||||
'bg-red-50 dark:bg-red-900/20': job.status === 'failed'
|
||||
}">
|
||||
<td class="py-4 px-4 text-center">
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" />
|
||||
</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">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-800': job.status === 'pending',
|
||||
'bg-blue-100 text-blue-800': job.status === 'in_progress',
|
||||
'bg-green-100 text-green-800': job.status === 'completed',
|
||||
'bg-red-100 text-red-800': job.status === 'failed'
|
||||
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': job.status === 'pending',
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': job.status === 'in_progress',
|
||||
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': job.status === 'completed',
|
||||
'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300': job.status === 'failed'
|
||||
}">
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div v-if="job.error" class="text-sm text-red-600">
|
||||
<div v-if="job.error" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ job.error }}
|
||||
</div>
|
||||
<div v-else 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) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div v-if="job.status === 'in_progress'" class="mt-2">
|
||||
<div class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||
:style="{ width: `${job.progress}%` }"></div>
|
||||
@@ -42,7 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||
style="width: 100%"></div>
|
||||
@@ -50,7 +67,7 @@
|
||||
100%
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-red-400 transition-all duration-300 ease-out"
|
||||
style="width: 100%"></div>
|
||||
@@ -58,14 +75,19 @@
|
||||
Erreur
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-yellow-400 transition-all duration-300 ease-out"
|
||||
style="width: 0%"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600">
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||
En attente
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="job.maxAttempts > 1 || job.attempts > 0"
|
||||
class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">
|
||||
{{ job.attempts }} / {{ job.maxAttempts }} tentative{{ job.maxAttempts > 1 ? 's' : '' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<button
|
||||
@@ -79,24 +101,33 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { defineEmits, defineProps } from 'vue';
|
||||
import { TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, defineEmits, defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
job: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
const props = defineProps({
|
||||
job: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
function onDelete() {
|
||||
emit('delete', props.job.id);
|
||||
}
|
||||
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) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
emit('delete', props.job.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,179 +1,153 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="mb-6" />
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activityStore.error" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<p>{{ activityStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="container mx-auto p-2">
|
||||
<!-- Debug pagination - À supprimer plus tard -->
|
||||
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" v-if="true">
|
||||
<strong>Debug Pagination:</strong>
|
||||
Total: {{ activityStore.total }},
|
||||
Limit: {{ activityStore.limit }},
|
||||
Pages: {{ activityStore.totalPages }},
|
||||
Page courante: {{ activityStore.currentPage }},
|
||||
Condition: {{ activityStore.total > activityStore.limit }}
|
||||
<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-indigo-500 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white">
|
||||
<!-- Error -->
|
||||
<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>
|
||||
|
||||
<!-- Content -->
|
||||
<section v-else class="border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Empty -->
|
||||
<div v-if="activityStore.jobs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
|
||||
<ClockIcon class="w-12 h-12 mb-3" />
|
||||
<p class="text-base">Aucun job en cours ou en attente.</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="bg-gray-200 text-gray-800">
|
||||
<th class="w-1/12 py-3 px-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox h-5 w-5 text-green-600"
|
||||
@change="toggleSelectAll" />
|
||||
</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 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-2/11 py-3 px-6 text-left">Type</th>
|
||||
<th class="w-2/11 py-3 px-4 text-left">Statut</th>
|
||||
<th class="w-3/11 py-3 px-4 text-left">Informations</th>
|
||||
<th class="w-3/11 py-3 px-4 text-left">Progression</th>
|
||||
<th class="w-1/11 py-3 px-4 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-700">
|
||||
<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
|
||||
v-for="job in activityStore.jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
@delete="deleteJob" />
|
||||
</template>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50 text-gray-700 dark:text-gray-300">
|
||||
<JobItem
|
||||
v-for="job in activityStore.jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
@delete="deleteJob" />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="activityStore.total > activityStore.limit"
|
||||
v-if="total > activityStore.limit"
|
||||
:current-page="activityStore.currentPage"
|
||||
:total-pages="activityStore.totalPages"
|
||||
:total="activityStore.total"
|
||||
:total="total"
|
||||
:limit="activityStore.limit"
|
||||
:has-next-page="activityStore.hasNextPage"
|
||||
:has-previous-page="activityStore.hasPreviousPage"
|
||||
@page-change="changePage" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowPathIcon, ClockIcon, FunnelIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ArrowPathIcon, BarsArrowDownIcon, ClockIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useActivityStore } from '../../application/store/activityStore';
|
||||
import JobItem from '../components/JobItem.vue';
|
||||
|
||||
const activityStore = useActivityStore();
|
||||
const selectedAll = ref(false);
|
||||
const activityStore = useActivityStore();
|
||||
|
||||
// Statuts disponibles pour le filtre
|
||||
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' }
|
||||
];
|
||||
const { sortBy, sortOrder, total, loading } = storeToRefs(activityStore);
|
||||
|
||||
// Index du statut actif (par défaut "Actifs")
|
||||
const activeStatusIndex = ref(0);
|
||||
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
|
||||
|
||||
// Configuration de la toolbar réactive
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{
|
||||
icon: FunnelIcon,
|
||||
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: [
|
||||
{
|
||||
icon: ArrowPathIcon,
|
||||
type: 'button',
|
||||
label: 'Rafraîchir',
|
||||
onClick: refreshJobs
|
||||
},
|
||||
{
|
||||
icon: TrashIcon,
|
||||
type: 'button',
|
||||
label: 'Supprimer visibles',
|
||||
onClick: deleteVisibleJobs
|
||||
}
|
||||
]
|
||||
}));
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Activité', class: 'text-sm font-medium' },
|
||||
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'dropdown',
|
||||
icon: BarsArrowDownIcon,
|
||||
label: 'Trier',
|
||||
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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: ArrowPathIcon,
|
||||
label: 'Rafraîchir',
|
||||
disabled: loading.value,
|
||||
onClick: () => activityStore.loadJobs(),
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: TrashIcon,
|
||||
label: 'Supprimer visibles',
|
||||
disabled: loading.value || total.value === 0,
|
||||
onClick: deleteVisibleJobs,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
loadJobs();
|
||||
});
|
||||
onMounted(() => {
|
||||
activityStore.loadJobs();
|
||||
activityStore.subscribeMercure();
|
||||
});
|
||||
|
||||
function loadJobs() {
|
||||
activityStore.loadJobs();
|
||||
onUnmounted(() => {
|
||||
activityStore.unsubscribeMercure();
|
||||
});
|
||||
|
||||
function changePage(page) {
|
||||
activityStore.goToPage(page);
|
||||
}
|
||||
|
||||
function deleteJob(id) {
|
||||
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
||||
activityStore.deleteJob(id);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshJobs() {
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
activityStore.goToPage(page);
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
selectedAll.value = !selectedAll.value;
|
||||
// La logique pour sélectionner tous les jobs serait ajoutée ici
|
||||
}
|
||||
|
||||
function setStatusFilter(index) {
|
||||
if (index >= 0 && index < statusOptions.length) {
|
||||
activeStatusIndex.value = index;
|
||||
activityStore.updateFilter({ status: statusOptions[index].value });
|
||||
}
|
||||
}
|
||||
|
||||
function deleteJob(id) {
|
||||
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
||||
activityStore.deleteJob(id);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteVisibleJobs() {
|
||||
if (activityStore.jobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusLabel = statusOptions[activeStatusIndex.value].label.toLowerCase();
|
||||
if (confirm(`Voulez-vous vraiment supprimer tous les jobs ${statusLabel} ?`)) {
|
||||
activityStore.deleteJobs({ status: activityStore.filter.status });
|
||||
}
|
||||
function deleteVisibleJobs() {
|
||||
if (activityStore.jobs.length === 0) return;
|
||||
if (confirm('Voulez-vous vraiment supprimer tous les jobs visibles ?')) {
|
||||
activityStore.deleteJobs({ status: ['pending', 'in_progress'] });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
|
||||
// État de l'interface
|
||||
isDragOver: false,
|
||||
showSuccessMessage: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -86,7 +85,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
this.clearError();
|
||||
this.conversionSuccess = false;
|
||||
this.convertedFile = null;
|
||||
this.showSuccessMessage = false;
|
||||
|
||||
// Stockage du fichier
|
||||
this.currentFile = file;
|
||||
@@ -125,7 +123,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
// Stockage du fichier converti
|
||||
this.convertedFile = convertedFileBlob;
|
||||
this.conversionSuccess = true;
|
||||
this.showSuccessMessage = true;
|
||||
|
||||
// Ajout à l'historique
|
||||
this.addToHistory({
|
||||
@@ -171,7 +168,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
this.currentFile = null;
|
||||
this.convertedFile = null;
|
||||
this.conversionSuccess = false;
|
||||
this.showSuccessMessage = false;
|
||||
this.conversionProgress = 0;
|
||||
this.clearError();
|
||||
},
|
||||
@@ -183,7 +179,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
setError(message) {
|
||||
this.conversionError = message;
|
||||
this.conversionSuccess = false;
|
||||
this.showSuccessMessage = false;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -193,13 +188,6 @@ export const useConversionStore = defineStore('conversion', {
|
||||
this.conversionError = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cache le message de succès
|
||||
*/
|
||||
hideSuccessMessage() {
|
||||
this.showSuccessMessage = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gère l'état du drag and drop
|
||||
* @param {boolean} isDragOver - Indique si un fichier est survolé
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
|
||||
<!-- Message de statut -->
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
<p v-if="fileName" class="text-xs text-gray-500">
|
||||
<p v-if="fileName" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ fileName }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -35,11 +35,11 @@
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div v-if="showProgress" class="space-y-2">
|
||||
<div class="flex justify-between text-xs text-gray-600">
|
||||
<div class="flex justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>Progression</span>
|
||||
<span>{{ Math.round(progress) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300 ease-out"
|
||||
:style="{ width: `${progress}%` }"
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Détails de la conversion -->
|
||||
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 space-y-1">
|
||||
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<div v-if="originalSize" class="flex justify-between">
|
||||
<span>Taille originale:</span>
|
||||
<span>{{ formatFileSize(originalSize) }}</span>
|
||||
@@ -77,7 +77,7 @@
|
||||
<button
|
||||
v-if="canReset"
|
||||
@click="$emit('reset')"
|
||||
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
>
|
||||
<ArrowPathIcon class="w-4 h-4" />
|
||||
<span>Convertir un autre fichier</span>
|
||||
@@ -85,14 +85,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur détaillé -->
|
||||
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div class="flex">
|
||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">
|
||||
Erreur de conversion
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-red-700">
|
||||
<p class="mt-1 text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
:class="[
|
||||
'border-2 border-dashed rounded-lg p-8 text-center transition-all duration-200',
|
||||
isDragOver
|
||||
? 'border-green-400 bg-green-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
? 'border-green-400 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
]"
|
||||
>
|
||||
<!-- Zone d'upload -->
|
||||
@@ -28,13 +28,13 @@
|
||||
|
||||
<!-- Message principal -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ isDragOver ? 'Déposez votre fichier ici' : 'Sélectionnez un fichier CBR ou CBZ' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Glissez-déposez votre fichier ou cliquez pour le sélectionner
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Fichiers supportés: .cbr, .cbz (max. 150MB)
|
||||
</p>
|
||||
</div>
|
||||
@@ -63,20 +63,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Informations du fichier sélectionné -->
|
||||
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<DocumentIcon class="w-8 h-8 text-gray-600" />
|
||||
<DocumentIcon class="w-8 h-8 text-gray-600 dark:text-gray-400" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ selectedFile.name }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatFileSize(selectedFile.size) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="clearFile"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
class="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
title="Supprimer le fichier"
|
||||
>
|
||||
<XMarkIcon class="w-5 h-5" />
|
||||
|
||||
@@ -1,285 +1,149 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<!-- En-tête -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-3 mb-4">
|
||||
<ArrowPathIcon class="w-8 h-8 text-green-600" />
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
Convertir CBR en CBZ
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-lg text-gray-600">
|
||||
Convertissez vos fichiers CBR (Comic Book RAR) en CBZ (Comic Book ZIP) pour une meilleure compatibilité.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<!-- Zone principale -->
|
||||
<div class="bg-white shadow-lg rounded-lg overflow-hidden">
|
||||
<!-- En-tête de la carte -->
|
||||
<div class="bg-gray-800 text-white p-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<ArchiveBoxIcon class="w-6 h-6" />
|
||||
<h2 class="text-xl font-semibold">
|
||||
Conversion de fichiers
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<!-- Contenu de la carte -->
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Zone d'upload -->
|
||||
<FileUploadArea
|
||||
:selected-file="conversionStore.currentFile"
|
||||
:disabled="conversionStore.isProcessing"
|
||||
@file-selected="handleFileSelected"
|
||||
@file-cleared="handleFileClear"
|
||||
/>
|
||||
<!-- Zone d'upload -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichier</h2>
|
||||
<FileUploadArea
|
||||
:selected-file="conversionStore.currentFile"
|
||||
:disabled="conversionStore.isProcessing"
|
||||
@file-selected="handleFileSelected"
|
||||
@file-cleared="handleFileClear"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Bouton de conversion -->
|
||||
<div v-if="conversionStore.hasSelectedFile && !conversionStore.hasSucceeded" class="flex justify-center">
|
||||
<button
|
||||
@click="handleConvert"
|
||||
:disabled="conversionStore.isProcessing"
|
||||
:class="[
|
||||
'flex items-center space-x-2 px-6 py-3 text-white font-medium rounded-lg transition-all duration-200',
|
||||
conversionStore.isProcessing
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2'
|
||||
]"
|
||||
>
|
||||
<ArrowPathIcon
|
||||
:class="[
|
||||
'w-5 h-5',
|
||||
conversionStore.isProcessing && 'animate-spin'
|
||||
]"
|
||||
/>
|
||||
<span>
|
||||
{{ conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Progression -->
|
||||
<section v-if="showProgress" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<ConversionProgress
|
||||
:is-converting="conversionStore.isProcessing"
|
||||
:progress="conversionStore.conversionProgress"
|
||||
:is-success="conversionStore.hasSucceeded"
|
||||
:has-error="conversionStore.hasError"
|
||||
:error-message="conversionStore.conversionError"
|
||||
:file-name="conversionStore.currentFileName"
|
||||
:original-size="conversionStore.currentFile?.size || 0"
|
||||
:converted-size="conversionStore.convertedFile?.size || 0"
|
||||
@download="handleDownload"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Progression et résultat -->
|
||||
<ConversionProgress
|
||||
v-if="showProgress"
|
||||
:is-converting="conversionStore.isProcessing"
|
||||
:progress="conversionStore.conversionProgress"
|
||||
:is-success="conversionStore.hasSucceeded"
|
||||
:has-error="conversionStore.hasError"
|
||||
:error-message="conversionStore.conversionError"
|
||||
:file-name="conversionStore.currentFileName"
|
||||
:original-size="conversionStore.currentFile?.size || 0"
|
||||
:converted-size="conversionStore.convertedFile?.size || 0"
|
||||
@download="handleDownload"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
<!-- Historique -->
|
||||
<section v-if="conversionStore.conversionCount > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Historique</h2>
|
||||
<button
|
||||
@click="conversionStore.clearHistory()"
|
||||
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="(conversion, index) in conversionStore.conversionHistory"
|
||||
:key="index"
|
||||
class="flex items-center justify-between py-3"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{{ conversion.originalName }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(conversion.timestamp) }}</p>
|
||||
</div>
|
||||
<div class="text-right text-sm">
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
||||
</p>
|
||||
<p class="text-xs text-green-600">{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Message d'information -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<InformationCircleIcon class="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">
|
||||
À propos de la conversion
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 space-y-1">
|
||||
<p>• Les fichiers CBZ sont plus largement supportés par les lecteurs de bandes dessinées</p>
|
||||
<p>• La compression ZIP permet généralement une meilleure accessibilité</p>
|
||||
<p>• Aucune perte de qualité lors de la conversion</p>
|
||||
<p>• Taille maximale supportée: 150MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historique des conversions -->
|
||||
<div v-if="conversionStore.conversionCount > 0" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Historique des conversions
|
||||
</h3>
|
||||
<button
|
||||
@click="handleClearHistory"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Effacer l'historique
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(conversion, index) in conversionStore.conversionHistory"
|
||||
:key="index"
|
||||
class="flex items-center justify-between py-2 border-b border-gray-200 last:border-b-0"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ conversion.originalName }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatDate(conversion.timestamp) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
||||
</p>
|
||||
<p class="text-xs text-green-600">
|
||||
{{ calculateSaving(conversion.originalSize, conversion.convertedSize) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast de notification -->
|
||||
<div
|
||||
v-if="conversionStore.showSuccessMessage"
|
||||
class="fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-3 z-50"
|
||||
>
|
||||
<CheckCircleIcon class="w-5 h-5" />
|
||||
<span class="font-medium">Conversion réussie !</span>
|
||||
<button
|
||||
@click="conversionStore.hideSuccessMessage()"
|
||||
class="ml-2 text-green-100 hover:text-white transition-colors"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
ArchiveBoxIcon,
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
InformationCircleIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
<script setup>
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useConversionStore } from '../../application/store/conversionStore';
|
||||
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||
import ConversionProgress from '../components/ConversionProgress.vue';
|
||||
import FileUploadArea from '../components/FileUploadArea.vue';
|
||||
|
||||
export default {
|
||||
name: 'ConversionPage',
|
||||
const conversionStore = useConversionStore();
|
||||
const { showSuccess, showError } = useNotifications();
|
||||
|
||||
components: {
|
||||
FileUploadArea,
|
||||
ConversionProgress,
|
||||
ArrowPathIcon,
|
||||
ArchiveBoxIcon,
|
||||
InformationCircleIcon,
|
||||
CheckCircleIcon,
|
||||
XMarkIcon,
|
||||
},
|
||||
const showProgress = computed(() =>
|
||||
conversionStore.hasSelectedFile &&
|
||||
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError)
|
||||
);
|
||||
|
||||
setup() {
|
||||
const conversionStore = useConversionStore();
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Conversion CBR → CBZ', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
...(conversionStore.hasSelectedFile && !conversionStore.hasSucceeded ? [{
|
||||
type: 'button',
|
||||
icon: ArrowPathIcon,
|
||||
label: conversionStore.isProcessing ? 'Conversion en cours...' : 'Convertir en CBZ',
|
||||
onClick: handleConvert,
|
||||
disabled: conversionStore.isProcessing,
|
||||
}] : []),
|
||||
],
|
||||
}));
|
||||
|
||||
// Computed properties
|
||||
const showProgress = computed(() => {
|
||||
return conversionStore.hasSelectedFile &&
|
||||
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError);
|
||||
});
|
||||
const handleFileSelected = (file) => {
|
||||
conversionStore.selectFile(file);
|
||||
};
|
||||
|
||||
// Event handlers
|
||||
const handleFileSelected = (file) => {
|
||||
const success = conversionStore.selectFile(file);
|
||||
if (!success) {
|
||||
// L'erreur est déjà gérée par le store
|
||||
console.warn('Fichier non valide:', file);
|
||||
}
|
||||
};
|
||||
const handleFileClear = () => {
|
||||
conversionStore.resetConversion();
|
||||
};
|
||||
|
||||
const handleFileClear = () => {
|
||||
conversionStore.resetConversion();
|
||||
};
|
||||
const handleConvert = async () => {
|
||||
if (!conversionStore.currentFile) return;
|
||||
const success = await conversionStore.convertCurrentFile();
|
||||
if (success) {
|
||||
showSuccess('Conversion réussie !');
|
||||
} else {
|
||||
showError(conversionStore.conversionError ?? 'Échec de la conversion');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvert = async () => {
|
||||
if (!conversionStore.currentFile) return;
|
||||
const handleDownload = () => conversionStore.downloadConvertedFile();
|
||||
const handleReset = () => conversionStore.resetConversion();
|
||||
|
||||
const success = await conversionStore.convertCurrentFile();
|
||||
if (success) {
|
||||
console.log('Conversion réussie');
|
||||
} else {
|
||||
console.error('Échec de la conversion');
|
||||
}
|
||||
};
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 octets';
|
||||
const k = 1024;
|
||||
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
conversionStore.downloadConvertedFile();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
conversionStore.resetConversion();
|
||||
};
|
||||
|
||||
const handleClearHistory = () => {
|
||||
conversionStore.clearHistory();
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 octets';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatDate = (isoString) => {
|
||||
const date = new Date(isoString);
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
const formatDate = (isoString) =>
|
||||
new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
}).format(new Date(isoString));
|
||||
|
||||
const calculateSaving = (originalSize, convertedSize) => {
|
||||
if (!originalSize || !convertedSize) return '';
|
||||
|
||||
const saving = ((originalSize - convertedSize) / originalSize) * 100;
|
||||
if (saving > 0) {
|
||||
return `-${saving.toFixed(1)}%`;
|
||||
} else if (saving < 0) {
|
||||
return `+${Math.abs(saving).toFixed(1)}%`;
|
||||
}
|
||||
return '0%';
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Réinitialiser l'état au montage de la page
|
||||
conversionStore.resetConversion();
|
||||
});
|
||||
|
||||
return {
|
||||
conversionStore,
|
||||
showProgress,
|
||||
handleFileSelected,
|
||||
handleFileClear,
|
||||
handleConvert,
|
||||
handleDownload,
|
||||
handleReset,
|
||||
handleClearHistory,
|
||||
formatFileSize,
|
||||
formatDate,
|
||||
calculateSaving,
|
||||
};
|
||||
},
|
||||
const calculateSaving = (originalSize, convertedSize) => {
|
||||
if (!originalSize || !convertedSize) return '';
|
||||
const saving = ((originalSize - convertedSize) / originalSize) * 100;
|
||||
if (saving > 0) return `-${saving.toFixed(1)}%`;
|
||||
if (saving < 0) return `+${Math.abs(saving).toFixed(1)}%`;
|
||||
return '0%';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Styles spécifiques si nécessaires */
|
||||
</style>
|
||||
onMounted(() => conversionStore.resetConversion());
|
||||
</script>
|
||||
|
||||
217
assets/vue/app/domain/import/README.md
Normal file
217
assets/vue/app/domain/import/README.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Domaine Import - Analyse et Import de Fichiers CBZ/CBR
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce domaine permet l'import de fichiers CBZ/CBR dans Mangarr en utilisant l'analyse intelligente de noms de fichiers pour trouver automatiquement les correspondances avec les mangas de la bibliothèque.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Structure des Dossiers
|
||||
|
||||
```
|
||||
domain/import/
|
||||
├── domain/
|
||||
│ └── entities/
|
||||
│ └── FileImport.js # Entité représentant un fichier à importer
|
||||
├── infrastructure/
|
||||
│ └── api/
|
||||
│ └── apiImportRepository.js # Client API
|
||||
├── application/
|
||||
│ └── store/
|
||||
│ └── newImportStore.js # Store Pinia principal
|
||||
└── presentation/
|
||||
├── pages/
|
||||
│ └── NewImportPage.vue # Page principale d'import
|
||||
└── components/
|
||||
├── FileImportCard.vue # Carte de fichier à importer
|
||||
├── ImportResults.vue # Résumé des résultats
|
||||
└── StatusBadge.vue # Badge de statut
|
||||
```
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### 1. Upload de Fichiers
|
||||
|
||||
- **Drag & Drop** : Support du glisser-déposer pour les fichiers CBZ/CBR
|
||||
- **Sélection multiple** : Import de plusieurs fichiers simultanément
|
||||
- **Validation** : Vérification automatique des formats acceptés
|
||||
|
||||
### 2. Analyse Intelligente
|
||||
|
||||
- **Extraction automatique** : Le système analyse le nom de fichier pour extraire :
|
||||
- Le titre du manga
|
||||
- Le numéro de chapitre (si présent)
|
||||
- Le numéro de volume (si présent)
|
||||
|
||||
- **Correspondance automatique** :
|
||||
- Recherche des mangas correspondants dans la bibliothèque
|
||||
- Score de correspondance pour chaque résultat
|
||||
- Sélection automatique du meilleur match
|
||||
|
||||
### 3. Sélection et Validation
|
||||
|
||||
- **Sélection de manga** : Dropdown avec tous les mangas correspondants et leur score
|
||||
- **Prévisualisation** : Affichage de la couverture et des informations du manga sélectionné
|
||||
- **Édition des numéros** : Possibilité de modifier les numéros de chapitre/volume extraits
|
||||
- **Exclusivité** : Un fichier ne peut être importé que comme chapitre OU volume (pas les deux)
|
||||
|
||||
### 4. Import
|
||||
|
||||
- **Import unitaire** : Import fichier par fichier
|
||||
- **Import groupé** : Import de tous les fichiers prêts en une seule fois
|
||||
- **Retry** : Possibilité de réessayer en cas d'erreur
|
||||
- **Suivi en temps réel** : Indicateurs de progression et statuts
|
||||
|
||||
### 5. Résultats
|
||||
|
||||
- **Statistiques** : Nombre de fichiers importés, erreurs, total
|
||||
- **Détails** : Liste des fichiers importés avec leurs associations
|
||||
- **Erreurs** : Affichage détaillé des erreurs pour débogage
|
||||
|
||||
## API Endpoints Utilisés
|
||||
|
||||
### Analyse de fichiers
|
||||
```
|
||||
GET /api/manga-matches?filename={filename}
|
||||
```
|
||||
Retourne :
|
||||
```json
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"id": "string",
|
||||
"title": "string",
|
||||
"slug": "string",
|
||||
"alternativeSlugs": ["string"],
|
||||
"thumbnailUrl": "string",
|
||||
"matchScore": 100
|
||||
}
|
||||
],
|
||||
"chapterNumber": 1.5,
|
||||
"volumeNumber": 2.0,
|
||||
"possibleTitles": ["string"]
|
||||
}
|
||||
```
|
||||
|
||||
### Import de fichier
|
||||
```
|
||||
POST /api/chapters/import
|
||||
```
|
||||
FormData :
|
||||
- `file`: Le fichier CBZ à importer
|
||||
- `mangaId`: ID du manga
|
||||
- `chapterNumber`: Numéro de chapitre (float, optionnel)
|
||||
|
||||
Réponse (200) :
|
||||
```json
|
||||
{
|
||||
"message": "Chapter imported successfully",
|
||||
"mangaId": "uuid",
|
||||
"chapterNumber": 1.5
|
||||
}
|
||||
```
|
||||
|
||||
Erreurs :
|
||||
- `404`: Manga ou Chapitre non trouvé
|
||||
- `422`: Paramètres invalides ou fichier absent
|
||||
- `400`: Fichier CBZ invalide
|
||||
|
||||
### Import de volume (À venir)
|
||||
```
|
||||
POST /api/volumes/import
|
||||
```
|
||||
FormData :
|
||||
- `file`: Le fichier CBZ à importer
|
||||
- `mangaId`: ID du manga
|
||||
- `volumeNumber`: Numéro de volume (int)
|
||||
|
||||
## Store Pinia
|
||||
|
||||
Le store `newImportStore` gère tout l'état de l'application :
|
||||
|
||||
### État
|
||||
- `files`: Liste des fichiers en cours de traitement
|
||||
- `analyzingFiles`: Set des IDs de fichiers en analyse
|
||||
- `importingFiles`: Set des IDs de fichiers en import
|
||||
- `isLoading`: État de chargement global
|
||||
- `globalError`: Erreur globale éventuelle
|
||||
|
||||
### Getters
|
||||
- `pendingFiles`: Fichiers en attente d'analyse
|
||||
- `analyzedFiles`: Fichiers analysés
|
||||
- `readyFiles`: Fichiers prêts pour l'import
|
||||
- `importedFiles`: Fichiers importés avec succès
|
||||
- `errorFiles`: Fichiers en erreur
|
||||
- `hasReadyFiles`: Au moins un fichier prêt
|
||||
- `allFilesProcessed`: Tous les fichiers traités
|
||||
- `progressPercentage`: Pourcentage de progression
|
||||
|
||||
### Actions Principales
|
||||
- `addFiles(fileList)`: Ajoute des fichiers et lance l'analyse automatique
|
||||
- `analyzeFile(fileId)`: Analyse un fichier spécifique
|
||||
- `setFileManga(fileId, manga)`: Définit le manga sélectionné
|
||||
- `setFileChapterNumber(fileId, number)`: Définit le numéro de chapitre
|
||||
- `setFileVolumeNumber(fileId, number)`: Définit le numéro de volume
|
||||
- `importFile(fileId)`: Importe un fichier
|
||||
- `importAllReadyFiles()`: Importe tous les fichiers prêts
|
||||
- `autoSelectBestMatches()`: Sélection automatique des meilleurs matchs
|
||||
- `retryFile(fileId)`: Réessaye l'analyse ou l'import d'un fichier
|
||||
|
||||
## Entité FileImport
|
||||
|
||||
Représente un fichier dans le processus d'import :
|
||||
|
||||
### Propriétés
|
||||
- `file`: Objet File du navigateur
|
||||
- `filename`: Nom du fichier original
|
||||
- `analysis`: Résultat de l'analyse (matches, chapterNumber, volumeNumber)
|
||||
- `selectedManga`: Manga sélectionné par l'utilisateur
|
||||
- `selectedChapterNumber`: Numéro de chapitre (auto ou manuel)
|
||||
- `selectedVolumeNumber`: Numéro de volume (auto ou manuel)
|
||||
- `status`: pending | analyzed | importing | imported | error
|
||||
- `errorMessage`: Message d'erreur le cas échéant
|
||||
|
||||
### Méthodes Utiles
|
||||
- `hasMatches()`: Vérifie si des correspondances ont été trouvées
|
||||
- `getMatches()`: Retourne la liste des correspondances
|
||||
- `getBestMatch()`: Retourne la meilleure correspondance
|
||||
- `isReadyForImport()`: Vérifie si le fichier est prêt à être importé
|
||||
- `getImportData()`: Prépare les données pour l'API d'import
|
||||
|
||||
## Workflow Utilisateur
|
||||
|
||||
1. **Upload**: L'utilisateur glisse-dépose ou sélectionne des fichiers CBZ/CBR
|
||||
2. **Analyse automatique**: Chaque fichier est analysé pour extraire les informations
|
||||
3. **Sélection auto**: Le meilleur match est automatiquement sélectionné
|
||||
4. **Validation**: L'utilisateur peut modifier le manga ou les numéros si nécessaire
|
||||
5. **Import**: Import unitaire ou groupé des fichiers prêts
|
||||
6. **Résultats**: Affichage du résumé avec succès et erreurs
|
||||
|
||||
## Gestion des Erreurs
|
||||
|
||||
### Erreurs d'analyse
|
||||
- Aucun manga trouvé → Message informatif, possibilité de réessayer
|
||||
- Erreur réseau → Message d'erreur, bouton retry disponible
|
||||
|
||||
### Erreurs d'import
|
||||
- Échec d'upload → Fichier marqué en erreur avec message détaillé
|
||||
- Erreur serveur → Fichier en erreur, possibilité de retry
|
||||
|
||||
## Améliorations Futures
|
||||
|
||||
1. **Recherche manuelle** : Permettre la recherche manuelle si aucun match
|
||||
2. **Multi-sélection** : Sélectionner plusieurs fichiers pour actions groupées
|
||||
3. **Historique** : Garder un historique des imports récents
|
||||
4. **Validation avancée** : Vérifier si le chapitre/volume existe déjà
|
||||
5. **Métadonnées** : Extraire et afficher plus de métadonnées des fichiers CBZ
|
||||
|
||||
## Composants Réutilisables
|
||||
|
||||
### Depuis Shared
|
||||
- `FileUpload.vue`: Zone d'upload avec drag & drop
|
||||
- `LoadingSpinner.vue`: Indicateur de chargement
|
||||
|
||||
### Spécifiques au Domaine
|
||||
- `FileImportCard.vue`: Carte complète de gestion d'un fichier
|
||||
- `StatusBadge.vue`: Badge de statut avec couleurs
|
||||
- `ImportResults.vue`: Résumé des résultats d'import
|
||||
316
assets/vue/app/domain/import/application/store/newImportStore.js
Normal file
316
assets/vue/app/domain/import/application/store/newImportStore.js
Normal file
@@ -0,0 +1,316 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||
import { FileImport } from '../../domain/entities/FileImport';
|
||||
import { ApiImportRepository } from '../../infrastructure/api/apiImportRepository';
|
||||
|
||||
const importRepository = new ApiImportRepository();
|
||||
const { showSuccess, showError, showInfo } = useNotifications();
|
||||
|
||||
export const useNewImportStore = defineStore('newImport', {
|
||||
state: () => ({
|
||||
// Files being processed
|
||||
files: [], // Array of FileImport entities
|
||||
|
||||
// Loading states
|
||||
analyzingFiles: new Set(), // File IDs being analyzed
|
||||
importingFiles: new Set(), // File IDs being imported
|
||||
|
||||
// Global states
|
||||
isLoading: false,
|
||||
globalError: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// File status getters
|
||||
pendingFiles: (state) => state.files.filter(f => f.isPending()),
|
||||
analyzedFiles: (state) => state.files.filter(f => f.isAnalyzed()),
|
||||
readyFiles: (state) => state.files.filter(f => f.isReadyForImport()),
|
||||
importedFiles: (state) => state.files.filter(f => f.isImported()),
|
||||
errorFiles: (state) => state.files.filter(f => f.hasError()),
|
||||
|
||||
// Counts
|
||||
totalFiles: (state) => state.files.length,
|
||||
readyCount: (state) => state.files.filter(f => f.isReadyForImport()).length,
|
||||
importedCount: (state) => state.files.filter(f => f.isImported()).length,
|
||||
errorCount: (state) => state.files.filter(f => f.hasError()).length,
|
||||
|
||||
// Status helpers
|
||||
hasFiles: (state) => state.files.length > 0,
|
||||
hasReadyFiles: (state) => state.files.some(f => f.isReadyForImport()),
|
||||
allFilesProcessed: (state) => {
|
||||
return state.files.length > 0 &&
|
||||
state.files.every(f => f.isImported() || f.hasError());
|
||||
},
|
||||
|
||||
// Progress
|
||||
progressPercentage: (state) => {
|
||||
if (state.files.length === 0) return 0;
|
||||
const processed = state.files.filter(f => f.isImported() || f.hasError()).length;
|
||||
return Math.round((processed / state.files.length) * 100);
|
||||
},
|
||||
|
||||
// Specific file finders
|
||||
getFileById: (state) => (id) => {
|
||||
return state.files.find(f => f.id === id);
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// === FILE MANAGEMENT ===
|
||||
|
||||
/**
|
||||
* Add files to the import queue
|
||||
*/
|
||||
addFiles(fileList) {
|
||||
const validFiles = Array.from(fileList).filter(file => {
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
return ['cbz', 'cbr'].includes(extension);
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
showError('Aucun fichier CBZ/CBR valide sélectionné');
|
||||
return;
|
||||
}
|
||||
|
||||
const newFiles = validFiles.map(file => FileImport.create(file));
|
||||
this.files.push(...newFiles);
|
||||
|
||||
showInfo(`${newFiles.length} fichier(s) ajouté(s) à la queue d'import`);
|
||||
|
||||
// Auto-analyze all new files
|
||||
this.analyzeAllPendingFiles();
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a file from the queue
|
||||
*/
|
||||
removeFile(fileId) {
|
||||
const index = this.files.findIndex(f => f.id === fileId);
|
||||
if (index !== -1) {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all files
|
||||
*/
|
||||
clearFiles() {
|
||||
this.files = [];
|
||||
this.analyzingFiles.clear();
|
||||
this.importingFiles.clear();
|
||||
this.globalError = null;
|
||||
},
|
||||
|
||||
// === ANALYSIS ACTIONS ===
|
||||
|
||||
/**
|
||||
* Analyze all pending files
|
||||
*/
|
||||
async analyzeAllPendingFiles() {
|
||||
const pendingFiles = this.pendingFiles;
|
||||
if (pendingFiles.length === 0) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
await Promise.all(
|
||||
pendingFiles.map(file => this.analyzeFile(file.id))
|
||||
);
|
||||
showSuccess(`${pendingFiles.length} fichier(s) analysé(s) avec succès`);
|
||||
} catch (error) {
|
||||
console.error('Error analyzing files:', error);
|
||||
this.globalError = 'Erreur lors de l\'analyse des fichiers';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Analyze a specific file
|
||||
*/
|
||||
async analyzeFile(fileId) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex === -1) return;
|
||||
|
||||
const file = this.files[fileIndex];
|
||||
if (!file.isPending()) return;
|
||||
|
||||
this.analyzingFiles.add(fileId);
|
||||
|
||||
try {
|
||||
const analysis = await importRepository.analyzeFilename(file.filename);
|
||||
file.setAnalysis(analysis);
|
||||
|
||||
// Force reactivity by replacing the object in the array
|
||||
this.files[fileIndex] = file;
|
||||
|
||||
if (!file.hasMatches()) {
|
||||
showError(`Aucun manga trouvé pour le fichier: ${file.filename}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error analyzing file ${file.filename}:`, error);
|
||||
file.setError(`Erreur d'analyse: ${error.message}`);
|
||||
this.files[fileIndex] = file;
|
||||
showError(`Erreur lors de l'analyse de ${file.filename}`);
|
||||
} finally {
|
||||
this.analyzingFiles.delete(fileId);
|
||||
}
|
||||
},
|
||||
|
||||
// === SELECTION ACTIONS ===
|
||||
|
||||
/**
|
||||
* Update manga selection for a file
|
||||
*/
|
||||
setFileManga(fileId, manga) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex !== -1) {
|
||||
this.files[fileIndex].setSelectedManga(manga);
|
||||
// Force reactivity
|
||||
this.files[fileIndex] = this.files[fileIndex];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update chapter number for a file
|
||||
*/
|
||||
setFileChapterNumber(fileId, chapterNumber) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex !== -1) {
|
||||
this.files[fileIndex].setSelectedChapterNumber(chapterNumber);
|
||||
// Force reactivity
|
||||
this.files[fileIndex] = this.files[fileIndex];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update volume number for a file
|
||||
*/
|
||||
setFileVolumeNumber(fileId, volumeNumber) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex !== -1) {
|
||||
this.files[fileIndex].setSelectedVolumeNumber(volumeNumber);
|
||||
// Force reactivity
|
||||
this.files[fileIndex] = this.files[fileIndex];
|
||||
}
|
||||
},
|
||||
|
||||
// === IMPORT ACTIONS ===
|
||||
|
||||
/**
|
||||
* Import all ready files
|
||||
*/
|
||||
async importAllReadyFiles() {
|
||||
const readyFiles = this.readyFiles;
|
||||
if (readyFiles.length === 0) {
|
||||
showError('Aucun fichier prêt pour l\'import');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
try {
|
||||
for (const file of readyFiles) {
|
||||
try {
|
||||
await this.importFile(file.id);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
console.error(`Failed to import file ${file.filename}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
showSuccess(`${successCount} fichier(s) importé(s) avec succès`);
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
showError(`${errorCount} fichier(s) ont échoué lors de l'import`);
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Import a specific file
|
||||
*/
|
||||
async importFile(fileId) {
|
||||
const file = this.getFileById(fileId);
|
||||
if (!file || !file.isReadyForImport()) {
|
||||
throw new Error('File is not ready for import');
|
||||
}
|
||||
|
||||
this.importingFiles.add(fileId);
|
||||
file.setImporting();
|
||||
|
||||
try {
|
||||
const importData = file.getImportData();
|
||||
await importRepository.importFile(
|
||||
file.file,
|
||||
importData.mangaId,
|
||||
importData.chapterNumber,
|
||||
importData.volumeNumber
|
||||
);
|
||||
|
||||
file.setImported();
|
||||
showSuccess(`Fichier ${file.filename} importé avec succès`);
|
||||
} catch (error) {
|
||||
console.error(`Error importing file ${file.filename}:`, error);
|
||||
file.setError(`Erreur d'import: ${error.message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.importingFiles.delete(fileId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retry import for a failed file
|
||||
*/
|
||||
async retryFile(fileId) {
|
||||
const file = this.getFileById(fileId);
|
||||
if (!file) return;
|
||||
|
||||
if (file.hasError() && file.selectedManga) {
|
||||
// If the file had an import error but has selections, retry import
|
||||
await this.importFile(fileId);
|
||||
} else {
|
||||
// If the file had an analysis error, retry analysis
|
||||
file.status = 'pending';
|
||||
file.errorMessage = null;
|
||||
await this.analyzeFile(fileId);
|
||||
}
|
||||
},
|
||||
|
||||
// === UTILITY ACTIONS ===
|
||||
|
||||
/**
|
||||
* Auto-select best matches for all files
|
||||
*/
|
||||
autoSelectBestMatches() {
|
||||
let selectedCount = 0;
|
||||
|
||||
this.analyzedFiles.forEach(file => {
|
||||
const bestMatch = file.getBestMatch();
|
||||
if (bestMatch) {
|
||||
file.setSelectedManga(bestMatch);
|
||||
selectedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedCount > 0) {
|
||||
showInfo(`${selectedCount} correspondance(s) automatique(s) effectuée(s)`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset global state
|
||||
*/
|
||||
resetGlobalState() {
|
||||
this.globalError = null;
|
||||
this.isLoading = false;
|
||||
this.analyzingFiles.clear();
|
||||
this.importingFiles.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
200
assets/vue/app/domain/import/domain/entities/FileImport.js
Normal file
200
assets/vue/app/domain/import/domain/entities/FileImport.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Entité représentant un fichier en cours d'import avec ses correspondances possibles
|
||||
*/
|
||||
export class FileImport {
|
||||
constructor({
|
||||
file, // File object from browser
|
||||
filename, // Original filename
|
||||
analysis = null, // Result from /api/manga-matches endpoint
|
||||
selectedManga = null, // Selected manga match
|
||||
selectedChapterNumber = null, // Selected chapter number (extracted from filename)
|
||||
selectedVolumeNumber = null, // Selected volume number (extracted from filename)
|
||||
status = 'pending', // 'pending', 'analyzed', 'importing', 'imported', 'error'
|
||||
errorMessage = null,
|
||||
importedAt = null
|
||||
}) {
|
||||
this.file = file;
|
||||
this.filename = filename;
|
||||
this.analysis = analysis;
|
||||
this.selectedManga = selectedManga;
|
||||
this.selectedChapterNumber = selectedChapterNumber;
|
||||
this.selectedVolumeNumber = selectedVolumeNumber;
|
||||
this.status = status;
|
||||
this.errorMessage = errorMessage;
|
||||
this.importedAt = importedAt;
|
||||
this.id = this._generateId();
|
||||
}
|
||||
|
||||
static create(file) {
|
||||
return new FileImport({
|
||||
file,
|
||||
filename: file.name
|
||||
});
|
||||
}
|
||||
|
||||
_generateId() {
|
||||
return `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Status helpers
|
||||
isPending() {
|
||||
return this.status === 'pending';
|
||||
}
|
||||
|
||||
isAnalyzed() {
|
||||
return this.status === 'analyzed';
|
||||
}
|
||||
|
||||
isImporting() {
|
||||
return this.status === 'importing';
|
||||
}
|
||||
|
||||
isImported() {
|
||||
return this.status === 'imported';
|
||||
}
|
||||
|
||||
hasError() {
|
||||
return this.status === 'error';
|
||||
}
|
||||
|
||||
// Analysis helpers
|
||||
hasMatches() {
|
||||
return this.analysis && this.analysis.matches && this.analysis.matches.length > 0;
|
||||
}
|
||||
|
||||
getMatches() {
|
||||
return this.analysis?.matches || [];
|
||||
}
|
||||
|
||||
getBestMatch() {
|
||||
const matches = this.getMatches();
|
||||
// Sort by matchScore (highest first) and return the best one
|
||||
return matches.length > 0 ? matches.sort((a, b) => b.matchScore - a.matchScore)[0] : null;
|
||||
}
|
||||
|
||||
// Analysis extracted data
|
||||
getExtractedChapterNumber() {
|
||||
return this.analysis?.chapterNumber || null;
|
||||
}
|
||||
|
||||
getExtractedVolumeNumber() {
|
||||
return this.analysis?.volumeNumber || null;
|
||||
}
|
||||
|
||||
// Selection helpers
|
||||
isReadyForImport() {
|
||||
// Ready if a manga is selected and at least chapter or volume number is set
|
||||
return this.selectedManga && (this.selectedChapterNumber !== null || this.selectedVolumeNumber !== null);
|
||||
}
|
||||
|
||||
getImportType() {
|
||||
if (this.selectedChapterNumber !== null) return 'chapter';
|
||||
if (this.selectedVolumeNumber !== null) return 'volume';
|
||||
return null;
|
||||
}
|
||||
|
||||
// File helpers
|
||||
getFormattedSize() {
|
||||
if (!this.file || !this.file.size) return 'Unknown';
|
||||
|
||||
const bytes = this.file.size;
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
getFileExtension() {
|
||||
const extension = this.filename.split('.').pop().toLowerCase();
|
||||
return extension;
|
||||
}
|
||||
|
||||
isValidFormat() {
|
||||
const validExtensions = ['cbz', 'cbr'];
|
||||
return validExtensions.includes(this.getFileExtension());
|
||||
}
|
||||
|
||||
// Update methods
|
||||
setAnalysis(analysis) {
|
||||
this.analysis = analysis;
|
||||
this.status = 'analyzed';
|
||||
|
||||
// Auto-set extracted chapter/volume numbers from analysis
|
||||
if (analysis.chapterNumber !== null && analysis.chapterNumber !== undefined) {
|
||||
this.selectedChapterNumber = analysis.chapterNumber;
|
||||
}
|
||||
if (analysis.volumeNumber !== null && analysis.volumeNumber !== undefined) {
|
||||
this.selectedVolumeNumber = analysis.volumeNumber;
|
||||
}
|
||||
|
||||
// Auto-select best match if available
|
||||
const bestMatch = this.getBestMatch();
|
||||
if (bestMatch) {
|
||||
this.selectedManga = bestMatch;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedManga(manga) {
|
||||
this.selectedManga = manga;
|
||||
// Keep the chapter/volume numbers from analysis
|
||||
}
|
||||
|
||||
setSelectedChapterNumber(chapterNumber) {
|
||||
this.selectedChapterNumber = chapterNumber;
|
||||
// If setting chapter, clear volume
|
||||
if (chapterNumber !== null) {
|
||||
this.selectedVolumeNumber = null;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedVolumeNumber(volumeNumber) {
|
||||
this.selectedVolumeNumber = volumeNumber;
|
||||
// If setting volume, clear chapter
|
||||
if (volumeNumber !== null) {
|
||||
this.selectedChapterNumber = null;
|
||||
}
|
||||
}
|
||||
|
||||
setImporting() {
|
||||
this.status = 'importing';
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setImported() {
|
||||
this.status = 'imported';
|
||||
this.importedAt = new Date().toISOString();
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setError(message) {
|
||||
this.status = 'error';
|
||||
this.errorMessage = message;
|
||||
}
|
||||
|
||||
// Export selection for API
|
||||
getImportData() {
|
||||
if (!this.isReadyForImport()) {
|
||||
throw new Error('File is not ready for import');
|
||||
}
|
||||
|
||||
const data = {
|
||||
mangaId: this.selectedManga.id
|
||||
};
|
||||
|
||||
if (this.selectedChapterNumber !== null) {
|
||||
data.chapterNumber = this.selectedChapterNumber;
|
||||
}
|
||||
|
||||
if (this.selectedVolumeNumber !== null) {
|
||||
data.volumeNumber = this.selectedVolumeNumber;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
239
assets/vue/app/domain/import/domain/entities/ImportFile.js
Normal file
239
assets/vue/app/domain/import/domain/entities/ImportFile.js
Normal file
@@ -0,0 +1,239 @@
|
||||
export class ImportFile {
|
||||
constructor({
|
||||
id,
|
||||
originalName,
|
||||
fileSize,
|
||||
extension,
|
||||
status = 'pending',
|
||||
createdAt,
|
||||
metadata = null,
|
||||
mangaMatches = [],
|
||||
selectedMangaSlug = null,
|
||||
selectedVolume = null,
|
||||
selectedChapter = null,
|
||||
errorMessage = null,
|
||||
processedAt = null,
|
||||
// New properties for simplified workflow
|
||||
file = null, // Browser File object
|
||||
analysis = null, // Analysis result from API
|
||||
selectedManga = null, // Selected manga object
|
||||
selectedChapterId = null // Selected chapter ID
|
||||
}) {
|
||||
this.id = id;
|
||||
this.originalName = originalName;
|
||||
this.fileSize = fileSize;
|
||||
this.extension = extension;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.metadata = metadata;
|
||||
this.mangaMatches = mangaMatches;
|
||||
this.selectedMangaSlug = selectedMangaSlug;
|
||||
this.selectedVolume = selectedVolume;
|
||||
this.selectedChapter = selectedChapter;
|
||||
this.errorMessage = errorMessage;
|
||||
this.processedAt = processedAt;
|
||||
|
||||
// New properties
|
||||
this.file = file;
|
||||
this.analysis = analysis;
|
||||
this.selectedManga = selectedManga;
|
||||
this.selectedChapterId = selectedChapterId;
|
||||
this.mangaMatches = mangaMatches; // Store found manga matches
|
||||
}
|
||||
|
||||
static create(data) {
|
||||
return new ImportFile({
|
||||
...data,
|
||||
createdAt: data.createdAt || new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Create from browser File object
|
||||
static createFromFile(file) {
|
||||
return new ImportFile({
|
||||
id: `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
originalName: file.name,
|
||||
fileSize: file.size,
|
||||
extension: file.name.split('.').pop().toLowerCase(),
|
||||
file: file,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
isProcessed() {
|
||||
return this.status === 'processed';
|
||||
}
|
||||
|
||||
hasError() {
|
||||
return this.status === 'error';
|
||||
}
|
||||
|
||||
isPending() {
|
||||
return this.status === 'pending';
|
||||
}
|
||||
|
||||
needsConversion() {
|
||||
return this.extension === 'cbr';
|
||||
}
|
||||
|
||||
isReadyForImport() {
|
||||
return this.isProcessed() && this.selectedMangaSlug && (this.selectedVolume || this.selectedChapter);
|
||||
}
|
||||
|
||||
getFormattedSize() {
|
||||
const bytes = parseInt(this.fileSize);
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
getContentType() {
|
||||
if (this.metadata?.chapter) {
|
||||
return `Chapter ${this.metadata.chapter}`;
|
||||
}
|
||||
if (this.metadata?.volume) {
|
||||
return `Volume ${this.metadata.volume}`;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// === NEW METHODS FOR SIMPLIFIED WORKFLOW ===
|
||||
|
||||
// Status helpers for new workflow
|
||||
isAnalyzed() {
|
||||
return this.status === 'analyzed';
|
||||
}
|
||||
|
||||
isImporting() {
|
||||
return this.status === 'importing';
|
||||
}
|
||||
|
||||
isImported() {
|
||||
return this.status === 'imported';
|
||||
}
|
||||
|
||||
// Analysis helpers
|
||||
hasAnalysis() {
|
||||
return this.analysis && this.analysis.possibleTitles && this.analysis.possibleTitles.length > 0;
|
||||
}
|
||||
|
||||
getPossibleTitles() {
|
||||
return this.analysis?.possibleTitles || [];
|
||||
}
|
||||
|
||||
getAnalyzedChapter() {
|
||||
return this.analysis?.chapterNumber || null;
|
||||
}
|
||||
|
||||
getAnalyzedVolume() {
|
||||
return this.analysis?.volumeNumber || null;
|
||||
}
|
||||
|
||||
// For backward compatibility with existing code
|
||||
hasMatches() {
|
||||
return this.mangaMatches && this.mangaMatches.length > 0;
|
||||
}
|
||||
|
||||
getMatches() {
|
||||
return this.mangaMatches || [];
|
||||
}
|
||||
|
||||
getBestMatch() {
|
||||
const matches = this.getMatches();
|
||||
return matches.length > 0 ? matches[0] : null;
|
||||
}
|
||||
|
||||
// Selection helpers
|
||||
isReadyForNewImport() {
|
||||
return this.selectedManga && (this.selectedChapterId || this.selectedVolume !== null);
|
||||
}
|
||||
|
||||
getImportType() {
|
||||
if (this.selectedChapterId) return 'chapter';
|
||||
if (this.selectedVolume !== null) return 'volume';
|
||||
return null;
|
||||
}
|
||||
|
||||
// File validation
|
||||
isValidFormat() {
|
||||
const validExtensions = ['cbz', 'cbr'];
|
||||
return validExtensions.includes(this.extension);
|
||||
}
|
||||
|
||||
// Update methods for new workflow
|
||||
setAnalysis(analysis) {
|
||||
this.analysis = analysis;
|
||||
this.status = 'analyzed';
|
||||
}
|
||||
|
||||
setMangaMatches(matches) {
|
||||
this.mangaMatches = matches;
|
||||
|
||||
// Auto-select best match if available
|
||||
const bestMatch = this.getBestMatch();
|
||||
if (bestMatch) {
|
||||
this.selectedManga = bestMatch;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedManga(manga) {
|
||||
this.selectedManga = manga;
|
||||
// Reset chapter/volume selection when manga changes
|
||||
this.selectedChapterId = null;
|
||||
this.selectedVolume = null;
|
||||
}
|
||||
|
||||
setSelectedChapterById(chapterId) {
|
||||
this.selectedChapterId = chapterId;
|
||||
this.selectedVolume = null; // Can't have both
|
||||
}
|
||||
|
||||
setSelectedVolumeNumber(volumeNumber) {
|
||||
this.selectedVolume = volumeNumber;
|
||||
this.selectedChapterId = null; // Can't have both
|
||||
}
|
||||
|
||||
setImporting() {
|
||||
this.status = 'importing';
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setImported() {
|
||||
this.status = 'imported';
|
||||
this.processedAt = new Date().toISOString();
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setError(message) {
|
||||
this.status = 'error';
|
||||
this.errorMessage = message;
|
||||
}
|
||||
|
||||
// Export selection for API
|
||||
getImportData() {
|
||||
if (!this.isReadyForNewImport()) {
|
||||
throw new Error('File is not ready for import');
|
||||
}
|
||||
|
||||
const data = {
|
||||
mangaId: this.selectedManga.id
|
||||
};
|
||||
|
||||
if (this.selectedChapterId) {
|
||||
data.chapterId = this.selectedChapterId;
|
||||
}
|
||||
|
||||
if (this.selectedVolume !== null) {
|
||||
data.volumeNumber = this.selectedVolume;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
export class ApiImportRepository {
|
||||
/**
|
||||
* Analyse le nom d'un fichier et trouve les mangas correspondants
|
||||
* @param {string} filename - Nom du fichier à analyser
|
||||
* @returns {Promise<Object>} - Résultat de l'analyse avec les correspondances
|
||||
*/
|
||||
async analyzeFilename(filename) {
|
||||
try {
|
||||
console.log('Analyzing filename:', filename);
|
||||
const response = await fetch(`/api/manga-matches?filename=${encodeURIComponent(filename)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Analyze filename failed:', response.status, errorText);
|
||||
throw new Error(`Failed to analyze filename: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Analyze result:', result);
|
||||
|
||||
// Extract chapter and volume numbers from the first match if available
|
||||
const firstMatch = result.matches && result.matches.length > 0 ? result.matches[0] : null;
|
||||
const chapterNumber = firstMatch?.chapterNumber ?? null;
|
||||
const volumeNumber = firstMatch?.volumeNumber ?? null;
|
||||
|
||||
return {
|
||||
matches: result.matches || [],
|
||||
chapterNumber,
|
||||
volumeNumber,
|
||||
possibleTitles: result.possibleTitles || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les détails d'un manga par son slug
|
||||
* @param {string} slug - Slug du manga
|
||||
* @returns {Promise<Object>} - Détails du manga avec chapitres et volumes
|
||||
*/
|
||||
async getMangaDetails(slug) {
|
||||
try {
|
||||
console.log('Fetching manga details for:', slug);
|
||||
const response = await fetch(`/api/mangas/${slug}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Get manga details failed:', response.status, errorText);
|
||||
throw new Error(`Failed to get manga details: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload et import d'un fichier avec les informations du manga
|
||||
* @param {File} file - Fichier à uploader
|
||||
* @param {string} mangaId - ID du manga
|
||||
* @param {number|null} chapterNumber - Numéro du chapitre (optionnel)
|
||||
* @param {number|null} volumeNumber - Numéro du volume (optionnel)
|
||||
* @returns {Promise<Object>} - Résultat de l'import
|
||||
*/
|
||||
async importFile(file, mangaId, chapterNumber = null, volumeNumber = null) {
|
||||
try {
|
||||
// Déterminer s'il s'agit d'un import de chapitre ou volume
|
||||
if (chapterNumber !== null && chapterNumber !== undefined) {
|
||||
return await this.importChapter(file, mangaId, chapterNumber);
|
||||
} else if (volumeNumber !== null && volumeNumber !== undefined) {
|
||||
return await this.importVolume(file, mangaId, volumeNumber);
|
||||
} else {
|
||||
throw new Error('Either chapterNumber or volumeNumber must be provided');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import d'un chapitre
|
||||
* @param {File} file - Fichier CBZ à uploader
|
||||
* @param {string} mangaId - ID du manga
|
||||
* @param {number} chapterNumber - Numéro du chapitre
|
||||
* @returns {Promise<Object>} - Résultat de l'import
|
||||
*/
|
||||
async importChapter(file, mangaId, chapterNumber) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('mangaId', mangaId);
|
||||
formData.append('chapterNumber', chapterNumber.toString());
|
||||
|
||||
console.log('Importing chapter:', chapterNumber, 'for manga:', mangaId);
|
||||
const response = await fetch('/api/chapters/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Import failed:', response.status, errorText);
|
||||
|
||||
// Parse the error response if it's JSON
|
||||
let errorMessage = `Failed to import chapter: ${response.status}`;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
errorMessage = errorJson.error || errorJson.details || errorMessage;
|
||||
} catch (e) {
|
||||
// Not JSON, use the status message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Import result:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import d'un volume (TODO: À implémenter)
|
||||
* @param {File} file - Fichier CBZ à uploader
|
||||
* @param {string} mangaId - ID du manga
|
||||
* @param {number} volumeNumber - Numéro du volume
|
||||
* @returns {Promise<Object>} - Résultat de l'import
|
||||
*/
|
||||
async importVolume(file, mangaId, volumeNumber) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('mangaId', mangaId);
|
||||
formData.append('volumeNumber', volumeNumber.toString());
|
||||
|
||||
console.log('Importing volume:', volumeNumber, 'for manga:', mangaId);
|
||||
const response = await fetch('/api/volumes/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Import failed:', response.status, errorText);
|
||||
|
||||
// Parse the error response if it's JSON
|
||||
let errorMessage = `Failed to import volume: ${response.status}`;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
errorMessage = errorJson.error || errorJson.details || errorMessage;
|
||||
} catch (e) {
|
||||
// Not JSON, use the status message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Import result:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="py-3">
|
||||
|
||||
<!-- Row principal : icône, nom, statut, actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||
<DocumentIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ file.getFormattedSize() }} · {{ file.getFileExtension().toUpperCase() }}
|
||||
<span v-if="file.isAnalyzed() && file.getExtractedChapterNumber()" class="ml-2 text-green-600 dark:text-green-400">
|
||||
Ch. {{ file.getExtractedChapterNumber() }}
|
||||
</span>
|
||||
<span v-if="file.isAnalyzed() && file.getExtractedVolumeNumber()" class="ml-2 text-green-600 dark:text-green-400">
|
||||
Vol. {{ file.getExtractedVolumeNumber() }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
||||
|
||||
<button
|
||||
v-if="file.isReadyForImport()"
|
||||
@click="$emit('import-file')"
|
||||
:disabled="isImporting"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
<ArrowUpTrayIcon class="w-3.5 h-3.5" />
|
||||
Importer
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="file.hasError()"
|
||||
@click="$emit('retry-file')"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="$emit('remove-file')"
|
||||
class="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<div v-if="file.hasError()" class="mt-2 flex items-start gap-2 text-xs text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-3 py-2">
|
||||
<ExclamationCircleIcon class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
{{ file.errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Aucun manga trouvé -->
|
||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-2 flex items-start gap-2 text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-3 py-2">
|
||||
<ExclamationTriangleIcon class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
Aucun manga correspondant trouvé. Vérifiez le nom du fichier.
|
||||
</div>
|
||||
|
||||
<!-- Sélection du manga -->
|
||||
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-3 space-y-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{{ file.getMatches().length }} correspondance(s)
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||
<MangaMatchCard
|
||||
v-for="match in sortedMatches"
|
||||
:key="match.id"
|
||||
:match="match"
|
||||
:is-selected="file.selectedManga?.id === match.id"
|
||||
@select-match="handleMangaSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Numéros de chapitre / volume -->
|
||||
<div v-if="file.selectedManga" class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Chapitre</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedChapterNumber ?? ''"
|
||||
@input="handleChapterNumberInput"
|
||||
:disabled="file.selectedVolumeNumber !== null"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:text-gray-400"
|
||||
placeholder="Ex: 1, 1.5..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Volume</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedVolumeNumber ?? ''"
|
||||
@input="handleVolumeNumberInput"
|
||||
:disabled="file.selectedChapterNumber !== null"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:text-gray-400"
|
||||
placeholder="Ex: 1, 1.5..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowUpTrayIcon, DocumentIcon, ExclamationCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed } from 'vue';
|
||||
import MangaMatchCard from './MangaMatchCard.vue';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
file: { type: Object, required: true },
|
||||
isAnalyzing: { type: Boolean, default: false },
|
||||
isImporting: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'manga-selected',
|
||||
'chapter-number-selected',
|
||||
'volume-number-selected',
|
||||
'import-file',
|
||||
'retry-file',
|
||||
'remove-file',
|
||||
]);
|
||||
|
||||
const sortedMatches = computed(() =>
|
||||
[...props.file.getMatches()].sort((a, b) => b.matchScore - a.matchScore)
|
||||
);
|
||||
|
||||
const handleMangaSelection = (manga) => emit('manga-selected', manga);
|
||||
|
||||
const handleChapterNumberInput = (event) => {
|
||||
const value = event.target.value;
|
||||
emit('chapter-number-selected', value ? parseFloat(value) : null);
|
||||
};
|
||||
|
||||
const handleVolumeNumberInput = (event) => {
|
||||
const value = event.target.value;
|
||||
emit('volume-number-selected', value ? parseFloat(value) : null);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tête -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center h-9 w-9 bg-green-100 dark:bg-green-900/40">
|
||||
<CheckCircleIcon class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">Import terminé</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Voici le résumé de votre session d'import</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-6 text-center">
|
||||
<div>
|
||||
<div class="text-xl font-bold text-green-600">{{ importedCount }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Importés</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-bold text-red-600">{{ errorCount }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Erreurs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-bold text-gray-600 dark:text-gray-300">{{ totalCount }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Fichiers importés -->
|
||||
<section v-if="importedFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||
Importés ({{ importedFiles.length }})
|
||||
</h2>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="file in importedFiles"
|
||||
:key="file.id"
|
||||
class="flex items-center gap-2 py-2.5 text-sm"
|
||||
>
|
||||
<CheckCircleIcon class="flex-shrink-0 h-4 w-4 text-green-400" />
|
||||
<span class="text-gray-900 dark:text-gray-100 truncate">{{ file.filename }}</span>
|
||||
<span v-if="file.selectedManga" class="text-gray-400 dark:text-gray-500 shrink-0">→ {{ file.selectedManga.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Fichiers en erreur -->
|
||||
<section v-if="errorFiles.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||
Erreurs ({{ errorFiles.length }})
|
||||
</h2>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="file in errorFiles"
|
||||
:key="file.id"
|
||||
class="flex items-start gap-2 py-2.5 text-sm"
|
||||
>
|
||||
<XCircleIcon class="flex-shrink-0 h-4 w-4 text-red-400 mt-0.5" />
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div>
|
||||
<div class="text-red-600 dark:text-red-400 text-xs mt-0.5">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="startNewImport"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Nouvel import
|
||||
</button>
|
||||
<button
|
||||
@click="goToLibrary"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Aller à la bibliothèque
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/solid';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useNewImportStore();
|
||||
|
||||
const importedFiles = computed(() => store.importedFiles);
|
||||
const errorFiles = computed(() => store.errorFiles);
|
||||
const importedCount = computed(() => store.importedCount);
|
||||
const errorCount = computed(() => store.errorCount);
|
||||
const totalCount = computed(() => store.totalFiles);
|
||||
|
||||
const startNewImport = () => {
|
||||
store.clearFiles();
|
||||
};
|
||||
|
||||
const goToLibrary = () => {
|
||||
router.push({ name: 'manga-collection' });
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div
|
||||
class="border p-2.5 cursor-pointer transition-all duration-150"
|
||||
:class="isSelected
|
||||
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800'"
|
||||
@click="$emit('select-match', match)"
|
||||
>
|
||||
<div class="flex gap-2.5">
|
||||
<!-- Couverture -->
|
||||
<img
|
||||
v-if="match.thumbnailUrl"
|
||||
:src="match.thumbnailUrl"
|
||||
:alt="match.title"
|
||||
class="w-12 h-16 object-cover shrink-0"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-12 h-16 bg-gray-100 dark:bg-gray-700 shrink-0 flex items-center justify-center"
|
||||
>
|
||||
<PhotoIcon class="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Infos -->
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-between py-0.5">
|
||||
<p class="text-xs font-medium text-gray-900 dark:text-gray-100 line-clamp-3 leading-snug" :title="match.title">
|
||||
{{ match.title }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ match.matchScore }}%</span>
|
||||
<CheckCircleIcon v-if="isSelected" class="w-4 h-4 text-green-500 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckCircleIcon, PhotoIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const props = defineProps({
|
||||
match: { type: Object, required: true },
|
||||
isSelected: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-match']);
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="manga-option">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div v-if="manga.coverUrl" class="flex-shrink-0">
|
||||
<img
|
||||
:src="manga.coverUrl"
|
||||
:alt="manga.title"
|
||||
class="w-12 h-16 object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex-shrink-0 w-12 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 truncate">
|
||||
{{ manga.title }}
|
||||
</h4>
|
||||
<div class="text-xs text-gray-500 space-y-1">
|
||||
<p v-if="manga.author" class="truncate">
|
||||
{{ manga.author }}
|
||||
</p>
|
||||
<p v-if="manga.publicationYear" class="truncate">
|
||||
{{ manga.publicationYear }}
|
||||
</p>
|
||||
<div v-if="manga.genres && manga.genres.length > 0" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="genre in manga.genres.slice(0, 3)"
|
||||
:key="genre"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{{ genre }}
|
||||
</span>
|
||||
<span v-if="manga.genres.length > 3" class="text-xs text-gray-400">
|
||||
+{{ manga.genres.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
manga: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center">
|
||||
<!-- Loading Spinner for analyzing/importing -->
|
||||
<svg v-if="isAnalyzing || isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4" 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>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<span :class="badgeClasses">
|
||||
{{ badgeText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isAnalyzing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isImporting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const badgeText = computed(() => {
|
||||
if (props.isImporting) return 'Import en cours...';
|
||||
if (props.isAnalyzing) return 'Analyse en cours...';
|
||||
|
||||
switch (props.status) {
|
||||
case 'pending': return 'En attente';
|
||||
case 'analyzed': return 'Analysé';
|
||||
case 'importing': return 'Import en cours';
|
||||
case 'imported': return 'Importé';
|
||||
case 'error': return 'Erreur';
|
||||
default: return 'Inconnu';
|
||||
}
|
||||
});
|
||||
|
||||
const badgeClasses = computed(() => {
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 text-xs font-medium';
|
||||
|
||||
if (props.isImporting || props.isAnalyzing) {
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
}
|
||||
|
||||
switch (props.status) {
|
||||
case 'pending':
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
|
||||
case 'analyzed':
|
||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
|
||||
case 'importing':
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
case 'imported':
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
case 'error':
|
||||
return `${baseClasses} bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<!-- Zone de dépôt -->
|
||||
<section v-if="!store.hasFiles" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Fichiers</h2>
|
||||
<FileUpload
|
||||
label="Importer des fichiers CBZ/CBR"
|
||||
accept=".cbz,.cbr"
|
||||
:multiple="true"
|
||||
description="Formats CBZ ou CBR uniquement"
|
||||
@files-selected="store.addFiles($event)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Fichiers en cours -->
|
||||
<template v-if="store.hasFiles && !store.allFilesProcessed">
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
{{ store.totalFiles }} fichier(s)
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ store.importedCount }}/{{ store.totalFiles }}
|
||||
<span v-if="store.errorCount > 0" class="text-red-500 ml-1">· {{ store.errorCount }} erreur(s)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-1.5 mb-4">
|
||||
<div
|
||||
class="bg-green-600 h-1.5 transition-all duration-300"
|
||||
:style="{ width: store.progressPercentage + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<FileImportCard
|
||||
v-for="file in store.files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:is-analyzing="store.analyzingFiles.has(file.id)"
|
||||
:is-importing="store.importingFiles.has(file.id)"
|
||||
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
|
||||
@chapter-number-selected="(n) => store.setFileChapterNumber(file.id, n)"
|
||||
@volume-number-selected="(n) => store.setFileVolumeNumber(file.id, n)"
|
||||
@import-file="() => importSingleFile(file.id)"
|
||||
@retry-file="() => retryFile(file.id)"
|
||||
@remove-file="() => store.removeFile(file.id)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- Résultats -->
|
||||
<ImportResults v-if="store.allFilesProcessed" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowUpTrayIcon, SparklesIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, onUnmounted } from 'vue';
|
||||
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||
import FileImportCard from '../components/FileImportCard.vue';
|
||||
import ImportResults from '../components/ImportResults.vue';
|
||||
|
||||
const store = useNewImportStore();
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Import de bibliothèque', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
...(store.analyzedFiles.length > 0 ? [{
|
||||
type: 'button',
|
||||
icon: SparklesIcon,
|
||||
label: 'Sélection auto',
|
||||
onClick: () => store.autoSelectBestMatches(),
|
||||
}] : []),
|
||||
...(store.hasReadyFiles ? [{
|
||||
type: 'button',
|
||||
icon: ArrowUpTrayIcon,
|
||||
label: `Importer (${store.readyCount})`,
|
||||
onClick: importAllFiles,
|
||||
disabled: store.isLoading,
|
||||
}] : []),
|
||||
{
|
||||
type: 'button',
|
||||
icon: TrashIcon,
|
||||
label: 'Effacer',
|
||||
onClick: () => store.clearFiles(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const importAllFiles = async () => {
|
||||
try {
|
||||
await store.importAllReadyFiles();
|
||||
} catch (error) {
|
||||
console.error('Error importing files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const importSingleFile = async (fileId) => {
|
||||
try {
|
||||
await store.importFile(fileId);
|
||||
} catch (error) {
|
||||
console.error('Error importing file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const retryFile = async (fileId) => {
|
||||
try {
|
||||
await store.retryFile(fileId);
|
||||
} catch (error) {
|
||||
console.error('Error retrying file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
store.resetGlobalState();
|
||||
});
|
||||
</script>
|
||||
@@ -40,7 +40,12 @@ export const useMangaStore = defineStore('manga', {
|
||||
|
||||
// --- Add Manga State ---
|
||||
addingManga: false,
|
||||
addMangaError: null
|
||||
addMangaError: null,
|
||||
|
||||
// --- Discover State ---
|
||||
discoverResults: [],
|
||||
loadingDiscover: false,
|
||||
discoverError: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -170,6 +175,25 @@ export const useMangaStore = defineStore('manga', {
|
||||
this.loadingSearch = false;
|
||||
},
|
||||
|
||||
// --- Discover Actions ---
|
||||
async loadDiscoverRecommendations() {
|
||||
if (this.loadingDiscover) return;
|
||||
|
||||
this.loadingDiscover = true;
|
||||
this.discoverError = null;
|
||||
this.discoverResults = [];
|
||||
|
||||
try {
|
||||
const data = await mangaRepository.discoverManga();
|
||||
this.discoverResults = data.items || [];
|
||||
} catch (error) {
|
||||
this.discoverError = error.message;
|
||||
throw error;
|
||||
} finally {
|
||||
this.loadingDiscover = false;
|
||||
}
|
||||
},
|
||||
|
||||
// --- Add Manga Actions ---
|
||||
async createFromMangaDex(externalId) {
|
||||
if (this.addingManga) return;
|
||||
|
||||
@@ -4,6 +4,7 @@ export class Manga {
|
||||
slug,
|
||||
title,
|
||||
description = null,
|
||||
author = null,
|
||||
authors = [],
|
||||
imageUrl = null,
|
||||
thumbnailUrl = null,
|
||||
@@ -11,13 +12,16 @@ export class Manga {
|
||||
status = null,
|
||||
rating = null,
|
||||
genres = [],
|
||||
createdAt = new Date().toISOString()
|
||||
createdAt = new Date().toISOString(),
|
||||
monitored = false,
|
||||
chaptersTotal = 0,
|
||||
chaptersScraped = 0,
|
||||
}) {
|
||||
this.id = id;
|
||||
this.slug = slug;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.authors = authors;
|
||||
this.authors = authors.length ? authors : (author ? [author] : []);
|
||||
this.imageUrl = imageUrl;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.publicationYear = publicationYear;
|
||||
@@ -25,6 +29,9 @@ export class Manga {
|
||||
this.rating = rating;
|
||||
this.genres = genres;
|
||||
this.createdAt = createdAt;
|
||||
this.monitored = monitored;
|
||||
this.chaptersTotal = chaptersTotal;
|
||||
this.chaptersScraped = chaptersScraped;
|
||||
}
|
||||
|
||||
static create(data) {
|
||||
|
||||
@@ -80,7 +80,7 @@ export class ApiMangaRepository {
|
||||
|
||||
async searchMangas(query) {
|
||||
try {
|
||||
const response = await fetch(`/api/mangas/search?q=${encodeURIComponent(query)}`);
|
||||
const response = await fetch(`/api/manga-search?q=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search mangas');
|
||||
}
|
||||
@@ -104,6 +104,17 @@ export class ApiMangaRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async discoverManga() {
|
||||
try {
|
||||
const response = await fetch('/api/manga-discover');
|
||||
if (!response.ok) throw new Error('Failed to fetch discover recommendations');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createFromMangaDex(externalId) {
|
||||
try {
|
||||
const response = await fetch('/api/mangas/create-from-mangadex', {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<TransitionRoot as="template" :show="isOpen">
|
||||
<Dialog as="div" class="relative z-50" @close="$emit('close')">
|
||||
<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">
|
||||
Options d'affichage
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Vue Grid -->
|
||||
<section>
|
||||
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Vue Grille
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<ToggleRow
|
||||
label="Titre"
|
||||
:value="options.grid.showTitle"
|
||||
@update="setOption('grid', 'showTitle', $event)" />
|
||||
<ToggleRow
|
||||
label="Année de publication"
|
||||
:value="options.grid.showYear"
|
||||
@update="setOption('grid', 'showYear', $event)" />
|
||||
<ToggleRow
|
||||
label="Auteur(s)"
|
||||
:value="options.grid.showAuthor"
|
||||
@update="setOption('grid', 'showAuthor', $event)" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<!-- Vue Overview -->
|
||||
<section>
|
||||
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Vue Overview
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<ToggleRow
|
||||
label="Couverture"
|
||||
:value="options.overview.showCover"
|
||||
@update="setOption('overview', 'showCover', $event)" />
|
||||
<ToggleRow
|
||||
label="Statut"
|
||||
:value="options.overview.showStatus"
|
||||
@update="setOption('overview', 'showStatus', $event)" />
|
||||
<ToggleRow
|
||||
label="Description"
|
||||
:value="options.overview.showDescription"
|
||||
@update="setOption('overview', 'showDescription', $event)" />
|
||||
<ToggleRow
|
||||
label="Auteur(s)"
|
||||
:value="options.overview.showAuthor"
|
||||
@update="setOption('overview', 'showAuthor', $event)" />
|
||||
<ToggleRow
|
||||
label="Année de publication"
|
||||
:value="options.overview.showYear"
|
||||
@update="setOption('overview', 'showYear', $event)" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<!-- Vue Table -->
|
||||
<section>
|
||||
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Vue Table
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<ToggleRow
|
||||
label="Monitoring"
|
||||
:value="options.table.showMonitoring"
|
||||
@update="setOption('table', 'showMonitoring', $event)" />
|
||||
<ToggleRow
|
||||
label="Source préférée"
|
||||
:value="options.table.showPreferredSource"
|
||||
@update="setOption('table', 'showPreferredSource', $event)" />
|
||||
<ToggleRow
|
||||
label="Progression chapitres"
|
||||
:value="options.table.showChapters"
|
||||
@update="setOption('table', 'showChapters', $event)" />
|
||||
<ToggleRow
|
||||
label="Statut"
|
||||
:value="options.table.showStatus"
|
||||
@update="setOption('table', 'showStatus', $event)" />
|
||||
<ToggleRow
|
||||
label="Auteur(s)"
|
||||
:value="options.table.showAuthor"
|
||||
@update="setOption('table', 'showAuthor', $event)" />
|
||||
<ToggleRow
|
||||
label="Année de publication"
|
||||
:value="options.table.showYear"
|
||||
@update="setOption('table', 'showYear', $event)" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<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="$emit('close')"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
|
||||
import ToggleRow from '../../../../shared/components/ui/ToggleRow.vue';
|
||||
|
||||
defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'update']);
|
||||
|
||||
function setOption(view, key, value) {
|
||||
emit('update', { view, key, value });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="handleClose"></div>
|
||||
|
||||
<!-- Modal avec style Material Design -->
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<!-- Header Material Design -->
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<FolderIcon class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-medium text-gray-900 leading-6">
|
||||
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100 leading-6">
|
||||
Gérer les chapitres
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ manga?.title }}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ manga?.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
|
||||
class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center transition-colors duration-200"
|
||||
>
|
||||
<XMarkIcon class="h-5 w-5 text-gray-600" />
|
||||
<XMarkIcon class="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content avec style Material Design -->
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-8">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-8">
|
||||
<div v-if="isLoading" class="flex justify-center items-center h-32">
|
||||
<div class="relative">
|
||||
<div class="w-8 h-8 border-4 border-green-200 rounded-full"></div>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
||||
<div v-else-if="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
||||
<div class="w-5 h-5 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<XMarkIcon class="h-3 w-3 text-red-600" />
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Actions avec style Material Design -->
|
||||
<div class="flex items-center justify-between bg-gray-50 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
@click="showCreateVolumeModal = true"
|
||||
@@ -58,7 +58,7 @@
|
||||
</button>
|
||||
<button
|
||||
@click="showUnassignedChapters = !showUnassignedChapters"
|
||||
class="text-gray-600 hover:text-gray-800 text-sm font-medium hover:bg-gray-100 px-3 py-2 rounded-lg transition-colors duration-200"
|
||||
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700 px-3 py-2 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{{ showUnassignedChapters ? 'Masquer' : 'Afficher' }} les chapitres non assignés
|
||||
</button>
|
||||
@@ -88,17 +88,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 bg-white px-3 py-1.5 rounded-lg border border-gray-200">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-700 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
{{ totalChapters }} chapitres, {{ volumes.length }} volumes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arborescence avec style Material Design -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden shadow-sm">
|
||||
<!-- Chapitres non assignés -->
|
||||
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
|
||||
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700/50 dark:to-gray-700/30 border-b border-gray-200 dark:border-gray-600">
|
||||
<div class="px-6 py-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center space-x-2">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center space-x-2">
|
||||
<DocumentIcon class="h-4 w-4 text-gray-500" />
|
||||
<span>Chapitres non assignés ({{ unassignedChapters.length }})</span>
|
||||
</h4>
|
||||
@@ -119,11 +119,11 @@
|
||||
/>
|
||||
</div>
|
||||
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
||||
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<div class="flex-1">
|
||||
<div v-if="!chapter.isEditing" class="flex items-center">
|
||||
<span
|
||||
class="text-sm text-gray-900 cursor-pointer hover:text-green-600 transition-colors duration-200"
|
||||
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
||||
@click="startEditingTitle(chapter)"
|
||||
>
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
@@ -173,22 +173,22 @@
|
||||
</div>
|
||||
|
||||
<!-- Volumes avec style Material Design -->
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<div
|
||||
v-for="volume in volumes"
|
||||
:key="volume.number"
|
||||
class="bg-white"
|
||||
class="bg-white dark:bg-gray-800"
|
||||
>
|
||||
<!-- En-tête du volume Material Design -->
|
||||
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 border-b border-green-100">
|
||||
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-b border-green-100 dark:border-green-900/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<FolderIcon class="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-green-900">Volume {{ volume.number }}</span>
|
||||
<span class="text-xs text-green-600 ml-2">({{ volume.chapters.length }} chapitres)</span>
|
||||
<span class="text-sm font-semibold text-green-900 dark:text-green-300">Volume {{ volume.number }}</span>
|
||||
<span class="text-xs text-green-600 dark:text-green-400 ml-2">({{ volume.chapters.length }} chapitres)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -211,10 +211,10 @@
|
||||
|
||||
<!-- Chapitres du volume -->
|
||||
<div v-if="volume.isExpanded" class="px-6 py-4">
|
||||
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500">
|
||||
<DocumentIcon class="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<DocumentIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
|
||||
<p class="text-sm">Aucun chapitre assigné à ce volume.</p>
|
||||
<p class="text-xs text-gray-400 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
@@ -233,11 +233,11 @@
|
||||
/>
|
||||
</div>
|
||||
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
||||
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<div class="flex-1">
|
||||
<div v-if="!chapter.isEditing" class="flex items-center">
|
||||
<span
|
||||
class="text-sm text-gray-900 cursor-pointer hover:text-green-600 transition-colors duration-200"
|
||||
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
||||
@click="startEditingTitle(chapter)"
|
||||
>
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
@@ -291,12 +291,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer Material Design -->
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="handleClose"
|
||||
:disabled="isSaving"
|
||||
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
@@ -320,24 +320,24 @@
|
||||
<div v-if="showCreateVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showCreateVolumeModal = false"></div>
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<PlusIcon class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900">Créer un nouveau volume</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Créer un nouveau volume</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Numéro du volume</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Numéro du volume</label>
|
||||
<input
|
||||
v-model="newVolumeNumber"
|
||||
type="number"
|
||||
min="1"
|
||||
class="block w-full border border-gray-300 rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
||||
class="block w-full border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-3 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
||||
placeholder="Ex: 1"
|
||||
/>
|
||||
</div>
|
||||
@@ -351,7 +351,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showCreateVolumeModal = false"
|
||||
@@ -376,8 +376,8 @@
|
||||
<div v-if="showAssignModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showAssignModal = false"></div>
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<DocumentIcon class="h-5 w-5 text-green-600" />
|
||||
@@ -385,7 +385,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Assigner le chapitre {{ selectedChapter?.number }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Volume</label>
|
||||
@@ -401,7 +401,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showAssignModal = false"
|
||||
@@ -426,8 +426,8 @@
|
||||
<div v-if="showMoveToVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showMoveToVolumeModal = false"></div>
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<ArrowPathIcon class="h-5 w-5 text-green-600" />
|
||||
@@ -435,7 +435,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Déplacer {{ selectedChapters.length }} chapitre(s)</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<p class="text-sm text-green-800 font-medium">
|
||||
@@ -457,7 +457,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showMoveToVolumeModal = false"
|
||||
@@ -491,7 +491,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Séparer le volume 00</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<p class="text-sm text-green-800 font-medium">
|
||||
@@ -517,7 +517,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showSplitVolumeZeroModal = false"
|
||||
|
||||
@@ -1,37 +1,65 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="bg-white rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
|
||||
<div class="relative pb-[150%]">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||
:alt="manga.title"
|
||||
class="absolute inset-0 w-full h-full object-cover bg-gray-100" />
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ manga.title }}</h3>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-500">{{ manga.publicationYear }}</span>
|
||||
<div class="group relative bg-white dark:bg-gray-800 overflow-hidden shadow-sm">
|
||||
<!-- Cover avec overlay -->
|
||||
<div class="relative pb-[140%]">
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="absolute inset-0">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||
:alt="manga.title"
|
||||
class="w-full h-full object-cover bg-gray-100" />
|
||||
</RouterLink>
|
||||
|
||||
<!-- Gradient + actions au survol -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
<div class="absolute bottom-2 left-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||
title="Éditer"
|
||||
@click="$emit('edit', manga)">
|
||||
<PencilIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||
title="Sources préférées"
|
||||
@click="$emit('sources', manga)">
|
||||
<Cog6ToothIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded transition-colors"
|
||||
title="Rafraîchir"
|
||||
@click="$emit('refresh', manga)">
|
||||
<ArrowPathIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500"> Added: {{ formatDate(manga.createdAt) }} </div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Titre + méta -->
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="block p-2">
|
||||
<h3 v-if="options.showTitle" class="text-xs font-medium text-gray-800 dark:text-gray-100 truncate">{{ manga.title }}</h3>
|
||||
<span v-if="options.showYear && manga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
|
||||
<span v-if="options.showAuthor && manga.authors?.length" class="text-xs text-gray-500 dark:text-gray-400 truncate block">{{ manga.authors[0] }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
manga: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
import { ArrowPathIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||
import { RouterLink } from 'vue-router';
|
||||
|
||||
const formatDate = dateString => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
defineProps({
|
||||
manga: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({ showTitle: true, showYear: true, showAuthor: false })
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['edit', 'sources', 'refresh']);
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
<template>
|
||||
<tr class="border-t hover:bg-green-100">
|
||||
<td class="px-4 py-2" :class="{ 'text-green-500': chapter.isAvailable }">
|
||||
{{ String(chapter.number).padStart(2, '0') }}
|
||||
<tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20">
|
||||
<td class="px-4 py-2 text-gray-900 dark:text-gray-100" :class="{ 'text-green-500 dark:text-green-400': chapter.isAvailable }">
|
||||
<template v-if="chapter.isVolumeGroup">{{ chapter.volumeChaptersRange }}</template>
|
||||
<template v-else>{{ String(chapter.number).padStart(2, '0') }}</template>
|
||||
</td>
|
||||
<td class="px-4 py-2 w-full text-left">
|
||||
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
|
||||
<router-link
|
||||
v-if="chapter.isAvailable"
|
||||
class="hover:text-green-500 dark:hover:text-green-400"
|
||||
:to="{
|
||||
name: 'reader',
|
||||
params: {
|
||||
chapterId: chapter.id
|
||||
}
|
||||
}">
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
<template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
|
||||
Chapitres {{ chapter.volumeChaptersRange }}
|
||||
</template>
|
||||
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||
</router-link>
|
||||
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||
<template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
|
||||
Chapitres {{ chapter.volumeChaptersRange }}
|
||||
</template>
|
||||
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 flex justify-end gap-2">
|
||||
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="p-2 border-t">
|
||||
<div class="p-2 border-t dark:border-gray-700">
|
||||
<table class="min-w-full table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<tr class="text-gray-700 dark:text-gray-300">
|
||||
<th class="px-4 py-2 text-left">#</th>
|
||||
<th class="px-4 py-2 text-left">Titre</th>
|
||||
<th class="px-4 py-2 text-right">Actions</th>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,15 +24,15 @@
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div class="mb-6">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Supprimer le manga
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ error.message || 'Une erreur est survenue lors de la suppression.' }}
|
||||
</div>
|
||||
|
||||
@@ -40,19 +40,19 @@
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
|
||||
<span class="text-sm font-medium text-gray-900">Action irréversible</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Action irréversible</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Êtes-vous sûr de vouloir supprimer le manga <strong>"{{ manga?.title }}"</strong> ?
|
||||
</p>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">
|
||||
Attention
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<p>Cette action supprimera définitivement :</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Le manga et toutes ses métadonnées</li>
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="closeModal"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,15 +24,15 @@
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
||||
<div class="mb-6">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Edit Manga
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ error.message || 'Une erreur est survenue lors de la sauvegarde.' }}
|
||||
</div>
|
||||
|
||||
@@ -41,49 +41,49 @@
|
||||
<!-- Titre et Slug -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Titre</label>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="formData.title"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Titre du manga"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700 mb-2">Slug</label>
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slug</label>
|
||||
<input
|
||||
id="slug"
|
||||
:value="manga?.slug || ''"
|
||||
type="text"
|
||||
disabled
|
||||
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm text-gray-500"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-600 shadow-sm sm:text-sm text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Année de publication -->
|
||||
<div>
|
||||
<label for="publicationYear" class="block text-sm font-medium text-gray-700 mb-2">Année de publication</label>
|
||||
<label for="publicationYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Année de publication</label>
|
||||
<input
|
||||
id="publicationYear"
|
||||
v-model.number="formData.publicationYear"
|
||||
type="number"
|
||||
min="1900"
|
||||
:max="new Date().getFullYear()"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="2023"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
rows="4"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Description du manga"
|
||||
/>
|
||||
</div>
|
||||
@@ -91,22 +91,22 @@
|
||||
<!-- Auteur et Statut -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="author" class="block text-sm font-medium text-gray-700 mb-2">Auteur</label>
|
||||
<label for="author" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Auteur</label>
|
||||
<input
|
||||
id="author"
|
||||
v-model="formData.author"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Auteur du manga"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-2">Statut</label>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Statut</label>
|
||||
<input
|
||||
id="status"
|
||||
v-model="formData.status"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="ongoing"
|
||||
/>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
<!-- Note -->
|
||||
<div>
|
||||
<label for="rating" class="block text-sm font-medium text-gray-700 mb-2">Note</label>
|
||||
<label for="rating" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Note</label>
|
||||
<input
|
||||
id="rating"
|
||||
v-model.number="formData.rating"
|
||||
@@ -122,20 +122,20 @@
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.001"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="9.541"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Slugs alternatifs -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Slugs alternatifs</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slugs alternatifs</label>
|
||||
<div class="space-y-2">
|
||||
<div v-if="formData.alternativeSlugs.length > 0" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(slug, index) in formData.alternativeSlugs"
|
||||
:key="index"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300"
|
||||
>
|
||||
{{ slug }}
|
||||
<button
|
||||
@@ -158,7 +158,7 @@
|
||||
<input
|
||||
v-model="newAlternativeSlug"
|
||||
type="text"
|
||||
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Nouveau slug alternatif"
|
||||
@keyup.enter="addAlternativeSlug"
|
||||
/>
|
||||
@@ -175,19 +175,19 @@
|
||||
|
||||
<!-- Genres -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Genres</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Genres</label>
|
||||
<div class="space-y-3">
|
||||
<div v-if="formData.genres.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<span
|
||||
v-for="(genre, index) in formData.genres"
|
||||
:key="index"
|
||||
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 text-gray-800"
|
||||
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{{ genre }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeGenre(index)"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 hover:text-gray-600"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMarkIcon class="w-3 h-3" />
|
||||
</button>
|
||||
@@ -204,7 +204,7 @@
|
||||
<input
|
||||
v-model="newGenre"
|
||||
type="text"
|
||||
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Nouveau genre"
|
||||
@keyup.enter="addGenre"
|
||||
/>
|
||||
@@ -224,7 +224,7 @@
|
||||
<div class="mt-8 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
@click="closeModal"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
|
||||
@@ -1,16 +1,101 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6">
|
||||
<MangaCard v-for="manga in mangas" :key="manga.id" :manga="manga" />
|
||||
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3 p-4">
|
||||
<MangaCard
|
||||
v-for="manga in mangas"
|
||||
:key="manga.id"
|
||||
:manga="manga"
|
||||
:options="options"
|
||||
@edit="openEdit"
|
||||
@sources="openSources"
|
||||
@refresh="doRefresh" />
|
||||
</div>
|
||||
|
||||
<!-- Modales -->
|
||||
<MangaEditModal
|
||||
:is-open="isEditModalOpen"
|
||||
:manga="selectedManga"
|
||||
:is-saving="editIsLoading"
|
||||
:error="editError"
|
||||
@close="closeEditModal"
|
||||
@save="handleSaveEdit" />
|
||||
|
||||
<MangaPreferredSourcesModal
|
||||
:is-open="isSourcesModalOpen"
|
||||
:sources="preferredSources"
|
||||
:is-loading="sourcesIsLoading"
|
||||
:error="sourcesError"
|
||||
:is-saving="sourcesIsSaving"
|
||||
@close="isSourcesModalOpen = false"
|
||||
@save="handleSaveSources" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MangaCard from './MangaCard.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||
import MangaCard from './MangaCard.vue';
|
||||
import MangaEditModal from './MangaEditModal.vue';
|
||||
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||
|
||||
defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({ showTitle: true, showYear: true, showAuthor: false })
|
||||
}
|
||||
});
|
||||
|
||||
const selectedManga = ref(null);
|
||||
const isSourcesModalOpen = ref(false);
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────
|
||||
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||
|
||||
function openEdit(manga) {
|
||||
selectedManga.value = manga;
|
||||
openEditModal();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(data) {
|
||||
if (!selectedManga.value) return;
|
||||
await editManga(selectedManga.value.id, data);
|
||||
}
|
||||
|
||||
// ── Sources préférées ─────────────────────────────────────
|
||||
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||
const {
|
||||
sources: preferredSources,
|
||||
isLoading: sourcesIsLoading,
|
||||
error: sourcesError,
|
||||
isSaving: sourcesIsSaving,
|
||||
savePreferredSources
|
||||
} = useMangaPreferredSources(selectedMangaId);
|
||||
|
||||
function openSources(manga) {
|
||||
selectedManga.value = manga;
|
||||
isSourcesModalOpen.value = true;
|
||||
}
|
||||
|
||||
function handleSaveSources(sourceIds) {
|
||||
savePreferredSources(sourceIds);
|
||||
isSourcesModalOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────
|
||||
const { refreshMetadata } = useMangaRefresh();
|
||||
const refreshingId = ref(null);
|
||||
|
||||
async function doRefresh(manga) {
|
||||
if (refreshingId.value) return;
|
||||
refreshingId.value = manga.id;
|
||||
try {
|
||||
await refreshMetadata(manga.id);
|
||||
} finally {
|
||||
refreshingId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="manga in mangas"
|
||||
:key="manga.id"
|
||||
class="flex bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg p-4 space-x-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
@click="$emit('manga-click', manga)">
|
||||
<!-- Cover Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" />
|
||||
<!-- TODO: Add placeholder image -->
|
||||
</div>
|
||||
|
||||
<!-- Manga Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg leading-7 font-medium text-gray-900 dark:text-gray-100 truncate">{{
|
||||
manga.title
|
||||
}}</h3>
|
||||
<p v-if="manga.publicationYear" class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{
|
||||
manga.publicationYear
|
||||
}}</p>
|
||||
<p v-if="manga.description" class="text-sm text-gray-700 dark:text-gray-300 mt-2">
|
||||
{{ truncateDescription(manga.description) }}
|
||||
</p>
|
||||
<p v-if="manga.createdAt" class="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Added: {{ formatDate(manga.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineEmits, defineProps } from 'vue';
|
||||
|
||||
const emit = defineEmits(['manga-click']);
|
||||
|
||||
const props = defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const formatDate = dateString => {
|
||||
if (!dateString) return '';
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString(undefined, options);
|
||||
} catch (e) {
|
||||
console.error('Error formatting date:', e);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const truncateDescription = description => {
|
||||
if (!description) return '';
|
||||
return description.length > 500 ? description.slice(0, 500) + '...' : description;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Pour s'assurer que line-clamp fonctionne */
|
||||
@supports (-webkit-line-clamp: 3) {
|
||||
.line-clamp-3 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.description-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
<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
|
||||
v-if="options.showCover"
|
||||
: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="options.showStatus && 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>
|
||||
<div class="flex items-center gap-3 mt-1 flex-wrap">
|
||||
<span v-if="options.showAuthor && manga.authors?.length" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ manga.authors.join(', ') }}
|
||||
</span>
|
||||
<span v-if="options.showYear && manga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ manga.publicationYear }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="options.showDescription && 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
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({ showCover: true, showStatus: true, showDescription: true, showAuthor: false, showYear: false })
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === 'ongoing') return 'text-blue-600 bg-blue-50 dark:bg-blue-900/20';
|
||||
if (status === 'completed') return 'text-green-600 bg-green-50 dark:bg-green-900/20';
|
||||
return 'text-gray-500 bg-gray-100 dark:bg-gray-700';
|
||||
}
|
||||
|
||||
// ── Selected manga ────────────────────────────────────────
|
||||
const selectedManga = ref(null);
|
||||
const isSourcesModalOpen = ref(false);
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────
|
||||
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||
|
||||
function openEdit(manga) {
|
||||
selectedManga.value = manga;
|
||||
openEditModal();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(data) {
|
||||
if (!selectedManga.value) return;
|
||||
await editManga(selectedManga.value.id, data);
|
||||
}
|
||||
|
||||
// ── Sources préférées ─────────────────────────────────────
|
||||
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||
const {
|
||||
sources: preferredSources,
|
||||
isLoading: sourcesIsLoading,
|
||||
error: sourcesError,
|
||||
isSaving: sourcesIsSaving,
|
||||
savePreferredSources
|
||||
} = useMangaPreferredSources(selectedMangaId);
|
||||
|
||||
function openSources(manga) {
|
||||
selectedManga.value = manga;
|
||||
isSourcesModalOpen.value = true;
|
||||
}
|
||||
|
||||
function handleSaveSources(sourceIds) {
|
||||
savePreferredSources(sourceIds);
|
||||
isSourcesModalOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────
|
||||
const { refreshMetadata } = useMangaRefresh();
|
||||
const refreshingId = ref(null);
|
||||
|
||||
async function doRefresh(manga) {
|
||||
if (refreshingId.value) return;
|
||||
refreshingId.value = manga.id;
|
||||
try {
|
||||
await refreshMetadata(manga.id);
|
||||
} finally {
|
||||
refreshingId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<span v-if="isLoading" class="text-gray-400 dark:text-gray-600 text-xs">…</span>
|
||||
<span v-else-if="sources.length" class="text-gray-700 dark:text-gray-300 truncate max-w-xs block">{{ sources[0].name }}</span>
|
||||
<span v-else class="text-gray-400 dark:text-gray-600">—</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||
|
||||
const props = defineProps({
|
||||
mangaId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const mangaIdRef = toRef(props, 'mangaId');
|
||||
const { sources, isLoading } = useMangaPreferredSources(mangaIdRef);
|
||||
</script>
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,17 +24,17 @@
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<div>
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
||||
<Cog6ToothIcon class="h-6 w-6 text-blue-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Sources préférées
|
||||
</DialogTitle>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configurez l'ordre de priorité des sources pour ce manga. Glissez-déposez les sources pour les réorganiser.
|
||||
</p>
|
||||
</div>
|
||||
@@ -47,13 +47,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="mt-5 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-else-if="error" class="mt-5 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ error.message || 'Une erreur est survenue lors du chargement des sources.' }}
|
||||
</div>
|
||||
|
||||
<!-- Sources list -->
|
||||
<div v-else class="mt-5">
|
||||
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500">
|
||||
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Aucune source disponible
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
@@ -63,10 +63,10 @@
|
||||
:class="[
|
||||
'group relative flex items-center p-4 rounded-lg border-2 transition-all duration-200 cursor-grab active:cursor-grabbing select-none',
|
||||
{
|
||||
'bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-300 shadow-md': index === 0,
|
||||
'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300': index === 1,
|
||||
'bg-gradient-to-r from-yellow-50 to-amber-50 border-yellow-300': index === 2,
|
||||
'bg-gray-50 border-gray-200': index > 2,
|
||||
'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-300 dark:border-blue-700 shadow-md': index === 0,
|
||||
'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-green-300 dark:border-green-700': index === 1,
|
||||
'bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border-yellow-300 dark:border-yellow-700': index === 2,
|
||||
'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600': index > 2,
|
||||
'scale-105 shadow-lg border-blue-400': draggedIndex === index,
|
||||
'opacity-50': dragOverIndex === index && draggedIndex !== index,
|
||||
'scale-95 active:scale-95': isPressed === index
|
||||
@@ -102,10 +102,10 @@
|
||||
<div :class="[
|
||||
'flex items-center space-x-1 px-3 py-1 rounded-full text-xs font-semibold',
|
||||
{
|
||||
'bg-blue-100 text-blue-800': index === 0,
|
||||
'bg-green-100 text-green-800': index === 1,
|
||||
'bg-yellow-100 text-yellow-800': index === 2,
|
||||
'bg-gray-100 text-gray-600': index > 2
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': index === 0,
|
||||
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': index === 1,
|
||||
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': index === 2,
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300': index > 2
|
||||
}
|
||||
]">
|
||||
<span v-if="index === 0">🥇 Priorité haute</span>
|
||||
@@ -117,14 +117,14 @@
|
||||
|
||||
<!-- Informations de la source -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-gray-900 truncate">{{ source.name }}</div>
|
||||
<div class="text-sm text-gray-600 truncate">
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100 truncate">{{ source.name }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
<a :href="source.baseUrl" target="_blank" class="hover:text-blue-600 hover:underline">{{ source.baseUrl }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur de drag -->
|
||||
<div class="ml-4 text-gray-400 group-hover:text-gray-600 transition-colors duration-200">
|
||||
<div class="ml-4 text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors duration-200">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9h8M8 15h8" />
|
||||
</svg>
|
||||
@@ -148,7 +148,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:col-start-1 sm:mt-0"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:col-start-1 sm:mt-0"
|
||||
@click="closeModal"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<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 v-if="options.showMonitoring" class="w-10 px-4 py-3"></th>
|
||||
<th class="py-3 pr-4 text-left font-medium">Titre</th>
|
||||
<th v-if="options.showAuthor" class="py-3 pr-4 text-left font-medium w-36">Auteur</th>
|
||||
<th v-if="options.showYear" class="py-3 pr-4 text-left font-medium w-20">Année</th>
|
||||
<th v-if="options.showStatus" class="py-3 pr-4 text-left font-medium w-28">Statut</th>
|
||||
<th v-if="options.showPreferredSource" class="py-3 pr-4 text-left font-medium w-44">Source préférée</th>
|
||||
<th v-if="options.showChapters" 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 v-if="options.showMonitoring" 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>
|
||||
|
||||
<!-- Auteur -->
|
||||
<td v-if="options.showAuthor" class="py-3 pr-4">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ manga.authors?.join(', ') || '—' }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Année -->
|
||||
<td v-if="options.showYear" class="py-3 pr-4">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ manga.publicationYear || '—' }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Statut -->
|
||||
<td v-if="options.showStatus" class="py-3 pr-4">
|
||||
<span
|
||||
v-if="manga.status"
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="statusClass(manga.status)">
|
||||
{{ manga.status }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400 dark:text-gray-600 text-xs">—</span>
|
||||
</td>
|
||||
|
||||
<!-- Source préférée -->
|
||||
<td v-if="options.showPreferredSource" class="py-3 pr-4">
|
||||
<MangaPreferredSourceCell :manga-id="manga.id" />
|
||||
</td>
|
||||
|
||||
<!-- Chapitres — barre de progression -->
|
||||
<td v-if="options.showChapters" 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
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({ showMonitoring: true, showPreferredSource: true, showChapters: true, showStatus: false, showAuthor: false, showYear: false })
|
||||
}
|
||||
});
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function progressPercent(manga) {
|
||||
if (!manga.chaptersTotal) return 0;
|
||||
return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100);
|
||||
}
|
||||
|
||||
// ── Monitoring ────────────────────────────────────────────
|
||||
const { toggleMonitoring } = useMangaMonitoring();
|
||||
|
||||
async function doToggleMonitoring(manga) {
|
||||
await toggleMonitoring(manga.id, !manga.monitored);
|
||||
manga.monitored = !manga.monitored;
|
||||
}
|
||||
|
||||
// ── Selected manga ────────────────────────────────────────
|
||||
const selectedManga = ref(null);
|
||||
const isSourcesModalOpen = ref(false);
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────
|
||||
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||
|
||||
function openEdit(manga) {
|
||||
selectedManga.value = manga;
|
||||
openEditModal();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(data) {
|
||||
if (!selectedManga.value) return;
|
||||
await editManga(selectedManga.value.id, data);
|
||||
}
|
||||
|
||||
// ── Sources préférées ─────────────────────────────────────
|
||||
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||
const {
|
||||
sources: preferredSources,
|
||||
isLoading: sourcesIsLoading,
|
||||
error: sourcesError,
|
||||
isSaving: sourcesIsSaving,
|
||||
savePreferredSources
|
||||
} = useMangaPreferredSources(selectedMangaId);
|
||||
|
||||
function openSources(manga) {
|
||||
selectedManga.value = manga;
|
||||
isSourcesModalOpen.value = true;
|
||||
}
|
||||
|
||||
function handleSaveSources(sourceIds) {
|
||||
savePreferredSources(sourceIds);
|
||||
isSourcesModalOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────
|
||||
const { refreshMetadata } = useMangaRefresh();
|
||||
const refreshingId = ref(null);
|
||||
|
||||
async function doRefresh(manga) {
|
||||
if (refreshingId.value) return;
|
||||
refreshingId.value = manga.id;
|
||||
try {
|
||||
await refreshMetadata(manga.id);
|
||||
} finally {
|
||||
refreshingId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-sm shadow mb-2">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-sm shadow mb-2">
|
||||
<!-- En-tête du volume -->
|
||||
<div class="relative bg-white p-3 sm:p-4 rounded-t-sm">
|
||||
<div class="relative bg-white dark:bg-gray-800 p-3 sm:p-4 rounded-t-sm">
|
||||
<!-- Layout mobile/desktop -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Partie gauche -->
|
||||
<div class="flex items-center space-x-1 sm:space-x-4 flex-1 min-w-0">
|
||||
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 flex-shrink-0" />
|
||||
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
|
||||
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 dark:text-gray-400 flex-shrink-0" />
|
||||
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0 dark:text-gray-100">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
:class="[
|
||||
@@ -65,7 +65,7 @@
|
||||
<MangaChapterList v-show="isOpen" :chapters="volume.chapters" :manga-slug="mangaSlug" :manga-id="mangaId" />
|
||||
|
||||
<!-- Chevron de fermeture -->
|
||||
<div v-show="isOpen" class="flex justify-center p-2 bg-white rounded-b-sm">
|
||||
<div v-show="isOpen" class="flex justify-center p-2 bg-white dark:bg-gray-800 rounded-b-sm">
|
||||
<button @click="toggleVolume" class="w-8 h-8 flex items-center justify-center">
|
||||
<ChevronUpIcon
|
||||
class="h-5 w-5 sm:h-6 sm:w-6 bg-gray-400 rounded-full p-1 text-white hover:bg-green-500 cursor-pointer"
|
||||
|
||||
@@ -1,80 +1,142 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Barre de recherche -->
|
||||
<div class="mb-8">
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="performSearch"
|
||||
placeholder="Rechercher un manga..."
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<button
|
||||
@click="performSearch"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
Rechercher
|
||||
</button>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<!-- Recherche -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Recherche</h2>
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="performSearch"
|
||||
placeholder="Rechercher un manga..."
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
|
||||
</section>
|
||||
|
||||
<!-- État de chargement -->
|
||||
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
|
||||
<span class="text-sm">Recherche en cours...</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<section v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Résultats -->
|
||||
<section v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Résultats</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ searchResults.length }} manga(s)</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="manga in searchResults"
|
||||
:key="manga.externalId"
|
||||
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
|
||||
@click="openMangaModal(manga)">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||
alt=""
|
||||
class="h-36 w-24 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
|
||||
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Aucun résultat -->
|
||||
<section v-else-if="hasSearched && !loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Aucun résultat trouvé</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- État de chargement -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Recherche en cours...</p>
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Résultats de recherche -->
|
||||
<div class="max-w-full overflow-hidden">
|
||||
<MangaList v-if="searchResults.length > 0" :mangas="searchResults" @manga-click="openMangaModal" />
|
||||
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600">Aucun résultat trouvé</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal de confirmation -->
|
||||
<!-- Modal de détail -->
|
||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" />
|
||||
|
||||
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel class="w-full max-w-lg bg-white rounded-xl shadow-xl p-6">
|
||||
<DialogTitle class="text-lg mb-4"> Ajouter à la bibliothèque </DialogTitle>
|
||||
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
|
||||
<div v-if="selectedManga">
|
||||
<div class="flex gap-4">
|
||||
<img
|
||||
:src="selectedManga.imageUrl || '/placeholder-cover.png'"
|
||||
:alt="selectedManga.title"
|
||||
class="h-48 w-32 object-cover" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-lg">{{ selectedManga.title }}</h4>
|
||||
<p class="mt-2">
|
||||
{{ truncatedDescription }}
|
||||
</p>
|
||||
<!-- En-tête avec couverture -->
|
||||
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<img
|
||||
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
|
||||
:alt="selectedManga.title"
|
||||
class="h-64 w-44 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
|
||||
{{ selectedManga.title }}
|
||||
</DialogTitle>
|
||||
<div class="mt-3 space-y-1.5">
|
||||
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Publication</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Statut</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Note</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
|
||||
<span
|
||||
v-for="genre in selectedManga.genres"
|
||||
:key="genre"
|
||||
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{{ genre }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<!-- Description -->
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{{ selectedManga.description }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50">
|
||||
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="addManga"
|
||||
:disabled="adding"
|
||||
class="px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center">
|
||||
<span v-if="adding" class="mr-2">
|
||||
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
||||
</span>
|
||||
{{ adding ? 'Ajout en cours...' : 'Ajouter' }}
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
|
||||
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
|
||||
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -82,77 +144,91 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, onBeforeUnmount } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaList from '../components/MangaList.vue';
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/solid';
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||
import { ArrowPathIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const mangaStore = useMangaStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const mangaStore = useMangaStore();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const isModalOpen = ref(false);
|
||||
const selectedManga = ref(null);
|
||||
const searchQuery = ref('');
|
||||
const hasSearched = ref(false);
|
||||
const isModalOpen = ref(false);
|
||||
const selectedManga = ref(null);
|
||||
|
||||
// Récupération des états du store
|
||||
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||
|
||||
const truncatedDescription = computed(() => {
|
||||
if (!selectedManga.value?.description) return '';
|
||||
return selectedManga.value.description.length > 500
|
||||
? selectedManga.value.description.slice(0, 500) + '...'
|
||||
: selectedManga.value.description;
|
||||
});
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Ajouter un manga', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: MagnifyingGlassIcon,
|
||||
label: 'Rechercher',
|
||||
onClick: performSearch,
|
||||
disabled: !searchQuery.value.trim() || loading.value,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Effectuer la recherche au chargement si un paramètre q est présent
|
||||
onMounted(() => {
|
||||
const queryParam = route.query.q;
|
||||
if (queryParam) {
|
||||
searchQuery.value = queryParam;
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
let debounceTimer = null;
|
||||
watch(searchQuery, newVal => {
|
||||
clearTimeout(debounceTimer);
|
||||
if (newVal.trim().length > 3) {
|
||||
debounceTimer = setTimeout(performSearch, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Nettoyer la recherche et les résultats lors du démontage du composant
|
||||
onBeforeUnmount(() => {
|
||||
searchQuery.value = '';
|
||||
mangaStore.clearSearchResults();
|
||||
});
|
||||
onMounted(() => {
|
||||
const queryParam = route.query.q;
|
||||
if (queryParam) {
|
||||
searchQuery.value = queryParam;
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
try {
|
||||
await mangaStore.searchMangaDex(searchQuery.value);
|
||||
} catch (e) {
|
||||
console.error('Erreur de recherche:', e);
|
||||
}
|
||||
};
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(debounceTimer);
|
||||
searchQuery.value = '';
|
||||
mangaStore.clearSearchResults();
|
||||
});
|
||||
|
||||
const openMangaModal = manga => {
|
||||
selectedManga.value = manga;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
try {
|
||||
await mangaStore.searchMangaDex(searchQuery.value);
|
||||
hasSearched.value = true;
|
||||
} catch (e) {
|
||||
console.error('Erreur de recherche:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedManga.value = null;
|
||||
};
|
||||
const openMangaModal = manga => {
|
||||
selectedManga.value = manga;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const addManga = async () => {
|
||||
if (!selectedManga.value) return;
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedManga.value = null;
|
||||
};
|
||||
|
||||
try {
|
||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||
await mangaStore.fetchMangaChapters(selectedManga.value.id);
|
||||
router.push('/manga');
|
||||
} catch (e) {
|
||||
console.error("Erreur d'ajout:", e);
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
const addManga = async () => {
|
||||
if (!selectedManga.value) return;
|
||||
try {
|
||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||
router.push('/manga');
|
||||
} catch (e) {
|
||||
console.error("Erreur d'ajout:", e);
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
192
assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
Normal file
192
assets/vue/app/domain/manga/presentation/pages/DiscoverPage.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
|
||||
<!-- État de chargement -->
|
||||
<section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
|
||||
<span class="text-sm">Chargement des recommandations...</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<section v-else-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Résultats -->
|
||||
<section v-else-if="discoverResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Recommandations</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ discoverResults.length }} manga(s)</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div
|
||||
v-for="manga in discoverResults"
|
||||
:key="manga.externalId"
|
||||
class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
|
||||
@click="openMangaModal(manga)">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
|
||||
alt=""
|
||||
class="h-36 w-24 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
|
||||
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Collection locale vide -->
|
||||
<section v-else-if="!loading" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Ajoutez des manga pour obtenir des recommandations.</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de détail -->
|
||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
|
||||
<!-- En-tête avec couverture -->
|
||||
<div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<img
|
||||
:src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
|
||||
:alt="selectedManga.title"
|
||||
class="h-64 w-44 object-cover flex-shrink-0"
|
||||
referrerpolicy="no-referrer" />
|
||||
<div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
|
||||
{{ selectedManga.title }}
|
||||
</DialogTitle>
|
||||
<div class="mt-3 space-y-1.5">
|
||||
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Publication</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Statut</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
|
||||
</p>
|
||||
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">Note</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
|
||||
<span
|
||||
v-for="genre in selectedManga.genres"
|
||||
:key="genre"
|
||||
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{{ genre }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
||||
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{{ selectedManga.description }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="addManga"
|
||||
:disabled="adding"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
|
||||
<ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
|
||||
{{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||
import { ArrowPathIcon, ArrowPathRoundedSquareIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
|
||||
const isModalOpen = ref(false);
|
||||
const selectedManga = ref(null);
|
||||
|
||||
const { discoverResults, loadingDiscover: loading, discoverError: error, addingManga: adding } = storeToRefs(mangaStore);
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Découvrir', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: ArrowPathRoundedSquareIcon,
|
||||
label: 'Actualiser',
|
||||
onClick: () => mangaStore.loadDiscoverRecommendations(),
|
||||
disabled: loading.value,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
mangaStore.loadDiscoverRecommendations();
|
||||
});
|
||||
|
||||
const openMangaModal = manga => {
|
||||
selectedManga.value = manga;
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
selectedManga.value = null;
|
||||
};
|
||||
|
||||
const addManga = async () => {
|
||||
if (!selectedManga.value) return;
|
||||
try {
|
||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||
router.push('/manga');
|
||||
} catch (e) {
|
||||
console.error("Erreur d'ajout:", e);
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,40 +1,64 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="container mx-auto px-4">
|
||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="collection?.items || []" />
|
||||
<MangaList
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="w-full">
|
||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" :options="prefs.displayOptions.grid" />
|
||||
<MangaOverview
|
||||
v-else-if="viewMode === 'list'"
|
||||
:mangas="collection?.items || []"
|
||||
:mangas="pagedItems"
|
||||
:options="prefs.displayOptions.overview"
|
||||
@manga-click="handleMangaClick" />
|
||||
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" :options="prefs.displayOptions.table" />
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
:total="sortedCollection.length"
|
||||
:limit="prefs.itemsPerPage"
|
||||
:has-next-page="currentPage < totalPages"
|
||||
:has-previous-page="currentPage > 1"
|
||||
@page-change="currentPage = $event" />
|
||||
<div
|
||||
v-if="isBackgroundLoading"
|
||||
class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Mise à jour en cours...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeDisplaySettingsModal
|
||||
:is-open="isDisplaySettingsOpen"
|
||||
:options="prefs.displayOptions"
|
||||
@close="isDisplaySettingsOpen = false"
|
||||
@update="({ view, key, value }) => prefs.setDisplayOption(view, key, value)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaGrid from '../components/MangaGrid.vue';
|
||||
import MangaList from '../components/MangaList.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
Cog6ToothIcon,
|
||||
EyeIcon,
|
||||
ArrowsUpDownIcon,
|
||||
FunnelIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
ArrowPathIcon,
|
||||
ArrowsUpDownIcon,
|
||||
Cog6ToothIcon,
|
||||
EyeIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import HomeDisplaySettingsModal from '../components/HomeDisplaySettingsModal.vue';
|
||||
import MangaGrid from '../components/MangaGrid.vue';
|
||||
import MangaOverview from '../components/MangaOverview.vue';
|
||||
import MangaTable from '../components/MangaTable.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
const prefs = useUserPreferencesStore();
|
||||
|
||||
const {
|
||||
collection,
|
||||
@@ -43,7 +67,9 @@
|
||||
isBackgroundLoadingCollection: isBackgroundLoading
|
||||
} = storeToRefs(mangaStore);
|
||||
|
||||
const viewMode = ref('grid');
|
||||
const viewMode = ref(prefs.defaultView);
|
||||
const currentPage = ref(1);
|
||||
const isDisplaySettingsOpen = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
mangaStore.loadCollection();
|
||||
@@ -53,26 +79,53 @@
|
||||
router.push({ name: 'manga-details', params: { id: manga.id } });
|
||||
};
|
||||
|
||||
const toolbarConfig = {
|
||||
const sortedCollection = computed(() => {
|
||||
let items = [...(collection.value?.items || [])];
|
||||
if (prefs.filterBy === 'completed') {
|
||||
items = items.filter(m => m.status?.toLowerCase() === 'completed');
|
||||
} else if (prefs.filterBy === 'ongoing') {
|
||||
items = items.filter(m => m.status?.toLowerCase() === 'ongoing');
|
||||
}
|
||||
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 = computed(() => ({
|
||||
leftSection: [
|
||||
{
|
||||
icon: ArrowPathIcon,
|
||||
label: 'Refresh',
|
||||
type: 'button',
|
||||
onClick: () => mangaStore.refreshCollectionInBackground(),
|
||||
active: isBackgroundLoading
|
||||
active: isBackgroundLoading.value
|
||||
},
|
||||
{ icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} }
|
||||
],
|
||||
rightSection: [
|
||||
{ icon: Cog6ToothIcon, type: 'button', onClick: () => {} },
|
||||
{ icon: Cog6ToothIcon, label: 'Options', type: 'button', onClick: () => { isDisplaySettingsOpen.value = true; } },
|
||||
{
|
||||
icon: EyeIcon,
|
||||
type: 'dropdown',
|
||||
label: 'View',
|
||||
items: [
|
||||
{ label: 'List', onClick: () => (viewMode.value = 'list') },
|
||||
{ label: 'Grid', onClick: () => (viewMode.value = 'grid') }
|
||||
{ label: 'Overview', isSelected: prefs.defaultView === 'list', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
|
||||
{ label: 'Grid', isSelected: prefs.defaultView === 'grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
|
||||
{ label: 'Table', isSelected: prefs.defaultView === 'table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -80,10 +133,9 @@
|
||||
type: 'dropdown',
|
||||
label: 'Sort',
|
||||
items: [
|
||||
{ label: 'Title', onClick: () => {} },
|
||||
{ label: 'Author', onClick: () => {} },
|
||||
{ label: 'Status', onClick: () => {} },
|
||||
{ label: 'Year', onClick: () => {} }
|
||||
{ label: 'Title', isSelected: prefs.sortBy === 'title', onClick: () => prefs.setSortBy('title') },
|
||||
{ label: "Date d'ajout", isSelected: prefs.sortBy === 'addedAt', onClick: () => prefs.setSortBy('addedAt') },
|
||||
{ label: 'Progression', isSelected: prefs.sortBy === 'progress', onClick: () => prefs.setSortBy('progress') }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -91,11 +143,11 @@
|
||||
type: 'dropdown',
|
||||
label: 'Filter',
|
||||
items: [
|
||||
{ label: 'All', onClick: () => {} },
|
||||
{ label: 'Completed', onClick: () => {} },
|
||||
{ label: 'In Progress', onClick: () => {} }
|
||||
{ label: 'All', isSelected: prefs.filterBy === 'all', onClick: () => prefs.setFilterBy('all') },
|
||||
{ label: 'Completed', isSelected: prefs.filterBy === 'completed', onClick: () => prefs.setFilterBy('completed') },
|
||||
{ label: 'In Progress', isSelected: prefs.filterBy === 'ongoing', onClick: () => prefs.setFilterBy('ongoing') }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}));
|
||||
</script>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Notifications Toast -->
|
||||
<NotificationToast />
|
||||
|
||||
<div v-if="errorDetails" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mx-4 mt-4">
|
||||
<Toolbar v-if="currentManga" :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
|
||||
<div v-if="errorDetails" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded mx-4 mt-4">
|
||||
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentManga" class="relative">
|
||||
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
||||
<MercureListener :manga-id="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 z-20">
|
||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 dark:text-gray-400 z-20">
|
||||
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
|
||||
@@ -24,7 +26,7 @@
|
||||
<div v-if="isLoadingVolumes" class="flex justify-center items-center h-32">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
<div v-else-if="errorVolumes" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-else-if="errorVolumes" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ errorVolumes.message || 'Une erreur est survenue lors du chargement des volumes.' }}
|
||||
</div>
|
||||
<MangaVolumeList
|
||||
@@ -84,9 +86,11 @@
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-gray-500 py-10 px-4">
|
||||
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-10 px-4">
|
||||
Aucun manga sélectionné ou trouvé.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -129,7 +133,7 @@ import { useMangaStore } from '../../application/store/mangaStore';
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
|
||||
const mangaId = computed(() => route.params.id || null);
|
||||
const mangaId = computed(() => Number(route.params.id) || null);
|
||||
|
||||
// État de la modale
|
||||
const isPreferredSourcesModalOpen = ref(false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useUserPreferencesStore } from '../../../setting/application/store/userPreferencesStore';
|
||||
import { Chapter } from '../../domain/entities/Chapter';
|
||||
import { ApiChapterRepository } from '../../infrastructure/repository/ApiChapterRepository';
|
||||
|
||||
@@ -13,7 +14,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
error: null,
|
||||
pages: [],
|
||||
totalPages: 0,
|
||||
loadedPages: new Set(), // Garder une trace des pages déjà chargées
|
||||
|
||||
// Paramètres pour les doubles pages
|
||||
doublePageSettings: {
|
||||
@@ -32,7 +32,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
|
||||
// Getters pour les doubles pages
|
||||
effectiveDoublePageMode: (state) => {
|
||||
// Si la détection automatique est désactivée, retourner 'normal'
|
||||
if (!state.doublePageSettings.autoDetect) {
|
||||
return 'normal';
|
||||
}
|
||||
@@ -55,28 +54,20 @@ export const useReaderStore = defineStore('reader', {
|
||||
try {
|
||||
const repository = new ApiChapterRepository();
|
||||
|
||||
// Charger les informations du chapitre
|
||||
const chapterData = await repository.getChapter(chapterId);
|
||||
const [chapterData, pagesData] = await Promise.all([
|
||||
repository.getChapter(chapterId),
|
||||
repository.getChapterPages(chapterId, 1, 9999),
|
||||
]);
|
||||
|
||||
this.currentChapter = Chapter.create(chapterData);
|
||||
|
||||
// Charger la liste des pages
|
||||
const pagesData = await repository.getChapterPages(chapterId);
|
||||
|
||||
// Initialiser le tableau avec des placeholders
|
||||
this.pages = new Array(pagesData.totalItems).fill(null);
|
||||
this.pages = pagesData.pages.map(p => ({
|
||||
id: p.id,
|
||||
pageNumber: p.pageNumber,
|
||||
url: p.url,
|
||||
dimensions: p.dimensions,
|
||||
}));
|
||||
this.totalPages = pagesData.totalItems;
|
||||
this.loadedPages.clear();
|
||||
|
||||
// Charger la première page
|
||||
if (this.totalPages > 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);
|
||||
}
|
||||
}
|
||||
this.currentPage = 0;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
@@ -84,100 +75,28 @@ export const useReaderStore = defineStore('reader', {
|
||||
}
|
||||
},
|
||||
|
||||
async loadPageData(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) {
|
||||
handlePageVisible(pageIndex) {
|
||||
if (pageIndex !== this.currentPage) {
|
||||
this.currentPage = pageIndex;
|
||||
// Précharger les pages suivantes
|
||||
if (this.readingMode === 'infinite') {
|
||||
await this.preloadNextPages(pageIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async nextPage() {
|
||||
nextPage() {
|
||||
if (!this.isLastPage) {
|
||||
this.currentPage++;
|
||||
await this.loadPageData(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
async previousPage() {
|
||||
previousPage() {
|
||||
if (!this.isFirstPage) {
|
||||
this.currentPage--;
|
||||
await this.loadPageData(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
async setReadingMode(mode) {
|
||||
if (mode === this.readingMode) return;
|
||||
|
||||
this.readingMode = mode;
|
||||
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) {
|
||||
@@ -190,7 +109,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
// Nouvelles actions pour les doubles pages
|
||||
setDoublePageMode(mode) {
|
||||
if (['rotate', 'scroll', 'normal'].includes(mode)) {
|
||||
this.doublePageSettings.mobileMode = mode;
|
||||
@@ -225,16 +143,10 @@ export const useReaderStore = defineStore('reader', {
|
||||
async goToPreviousChapter() {
|
||||
if (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);
|
||||
// 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() {
|
||||
try {
|
||||
const preferences = {
|
||||
@@ -252,10 +164,19 @@ export const useReaderStore = defineStore('reader', {
|
||||
loadPreferences() {
|
||||
try {
|
||||
const stored = localStorage.getItem('mangarr-reader-preferences');
|
||||
if (!stored) {
|
||||
const userPrefs = useUserPreferencesStore();
|
||||
this.readingDirection = userPrefs.readingDirection;
|
||||
const modeMap = { scroll: 'infinite', single: 'single', double: 'single' };
|
||||
this.readingMode = modeMap[userPrefs.readingMode] ?? 'single';
|
||||
if (userPrefs.readingMode === 'double') {
|
||||
this.doublePageSettings.autoDetect = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stored) {
|
||||
const preferences = JSON.parse(stored);
|
||||
|
||||
// Appliquer les préférences sauvegardées
|
||||
if (preferences.readingMode) this.readingMode = preferences.readingMode;
|
||||
if (preferences.readingDirection) this.readingDirection = preferences.readingDirection;
|
||||
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() {
|
||||
this.readingMode = 'single';
|
||||
this.readingDirection = 'ltr';
|
||||
|
||||
@@ -9,7 +9,7 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getChapterPages(chapterId, page = 1, itemsPerPage = 20) {
|
||||
async getChapterPages(chapterId, page = 1, itemsPerPage = 9999) {
|
||||
const response = await fetch(
|
||||
`/api/reader/chapter/${chapterId}/pages?page=${page}&itemsPerPage=${itemsPerPage}`
|
||||
);
|
||||
@@ -18,12 +18,4 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
|
||||
}
|
||||
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 v-else class="reader-content">
|
||||
<ReaderControls
|
||||
v-if="store.readingMode === 'single'"
|
||||
:current-page="store.currentPage"
|
||||
:total-pages="store.totalPages"
|
||||
:is-first-page="store.isFirstPage"
|
||||
:is-last-page="store.isLastPage"
|
||||
:available-chapters="availableChapters"
|
||||
:settings-open="settingsOpen"
|
||||
@previous="store.previousPage"
|
||||
@next="store.nextPage"
|
||||
@chapter-selected="handleChapterSelected"
|
||||
@toggle-settings="toggleSettings" />
|
||||
|
||||
<template v-if="store.readingMode === 'single'">
|
||||
<SingleModeReader
|
||||
:page-data="store.currentPageData"
|
||||
@@ -35,29 +22,10 @@
|
||||
:pages="store.pages"
|
||||
:zoom="store.zoom"
|
||||
:double-page-mode="store.effectiveDoublePageMode"
|
||||
:initial-page="store.currentPage"
|
||||
@page-visible="store.handlePageVisible"
|
||||
@buttons-visibility-change="handleButtonsVisibilityChange"
|
||||
ref="infiniteReaderRef" />
|
||||
</template>
|
||||
|
||||
<ReaderSettings
|
||||
:reading-mode="store.readingMode"
|
||||
:reading-direction="store.readingDirection"
|
||||
:zoom="store.zoom"
|
||||
:double-page-mode="store.effectiveDoublePageMode"
|
||||
:double-page-settings="store.doublePageSettings"
|
||||
:visible="showFloatingButtons"
|
||||
:force-open="store.readingMode === 'single' ? settingsOpen : null"
|
||||
@toggle-reading-mode="toggleReadingMode"
|
||||
@toggle-reading-direction="toggleReadingDirection"
|
||||
@zoom-in="zoomIn"
|
||||
@zoom-out="zoomOut"
|
||||
@zoom-change="handleZoomChange"
|
||||
@double-page-mode-change="handleDoublePageModeChange"
|
||||
@double-page-auto-detect-change="handleDoublePageAutoDetectChange"
|
||||
@detection-threshold-change="handleDetectionThresholdChange"
|
||||
@reset-preferences="handleResetPreferences"
|
||||
@button-click="resetButtonsTimer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,10 +33,9 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||
import { useReaderStore } from '../../application/store/readerStore';
|
||||
import InfiniteReader from './InfiniteReader.vue';
|
||||
import ReaderControls from './ReaderControls.vue';
|
||||
import ReaderSettings from './ReaderSettings.vue';
|
||||
import SingleModeReader from './SingleModeReader.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -84,128 +51,64 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
|
||||
const store = useReaderStore();
|
||||
const headerStore = useHeaderStore();
|
||||
const prefs = useUserPreferencesStore();
|
||||
|
||||
// Référence vers InfiniteReader pour accéder à ses méthodes
|
||||
const infiniteReaderRef = ref(null);
|
||||
|
||||
// État pour la visibilité des boutons (géré par InfiniteReader en mode infini, localement en mode simple)
|
||||
const showFloatingButtons = ref(false);
|
||||
const settingsOpen = ref(false); // Nouvel état pour gérer l'ouverture des paramètres
|
||||
let localButtonsTimer = null;
|
||||
|
||||
// Actions de l'interface lecteur
|
||||
const toggleReadingMode = () => {
|
||||
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
|
||||
store.setReadingMode(newMode);
|
||||
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
|
||||
|
||||
// Gérer la visibilité selon le mode
|
||||
if (newMode === 'single') {
|
||||
headerStore.disableAutoHide();
|
||||
// En mode simple : toujours visible
|
||||
showFloatingButtons.value = true;
|
||||
clearTimeout(localButtonsTimer); // Annuler tout timer local
|
||||
headerStore.disableReaderToolbarAutoHide();
|
||||
} else {
|
||||
// En mode infini : utiliser la logique d'InfiniteReader
|
||||
headerStore.enableReaderToolbarAutoHide();
|
||||
headerStore.enableAutoHide();
|
||||
showButtonsWithTimer();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReadingDirection = () => {
|
||||
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
|
||||
resetButtonsTimer();
|
||||
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
|
||||
store.setReadingDirection(newDir);
|
||||
prefs.setReadingDirection(newDir);
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||
resetButtonsTimer();
|
||||
};
|
||||
const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||
const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||
|
||||
const zoomOut = () => {
|
||||
store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||
resetButtonsTimer();
|
||||
};
|
||||
const handleZoomChange = (zoom) => store.setZoom(zoom);
|
||||
|
||||
const handleZoomChange = (zoom) => {
|
||||
store.setZoom(zoom);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
const handleDoublePageModeChange = (mode) => store.setDoublePageMode(mode);
|
||||
const handleDoublePageAutoDetectChange = (enabled) => store.setDoublePageAutoDetect(enabled);
|
||||
const handleDetectionThresholdChange = (threshold) => store.setDoublePageDetectionThreshold(threshold);
|
||||
const handleResetPreferences = () => store.resetPreferences();
|
||||
|
||||
// Fonctions pour les doubles pages
|
||||
const handleDoublePageModeChange = (mode) => {
|
||||
store.setDoublePageMode(mode);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const handleDoublePageAutoDetectChange = (enabled) => {
|
||||
store.setDoublePageAutoDetect(enabled);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const handleDetectionThresholdChange = (threshold) => {
|
||||
store.setDoublePageDetectionThreshold(threshold);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
const handleResetPreferences = () => {
|
||||
store.resetPreferences();
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
// Fonction pour afficher les boutons avec timer (avec fallback pour mode simple)
|
||||
const showButtonsWithTimer = () => {
|
||||
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
||||
// Mode infini : utiliser la logique d'InfiniteReader
|
||||
infiniteReaderRef.value.showButtonsWithTimer();
|
||||
} else {
|
||||
// Mode simple : toujours visible, pas de timer
|
||||
showFloatingButtons.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction centralisée pour réinitialiser le timer
|
||||
const resetButtonsTimer = () => {
|
||||
if (store.readingMode === 'infinite' && infiniteReaderRef.value) {
|
||||
// Mode infini : utiliser la logique d'InfiniteReader
|
||||
infiniteReaderRef.value.resetButtonsTimer();
|
||||
} else {
|
||||
// Mode simple : toujours visible, pas de timer
|
||||
showFloatingButtons.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Gestionnaire pour les changements de visibilité des boutons
|
||||
const handleButtonsVisibilityChange = (visible) => {
|
||||
if (store.readingMode === 'infinite') {
|
||||
showFloatingButtons.value = visible;
|
||||
}
|
||||
// En mode simple, on ignore les changements et on reste toujours visible
|
||||
};
|
||||
|
||||
const handleKeyPress = event => {
|
||||
if (store.readingMode === 'single') {
|
||||
if (event.key === 'ArrowRight') {
|
||||
store.nextPage();
|
||||
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
store.previousPage();
|
||||
showButtonsWithTimer(); // Afficher les boutons lors de la navigation clavier
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChapterSelected = (chapterId) => {
|
||||
// La navigation est déjà gérée par le ChapterSelector via le store
|
||||
// Cette fonction est là pour d'éventuelles actions supplémentaires
|
||||
console.log('Chapitre sélectionné:', chapterId);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
// Gestion des paramètres via le bouton intégré
|
||||
const toggleSettings = () => {
|
||||
settingsOpen.value = !settingsOpen.value;
|
||||
resetButtonsTimer(); // Réinitialiser le timer lors de l'interaction
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.chapterId,
|
||||
newId => {
|
||||
@@ -217,28 +120,46 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Charger les préférences sauvegardées
|
||||
store.loadPreferences();
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
// Afficher les boutons au démarrage
|
||||
showButtonsWithTimer();
|
||||
if (prefs.autoHideHeaderReader) {
|
||||
headerStore.enableAutoHide();
|
||||
}
|
||||
|
||||
if (store.readingMode === 'infinite') {
|
||||
headerStore.enableReaderToolbarAutoHide();
|
||||
}
|
||||
|
||||
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyPress);
|
||||
// S'assurer que l'auto-hide est désactivé en quittant le lecteur
|
||||
headerStore.disableAutoHide();
|
||||
// Nettoyer le timer local
|
||||
clearTimeout(localButtonsTimer);
|
||||
headerStore.disableReaderToolbarAutoHide();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
toggleReadingMode,
|
||||
toggleReadingDirection,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
handleZoomChange,
|
||||
handleDoublePageModeChange,
|
||||
handleDoublePageAutoDetectChange,
|
||||
handleDetectionThresholdChange,
|
||||
handleResetPreferences,
|
||||
resetButtonsTimer,
|
||||
showButtonsWithTimer,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.chapter-reader {
|
||||
@apply w-full h-full flex flex-col items-center justify-center bg-gray-900 text-white;
|
||||
@apply p-0 sm:p-2;
|
||||
@apply w-full h-full flex flex-col bg-gray-900 text-white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -250,8 +171,7 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
}
|
||||
|
||||
.reader-content {
|
||||
@apply w-full h-full flex flex-col;
|
||||
@apply p-0 sm:p-2;
|
||||
@apply w-full flex-1 flex flex-col min-h-0;
|
||||
}
|
||||
|
||||
.rtl {
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
<template>
|
||||
<div class="infinite-reader" ref="containerRef">
|
||||
<!-- Navigation en haut -->
|
||||
<div class="navigation-wrapper top">
|
||||
<ChapterNavigation position="top" />
|
||||
</div>
|
||||
<div v-for="(page, index) in pages" :key="index"
|
||||
class="page-wrapper" :data-page-index="index">
|
||||
|
||||
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
|
||||
<div v-if="page?.loading" class="loading">
|
||||
<!-- Pas d'URL : spinner de chargement -->
|
||||
<div v-if="!page?.url" class="loading">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
<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 -->
|
||||
<div class="navigation-wrapper bottom">
|
||||
<ChapterNavigation position="bottom" />
|
||||
<!-- Hors de la zone de rendu : placeholder dimensionné -->
|
||||
<div v-else-if="!mountedPageIndices.has(index)"
|
||||
class="page-placeholder"
|
||||
:style="{ height: getPlaceholderHeight(page) + 'px' }" />
|
||||
|
||||
<!-- Dans la zone : composant complet -->
|
||||
<ReaderPage v-else
|
||||
:page-data="page"
|
||||
:page-number="index + 1"
|
||||
:zoom="zoom"
|
||||
:double-page-mode="doublePageMode"
|
||||
:window-width="windowWidth"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
|
||||
<!-- Bouton flottant pour revenir en haut -->
|
||||
@@ -32,22 +35,22 @@
|
||||
<button
|
||||
v-show="showFloatingButtons"
|
||||
@click="scrollToTop"
|
||||
class="fixed bottom-6 right-6 z-[9999] bg-blue-600 hover:bg-blue-700 text-white w-12 h-12 rounded-full shadow-lg hover:shadow-xl flex items-center justify-center transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
class="fixed bottom-6 right-6 z-[9999] bg-gray-800 hover:bg-gray-700 text-white hover:text-green-500 flex flex-col items-center justify-center w-12 h-12 rounded shadow-lg transition-colors duration-200"
|
||||
title="Revenir en haut"
|
||||
type="button"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
<span class="text-xs hidden sm:inline">Haut</span>
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import ChapterNavigation from './ChapterNavigation.vue';
|
||||
import ReaderPage from './ReaderPage.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -70,6 +73,8 @@ import ReaderPage from './ReaderPage.vue';
|
||||
const headerStore = useHeaderStore();
|
||||
const containerRef = ref(null);
|
||||
const observer = ref(null);
|
||||
const visibilityObserver = ref(null);
|
||||
const mountedPageIndices = reactive(new Set());
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
|
||||
// État unique pour tous les boutons flottants avec timer de 3 secondes
|
||||
@@ -89,24 +94,46 @@ import ReaderPage from './ReaderPage.vue';
|
||||
});
|
||||
};
|
||||
|
||||
const setupIntersectionObserver = () => {
|
||||
if (observer.value) {
|
||||
observer.value.disconnect();
|
||||
}
|
||||
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage, zoom inclus
|
||||
const getPlaceholderHeight = (page) => {
|
||||
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, {
|
||||
root: null,
|
||||
root: containerRef.value,
|
||||
threshold: 0.5
|
||||
});
|
||||
|
||||
nextTick(() => {
|
||||
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
|
||||
if (pageElements) {
|
||||
pageElements.forEach((element, index) => {
|
||||
element.setAttribute('data-page-index', index);
|
||||
observer.value.observe(element);
|
||||
visibilityObserver.value = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach(entry => {
|
||||
const idx = parseInt(entry.target.getAttribute('data-page-index'));
|
||||
if (entry.isIntersecting) {
|
||||
mountedPageIndices.add(idx);
|
||||
} else {
|
||||
mountedPageIndices.delete(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ root: 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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -172,10 +199,8 @@ import ReaderPage from './ReaderPage.vue';
|
||||
scrollDirection = 'up';
|
||||
}
|
||||
|
||||
// Gestion du header auto-hide (seulement si largeur < 1200px)
|
||||
if (windowWidth.value < 1200) {
|
||||
headerStore.updateScrollDirection(scrollTop);
|
||||
}
|
||||
// Gestion du header auto-hide (header : seulement si largeur < 1200px, toolbar : toujours)
|
||||
headerStore.updateScrollDirection(scrollTop);
|
||||
|
||||
// Gestion de la visibilité des boutons flottants (même condition pour tous)
|
||||
// Afficher si on scroll et qu'on est à plus de 300px
|
||||
@@ -192,21 +217,16 @@ import ReaderPage from './ReaderPage.vue';
|
||||
|
||||
// Fonction pour revenir en haut de la page
|
||||
const scrollToTop = () => {
|
||||
console.log('scrollToTop appelée'); // Debug
|
||||
|
||||
// Réinitialiser le timer lors du clic
|
||||
resetButtonsTimer();
|
||||
|
||||
// Stratégie 1: Scroll sur le conteneur direct
|
||||
if (containerRef.value) {
|
||||
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
|
||||
|
||||
if (containerRef.value.scrollTop > 0) {
|
||||
containerRef.value.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
console.log('Scroll sur containerRef effectué'); // Debug
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -216,7 +236,6 @@ import ReaderPage from './ReaderPage.vue';
|
||||
while (currentElement) {
|
||||
const styles = window.getComputedStyle(currentElement);
|
||||
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
|
||||
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
|
||||
currentElement.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
@@ -227,7 +246,6 @@ import ReaderPage from './ReaderPage.vue';
|
||||
}
|
||||
|
||||
// Stratégie 3: Scroll sur la fenêtre entière
|
||||
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
@@ -243,7 +261,8 @@ import ReaderPage from './ReaderPage.vue';
|
||||
watch(
|
||||
() => props.pages,
|
||||
() => {
|
||||
setupIntersectionObserver();
|
||||
mountedPageIndices.clear();
|
||||
setupObservers();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
@@ -262,7 +281,7 @@ import ReaderPage from './ReaderPage.vue';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver();
|
||||
setupObservers();
|
||||
|
||||
// Activer l'auto-hide du header si la largeur < 1200px
|
||||
if (windowWidth.value < 1200) {
|
||||
@@ -282,9 +301,8 @@ import ReaderPage from './ReaderPage.vue';
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer.value) {
|
||||
observer.value.disconnect();
|
||||
}
|
||||
observer.value?.disconnect();
|
||||
visibilityObserver.value?.disconnect();
|
||||
|
||||
// Désactiver l'auto-hide du header en quittant
|
||||
headerStore.disableAutoHide();
|
||||
@@ -307,19 +325,22 @@ import ReaderPage from './ReaderPage.vue';
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.infinite-reader {
|
||||
@apply flex-1 flex flex-col items-center overflow-y-auto relative;
|
||||
@apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
|
||||
/* Réduction du padding sur mobile */
|
||||
@apply py-2 sm:py-8;
|
||||
height: calc(100vh - 8rem);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
@apply w-full flex justify-center min-h-[200px];
|
||||
/* Réduction des marges sur mobile */
|
||||
@apply w-full flex justify-center;
|
||||
@apply mb-2 sm:mb-4 px-1 sm:px-4;
|
||||
}
|
||||
|
||||
.page-placeholder {
|
||||
@apply w-full;
|
||||
max-width: 1200px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
@apply flex items-center justify-center min-h-[400px];
|
||||
@@ -345,15 +366,4 @@ import ReaderPage from './ReaderPage.vue';
|
||||
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
|
||||
}
|
||||
|
||||
.navigation-wrapper {
|
||||
@apply w-full max-w-4xl mx-auto px-4 mb-6;
|
||||
}
|
||||
|
||||
.navigation-wrapper.top {
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
.navigation-wrapper.bottom {
|
||||
@apply mt-8 mb-4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div class="page-container" :style="{ transform: `scale(${zoom})` }">
|
||||
<div
|
||||
class="page-container"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div>
|
||||
<div v-else-if="!pageData.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 -->
|
||||
<div v-else-if="isDoublePage && isMobile && doublePageMode !== 'normal'" class="double-page-mobile">
|
||||
@@ -75,23 +78,33 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
type: String,
|
||||
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
||||
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
||||
},
|
||||
windowWidth: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const store = useReaderStore();
|
||||
|
||||
// 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 scrollContainerRef = ref(null);
|
||||
const naturalWidth = ref(0);
|
||||
const naturalHeight = ref(0);
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
const localWindowWidth = ref(window.innerWidth);
|
||||
const effectiveWindowWidth = computed(() =>
|
||||
props.windowWidth !== null ? props.windowWidth : localWindowWidth.value
|
||||
);
|
||||
const isMobile = computed(() => effectiveWindowWidth.value < 768);
|
||||
const imageLoaded = ref(false);
|
||||
|
||||
const imageSource = computed(() => {
|
||||
if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
|
||||
return '';
|
||||
}
|
||||
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`;
|
||||
return props.pageData?.url ?? '';
|
||||
});
|
||||
|
||||
// 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
|
||||
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
||||
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
|
||||
const isDouble = ratio > threshold;
|
||||
console.log(`API Dimensions - Page ${props.pageNumber}: ${props.pageData.dimensions.width}x${props.pageData.dimensions.height}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
|
||||
return isDouble;
|
||||
return ratio > threshold;
|
||||
}
|
||||
|
||||
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
|
||||
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
|
||||
const ratio = naturalWidth.value / naturalHeight.value;
|
||||
const isDouble = ratio > threshold;
|
||||
console.log(`Natural Dimensions - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
|
||||
return isDouble;
|
||||
return ratio > threshold;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -127,7 +136,6 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
naturalWidth.value = imageRef.value.naturalWidth;
|
||||
naturalHeight.value = imageRef.value.naturalHeight;
|
||||
imageLoaded.value = true;
|
||||
console.log(`Image loaded - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}`);
|
||||
|
||||
// Positionner le scroll à droite si c'est le mode scroll
|
||||
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
|
||||
@@ -178,7 +186,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
|
||||
if (!width || !height) return null;
|
||||
|
||||
const availableWidth = windowWidth.value;
|
||||
const availableWidth = effectiveWindowWidth.value;
|
||||
|
||||
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur
|
||||
if (availableWidth < 1200) {
|
||||
@@ -190,13 +198,27 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
});
|
||||
|
||||
const imageStyle = computed(() => {
|
||||
if (!maxWidth.value) return {};
|
||||
// Mode simple : laisser CSS contraindre les deux dimensions proportionnellement
|
||||
if (store.readingMode === 'single') {
|
||||
return {
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: `${maxWidth.value}px`,
|
||||
// Mode scroll : fixer la largeur, hauteur libre
|
||||
const style = {
|
||||
height: 'auto',
|
||||
maxWidth: '100%'
|
||||
maxWidth: '100%',
|
||||
};
|
||||
|
||||
if (maxWidth.value) {
|
||||
style.width = `${maxWidth.value}px`;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
// Styles spéciaux pour les doubles pages
|
||||
@@ -213,7 +235,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
if (!width || !height) return {};
|
||||
|
||||
// En mode rotation : maximiser l'utilisation de l'espace
|
||||
const availableWidth = windowWidth.value;
|
||||
const availableWidth = effectiveWindowWidth.value;
|
||||
const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles
|
||||
|
||||
// Après rotation, la largeur originale devient la hauteur affichée
|
||||
@@ -263,36 +285,32 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
};
|
||||
});
|
||||
|
||||
// Gestion du redimensionnement de la fenêtre
|
||||
const handleResize = () => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
};
|
||||
let ownResizeHandler = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (imageRef.value && imageRef.value.complete) {
|
||||
handleImageLoad();
|
||||
if (props.windowWidth === null) {
|
||||
ownResizeHandler = () => { localWindowWidth.value = window.innerWidth; };
|
||||
window.addEventListener('resize', ownResizeHandler, { passive: true });
|
||||
}
|
||||
window.addEventListener('resize', handleResize);
|
||||
if (imageRef.value?.complete) handleImageLoad();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (ownResizeHandler) window.removeEventListener('resize', ownResizeHandler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.page-container {
|
||||
@apply flex-1 flex items-center justify-center overflow-hidden;
|
||||
@apply flex items-center justify-center;
|
||||
transform-origin: center;
|
||||
/* Réduction des marges sur mobile */
|
||||
@apply p-0 sm:p-2;
|
||||
}
|
||||
|
||||
.page-image {
|
||||
@apply object-contain;
|
||||
/* La largeur est gérée par le JavaScript, on garde juste les contraintes max */
|
||||
/* La largeur et max-height sont gérées par imageStyle selon le mode */
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Styles pour les doubles pages sur mobile */
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
<template>
|
||||
<div class="reader-settings">
|
||||
<!-- Bouton pour ouvrir/fermer les paramètres -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-5 scale-75"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-5 scale-75"
|
||||
>
|
||||
<button
|
||||
v-show="visible"
|
||||
@click="toggleSettings"
|
||||
class="settings-toggle"
|
||||
:class="{ 'active': effectiveIsOpen }"
|
||||
:data-external-control="forceOpen !== null"
|
||||
title="Paramètres du lecteur"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||
</svg>
|
||||
</button>
|
||||
</Transition>
|
||||
|
||||
<!-- Panel des paramètres -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
@@ -32,63 +8,9 @@
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-4 scale-95"
|
||||
>
|
||||
<div v-show="effectiveIsOpen" class="settings-panel" :data-external-control="forceOpen !== null" ref="panelRef">
|
||||
<!-- Paramètres de base -->
|
||||
<div class="settings-section">
|
||||
<h3 class="section-title">Mode de lecture</h3>
|
||||
<div class="setting-group">
|
||||
<button
|
||||
@click="onToggleReadingMode"
|
||||
class="setting-button"
|
||||
:class="{ 'active': readingMode === 'infinite' }"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
||||
{{ readingMode === 'single' ? 'Mode Infini' : 'Mode Simple' }}
|
||||
</button>
|
||||
<div v-show="open" class="settings-panel" ref="panelRef">
|
||||
|
||||
<button
|
||||
@click="onToggleReadingDirection"
|
||||
class="setting-button"
|
||||
:class="{ 'active': readingDirection === 'rtl' }"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18" />
|
||||
</svg>
|
||||
{{ readingDirection === 'ltr' ? 'RTL' : 'LTR' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contrôles du zoom -->
|
||||
<div class="settings-section">
|
||||
<h3 class="section-title">Zoom</h3>
|
||||
<div class="zoom-controls">
|
||||
<button @click="onZoomOut" class="zoom-button" :disabled="zoom <= 0.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="zoom-display">{{ Math.round(zoom * 100) }}%</span>
|
||||
<button @click="onZoomIn" class="zoom-button" :disabled="zoom >= 2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:value="zoom"
|
||||
@input="onZoomChange($event.target.value)"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.1"
|
||||
class="zoom-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Paramètres des doubles pages -->
|
||||
<!-- Paramètres des doubles pages (mobile uniquement) -->
|
||||
<div class="settings-section" v-if="isMobile">
|
||||
<h3 class="section-title">
|
||||
<svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -97,7 +19,6 @@
|
||||
Doubles pages (Mobile)
|
||||
</h3>
|
||||
|
||||
<!-- Activation/désactivation -->
|
||||
<div class="setting-item">
|
||||
<label class="setting-label">
|
||||
<input
|
||||
@@ -113,7 +34,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mode d'affichage (si la détection automatique est activée) -->
|
||||
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
||||
<label class="setting-label">Mode d'affichage</label>
|
||||
<select
|
||||
@@ -125,22 +45,13 @@
|
||||
<option value="scroll">Défilement horizontal</option>
|
||||
<option value="normal">Affichage normal</option>
|
||||
</select>
|
||||
|
||||
<!-- Descriptions des modes -->
|
||||
<p class="setting-description">
|
||||
<span v-if="doublePageMode === 'rotate'">
|
||||
Suggère de tourner l'appareil pour une meilleure lecture
|
||||
</span>
|
||||
<span v-else-if="doublePageMode === 'scroll'">
|
||||
Permet le défilement horizontal pour naviguer dans la page (commence à droite)
|
||||
</span>
|
||||
<span v-else>
|
||||
Affichage standard sans optimisation spéciale
|
||||
</span>
|
||||
<span v-if="doublePageMode === 'rotate'">Suggère de tourner l'appareil pour une meilleure lecture</span>
|
||||
<span v-else-if="doublePageMode === 'scroll'">Permet le défilement horizontal pour naviguer dans la page (commence à droite)</span>
|
||||
<span v-else>Affichage standard sans optimisation spéciale</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Seuil de détection -->
|
||||
<div v-if="doublePageSettings.autoDetect" class="setting-item">
|
||||
<label class="setting-label">
|
||||
Sensibilité de détection: {{ doublePageSettings.detectionThreshold.toFixed(1) }}
|
||||
@@ -160,14 +71,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<!-- Réinitialiser -->
|
||||
<div class="settings-section">
|
||||
<div class="setting-actions">
|
||||
<button @click="onResetPreferences" class="action-button reset">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Réinitialiser
|
||||
Réinitialiser les préférences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,21 +88,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
readingMode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
readingDirection: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
zoom: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
doublePageMode: {
|
||||
type: String,
|
||||
default: 'rotate'
|
||||
@@ -204,138 +103,38 @@
|
||||
detectionThreshold: 1.4
|
||||
})
|
||||
},
|
||||
// Visibilité contrôlée par le parent
|
||||
visible: {
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// Contrôle externe de l'ouverture (pour le bouton intégré)
|
||||
forceOpen: {
|
||||
type: Boolean,
|
||||
default: null // null = pas de contrôle externe, true/false = contrôle externe
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'toggleReadingMode',
|
||||
'toggleReadingDirection',
|
||||
'zoomIn',
|
||||
'zoomOut',
|
||||
'zoomChange',
|
||||
'toggleSettings',
|
||||
'doublePageModeChange',
|
||||
'doublePageAutoDetectChange',
|
||||
'detectionThresholdChange',
|
||||
'resetPreferences',
|
||||
'buttonClick' // Signaler l'interaction au parent
|
||||
]);
|
||||
|
||||
const isOpen = ref(false);
|
||||
const isMobile = computed(() => window.innerWidth < 768);
|
||||
const panelRef = ref(null);
|
||||
|
||||
// Computed pour gérer l'état d'ouverture (interne ou externe)
|
||||
const effectiveIsOpen = computed(() => {
|
||||
// Si forceOpen est défini (true/false), on l'utilise
|
||||
if (props.forceOpen !== null) {
|
||||
return props.forceOpen;
|
||||
}
|
||||
// Sinon, on utilise l'état interne
|
||||
return isOpen.value;
|
||||
});
|
||||
|
||||
const toggleSettings = () => {
|
||||
// Si on est en contrôle externe, ne pas permettre le toggle via le bouton flottant
|
||||
if (props.forceOpen !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = !isOpen.value;
|
||||
// Signaler l'interaction au parent
|
||||
emit('buttonClick');
|
||||
};
|
||||
|
||||
// Fonction pour fermer le panel (utilisée par les clics externes et internes)
|
||||
const closePanel = () => {
|
||||
if (props.forceOpen !== null) {
|
||||
// Mode externe : émettre l'événement pour que le parent gère la fermeture
|
||||
emit('buttonClick');
|
||||
} else {
|
||||
// Mode interne : fermer directement
|
||||
isOpen.value = false;
|
||||
emit('buttonClick');
|
||||
}
|
||||
};
|
||||
|
||||
// Gestion des clics en dehors du panel
|
||||
const handleClickOutside = (event) => {
|
||||
if (effectiveIsOpen.value && panelRef.value && !panelRef.value.contains(event.target)) {
|
||||
// Vérifier que le clic n'est pas sur le bouton de toggle
|
||||
const settingsButton = document.querySelector('.settings-toggle, .settings-button');
|
||||
if (settingsButton && settingsButton.contains(event.target)) {
|
||||
return; // Laisser le bouton gérer le toggle
|
||||
}
|
||||
|
||||
closePanel();
|
||||
if (props.open && panelRef.value && !panelRef.value.contains(event.target)) {
|
||||
emit('toggleSettings');
|
||||
}
|
||||
};
|
||||
|
||||
// Watcher pour empêcher la fermeture du bouton quand le panel est ouvert
|
||||
watch(
|
||||
() => effectiveIsOpen.value,
|
||||
(newIsOpen) => {
|
||||
if (newIsOpen || !newIsOpen) {
|
||||
// Signaler l'interaction à chaque changement
|
||||
emit('buttonClick');
|
||||
}
|
||||
}
|
||||
);
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside, true));
|
||||
onUnmounted(() => document.removeEventListener('click', handleClickOutside, true));
|
||||
|
||||
// Cycle de vie des event listeners
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside, true);
|
||||
});
|
||||
|
||||
// Méthodes des événements (toutes signalent l'interaction)
|
||||
const onToggleReadingMode = () => {
|
||||
emit('toggleReadingMode');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onToggleReadingDirection = () => {
|
||||
emit('toggleReadingDirection');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onZoomIn = () => {
|
||||
emit('zoomIn');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onZoomOut = () => {
|
||||
emit('zoomOut');
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onZoomChange = (value) => {
|
||||
emit('zoomChange', parseFloat(value));
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDoublePageModeChange = (mode) => {
|
||||
emit('doublePageModeChange', mode);
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDoublePageAutoDetectChange = (enabled) => {
|
||||
emit('doublePageAutoDetectChange', enabled);
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDetectionThresholdChange = (threshold) => {
|
||||
emit('detectionThresholdChange', parseFloat(threshold));
|
||||
emit('buttonClick');
|
||||
};
|
||||
const onDoublePageModeChange = (mode) => emit('doublePageModeChange', mode);
|
||||
const onDoublePageAutoDetectChange = (enabled) => emit('doublePageAutoDetectChange', enabled);
|
||||
const onDetectionThresholdChange = (threshold) => emit('detectionThresholdChange', parseFloat(threshold));
|
||||
const onResetPreferences = () => {
|
||||
emit('resetPreferences');
|
||||
emit('buttonClick');
|
||||
isOpen.value = false;
|
||||
emit('toggleSettings');
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -344,25 +143,10 @@
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
@apply fixed top-20 right-4 z-50 w-12 h-12 bg-gray-800 hover:bg-gray-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
/* Masquer le bouton flottant si on est en contrôle externe */
|
||||
.settings-toggle[data-external-control="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-toggle.active {
|
||||
@apply bg-blue-600 hover:bg-blue-700;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
@apply fixed top-36 right-4 z-40 w-80 max-w-[calc(100vw-2rem)] bg-gray-800 rounded-lg shadow-xl border border-gray-700 max-h-[80vh] overflow-y-auto;
|
||||
@apply fixed top-20 right-4 z-40 w-80 max-w-[calc(100vw-2rem)] bg-gray-800 rounded-lg shadow-xl border border-gray-700 max-h-[80vh] overflow-y-auto;
|
||||
}
|
||||
|
||||
/* Responsive pour settings-panel */
|
||||
@media (max-width: 480px) {
|
||||
.settings-panel {
|
||||
width: 90vw;
|
||||
@@ -371,14 +155,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Position adaptative pour le contrôle externe (bouton intégré) */
|
||||
.settings-panel[data-external-control="true"] {
|
||||
@apply top-32 left-1/2 right-auto;
|
||||
transform: translateX(-50%);
|
||||
/* S'assurer qu'il ne couvre pas les contrôles */
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
@apply p-4 border-b border-gray-700 last:border-b-0;
|
||||
}
|
||||
@@ -387,44 +163,6 @@
|
||||
@apply text-white font-semibold text-lg mb-3 flex items-center;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.setting-button {
|
||||
@apply flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors duration-200 text-sm;
|
||||
}
|
||||
|
||||
.setting-button.active {
|
||||
@apply bg-blue-600 hover:bg-blue-700;
|
||||
}
|
||||
|
||||
/* Contrôles du zoom */
|
||||
.zoom-controls {
|
||||
@apply flex items-center gap-3 mb-2;
|
||||
}
|
||||
|
||||
.zoom-button {
|
||||
@apply w-8 h-8 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-white rounded flex items-center justify-center transition-colors;
|
||||
}
|
||||
|
||||
.zoom-display {
|
||||
@apply text-white font-mono text-sm min-w-[3rem] text-center;
|
||||
}
|
||||
|
||||
.zoom-slider {
|
||||
@apply w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer;
|
||||
}
|
||||
|
||||
.zoom-slider::-webkit-slider-thumb {
|
||||
@apply appearance-none w-4 h-4 bg-blue-600 rounded-full cursor-pointer;
|
||||
}
|
||||
|
||||
.zoom-slider::-moz-range-thumb {
|
||||
@apply w-4 h-4 bg-blue-600 rounded-full cursor-pointer border-none;
|
||||
}
|
||||
|
||||
/* Paramètres des doubles pages */
|
||||
.setting-item {
|
||||
@apply mb-4 last:mb-0;
|
||||
}
|
||||
@@ -457,7 +195,6 @@
|
||||
@apply text-gray-400 text-xs leading-relaxed;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.setting-actions {
|
||||
@apply flex gap-2;
|
||||
}
|
||||
@@ -470,23 +207,9 @@
|
||||
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.settings-panel {
|
||||
@apply right-2 w-72;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
@apply right-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pour les très petits écrans */
|
||||
@media (max-width: 480px) {
|
||||
.settings-toggle {
|
||||
right: 0.25rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<Toolbar :config="toolbarConfig">
|
||||
<template #center>
|
||||
<!-- Mode simple : navigation entre pages -->
|
||||
<div v-if="store.readingMode === 'single'" class="flex items-center gap-1">
|
||||
<button
|
||||
@click="store.previousPage()"
|
||||
:disabled="store.isFirstPage"
|
||||
class="nav-btn"
|
||||
title="Page précédente"
|
||||
>
|
||||
<ChevronLeftIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<span class="text-white text-sm w-16 text-center">
|
||||
{{ store.currentPage + 1 }} / {{ store.totalPages }}
|
||||
</span>
|
||||
<button
|
||||
@click="store.nextPage()"
|
||||
:disabled="store.isLastPage"
|
||||
class="nav-btn"
|
||||
title="Page suivante"
|
||||
>
|
||||
<ChevronRightIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mode scroll : navigation entre chapitres (ordre inversé en RTL) -->
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<button
|
||||
@click="leftChapterAction"
|
||||
:disabled="!canGoLeftChapter || store.isLoading"
|
||||
class="chapter-nav-btn"
|
||||
:title="store.readingDirection === 'rtl' ? 'Chapitre suivant' : 'Chapitre précédent'"
|
||||
>
|
||||
<ChevronDoubleLeftIcon class="h-4 w-4 flex-shrink-0" />
|
||||
<span class="text-xs">{{ store.readingDirection === 'rtl' ? 'Suivant' : 'Précédent' }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="rightChapterAction"
|
||||
:disabled="!canGoRightChapter || store.isLoading"
|
||||
class="chapter-nav-btn"
|
||||
:title="store.readingDirection === 'rtl' ? 'Chapitre précédent' : 'Chapitre suivant'"
|
||||
>
|
||||
<span class="text-xs">{{ store.readingDirection === 'rtl' ? 'Précédent' : 'Suivant' }}</span>
|
||||
<ChevronDoubleRightIcon class="h-4 w-4 flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ChevronDoubleLeftIcon,
|
||||
ChevronDoubleRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DocumentIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
ListBulletIcon,
|
||||
MinusIcon,
|
||||
PlusIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import { useReaderStore } from '../../application/store/readerStore';
|
||||
|
||||
const props = defineProps({
|
||||
chapterReaderRef: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const store = useReaderStore();
|
||||
const headerStore = useHeaderStore();
|
||||
const router = useRouter();
|
||||
|
||||
// Vue auto-unwrap les refs dans le template : chapterReaderRef est déjà l'instance
|
||||
const reader = computed(() => props.chapterReaderRef);
|
||||
|
||||
const goBack = () => {
|
||||
const mangaId = store.currentChapter?.mangaId;
|
||||
if (mangaId) {
|
||||
router.push({ name: 'manga-details', params: { id: mangaId } });
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReadingMode = () => reader.value?.toggleReadingMode();
|
||||
const toggleReadingDirection = () => reader.value?.toggleReadingDirection();
|
||||
const zoomIn = () => store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||
const zoomOut = () => store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||
|
||||
// En RTL, le bouton gauche (◄◄) avance dans l'histoire (chapitre suivant)
|
||||
const isRtl = computed(() => store.readingDirection === 'rtl');
|
||||
const leftChapterAction = () => isRtl.value ? store.goToNextChapter() : store.goToPreviousChapter();
|
||||
const rightChapterAction = () => isRtl.value ? store.goToPreviousChapter() : store.goToNextChapter();
|
||||
const canGoLeftChapter = computed(() => isRtl.value ? store.hasNextChapter : store.hasPreviousChapter);
|
||||
const canGoRightChapter = computed(() => isRtl.value ? store.hasPreviousChapter : store.hasNextChapter);
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: ArrowLeftIcon,
|
||||
label: 'Retour',
|
||||
onClick: goBack,
|
||||
},
|
||||
{
|
||||
type: 'label',
|
||||
text: store.currentChapter?.title || '',
|
||||
class: 'text-sm font-medium',
|
||||
},
|
||||
...(store.currentChapter?.number != null ? [{
|
||||
type: 'label',
|
||||
text: `Ch.${store.currentChapter.number}`,
|
||||
}] : []),
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: store.readingMode === 'single' ? ListBulletIcon : DocumentIcon,
|
||||
label: store.readingMode === 'single' ? 'Scroll' : 'Simple',
|
||||
active: store.readingMode === 'infinite',
|
||||
onClick: toggleReadingMode,
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
label: store.readingDirection.toUpperCase(),
|
||||
active: store.readingDirection === 'rtl',
|
||||
onClick: toggleReadingDirection,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'button',
|
||||
icon: MinusIcon,
|
||||
disabled: store.zoom <= 0.5,
|
||||
onClick: zoomOut,
|
||||
},
|
||||
{
|
||||
type: 'label',
|
||||
text: `${Math.round(store.zoom * 100)}%`,
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: PlusIcon,
|
||||
disabled: store.zoom >= 2,
|
||||
onClick: zoomIn,
|
||||
},
|
||||
...(store.readingMode === 'infinite' ? [
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'button',
|
||||
icon: headerStore.isReaderToolbarAutoHideEnabled ? EyeSlashIcon : EyeIcon,
|
||||
active: headerStore.isReaderToolbarAutoHideEnabled,
|
||||
title: headerStore.isReaderToolbarAutoHideEnabled ? 'Toolbar auto-masquée' : 'Toolbar toujours visible',
|
||||
onClick: () => headerStore.toggleReaderToolbarAutoHide(),
|
||||
},
|
||||
] : []),
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.nav-btn {
|
||||
@apply flex items-center justify-center w-7 h-7 rounded bg-gray-700 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-white;
|
||||
}
|
||||
|
||||
.chapter-nav-btn {
|
||||
@apply flex items-center justify-between gap-1 h-7 w-28 px-2 rounded bg-gray-700 hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-white;
|
||||
}
|
||||
</style>
|
||||
@@ -5,10 +5,10 @@
|
||||
<!-- Zone de navigation gauche (invisible) -->
|
||||
<div
|
||||
class="navigation-zone left-zone"
|
||||
@click.stop="goToPrevious"
|
||||
@click.stop="onLeftZoneClick"
|
||||
@mouseenter="showLeftHint"
|
||||
@mouseleave="hideLeftHint"
|
||||
title="Page précédente"
|
||||
:title="isRtl ? 'Page suivante' : 'Page précédente'"
|
||||
></div>
|
||||
|
||||
<!-- Page centrale -->
|
||||
@@ -24,21 +24,21 @@
|
||||
<!-- Zone de navigation droite (invisible) -->
|
||||
<div
|
||||
class="navigation-zone right-zone"
|
||||
@click.stop="goToNext"
|
||||
@click.stop="onRightZoneClick"
|
||||
@mouseenter="showRightHint"
|
||||
@mouseleave="hideRightHint"
|
||||
title="Page suivante"
|
||||
:title="isRtl ? 'Page précédente' : 'Page suivante'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Indicateurs visuels de navigation -->
|
||||
<div class="navigation-hints">
|
||||
<div class="hint left-hint" v-if="canGoToPrevious && (showNavigationHints || showLeftHintHover)">
|
||||
<div class="hint left-hint" v-if="canGoLeft && (showNavigationHints || showLeftHintHover)">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hint right-hint" v-if="canGoToNext && (showNavigationHints || showRightHintHover)">
|
||||
<div class="hint right-hint" v-if="canGoRight && (showNavigationHints || showRightHintHover)">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@@ -81,14 +81,18 @@ const showLeftHintHover = ref(false);
|
||||
const showRightHintHover = ref(false);
|
||||
let hintTimeout = null;
|
||||
|
||||
// Computed pour vérifier les possibilités de navigation
|
||||
const canGoToPrevious = computed(() => {
|
||||
return !store.isFirstPage || store.hasPreviousChapter;
|
||||
});
|
||||
const isRtl = computed(() => store.readingDirection === 'rtl');
|
||||
|
||||
const canGoToNext = computed(() => {
|
||||
return !store.isLastPage || store.hasNextChapter;
|
||||
});
|
||||
// Computed pour vérifier les possibilités de navigation
|
||||
const canGoToPrevious = computed(() => !store.isFirstPage || store.hasPreviousChapter);
|
||||
const canGoToNext = computed(() => !store.isLastPage || store.hasNextChapter);
|
||||
|
||||
// En RTL, le côté gauche avance dans l'histoire (page suivante) et le droit recule
|
||||
const canGoLeft = computed(() => isRtl.value ? canGoToNext.value : canGoToPrevious.value);
|
||||
const canGoRight = computed(() => isRtl.value ? canGoToPrevious.value : canGoToNext.value);
|
||||
|
||||
const onLeftZoneClick = () => isRtl.value ? goToNext() : goToPrevious();
|
||||
const onRightZoneClick = () => isRtl.value ? goToPrevious() : goToNext();
|
||||
|
||||
// Navigation vers la page/chapitre précédent
|
||||
const goToPrevious = async () => {
|
||||
@@ -151,22 +155,20 @@ const hideRightHint = () => {
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.single-mode-reader {
|
||||
@apply relative w-full h-full flex items-center justify-center;
|
||||
/* Suppression des marges sur mobile */
|
||||
@apply p-0 sm:p-2;
|
||||
/* Ajouter des marges en haut et en bas pour l'espace des contrôles et paramètres */
|
||||
@apply py-8 sm:py-12;
|
||||
@apply relative w-full flex-1 flex flex-col min-h-0 overflow-hidden;
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.page-navigation-wrapper {
|
||||
@apply relative w-full h-full flex items-center justify-center cursor-pointer;
|
||||
/* overflow-auto : scrollbars quand l'image zoomée déborde */
|
||||
@apply relative w-full flex-1 min-h-0 overflow-auto cursor-pointer;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
@apply flex-1 h-full flex items-center justify-center;
|
||||
pointer-events: none; /* Empêche les clics sur l'image elle-même */
|
||||
/* Optimisation pour mobile */
|
||||
@apply p-0;
|
||||
/* min-h-full : centre l'image quand elle est plus petite que le conteneur */
|
||||
min-height: 100%;
|
||||
@apply flex items-center justify-center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.navigation-zone {
|
||||
|
||||
@@ -1,56 +1,31 @@
|
||||
<template>
|
||||
<div class="chapter-page">
|
||||
<div class="chapter-header">
|
||||
<!-- Bouton de retour -->
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<button
|
||||
@click="goBackToManga"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors duration-200"
|
||||
:disabled="!currentChapter?.mangaId"
|
||||
>
|
||||
<ArrowLeftIcon class="h-5 w-5" />
|
||||
<span class="text-sm font-medium">Retour au manga</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Titre du chapitre amélioré -->
|
||||
<div class="chapter-title-section">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-white leading-tight">
|
||||
{{ currentChapter?.title || 'Chargement...' }}
|
||||
</h1>
|
||||
<div class="chapter-meta mt-3">
|
||||
<span class="inline-flex items-center px-3 py-1 bg-blue-600 text-white text-sm font-semibold rounded-full">
|
||||
Chapitre {{ currentChapter?.number }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="toolbar-wrapper"
|
||||
:class="{ 'toolbar-hidden': !headerStore.shouldShowReaderToolbar }"
|
||||
>
|
||||
<div class="toolbar-slide">
|
||||
<ReaderToolbar :chapter-reader-ref="chapterReaderRef" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reader-container">
|
||||
<ChapterReader :chapter-id="chapterId" />
|
||||
<ChapterReader ref="chapterReaderRef" :chapter-id="chapterId" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowLeftIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useReaderStore } from '../../application/store/readerStore';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import ChapterReader from '../components/ChapterReader.vue';
|
||||
import ReaderToolbar from '../components/ReaderToolbar.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useReaderStore();
|
||||
const headerStore = useHeaderStore();
|
||||
|
||||
const chapterId = computed(() => route.params.chapterId);
|
||||
const currentChapter = computed(() => store.currentChapter);
|
||||
|
||||
const goBackToManga = () => {
|
||||
if (currentChapter.value?.mangaId) {
|
||||
router.push({ name: 'manga-details', params: { id: currentChapter.value.mangaId } });
|
||||
}
|
||||
};
|
||||
const chapterReaderRef = ref(null);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
@@ -58,19 +33,26 @@ import ChapterReader from '../components/ChapterReader.vue';
|
||||
@apply w-full h-full flex flex-col;
|
||||
}
|
||||
|
||||
.chapter-header {
|
||||
@apply p-6 bg-gradient-to-b from-gray-800 to-gray-900 border-b border-gray-700 shadow-lg;
|
||||
.toolbar-wrapper {
|
||||
@apply overflow-hidden;
|
||||
max-height: 5rem;
|
||||
transition: max-height 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.chapter-title-section {
|
||||
@apply space-y-2;
|
||||
.toolbar-wrapper.toolbar-hidden {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.chapter-meta {
|
||||
@apply flex flex-wrap items-center gap-3;
|
||||
.toolbar-slide {
|
||||
transform: translateY(0);
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.toolbar-hidden .toolbar-slide {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.reader-container {
|
||||
@apply flex-1 overflow-hidden;
|
||||
@apply flex-1 overflow-hidden min-h-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,15 @@ export const useContentSourceStore = defineStore('contentSource', {
|
||||
importing: false,
|
||||
exporting: false,
|
||||
importError: null,
|
||||
exportError: null
|
||||
exportError: null,
|
||||
|
||||
// Health check state
|
||||
checkingHealth: false,
|
||||
checkHealthError: null,
|
||||
|
||||
// Delete state
|
||||
deleting: false,
|
||||
deleteError: null,
|
||||
}),
|
||||
|
||||
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
|
||||
clearCurrentSource() {
|
||||
this.currentSource = 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
|
||||
clearErrors() {
|
||||
this.sourcesError = null;
|
||||
@@ -181,6 +241,7 @@ export const useContentSourceStore = defineStore('contentSource', {
|
||||
this.saveError = null;
|
||||
this.importError = null;
|
||||
this.exportError = null;
|
||||
this.checkHealthError = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
const STORAGE_KEY = 'mangarr_preferences';
|
||||
|
||||
const defaultState = {
|
||||
theme: 'system',
|
||||
language: 'fr',
|
||||
defaultView: 'grid',
|
||||
itemsPerPage: 20,
|
||||
sortBy: 'title',
|
||||
filterBy: 'all',
|
||||
displayOptions: {
|
||||
grid: { showTitle: true, showYear: true, showAuthor: false },
|
||||
overview: { showCover: true, showStatus: true, showDescription: true, showAuthor: false, showYear: false },
|
||||
table: { showMonitoring: true, showPreferredSource: true, showChapters: true, showStatus: false, showAuthor: false, showYear: false }
|
||||
},
|
||||
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();
|
||||
},
|
||||
|
||||
setFilterBy(filter) {
|
||||
this.filterBy = filter;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setDisplayOption(view, key, value) {
|
||||
this.displayOptions[view][key] = value;
|
||||
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,
|
||||
filterBy: this.filterBy,
|
||||
displayOptions: this.displayOptions,
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
@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 -->
|
||||
<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">
|
||||
@@ -20,16 +20,24 @@
|
||||
<!-- Badge type de scraping -->
|
||||
<span
|
||||
: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' }}
|
||||
</span>
|
||||
|
||||
<!-- Badge orientation basé sur les sélecteurs -->
|
||||
<span
|
||||
: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) }}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -39,6 +47,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
|
||||
import { ScraperHealthStatus } from '../../domain/model/ScraperHealthStatus';
|
||||
|
||||
defineProps({
|
||||
source: {
|
||||
@@ -86,4 +95,26 @@ const getOrientationBadgeClass = (source) => {
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user