Compare commits
120 Commits
0482ec9f7f
...
21a87a3eb3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a87a3eb3 | ||
|
|
ffceda606f | ||
|
|
b05bd98f63 | ||
|
|
9e7f7b4cfc | ||
|
|
50b33f53d7 | ||
|
|
3170a7c60e | ||
|
|
fbe9619224 | ||
|
|
8d14676656 | ||
|
|
bec1572fcb | ||
|
|
f1eb97f156 | ||
|
|
f09f744a9b | ||
|
|
7f9d583c94 | ||
|
|
330a0fac34 | ||
|
|
be283833e9 | ||
|
|
551db0bf77 | ||
|
|
00d63dffeb | ||
|
|
d9e78b5229 | ||
|
|
7a05934116 | ||
|
|
b4bfa48d00 | ||
|
|
b456f9304d | ||
|
|
cbb62989d4 | ||
|
|
ee2a9b3750 | ||
|
|
5a5569cf2c | ||
|
|
a6ca8a2c9a | ||
|
|
9255509042 | ||
|
|
896c57ac34 | ||
|
|
d23c82631e | ||
|
|
17f9feea7b | ||
|
|
8692fa14c6 | ||
|
|
37e1b202c2 | ||
|
|
7fe4ac0d3b | ||
|
|
a00858ae6e | ||
|
|
dac2f91998 | ||
|
|
32b4e4fbb2 | ||
|
|
ebcca466a9 | ||
|
|
4848a1736f | ||
|
|
4dc6e5cfab | ||
|
|
d753761556 | ||
|
|
75f8e1686c | ||
|
|
15d92d1aff | ||
|
|
05dd7262eb | ||
|
|
72d7c233f7 | ||
|
|
cfa2214db5 | ||
|
|
c0bd9c69b1 | ||
|
|
5928cfd5f0 | ||
|
|
e51712a800 | ||
|
|
b187f3e153 | ||
|
|
c9f1771522 | ||
|
|
e29433bb0c | ||
|
|
c813368e2b | ||
|
|
68fed587be | ||
|
|
fcfbf140a3 | ||
|
|
d8e1f3a0cb | ||
|
|
0111f1b5f1 | ||
|
|
34dfa57dc0 | ||
|
|
9950d7ff84 | ||
|
|
a172e224c1 | ||
|
|
f06e6c1f61 | ||
|
|
787ba6caad | ||
|
|
b1b5177d4e | ||
|
|
77f05b287c | ||
|
|
71242433e6 | ||
|
|
fd2d3cd640 | ||
|
|
4d1d5b9f21 | ||
|
|
d7ccc1e603 | ||
|
|
d7088b14c2 | ||
|
|
cdee6f77fc | ||
|
|
54b5641947 | ||
|
|
2f73d3d42d | ||
|
|
7051bf5274 | ||
|
|
6ea24deacf | ||
|
|
346fede878 | ||
|
|
d123166dcb | ||
|
|
5e0fc96cd1 | ||
|
|
85abca7906 | ||
|
|
bf8ca79290 | ||
|
|
7c7b65128d | ||
|
|
22cf4eb186 | ||
|
|
eeb8447d7a | ||
|
|
53365df456 | ||
|
|
d9e935f7de | ||
|
|
ed0a075a6c | ||
|
|
41dc3c51aa | ||
|
|
bee8572dc5 | ||
|
|
ca9a74fe69 | ||
|
|
19a697c712 | ||
|
|
fe92e53be7 | ||
|
|
e444d79101 | ||
|
|
4f4f86fb91 | ||
|
|
7303d63198 | ||
|
|
140cc14316 | ||
|
|
668702b1fb | ||
|
|
33f5a5568a | ||
|
|
55945adc53 | ||
|
|
e90c0a140e | ||
|
|
30d26f530d | ||
|
|
504c62c155 | ||
|
|
666636e5bf | ||
|
|
73774f84ff | ||
|
|
879b8fa2dc | ||
|
|
3dc0a0b406 | ||
|
|
4017cabff2 | ||
|
|
50080f9779 | ||
|
|
ae0eac3197 | ||
|
|
6667cc224b | ||
|
|
2f615a4936 | ||
|
|
e3d380eadd | ||
|
|
073439163b | ||
|
|
0374ab0e46 | ||
|
|
c55cd62ec7 | ||
|
|
ba874480ee | ||
|
|
6bc3696190 | ||
|
|
89570ad951 | ||
|
|
21fcdd1084 | ||
|
|
52441c26da | ||
|
|
97d7bcf061 | ||
|
|
0e3d72cc5e | ||
|
|
0a8e6786a8 | ||
|
|
0c8ca6cca9 | ||
|
|
8f7b5d71c5 |
224
.cursor/rules/api_platform.mdc
Normal file
224
.cursor/rules/api_platform.mdc
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: *.php
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
```
|
||||||
|
Domain/Manga/Infrastructure/ApiPlatform/
|
||||||
|
├── Resource/ # Resources API par opération
|
||||||
|
│ └── GetMangaResource.php # Resources pour l'opération Get
|
||||||
|
│ └── CreateMangaResource.php # Resources pour l'opération Create
|
||||||
|
├── State/ # Providers et Processors par opération
|
||||||
|
├── Provider/ # State Providers
|
||||||
|
│ └── GetMangaStateProvider.php
|
||||||
|
└── Processor/ # State Processors
|
||||||
|
└── CreateMangaStateProcessor.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Règles d'Organisation
|
||||||
|
|
||||||
|
### 1. Resources
|
||||||
|
- Localisation : `Infrastructure/ApiPlatform/Resource/`
|
||||||
|
- Principes :
|
||||||
|
- Une Resource par Operation
|
||||||
|
- Validation des données avec les attributs Symfony dans la Resource
|
||||||
|
- Documentation exhaustive avec les attributs PHP 8
|
||||||
|
- Nommage : `{Operation}Resource`
|
||||||
|
- Contient tous les attributs nécessaires en public
|
||||||
|
- Doit implémenter les interfaces de validation appropriées
|
||||||
|
|
||||||
|
### 2. State Providers
|
||||||
|
- Localisation : `Infrastructure/ApiPlatform/State/Provider/`
|
||||||
|
- Principes :
|
||||||
|
- Un Provider par Operation de type Query
|
||||||
|
- Utilise les QueryHandler du domaine
|
||||||
|
- Convertit la Response du QueryHandler en Resource
|
||||||
|
- Renvoie toujours une Resource
|
||||||
|
- Nommage : `{Operation}StateProvider`
|
||||||
|
|
||||||
|
### 3. State Processors
|
||||||
|
- Localisation : `Infrastructure/ApiPlatform/State/Processor/`
|
||||||
|
- Principes :
|
||||||
|
- Un Processor par Operation de type Command
|
||||||
|
- Utilise les CommandHandler du domaine
|
||||||
|
- Convertit la Resource en Command
|
||||||
|
- Renvoie uniquement un code HTTP
|
||||||
|
- Nommage : `{Operation}StateProcessor`
|
||||||
|
|
||||||
|
## Exemples de Code
|
||||||
|
|
||||||
|
### 1. Resource API
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaStateProvider;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'Manga',
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/mangas/{id}',
|
||||||
|
provider: GetMangaStateProvider::class,
|
||||||
|
output: GetMangaResource::class,
|
||||||
|
description: 'Récupère un manga par son identifiant'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class GetMangaResource
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\Uuid]
|
||||||
|
public readonly string $id,
|
||||||
|
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
public readonly string $title,
|
||||||
|
|
||||||
|
public readonly ?string $description = null,
|
||||||
|
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\All([
|
||||||
|
new Assert\Type('string')
|
||||||
|
])]
|
||||||
|
public readonly array $authors = [],
|
||||||
|
|
||||||
|
#[Assert\Url]
|
||||||
|
public readonly ?string $coverUrl = null
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. State Provider
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource\CreateManga;
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Domain\Manga\Application\Query\GetMangaByIdQuery;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\GetMangaResource;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
class GetMangaStateProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MessageBusInterface $queryBus
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?GetMangaResource
|
||||||
|
{
|
||||||
|
$query = new GetMangaByIdQuery($uriVariables['id']);
|
||||||
|
$response = $this->queryBus->dispatch($query);
|
||||||
|
|
||||||
|
if (null === $response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GetMangaResource(
|
||||||
|
id: $response->id,
|
||||||
|
title: $response->title,
|
||||||
|
description: $response->description,
|
||||||
|
authors: $response->authors,
|
||||||
|
coverUrl: $response->coverUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Resource CreateManga
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\CreateMangaStateProcessor;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'Manga',
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/mangas',
|
||||||
|
processor: CreateMangaStateProcessor::class,
|
||||||
|
input: CreateMangaResource::class,
|
||||||
|
status: 201,
|
||||||
|
description: 'Crée un nouveau manga'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class CreateMangaResource
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\NotBlank(message: 'Le titre est obligatoire')]
|
||||||
|
#[Assert\Length(min: 1, max: 255)]
|
||||||
|
public readonly string $title,
|
||||||
|
|
||||||
|
#[Assert\Length(max: 1000)]
|
||||||
|
public readonly ?string $description = null,
|
||||||
|
|
||||||
|
#[Assert\NotNull]
|
||||||
|
#[Assert\Count(min: 1, max: 10)]
|
||||||
|
#[Assert\All([
|
||||||
|
new Assert\Type('string'),
|
||||||
|
new Assert\Length(min: 1, max: 100)
|
||||||
|
])]
|
||||||
|
public readonly array $authors = [],
|
||||||
|
|
||||||
|
#[Assert\Url]
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public readonly ?string $coverUrl = null
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. State Processor
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Domain\Manga\Application\Command\CreateMangaCommand;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\CreateMangaResource;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
class CreateMangaStateProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MessageBusInterface $commandBus
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): int
|
||||||
|
{
|
||||||
|
assert($data instanceof CreateMangaResource);
|
||||||
|
|
||||||
|
$command = new CreateMangaCommand(
|
||||||
|
title: $data->title,
|
||||||
|
description: $data->description,
|
||||||
|
authors: $data->authors,
|
||||||
|
coverUrl: $data->coverUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->commandBus->dispatch($command);
|
||||||
|
|
||||||
|
return Response::HTTP_CREATED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bonnes Pratiques
|
||||||
|
|
||||||
|
### 1. Documentation
|
||||||
|
- Documentation exhaustive des endpoints
|
||||||
|
- Description claire des paramètres
|
||||||
|
- Exemples de requêtes/réponses
|
||||||
|
- Documentation des codes d'erreur
|
||||||
|
|
||||||
|
### 2. Validation
|
||||||
|
- Validation dans les Resources uniquement
|
||||||
|
- Groupes de validation par contexte
|
||||||
|
- Messages d'erreur explicites
|
||||||
|
- Validation des types et formats
|
||||||
210
.cursor/rules/architecture.mdc
Normal file
210
.cursor/rules/architecture.mdc
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: *.php,src/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Architecture Hexagonale de Mangarr
|
||||||
|
|
||||||
|
## Structure Générale
|
||||||
|
L'application suit une architecture hexagonale (ports & adapters) avec une séparation claire des responsabilités. Le code métier est organisé en domaines distincts dans le dossier `src/Domain/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
└── Domain/
|
||||||
|
├── Shared/ # Code partagé entre les domaines
|
||||||
|
├── Manga/ # Domaine de gestion des mangas
|
||||||
|
├── Reader/ # Domaine de lecture
|
||||||
|
└── Scraping/ # Domaine de scraping
|
||||||
|
```
|
||||||
|
|
||||||
|
## Organisation des Domaines
|
||||||
|
Chaque domaine suit la même structure hexagonale :
|
||||||
|
|
||||||
|
```
|
||||||
|
Domain/Manga/
|
||||||
|
├── Domain/ # Cœur métier
|
||||||
|
│ ├── Entity/ # Entités du domaine
|
||||||
|
│ ├── ValueObject/ # Objets de valeur
|
||||||
|
│ ├── Event/ # Événements du domaine
|
||||||
|
│ └── Exception/ # Exceptions métier
|
||||||
|
├── Application/ # Cas d'utilisation
|
||||||
|
│ ├── Command/ # Commandes (DTO)
|
||||||
|
│ ├── CommandHandler/# Gestionnaires de commandes
|
||||||
|
│ ├── Query/ # Requêtes (DTO)
|
||||||
|
│ ├── QueryHandler/ # Gestionnaires de requêtes
|
||||||
|
│ └── Response/ # Objets de réponse (DTO)
|
||||||
|
└── Infrastructure/ # Adaptateurs
|
||||||
|
├── Repository/ # Implémentation des repositories
|
||||||
|
├── Service/ # Services techniques
|
||||||
|
└── Persistence/ # Persistence des données
|
||||||
|
```
|
||||||
|
|
||||||
|
## Règles d'Architecture
|
||||||
|
|
||||||
|
### 1. Règles Générales
|
||||||
|
- Tout le code métier doit résider dans le namespace `App\Domain`
|
||||||
|
- Les dépendances externes doivent être limitées et explicitement autorisées
|
||||||
|
- Les exceptions standards et utilitaires autorisés :
|
||||||
|
- `DateTimeImmutable`
|
||||||
|
- `RuntimeException`
|
||||||
|
- `Exception`
|
||||||
|
- `DomainException`
|
||||||
|
- `Symfony\Component\HttpKernel\Exception`
|
||||||
|
- `InvalidArgumentException`
|
||||||
|
|
||||||
|
### 2. Domaine Shared
|
||||||
|
- Le domaine `Shared` ne doit dépendre d'aucun autre domaine
|
||||||
|
- Il contient les contrats et les types partagés entre les domaines
|
||||||
|
- Exemple : `App\Domain\Shared\Contract\UuidInterface`
|
||||||
|
|
||||||
|
### 3. Couche Domain
|
||||||
|
- Ne doit dépendre que d'elle-même et du domaine Shared
|
||||||
|
- Contient la logique métier pure
|
||||||
|
- Ne doit pas avoir de dépendances externes
|
||||||
|
- Structure des composants :
|
||||||
|
- Les `Entity` sont les objets métier principaux
|
||||||
|
- Les `ValueObject` sont immuables et s'auto-valident
|
||||||
|
- Les `Event` représentent les changements d'état du domaine
|
||||||
|
- Les `Exception` définissent les erreurs métier spécifiques
|
||||||
|
|
||||||
|
### 4. Couche Application
|
||||||
|
- Peut dépendre de son propre domaine et du domaine Shared
|
||||||
|
- Peut utiliser les dépendances externes autorisées :
|
||||||
|
- `Symfony\Component\Messenger`
|
||||||
|
- `Ramsey\Uuid`
|
||||||
|
- Ne doit JAMAIS dépendre de la couche Infrastructure
|
||||||
|
- Structure des composants :
|
||||||
|
- Les `Query` sont des DTO (Data Transfer Objects) en lecture seule
|
||||||
|
- Les `Command` sont des DTO pour les modifications
|
||||||
|
- Les `QueryHandler` doivent :
|
||||||
|
- Implémenter `QueryHandlerInterface`
|
||||||
|
- Prendre une seule `Query` en paramètre
|
||||||
|
- Retourner une `Response`
|
||||||
|
- Les `CommandHandler` doivent :
|
||||||
|
- Implémenter `CommandHandlerInterface`
|
||||||
|
- Prendre une seule `Command` en paramètre
|
||||||
|
- Ne pas retourner de valeur (void)
|
||||||
|
- Les `Response` sont des DTO immuables pour les résultats de requêtes
|
||||||
|
|
||||||
|
### 5. Couche Infrastructure
|
||||||
|
- Implémente les interfaces définies dans le domaine
|
||||||
|
- Peut dépendre de toutes les couches de son domaine
|
||||||
|
- Contient les adaptateurs pour les services externes
|
||||||
|
- Structure des composants :
|
||||||
|
- Les `Repository` implémentent les interfaces du domaine
|
||||||
|
- Les `Service` fournissent des fonctionnalités techniques
|
||||||
|
- La `Persistence` gère le stockage des données
|
||||||
|
|
||||||
|
## Flux de Dépendances
|
||||||
|
```
|
||||||
|
Infrastructure → Application → Domain
|
||||||
|
↓ ↓ ↓
|
||||||
|
External Shared Shared
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
Les règles d'architecture sont validées par phparkitect. Les violations de ces règles entraîneront une erreur lors de la validation.
|
||||||
|
|
||||||
|
## Exemples de Code
|
||||||
|
|
||||||
|
### Domain Layer
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Domain\Entity;
|
||||||
|
|
||||||
|
class Manga
|
||||||
|
{
|
||||||
|
private MangaId $id;
|
||||||
|
private Title $title;
|
||||||
|
private Description $description;
|
||||||
|
|
||||||
|
public function __construct(MangaId $id, Title $title)
|
||||||
|
{
|
||||||
|
$this->id = $id;
|
||||||
|
$this->title = $title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application Layer
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Application\Query;
|
||||||
|
|
||||||
|
readonly class GetMangaByIdQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\QueryHandler;
|
||||||
|
|
||||||
|
class GetMangaByIdQueryHandler implements QueryHandlerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MangaRepositoryInterface $mangaRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(GetMangaByIdQuery $query): MangaResponse
|
||||||
|
{
|
||||||
|
$manga = $this->mangaRepository->get($query->id);
|
||||||
|
return new MangaResponse($manga);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\Command;
|
||||||
|
|
||||||
|
readonly class CreateMangaCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $title,
|
||||||
|
public ?string $description = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\CommandHandler;
|
||||||
|
|
||||||
|
class CreateMangaCommandHandler implements CommandHandlerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MangaRepositoryInterface $mangaRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(CreateMangaCommand $command): void
|
||||||
|
{
|
||||||
|
$manga = Manga::create($command->title, $command->description);
|
||||||
|
$this->mangaRepository->save($manga);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\Response;
|
||||||
|
|
||||||
|
readonly class MangaResponse
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $title,
|
||||||
|
public ?string $description
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromEntity(Manga $manga): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$manga->getId()->toString(),
|
||||||
|
$manga->getTitle()->value(),
|
||||||
|
$manga->getDescription()?->value()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infrastructure Layer
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Repository;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Repository\MangaRepositoryInterface;
|
||||||
|
|
||||||
|
class DoctrineMangaRepository implements MangaRepositoryInterface
|
||||||
|
{
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
54
.cursor/rules/business.mdc
Normal file
54
.cursor/rules/business.mdc
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Contexte Métier de Mangarr
|
||||||
|
|
||||||
|
## Objectif Principal
|
||||||
|
Mangarr est une application de gestion et d'automatisation pour la collection de mangas, inspirée par Sonarr. Elle permet aux utilisateurs de suivre, télécharger et organiser automatiquement leurs mangas depuis différentes sources en ligne.
|
||||||
|
|
||||||
|
## Fonctionnalités Principales
|
||||||
|
|
||||||
|
### 1. Gestion de la Bibliothèque
|
||||||
|
- Suivi des séries de mangas
|
||||||
|
- Organisation automatique des chapitres
|
||||||
|
- Gestion des métadonnées (titres, auteurs, descriptions, couvertures)
|
||||||
|
|
||||||
|
### 2. Automatisation
|
||||||
|
- Scraping automatique des nouvelles sorties
|
||||||
|
- Téléchargement automatique des nouveaux chapitres
|
||||||
|
- Notifications lors de nouvelles sorties
|
||||||
|
|
||||||
|
### 3. Sources et Scraping
|
||||||
|
- Support de multiples sources de mangas en ligne
|
||||||
|
- Système de scraping modulaire et extensible
|
||||||
|
- Gestion des priorités des sources
|
||||||
|
|
||||||
|
### 4. Interface Utilisateur
|
||||||
|
- Téléchargement des chapitres en .cbz pour l'utilisateur
|
||||||
|
- Calendrier des sorties
|
||||||
|
- État des téléchargements
|
||||||
|
- Configuration des préférences
|
||||||
|
- Recherche et découverte de nouveaux mangas
|
||||||
|
|
||||||
|
### 5. Intégration
|
||||||
|
- API RESTful pour l'intégration avec d'autres services
|
||||||
|
- Support des lecteurs de manga externes
|
||||||
|
- Export/Import de la bibliothèque
|
||||||
|
|
||||||
|
## Règles Métier Importantes
|
||||||
|
1. Un manga peut avoir plusieurs sources disponibles
|
||||||
|
2. Les chapitres doivent être uniques (pas de doublons)
|
||||||
|
3. Les métadonnées doivent être cohérentes entre les sources
|
||||||
|
4. Le système doit respecter les limitations des sites sources
|
||||||
|
5. La qualité des scans doit être vérifiée avant l'archivage
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
L'application suit une architecture modulaire avec :
|
||||||
|
- Backend en PHP, Symfony pour le scraping et la gestion
|
||||||
|
- Frontend moderne pour l'interface utilisateur
|
||||||
|
- Base de données pour le stockage des métadonnées
|
||||||
|
- Système de files d'attente pour les téléchargements
|
||||||
|
- Cache pour optimiser les performances
|
||||||
147
.cursor/rules/commands.mdc
Normal file
147
.cursor/rules/commands.mdc
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# Commandes Makefile de Mangarr
|
||||||
|
|
||||||
|
Toujours chercher si une commande est disponible dans [Makefile](mdc:Makefile).
|
||||||
|
|
||||||
|
## Structure Générale
|
||||||
|
Le Makefile est organisé en plusieurs sections distinctes :
|
||||||
|
- Docker 🐳
|
||||||
|
- Composer 🧙
|
||||||
|
- Symfony 🎵
|
||||||
|
- Webpack Encore 📦
|
||||||
|
|
||||||
|
## Variables Principales
|
||||||
|
```makefile
|
||||||
|
# Exécutables Docker
|
||||||
|
DOCKER_COMP = docker compose
|
||||||
|
DOCKER_COMP_EXEC = $(DOCKER_COMP) exec
|
||||||
|
|
||||||
|
# Conteneurs
|
||||||
|
PHP_CONT = $(DOCKER_COMP_EXEC) php
|
||||||
|
NODE_CONT = $(DOCKER_COMP_EXEC) node
|
||||||
|
|
||||||
|
# Exécutables dans les conteneurs
|
||||||
|
PHP = $(PHP_CONT) php
|
||||||
|
COMPOSER = $(PHP_CONT) composer
|
||||||
|
SYMFONY = $(PHP) bin/console
|
||||||
|
SF_MEMORY = $(PHP) -d memory_limit=256M bin/console
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bonnes Pratiques
|
||||||
|
|
||||||
|
### 1. Organisation des Commandes
|
||||||
|
- Regrouper les commandes par catégorie avec des commentaires clairs
|
||||||
|
- Utiliser des variables pour les commandes répétitives
|
||||||
|
- Documenter chaque commande avec `##` pour l'aide automatique
|
||||||
|
- Préfixer les commandes internes avec `_` (exemple: `_check-deps`)
|
||||||
|
|
||||||
|
### 2. Paramètres et Options
|
||||||
|
- Utiliser la syntaxe `make command p=value` pour les paramètres
|
||||||
|
- Documenter les paramètres possibles dans les commentaires
|
||||||
|
- Utiliser `?=` pour les valeurs par défaut modifiables
|
||||||
|
|
||||||
|
### 3. Dépendances
|
||||||
|
- Définir clairement les dépendances entre les commandes
|
||||||
|
- Utiliser des commandes composées pour les tâches complexes
|
||||||
|
- Éviter les dépendances circulaires
|
||||||
|
|
||||||
|
### 4. Documentation
|
||||||
|
- Chaque commande doit avoir une description avec `##`
|
||||||
|
- Inclure des exemples d'utilisation pour les commandes complexes
|
||||||
|
- Utiliser la commande `help` pour afficher la documentation
|
||||||
|
|
||||||
|
## Commandes Disponibles
|
||||||
|
|
||||||
|
### Docker 🐳
|
||||||
|
```makefile
|
||||||
|
build: ## Construit les images Docker
|
||||||
|
up: ## Démarre les conteneurs
|
||||||
|
start: ## Démarre les conteneurs en mode détaché
|
||||||
|
down: ## Arrête et supprime les conteneurs
|
||||||
|
logs: ## Affiche les logs en temps réel
|
||||||
|
sh: ## Se connecte au conteneur PHP
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composer 🧙
|
||||||
|
```makefile
|
||||||
|
composer: ## Exécute une commande composer (c=command)
|
||||||
|
vendor: ## Installe les dépendances
|
||||||
|
```
|
||||||
|
|
||||||
|
### Symfony 🎵
|
||||||
|
```makefile
|
||||||
|
sf: ## Liste/exécute les commandes Symfony (c=command)
|
||||||
|
cc: ## Vide le cache
|
||||||
|
migration: ## Crée une nouvelle migration
|
||||||
|
fixtures: ## Charge les fixtures
|
||||||
|
consume: ## Consomme les messages de la queue
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webpack Encore 📦
|
||||||
|
```makefile
|
||||||
|
npm-install: ## Installe les dépendances npm
|
||||||
|
npm-run: ## Lance le serveur de développement
|
||||||
|
npm-watch: ## Surveille les changements
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exemples d'Utilisation
|
||||||
|
|
||||||
|
### 1. Installation du Projet
|
||||||
|
```bash
|
||||||
|
make install # Construit et démarre les conteneurs, installe les dépendances
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Développement Quotidien
|
||||||
|
```bash
|
||||||
|
make start # Démarre les conteneurs
|
||||||
|
make npm-watch # Lance la compilation des assets
|
||||||
|
make consume # Démarre les workers
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Commandes avec Paramètres
|
||||||
|
```bash
|
||||||
|
make composer c="require symfony/orm-pack" # Ajoute une dépendance
|
||||||
|
make sf c="make:entity" # Crée une entité
|
||||||
|
make test f="ScrapeChapterHandlerTest" # Lance un test spécifique
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ajout de Nouvelles Commandes
|
||||||
|
|
||||||
|
### 1. Structure de Base
|
||||||
|
```makefile
|
||||||
|
command-name: ## Description de la commande
|
||||||
|
@$(DOCKER_COMP) ... # Commande à exécuter
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Avec Paramètres
|
||||||
|
```makefile
|
||||||
|
command-with-param: ## Description (p=value)
|
||||||
|
@$(eval p ?=)
|
||||||
|
@$(DOCKER_COMP) ... $(p)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Commande Composée
|
||||||
|
```makefile
|
||||||
|
full-install: build start vendor npm-install ## Description complète
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### 1. Nettoyage
|
||||||
|
- Supprimer les commandes obsolètes
|
||||||
|
- Mettre à jour les descriptions
|
||||||
|
- Vérifier les dépendances
|
||||||
|
|
||||||
|
### 2. Documentation
|
||||||
|
- Maintenir la section d'aide à jour
|
||||||
|
- Ajouter des exemples pour les nouvelles commandes
|
||||||
|
- Documenter les changements importants
|
||||||
|
|
||||||
|
### 3. Tests
|
||||||
|
- Tester les nouvelles commandes
|
||||||
|
- Vérifier les dépendances
|
||||||
|
- Valider les paramètres
|
||||||
183
.cursor/rules/front_vue.mdc
Normal file
183
.cursor/rules/front_vue.mdc
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: *.vue,*.js,assets/vue/app/*,assets/vue/app/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Architecture Frontend Vue.js
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
En tant que développeur front-end expérimenté spécialisé en Vue.js, vous devez suivre les meilleures pratiques et standards de développement établis pour ce projet. Votre expertise en Vue.js, TypeScript, et votre maîtrise des patterns de conception modernes sont essentiels pour maintenir une base de code cohérente et maintenable.
|
||||||
|
|
||||||
|
## Stack Technique
|
||||||
|
- **Framework Principal**: Vue.js 3.x avec Composition API
|
||||||
|
- **Store Management**: Pinia 3.x
|
||||||
|
- **Routage**: Vue Router 4.x
|
||||||
|
- **Styling**:
|
||||||
|
- TailwindCSS 4.x pour les utilitaires CSS
|
||||||
|
- HeadlessUI pour les composants accessibles
|
||||||
|
- Heroicons pour l'iconographie
|
||||||
|
- **Build Tool**: Vite
|
||||||
|
- **Testing**: Vitest avec Vue Test Utils
|
||||||
|
- **Linting & Formatting**:
|
||||||
|
- ESLint avec la configuration Vue.js
|
||||||
|
- Prettier pour le formatage
|
||||||
|
|
||||||
|
## Conventions de Nommage
|
||||||
|
- **Composants**: PascalCase (ex: `MangaCard.vue`, `SearchBar.vue`)
|
||||||
|
- **Fichiers**:
|
||||||
|
- Composants: PascalCase avec extension .vue
|
||||||
|
- Utilitaires: camelCase avec extension .js/.ts
|
||||||
|
- Tests: PascalCase.spec.ts
|
||||||
|
- **Props**: camelCase dans le template, PascalCase dans le script
|
||||||
|
- **Events**: kebab-case dans le template, camelCase dans le script
|
||||||
|
- **Stores**: camelCase avec suffixe "Store" (ex: `mangaStore.js`)
|
||||||
|
- **Composables**: camelCase avec préfixe "use" (ex: `useSearch.js`)
|
||||||
|
|
||||||
|
## Structure Générale
|
||||||
|
L'application Vue.js suit une architecture hexagonale (ports & adapters) avec une séparation claire des responsabilités. Le code est organisé en domaines distincts dans le dossier `assets/vue/`.
|
||||||
|
Pour ce qui est du style, on utilise TailwindCss, Headlessui et Heroicons.
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/vue/
|
||||||
|
├── app/
|
||||||
|
│ ├── shared/ # Code partagé entre les domaines
|
||||||
|
│ │ ├── components/ # Composants réutilisables
|
||||||
|
│ │ │ ├── ui/ # Composants UI génériques (boutons, inputs, etc.)
|
||||||
|
│ │ │ └── layout/ # Layouts réutilisables
|
||||||
|
│ │ ├── composables/ # Composables Vue partagés
|
||||||
|
│ │ ├── plugins/ # Plugins Vue (router, pinia, etc.)
|
||||||
|
│ │ └── utils/ # Utilitaires partagés
|
||||||
|
│ │
|
||||||
|
│ ├── domain/ # Domaines métier
|
||||||
|
│ │ ├── manga/ # Domaine Manga
|
||||||
|
│ │ │ ├── application/ # Cas d'utilisation
|
||||||
|
│ │ │ │ ├── commands/ # Commands & CommandHandlers
|
||||||
|
│ │ │ │ ├── queries/ # Queries & QueryHandlers
|
||||||
|
│ │ │ │ └── store/ # Store Pinia du domaine
|
||||||
|
│ │ │ ├── domain/ # Cœur métier
|
||||||
|
│ │ │ │ ├── entities/ # Entités
|
||||||
|
│ │ │ │ ├── value-objects/# Objets valeur
|
||||||
|
│ │ │ │ └── services/ # Services métier
|
||||||
|
│ │ │ ├── infrastructure/ # Adaptateurs
|
||||||
|
│ │ │ │ ├── api/ # Client API
|
||||||
|
│ │ │ │ └── repository/ # Implémentation repository
|
||||||
|
│ │ │ └── presentation/ # Interface utilisateur
|
||||||
|
│ │ │ ├── components/ # Composants spécifiques au domaine
|
||||||
|
│ │ │ ├── composables/ # Composables spécifiques
|
||||||
|
│ │ │ └── pages/ # Pages du domaine
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── reader/ # Domaine Reader (même structure)
|
||||||
|
│ │ └── scraping/ # Domaine Scraping (même structure)
|
||||||
|
│ │
|
||||||
|
│ └── router/ # Configuration du routeur
|
||||||
|
│ └── index.js # Point d'entrée du routeur
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contrat d'API
|
||||||
|
Le contrat d'API complet est disponible dans le fichier [api-docs.json](mdc:public/api-docs.json). Ce fichier contient la documentation OpenAPI de toutes les routes disponibles et leurs schémas.
|
||||||
|
|
||||||
|
## Règles d'Architecture
|
||||||
|
|
||||||
|
### 1. Règles Générales
|
||||||
|
- Chaque domaine est isolé et ne dépend que de lui-même et du domaine `shared`
|
||||||
|
- Les dépendances externes sont gérées via les adaptateurs dans l'infrastructure
|
||||||
|
- L'application est une SPA (Single Page Application) sans rechargement de page
|
||||||
|
- Utilisation de Vue Router pour la navigation côté client
|
||||||
|
- Gestion d'état avec Pinia organisée par domaine
|
||||||
|
|
||||||
|
### 2. Couche Domain
|
||||||
|
- Contient les entités et la logique métier pure
|
||||||
|
- Ne dépend d'aucune bibliothèque externe sauf Vue.js
|
||||||
|
- Les entités sont des classes JavaScript standard
|
||||||
|
- Exemple :
|
||||||
|
```javascript
|
||||||
|
export class Manga {
|
||||||
|
constructor({ id, title, description = null }) {
|
||||||
|
this.id = id;
|
||||||
|
this.title = title;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(data) {
|
||||||
|
return new Manga(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Couche Application
|
||||||
|
- Gère les cas d'utilisation via les stores Pinia
|
||||||
|
- Coordonne les interactions entre l'UI et le domaine
|
||||||
|
- Transforme les données du domaine pour l'UI
|
||||||
|
- Exemple de store :
|
||||||
|
```javascript
|
||||||
|
export const useMangaStore = defineStore('manga', {
|
||||||
|
state: () => ({
|
||||||
|
mangas: [],
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async fetchMangas() {
|
||||||
|
// Logique de chargement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Couche Infrastructure
|
||||||
|
- Gère la communication avec l'API
|
||||||
|
- Isole les dépendances externes
|
||||||
|
- Exemple d'API client :
|
||||||
|
```javascript
|
||||||
|
export class MangaApi {
|
||||||
|
static async fetchAll() {
|
||||||
|
const response = await fetch('/api/mangas');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Couche Présentation
|
||||||
|
- Composants Vue.js spécifiques au domaine
|
||||||
|
- Utilise les composants UI partagés
|
||||||
|
- Communique avec la couche application via les stores
|
||||||
|
- Exemple de composant :
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="manga-list">
|
||||||
|
<MangaCard v-for="manga in mangas" :key="manga.id" :manga="manga" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bonnes Pratiques
|
||||||
|
|
||||||
|
### 1. Composants
|
||||||
|
- Utiliser la Composition API pour la logique
|
||||||
|
- Séparer les composants UI génériques des composants métier
|
||||||
|
- Favoriser les props et events pour la communication parent-enfant
|
||||||
|
- Utiliser les stores pour la communication entre composants distants
|
||||||
|
|
||||||
|
### 2. État
|
||||||
|
- Un store Pinia par domaine
|
||||||
|
- Actions asynchrones dans les stores
|
||||||
|
- Getters pour les données dérivées
|
||||||
|
- État local dans les composants quand possible
|
||||||
|
|
||||||
|
### 3. Router
|
||||||
|
- Routes organisées par domaine
|
||||||
|
- Lazy loading des composants de page
|
||||||
|
- Navigation programmatique via le router
|
||||||
|
- Guards pour la protection des routes
|
||||||
|
|
||||||
|
### 4. Style
|
||||||
|
- Utilisation de Tailwind CSS
|
||||||
|
- Classes utilitaires pour le style
|
||||||
|
- Composants Headless UI pour l'accessibilité
|
||||||
|
- Design system cohérent via les composants partagés
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
Les règles d'architecture peuvent être validées par des outils comme :
|
||||||
|
- ESLint pour les règles de code
|
||||||
|
- Tests unitaires pour les composants
|
||||||
|
- Tests d'intégration pour les stores
|
||||||
163
.cursor/rules/jobs.mdc
Normal file
163
.cursor/rules/jobs.mdc
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: *.php
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Architecture des Jobs dans Mangarr
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Le système de jobs de Mangarr est conçu pour gérer les tâches asynchrones et de longue durée de manière uniforme à travers tous les domaines. Il est basé sur une architecture centralisée dans le domaine `Shared` et peut être étendu par chaque domaine spécifique.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/Domain/Shared/
|
||||||
|
├── Domain/
|
||||||
|
│ ├── Model/
|
||||||
|
│ │ ├── Job.php # Classe abstraite de base
|
||||||
|
│ │ ├── JobStatus.php # États possibles d'un job
|
||||||
|
│ │ └── FailedJob.php # Représentation d'un job échoué
|
||||||
|
│ ├── Contract/
|
||||||
|
│ │ ├── JobRepositoryInterface.php
|
||||||
|
│ │ └── FailedJobRepositoryInterface.php
|
||||||
|
│ └── Exception/
|
||||||
|
│ ├── JobNotFoundException.php
|
||||||
|
│ └── JobNotRetryableException.php
|
||||||
|
└── Infrastructure/
|
||||||
|
├── Persistence/
|
||||||
|
│ ├── Entity/
|
||||||
|
│ │ ├── JobEntity.php
|
||||||
|
│ │ └── FailedJobEntity.php
|
||||||
|
│ └── Repository/
|
||||||
|
│ ├── DoctrineJobRepository.php
|
||||||
|
│ └── DoctrineFailedJobRepository.php
|
||||||
|
└── Service/
|
||||||
|
└── JobRetryService.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cycle de Vie d'un Job
|
||||||
|
|
||||||
|
### États Possibles
|
||||||
|
```php
|
||||||
|
enum JobStatus: string
|
||||||
|
{
|
||||||
|
case PENDING = 'pending'; // Job créé, en attente d'exécution
|
||||||
|
case IN_PROGRESS = 'in_progress';// Job en cours d'exécution
|
||||||
|
case COMPLETED = 'completed'; // Job terminé avec succès
|
||||||
|
case FAILED = 'failed'; // Job échoué définitivement
|
||||||
|
case CANCELLED = 'cancelled'; // Job annulé manuellement
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transitions d'États
|
||||||
|
1. `PENDING` → `IN_PROGRESS` : Lors du démarrage du job
|
||||||
|
2. `IN_PROGRESS` → `COMPLETED` : Lorsque le job se termine avec succès
|
||||||
|
3. `IN_PROGRESS` → `FAILED` : Lorsque le job échoue et atteint le nombre maximum de tentatives
|
||||||
|
4. `IN_PROGRESS` → `PENDING` : Lorsque le job échoue mais peut être réessayé
|
||||||
|
5. Tout état → `CANCELLED` : Lorsque le job est annulé manuellement
|
||||||
|
|
||||||
|
## Création d'un Nouveau Type de Job
|
||||||
|
|
||||||
|
1. **Créer une classe de job spécifique**
|
||||||
|
```php
|
||||||
|
class MyCustomJob extends Job
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $id,
|
||||||
|
public readonly string $someData,
|
||||||
|
public readonly array $additionalData = []
|
||||||
|
) {
|
||||||
|
parent::__construct($id, 'my_custom_job');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Définir le Handler**
|
||||||
|
```php
|
||||||
|
class MyCustomJobHandler
|
||||||
|
{
|
||||||
|
public function __invoke(MyCustomJob $job): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$job->start();
|
||||||
|
// Logique métier
|
||||||
|
$job->complete();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$job->fail($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gestion des Échecs
|
||||||
|
|
||||||
|
### Retry Automatique
|
||||||
|
- Un job peut être réessayé tant que `$attempts < $maxAttempts`
|
||||||
|
- Lors d'un échec, si des tentatives sont encore possibles, le statut redevient `PENDING`
|
||||||
|
- Les informations d'échec sont conservées dans `FailedJob`
|
||||||
|
|
||||||
|
### Informations de Debug
|
||||||
|
Chaque job contient :
|
||||||
|
- `failureReason` : La raison de l'échec
|
||||||
|
- `attempts` : Nombre de tentatives effectuées
|
||||||
|
- `context` : Données contextuelles pour le debug
|
||||||
|
- `createdAt`, `startedAt`, `completedAt` : Timestamps pour le suivi
|
||||||
|
|
||||||
|
## Bonnes Pratiques
|
||||||
|
|
||||||
|
### 1. Création de Jobs
|
||||||
|
```php
|
||||||
|
$job = new MyCustomJob(
|
||||||
|
id: Uuid::v4(),
|
||||||
|
someData: 'data',
|
||||||
|
additionalData: ['key' => 'value']
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Gestion du Contexte
|
||||||
|
```php
|
||||||
|
$job->context['important_info'] = 'value';
|
||||||
|
$job->context['debug_data'] = $debugInfo;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Retry Manuel
|
||||||
|
```php
|
||||||
|
if ($failedJob->canBeRetried()) {
|
||||||
|
$job->attempts = 0;
|
||||||
|
$job->status = JobStatus::PENDING;
|
||||||
|
$jobRepository->save($job);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Monitoring
|
||||||
|
- Utiliser `findByStatus()` pour surveiller les jobs par état
|
||||||
|
- Utiliser `findFailedJobs()` pour vérifier les échecs
|
||||||
|
- Consulter `FailedJob` pour les détails des échecs
|
||||||
|
|
||||||
|
## Règles Importantes
|
||||||
|
|
||||||
|
1. **Idempotence**
|
||||||
|
- Les jobs doivent être idempotents
|
||||||
|
- Gérer les cas de réexécution
|
||||||
|
- Vérifier l'état avant les opérations
|
||||||
|
|
||||||
|
2. **Contexte**
|
||||||
|
- Toujours fournir un contexte utile
|
||||||
|
- Inclure les IDs des entités concernées
|
||||||
|
- Ajouter des informations de debug pertinentes
|
||||||
|
|
||||||
|
3. **Durée**
|
||||||
|
- Les jobs doivent être de longue durée
|
||||||
|
- Pour les opérations courtes, utiliser des appels directs
|
||||||
|
- Prévoir des timeouts appropriés
|
||||||
|
|
||||||
|
4. **Statut**
|
||||||
|
- Ne jamais modifier le statut directement
|
||||||
|
- Utiliser les méthodes `start()`, `complete()`, `fail()`, `cancel()`
|
||||||
|
- Toujours sauvegarder après un changement de statut
|
||||||
|
|
||||||
|
5. **Échecs**
|
||||||
|
- Capturer et logger toutes les exceptions
|
||||||
|
- Fournir des messages d'erreur explicites
|
||||||
|
- Conserver le contexte d'échec pour le debug
|
||||||
302
.cursor/rules/persistence.mdc
Normal file
302
.cursor/rules/persistence.mdc
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: *.php
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Persistence dans l'Architecture Hexagonale
|
||||||
|
|
||||||
|
## Structure de la Persistence
|
||||||
|
|
||||||
|
```
|
||||||
|
Domain/Manga/
|
||||||
|
└── Infrastructure/
|
||||||
|
└── Persistence/
|
||||||
|
├── Repository/ # Implémentations des repositories
|
||||||
|
│ └── DoctrineMangaRepository.php
|
||||||
|
├── Entity/ # Entités Doctrine
|
||||||
|
│ └── MangaEntity.php
|
||||||
|
└── Mapper/ # Mappers Domain <-> Entity
|
||||||
|
└── MangaMapper.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Règles d'Organisation
|
||||||
|
|
||||||
|
### 1. Repositories
|
||||||
|
- Localisation : `Infrastructure/Persistence/Repository/`
|
||||||
|
- Principes :
|
||||||
|
- Un repository par agrégat du domaine
|
||||||
|
- Implémente l'interface du domaine
|
||||||
|
- Utilise un mapper dédié
|
||||||
|
- Gère uniquement la persistence
|
||||||
|
- Pas de logique métier
|
||||||
|
- Nommage : `Doctrine{Aggregate}Repository`
|
||||||
|
|
||||||
|
### 2. Entités
|
||||||
|
- Localisation : `Infrastructure/Persistence/Entity/`
|
||||||
|
- Principes :
|
||||||
|
- Une entité par agrégat du domaine
|
||||||
|
- Uniquement des getters/setters
|
||||||
|
- Pas de logique métier
|
||||||
|
- Nommage : `{Aggregate}Entity`
|
||||||
|
- Suffixe `Entity` obligatoire pour éviter la confusion avec les modèles du domaine
|
||||||
|
|
||||||
|
### 3. Mappers
|
||||||
|
- Localisation : `Infrastructure/Persistence/Mapper/`
|
||||||
|
- Principes :
|
||||||
|
- Un mapper par agrégat
|
||||||
|
- Conversion bidirectionnelle Domain <-> Entity
|
||||||
|
- Gestion des Value Objects
|
||||||
|
- Nommage : `{Aggregate}Mapper`
|
||||||
|
|
||||||
|
## Exemples de Code
|
||||||
|
|
||||||
|
### 1. Query et Repository
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Application\Query;
|
||||||
|
|
||||||
|
readonly class GetMangaListQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $page = 1,
|
||||||
|
public int $limit = 20,
|
||||||
|
public string $sortBy = 'title',
|
||||||
|
public string $sortOrder = 'asc',
|
||||||
|
public ?string $search = null,
|
||||||
|
public array $genres = []
|
||||||
|
) {
|
||||||
|
if ($this->page < 1) {
|
||||||
|
throw new \InvalidArgumentException('Page must be greater than 0');
|
||||||
|
}
|
||||||
|
if ($this->limit < 1) {
|
||||||
|
throw new \InvalidArgumentException('Limit must be greater than 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOffset(): int
|
||||||
|
{
|
||||||
|
return ($this->page - 1) * $this->limit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Repository;
|
||||||
|
|
||||||
|
interface MangaRepositoryInterface
|
||||||
|
{
|
||||||
|
public function findByQuery(GetMangaListQuery $query): array;
|
||||||
|
public function count(GetMangaListQuery $query): int;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Persistence\Repository;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Repository\MangaRepositoryInterface;
|
||||||
|
use App\Domain\Manga\Infrastructure\Persistence\Entity\MangaEntity;
|
||||||
|
use App\Domain\Manga\Infrastructure\Persistence\Mapper\MangaMapper;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
readonly class DoctrineMangaRepository implements MangaRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private MangaMapper $mapper
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function findByQuery(GetMangaListQuery $query): array
|
||||||
|
{
|
||||||
|
$qb = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('m')
|
||||||
|
->from(MangaEntity::class, 'm');
|
||||||
|
|
||||||
|
if ($query->search) {
|
||||||
|
$qb->andWhere('m.title LIKE :search')
|
||||||
|
->setParameter('search', '%' . $query->search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($query->genres)) {
|
||||||
|
$qb->andWhere('m.genres && :genres')
|
||||||
|
->setParameter('genres', $query->genres);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb->orderBy('m.' . $query->sortBy, $query->sortOrder)
|
||||||
|
->setFirstResult($query->getOffset())
|
||||||
|
->setMaxResults($query->limit);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (MangaEntity $entity) => $this->mapper->toDomain($entity),
|
||||||
|
$qb->getQuery()->getResult()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(GetMangaListQuery $query): int
|
||||||
|
{
|
||||||
|
$qb = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('COUNT(m.id)')
|
||||||
|
->from(MangaEntity::class, 'm');
|
||||||
|
|
||||||
|
if ($query->search) {
|
||||||
|
$qb->andWhere('m.title LIKE :search')
|
||||||
|
->setParameter('search', '%' . $query->search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($query->genres)) {
|
||||||
|
$qb->andWhere('m.genres && :genres')
|
||||||
|
->setParameter('genres', $query->genres);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb->getQuery()->getSingleScalarResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(string $id): ?Manga
|
||||||
|
{
|
||||||
|
$entity = $this->entityManager->find(MangaEntity::class, $id);
|
||||||
|
|
||||||
|
return $entity ? $this->mapper->toDomain($entity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Manga $manga): void
|
||||||
|
{
|
||||||
|
$entity = $this->mapper->toEntity($manga);
|
||||||
|
|
||||||
|
$this->entityManager->persist($entity);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
// Met à jour l'ID du modèle du domaine si nécessaire
|
||||||
|
if ($entity->getId() && $manga->getId() === null) {
|
||||||
|
$manga->updateId($entity->getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Entity
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Persistence\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'manga')]
|
||||||
|
class MangaEntity
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $title;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $authors = [];
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $coverUrl = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
public function getId(): ?int { return $this->id; }
|
||||||
|
public function getTitle(): string { return $this->title; }
|
||||||
|
public function getDescription(): ?string { return $this->description; }
|
||||||
|
public function getAuthors(): array { return $this->authors; }
|
||||||
|
public function getCoverUrl(): ?string { return $this->coverUrl; }
|
||||||
|
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
|
||||||
|
|
||||||
|
// Setters (fluent interface)
|
||||||
|
public function setTitle(string $title): self
|
||||||
|
{
|
||||||
|
$this->title = $title;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): self
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... autres setters
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Mapper
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Persistence\Mapper;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\Title;
|
||||||
|
use App\Domain\Manga\Infrastructure\Persistence\Entity\MangaEntity;
|
||||||
|
|
||||||
|
readonly class MangaMapper
|
||||||
|
{
|
||||||
|
public function toDomain(MangaEntity $entity): Manga
|
||||||
|
{
|
||||||
|
return new Manga(
|
||||||
|
id: new MangaId((string) $entity->getId()),
|
||||||
|
title: new Title($entity->getTitle()),
|
||||||
|
description: $entity->getDescription(),
|
||||||
|
authors: $entity->getAuthors(),
|
||||||
|
coverUrl: $entity->getCoverUrl(),
|
||||||
|
createdAt: $entity->getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toEntity(Manga $manga): MangaEntity
|
||||||
|
{
|
||||||
|
$entity = new MangaEntity();
|
||||||
|
|
||||||
|
$entity->setTitle($manga->getTitle()->value())
|
||||||
|
->setDescription($manga->getDescription())
|
||||||
|
->setAuthors($manga->getAuthors())
|
||||||
|
->setCoverUrl($manga->getCoverUrl());
|
||||||
|
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bonnes Pratiques
|
||||||
|
|
||||||
|
### 1. Gestion des Erreurs
|
||||||
|
- Convertir les exceptions Doctrine en exceptions du domaine
|
||||||
|
- Ne pas exposer les détails de l'infrastructure
|
||||||
|
- Gérer les cas d'erreur spécifiques (contraintes uniques, etc.)
|
||||||
|
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Persistence\Exception;
|
||||||
|
|
||||||
|
class PersistenceException extends \RuntimeException
|
||||||
|
{
|
||||||
|
public static function entityNotFound(string $id): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Entity with id %s not found', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function uniqueConstraintViolation(string $field): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Entity with %s already exists', $field));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Performance
|
||||||
|
- Utiliser les bonnes stratégies de chargement (EAGER vs LAZY)
|
||||||
|
- Optimiser les requêtes avec des QueryBuilder
|
||||||
|
- Paginer les résultats
|
||||||
|
- Utiliser le cache quand nécessaire
|
||||||
|
|
||||||
|
### 3. Tests
|
||||||
|
- Créer des repositories In-Memory pour les tests
|
||||||
|
- Utiliser SQLite en mémoire pour les tests d'intégration
|
||||||
|
- Tester les cas d'erreur
|
||||||
|
- Vérifier les contraintes de base de données
|
||||||
202
.cursor/rules/tests.mdc
Normal file
202
.cursor/rules/tests.mdc
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: *.php
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Tests de Mangarr
|
||||||
|
|
||||||
|
## Structure des Tests
|
||||||
|
L'application suit une organisation stricte des tests reflétant l'architecture hexagonale :
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── Domain/ # Tests unitaires par domaine
|
||||||
|
│ ├── Manga/
|
||||||
|
│ │ ├── Application/ # Tests des cas d'utilisation
|
||||||
|
│ │ ├── Domain/ # Tests du cœur métier
|
||||||
|
│ │ └── Adapter/ # Implémentations InMemory des ports
|
||||||
|
│ ├── Reader/
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── Scraping/
|
||||||
|
│ └── ...
|
||||||
|
├── Feature/ # Tests fonctionnels par domaine
|
||||||
|
│ ├── Manga/
|
||||||
|
│ ├── Reader/
|
||||||
|
│ └── Scraping/
|
||||||
|
├── Shared/ # Tests et adapters partagés
|
||||||
|
│ └── Adapter/ # Adapters partagés entre domaines
|
||||||
|
└── Fixtures/ # Fixtures de test partagées
|
||||||
|
```
|
||||||
|
|
||||||
|
## Règles de Test
|
||||||
|
|
||||||
|
### 1. Tests Unitaires (Domain)
|
||||||
|
- Localisation : `tests/Domain/NomDuDomain/`
|
||||||
|
- Principes :
|
||||||
|
- Tester chaque composant de manière isolée
|
||||||
|
- Éviter l'utilisation de mocks
|
||||||
|
- Utiliser des adapters InMemory
|
||||||
|
- Nommer les classes de test avec le suffixe `Test`
|
||||||
|
|
||||||
|
### 2. Adapters de Test
|
||||||
|
- Localisation : `tests/Domain/NomDuDomain/Adapter/`
|
||||||
|
- Principes :
|
||||||
|
- Implémenter les interfaces du domaine
|
||||||
|
- Stocker les données dans des tableaux
|
||||||
|
- Préfixer les classes avec `InMemory`
|
||||||
|
- Si utilisé par plusieurs domaines → déplacer dans `tests/Shared/Adapter/`
|
||||||
|
|
||||||
|
### 3. Tests Fonctionnels (Feature)
|
||||||
|
- Localisation : `tests/Feature/NomDuDomain/`
|
||||||
|
- Principes :
|
||||||
|
- Tester les endpoints HTTP
|
||||||
|
- Utiliser le trait `ResetDatabase`
|
||||||
|
- Tester le flux complet
|
||||||
|
- Nommer les classes avec le suffixe `Test`
|
||||||
|
|
||||||
|
## Exemples de Code
|
||||||
|
|
||||||
|
### 1. Adapter InMemory
|
||||||
|
```php
|
||||||
|
namespace Tests\Domain\Manga\Adapter;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Entity\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Repository\MangaRepositoryInterface;
|
||||||
|
|
||||||
|
class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||||
|
{
|
||||||
|
/** @var array<string, Manga> */
|
||||||
|
private array $mangas = [];
|
||||||
|
|
||||||
|
public function save(Manga $manga): void
|
||||||
|
{
|
||||||
|
$this->mangas[$manga->getId()->toString()] = $manga;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $id): ?Manga
|
||||||
|
{
|
||||||
|
return $this->mangas[$id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->mangas = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Unitaire
|
||||||
|
```php
|
||||||
|
namespace Tests\Domain\Manga\Application;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Command\CreateMangaCommand;
|
||||||
|
use App\Domain\Manga\Application\CommandHandler\CreateMangaCommandHandler;
|
||||||
|
use Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class CreateMangaCommandHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private InMemoryMangaRepository $repository;
|
||||||
|
private CreateMangaCommandHandler $handler;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->repository = new InMemoryMangaRepository();
|
||||||
|
$this->handler = new CreateMangaCommandHandler($this->repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_creates_manga(): void
|
||||||
|
{
|
||||||
|
// Given
|
||||||
|
$command = new CreateMangaCommand('One Piece');
|
||||||
|
|
||||||
|
// When
|
||||||
|
$this->handler->__invoke($command);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
$mangas = $this->repository->findAll();
|
||||||
|
$this->assertCount(1, $mangas);
|
||||||
|
$this->assertEquals('One Piece', $mangas[0]->getTitle()->value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test Fonctionnel
|
||||||
|
```php
|
||||||
|
namespace Tests\Feature\Manga;
|
||||||
|
|
||||||
|
use Tests\Shared\ResetDatabase;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
class CreateMangaTest extends WebTestCase
|
||||||
|
{
|
||||||
|
use ResetDatabase;
|
||||||
|
|
||||||
|
public function test_it_creates_manga_through_api(): void
|
||||||
|
{
|
||||||
|
// Given
|
||||||
|
$client = static::createClient();
|
||||||
|
$data = ['title' => 'One Piece'];
|
||||||
|
|
||||||
|
// When
|
||||||
|
$client->request('POST', '/api/mangas', [], [], [], json_encode($data));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$this->assertJsonContains(['title' => 'One Piece']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Adapter Partagé
|
||||||
|
```php
|
||||||
|
namespace Tests\Shared\Adapter;
|
||||||
|
|
||||||
|
use App\Domain\Shared\Contract\MessageBusInterface;
|
||||||
|
|
||||||
|
class InMemoryMessageBus implements MessageBusInterface
|
||||||
|
{
|
||||||
|
/** @var array<object> */
|
||||||
|
private array $messages = [];
|
||||||
|
|
||||||
|
public function dispatch(object $message): void
|
||||||
|
{
|
||||||
|
$this->messages[] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDispatchedMessages(): array
|
||||||
|
{
|
||||||
|
return $this->messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->messages = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bonnes Pratiques
|
||||||
|
|
||||||
|
### 1. Organisation des Tests
|
||||||
|
- Un test par classe
|
||||||
|
- Regrouper les tests par fonctionnalité
|
||||||
|
- Suivre la même structure que le code source
|
||||||
|
- Utiliser des données de test explicites
|
||||||
|
|
||||||
|
### 2. Nommage
|
||||||
|
- Classes de test : `{ClassTestée}Test`
|
||||||
|
- Méthodes de test : `test_it_{comportement_testé}`
|
||||||
|
- Adapters : `InMemory{Interface}`
|
||||||
|
|
||||||
|
### 3. Assertions
|
||||||
|
- Utiliser des assertions spécifiques
|
||||||
|
- Vérifier les états plutôt que les interactions
|
||||||
|
- Tester les cas d'erreur
|
||||||
|
- Tester les cas limites
|
||||||
|
|
||||||
|
### 4. Données de Test
|
||||||
|
- Utiliser des fixtures pour les données complexes
|
||||||
|
- Créer des données spécifiques au test quand possible
|
||||||
|
- Éviter les dépendances entre tests
|
||||||
|
- Nettoyer l'état après chaque test
|
||||||
@@ -5,5 +5,6 @@ SYMFONY_DEPRECATIONS_HELPER=999999
|
|||||||
PANTHER_APP_ENV=panther
|
PANTHER_APP_ENV=panther
|
||||||
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
||||||
|
|
||||||
POSTGRES_DB=app
|
# Configuration PostgreSQL pour les tests
|
||||||
POSTGRE_VERSION=16
|
POSTGRES_DB=app_test
|
||||||
|
POSTGRES_VERSION=16
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,6 +23,7 @@
|
|||||||
###> phpunit/phpunit ###
|
###> phpunit/phpunit ###
|
||||||
/phpunit.xml
|
/phpunit.xml
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
.phpunit.cache/*
|
||||||
###< phpunit/phpunit ###
|
###< phpunit/phpunit ###
|
||||||
|
|
||||||
###> symfony/webpack-encore-bundle ###
|
###> symfony/webpack-encore-bundle ###
|
||||||
@@ -35,3 +36,5 @@ yarn-error.log
|
|||||||
/public/manga-images/
|
/public/manga-images/
|
||||||
/public/cbz/
|
/public/cbz/
|
||||||
/public/images/
|
/public/images/
|
||||||
|
src/Controller/TestController.php
|
||||||
|
.phpunit.cache/test-results
|
||||||
|
|||||||
16
.prettierrc
Normal file
16
.prettierrc
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"printWidth": 120,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"htmlWhitespaceSensitivity": "strict",
|
||||||
|
"singleAttributePerLine": false,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"jsxBracketSameLine": true,
|
||||||
|
"vueIndentScriptAndStyle": true,
|
||||||
|
"bracketSameLine": true
|
||||||
|
}
|
||||||
25
.vscode/settings.json
vendored
Normal file
25
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"symfony-vscode.shellExecutable": "/bin/bash",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
23
Makefile
23
Makefile
@@ -17,7 +17,7 @@ SF_MEMORY = $(PHP) -d memory_limit=256M bin/console
|
|||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DEFAULT_GOAL = help
|
.DEFAULT_GOAL = help
|
||||||
.PHONY : help build start install down stop logs sh composer vendor sf cc test phpmd phpcs quality fix-permissions controller entity migration migration-diff migration-migrate form crud fixtures command auth subscriber state-processor state-provider npm-install npm-run npm-watch
|
.PHONY : help build start install down stop logs sh composer vendor sf cc test phpmd phpcs quality fix-permissions controller entity migration migration-diff migration-migrate form crud fixtures command auth subscriber state-processor state-provider npm-install npm-run npm-watch openapi
|
||||||
|
|
||||||
## —— 🎵 🐳 The Symfony Docker Makefile 🐳 🎵 ——————————————————————————————————
|
## —— 🎵 🐳 The Symfony Docker Makefile 🐳 🎵 ——————————————————————————————————
|
||||||
help: ## Outputs this help screen
|
help: ## Outputs this help screen
|
||||||
@@ -27,6 +27,12 @@ help: ## Outputs this help screen
|
|||||||
build: ## Builds the Docker images
|
build: ## Builds the Docker images
|
||||||
@$(DOCKER_COMP) build --pull --no-cache
|
@$(DOCKER_COMP) build --pull --no-cache
|
||||||
|
|
||||||
|
phparkitect: ## Vérifie l'architecture avec PHPArkitect
|
||||||
|
@$(PHP_CONT) vendor/bin/phparkitect check
|
||||||
|
|
||||||
|
up: ## Start the docker hub
|
||||||
|
@$(DOCKER_COMP) up -d
|
||||||
|
|
||||||
start: ## Start the docker hub in detached mode (no logs)
|
start: ## Start the docker hub in detached mode (no logs)
|
||||||
@$(DOCKER_COMP) up --pull always -d --wait
|
@$(DOCKER_COMP) up --pull always -d --wait
|
||||||
|
|
||||||
@@ -44,9 +50,10 @@ logs: ## Show live logs
|
|||||||
sh: ## Connect to the FrankenPHP container
|
sh: ## Connect to the FrankenPHP container
|
||||||
@$(PHP_CONT) sh
|
@$(PHP_CONT) sh
|
||||||
|
|
||||||
test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure"
|
test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure", or "f=" to specify a test file, example: make test f="ScrapeChapterHandlerTest"
|
||||||
@$(eval c ?=)
|
@$(eval c ?=)
|
||||||
@$(DOCKER_COMP) exec -e APP_ENV=test php bin/phpunit $(c)
|
@$(eval f ?=)
|
||||||
|
@$(DOCKER_COMP) exec -e APP_ENV=test php bin/phpunit $(c) $(if $(f),--filter=$(f),)
|
||||||
|
|
||||||
phpmd: ## Start PHP Mess Detector
|
phpmd: ## Start PHP Mess Detector
|
||||||
@if ! $(DOCKER_COMP) exec php vendor/bin/phpmd src/ text phpmd.xml -v; then \
|
@if ! $(DOCKER_COMP) exec php vendor/bin/phpmd src/ text phpmd.xml -v; then \
|
||||||
@@ -138,8 +145,11 @@ twig-extension: ## Create a new twig extension
|
|||||||
stimulus: ## Create a new stimulus controller
|
stimulus: ## Create a new stimulus controller
|
||||||
@$(SYMFONY) make:stimulus-controller
|
@$(SYMFONY) make:stimulus-controller
|
||||||
|
|
||||||
consume: ## Consume messages
|
consume-commands: ## Consume commands messages
|
||||||
@$(SYMFONY) messenger:consume async -vv
|
@$(SYMFONY) messenger:consume commands -vv
|
||||||
|
|
||||||
|
consume-events: ## Consume events messages
|
||||||
|
@$(SYMFONY) messenger:consume events -vv
|
||||||
|
|
||||||
consume-schedule: ## Consume schedule messages
|
consume-schedule: ## Consume schedule messages
|
||||||
@$(SYMFONY) messenger:consume async -vv scheduler_default
|
@$(SYMFONY) messenger:consume async -vv scheduler_default
|
||||||
@@ -147,6 +157,9 @@ consume-schedule: ## Consume schedule messages
|
|||||||
message: ## Create a new message and handler
|
message: ## Create a new message and handler
|
||||||
@$(SYMFONY) make:message
|
@$(SYMFONY) make:message
|
||||||
|
|
||||||
|
openapi: ## Exporter la documentation OpenAPI en JSON
|
||||||
|
@$(SYMFONY) api:openapi:export --output=public/api-docs.json
|
||||||
|
|
||||||
## —— Webpack Encore —————————————————————————————————————————————————————————————
|
## —— Webpack Encore —————————————————————————————————————————————————————————————
|
||||||
npm-install: ## Install npm dependencies
|
npm-install: ## Install npm dependencies
|
||||||
@$(DOCKER_COMP) exec node npm install --force
|
@$(DOCKER_COMP) exec node npm install --force
|
||||||
|
|||||||
@@ -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 :
|
Pour mettre en place le projet, suivez ces étapes :
|
||||||
|
|
||||||
1. Clonez le dépôt du projet :
|
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` :
|
2. Copiez le fichier `.env.example` en `.env` :
|
||||||
```cp .env.example .env```
|
```cp .env.example .env```
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ import './styles/app.scss';
|
|||||||
|
|
||||||
// start the Stimulus application
|
// start the Stimulus application
|
||||||
import './bootstrap';
|
import './bootstrap';
|
||||||
|
|
||||||
|
// La ligne registerReactControllerComponents a déjà été commentée
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@symfony/ux-react": {
|
||||||
|
"react": {
|
||||||
|
"enabled": true,
|
||||||
|
"fetch": "eager"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@symfony/ux-turbo": {
|
"@symfony/ux-turbo": {
|
||||||
"turbo-core": {
|
"turbo-core": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
20
assets/vue/app/App.vue
Normal file
20
assets/vue/app/App.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<router-view></router-view>
|
||||||
|
<NotificationToast />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
|
||||||
|
|
||||||
|
const jobRepository = new ApiJobRepository();
|
||||||
|
|
||||||
|
export const useActivityStore = defineStore('activity', {
|
||||||
|
state: () => ({
|
||||||
|
jobs: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
// Pagination
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 0,
|
||||||
|
total: 0,
|
||||||
|
limit: 20,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPreviousPage: false,
|
||||||
|
// Filtres
|
||||||
|
filter: {
|
||||||
|
status: ['pending', 'in_progress'], // Par défaut, ne montrer que les actifs
|
||||||
|
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,
|
||||||
|
total: state.total,
|
||||||
|
limit: state.limit,
|
||||||
|
hasNextPage: state.hasNextPage,
|
||||||
|
hasPreviousPage: state.hasPreviousPage
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
page: page || this.currentPage,
|
||||||
|
limit: this.limit,
|
||||||
|
sortBy: this.filter.sortBy,
|
||||||
|
sortOrder: this.filter.sortOrder,
|
||||||
|
status: this.filter.status
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Va à une page spécifique
|
||||||
|
* @param {number} page
|
||||||
|
*/
|
||||||
|
async goToPage(page) {
|
||||||
|
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||||
|
this.currentPage = page;
|
||||||
|
await this.loadJobs(page);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
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
|
||||||
|
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;
|
||||||
|
console.error('Error deleting job:', error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime tous les jobs correspondant aux critères
|
||||||
|
* @param {Object} criteria
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
this.error = error.message;
|
||||||
|
console.error('Error deleting jobs:', error);
|
||||||
|
} finally {
|
||||||
|
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({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
50
assets/vue/app/domain/activity/domain/entities/job.js
Normal file
50
assets/vue/app/domain/activity/domain/entities/job.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export class Job {
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
progress = 0,
|
||||||
|
payload = {},
|
||||||
|
result = null,
|
||||||
|
error = null,
|
||||||
|
createdAt = new Date().toISOString(),
|
||||||
|
updatedAt = new Date().toISOString()
|
||||||
|
}) {
|
||||||
|
this.id = id;
|
||||||
|
this.type = type;
|
||||||
|
this.status = status;
|
||||||
|
this.progress = progress;
|
||||||
|
this.payload = payload;
|
||||||
|
this.result = result;
|
||||||
|
this.error = error;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(data) {
|
||||||
|
return new Job(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive() {
|
||||||
|
return ['pending', 'in_progress'].includes(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasError() {
|
||||||
|
return this.status === 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
isCompleted() {
|
||||||
|
return this.status === 'completed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JobCollection {
|
||||||
|
constructor(items, total, page, limit, hasNextPage, hasPreviousPage) {
|
||||||
|
this.items = items.map(item => Job.create(item));
|
||||||
|
this.total = total;
|
||||||
|
this.page = page;
|
||||||
|
this.limit = limit;
|
||||||
|
this.hasNextPage = hasNextPage;
|
||||||
|
this.hasPreviousPage = hasPreviousPage;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export class JobRepositoryInterface {
|
||||||
|
/**
|
||||||
|
* Récupère la liste des jobs
|
||||||
|
* @param {Object} options Les options de filtrage et pagination
|
||||||
|
* @param {number} options.page Numéro de la page
|
||||||
|
* @param {number} options.limit Nombre d'éléments par page
|
||||||
|
* @param {string} options.sortBy Champ pour le tri
|
||||||
|
* @param {string} options.sortOrder Direction du tri ('ASC' ou 'DESC')
|
||||||
|
* @param {Array<string>} options.status Liste des statuts à filtrer
|
||||||
|
* @returns {Promise<JobCollection>} Collection de jobs
|
||||||
|
*/
|
||||||
|
async getJobs(options) {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un job par son ID
|
||||||
|
* @param {string} id Identifiant du job
|
||||||
|
* @returns {Promise<Job>} Job
|
||||||
|
*/
|
||||||
|
async getJobById(id) {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un job
|
||||||
|
* @param {string} id Identifiant du job
|
||||||
|
* @returns {Promise<boolean>} Succès de l'opération
|
||||||
|
*/
|
||||||
|
async deleteJob(id) {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime tous les jobs correspondant aux critères
|
||||||
|
* @param {Object} criteria Critères de suppression
|
||||||
|
* @returns {Promise<number>} Nombre de jobs supprimés
|
||||||
|
*/
|
||||||
|
async deleteJobs(criteria) {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { Job, JobCollection } from '../../domain/entities/job';
|
||||||
|
import { JobRepositoryInterface } from '../../domain/repository/JobRepositoryInterface';
|
||||||
|
|
||||||
|
export class ApiJobRepository extends JobRepositoryInterface {
|
||||||
|
/**
|
||||||
|
* Récupère la liste des jobs
|
||||||
|
* @param {Object} options Les options de filtrage et pagination
|
||||||
|
* @param {number} options.page Numéro de la page
|
||||||
|
* @param {number} options.limit Nombre d'éléments par page
|
||||||
|
* @param {string} options.sortBy Champ pour le tri
|
||||||
|
* @param {string} options.sortOrder Direction du tri ('ASC' ou 'DESC')
|
||||||
|
* @param {Array<string>} options.status Liste des statuts à filtrer
|
||||||
|
* @returns {Promise<JobCollection>} Collection de jobs
|
||||||
|
*/
|
||||||
|
async getJobs(options = {}) {
|
||||||
|
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [] } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
||||||
|
|
||||||
|
// Ajouter les filtres de statut s'ils sont fournis
|
||||||
|
if (status && status.length > 0) {
|
||||||
|
url += `&status=${status.join(',')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching jobs from URL:', url);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch jobs');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
// Si l'API retourne directement un tableau
|
||||||
|
jobs = data;
|
||||||
|
total = data.length;
|
||||||
|
currentPage = page;
|
||||||
|
limit_returned = limit;
|
||||||
|
hasNext = false;
|
||||||
|
hasPrev = false;
|
||||||
|
} else if (data.items || data.data) {
|
||||||
|
// Si l'API retourne un objet avec les données dans items ou data
|
||||||
|
jobs = data.items || data.data || [];
|
||||||
|
total = data.total || data.totalCount || jobs.length;
|
||||||
|
currentPage = data.page || data.currentPage || page;
|
||||||
|
limit_returned = data.limit || data.perPage || limit;
|
||||||
|
hasNext = data.hasNextPage || data.hasNext || (currentPage * limit_returned < total);
|
||||||
|
hasPrev = data.hasPreviousPage || data.hasPrev || currentPage > 1;
|
||||||
|
} else {
|
||||||
|
// Format par défaut
|
||||||
|
jobs = data || [];
|
||||||
|
total = data.total || 0;
|
||||||
|
currentPage = data.page || 1;
|
||||||
|
limit_returned = data.limit || limit;
|
||||||
|
hasNext = !!data.hasNextPage;
|
||||||
|
hasPrev = !!data.hasPreviousPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Processed data:', {
|
||||||
|
jobs: jobs.length,
|
||||||
|
total,
|
||||||
|
currentPage,
|
||||||
|
limit_returned,
|
||||||
|
hasNext,
|
||||||
|
hasPrev
|
||||||
|
});
|
||||||
|
|
||||||
|
return new JobCollection(
|
||||||
|
jobs,
|
||||||
|
total,
|
||||||
|
currentPage,
|
||||||
|
limit_returned,
|
||||||
|
hasNext,
|
||||||
|
hasPrev
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un job par son ID
|
||||||
|
* @param {string} id Identifiant du job
|
||||||
|
* @returns {Promise<Job>} Job
|
||||||
|
*/
|
||||||
|
async getJobById(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/jobs/${id}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch job');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return Job.create(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un job
|
||||||
|
* @param {string} id Identifiant du job
|
||||||
|
* @returns {Promise<boolean>} Succès de l'opération
|
||||||
|
*/
|
||||||
|
async deleteJob(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/jobs/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete job');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime tous les jobs correspondant aux critères
|
||||||
|
* @param {Object} criteria Critères de suppression
|
||||||
|
* @returns {Promise<number>} Nombre de jobs supprimés
|
||||||
|
*/
|
||||||
|
async deleteJobs(criteria = {}) {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
// Ajouter les critères à l'URL
|
||||||
|
Object.entries(criteria).forEach(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
queryParams.append(key, value.join(','));
|
||||||
|
} else {
|
||||||
|
queryParams.append(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/jobs?${queryParams.toString()}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete jobs');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.deleted || 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<tr
|
||||||
|
class="border-b border-gray-200 hover:bg-gray-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'
|
||||||
|
}">
|
||||||
|
<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">
|
||||||
|
<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'
|
||||||
|
}">
|
||||||
|
{{ job.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-4 px-4">
|
||||||
|
<div v-if="job.error" class="text-sm text-red-600">
|
||||||
|
{{ job.error }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-gray-600">
|
||||||
|
{{ 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="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||||
|
:style="{ width: `${job.progress}%` }"></div>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
|
||||||
|
{{ job.progress }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 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>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
|
||||||
|
100%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 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>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
|
||||||
|
Erreur
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="relative bg-gray-200 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">
|
||||||
|
En attente
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-4 px-4">
|
||||||
|
<button
|
||||||
|
@click="onDelete"
|
||||||
|
class="text-red-500 hover:text-red-700 transition duration-150 ease-in-out"
|
||||||
|
title="Supprimer">
|
||||||
|
<TrashIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete() {
|
||||||
|
emit('delete', props.job.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<Toolbar :config="toolbarConfig" class="mb-6" />
|
||||||
|
|
||||||
|
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
||||||
|
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="activityStore.error" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full bg-white">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<Pagination
|
||||||
|
v-if="activityStore.total > activityStore.limit"
|
||||||
|
:current-page="activityStore.currentPage"
|
||||||
|
:total-pages="activityStore.totalPages"
|
||||||
|
:total="activityStore.total"
|
||||||
|
:limit="activityStore.limit"
|
||||||
|
:has-next-page="activityStore.hasNextPage"
|
||||||
|
:has-previous-page="activityStore.hasPreviousPage"
|
||||||
|
@page-change="changePage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowPathIcon, ClockIcon, FunnelIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, onMounted, ref } 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);
|
||||||
|
|
||||||
|
// 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' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Index du statut actif (par défaut "Actifs")
|
||||||
|
const activeStatusIndex = ref(0);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadJobs();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadJobs() {
|
||||||
|
activityStore.loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ApiConversionRepository } from '../../infrastructure/api/apiConversionRepository';
|
||||||
|
|
||||||
|
const conversionRepository = new ApiConversionRepository();
|
||||||
|
|
||||||
|
export const useConversionStore = defineStore('conversion', {
|
||||||
|
state: () => ({
|
||||||
|
// État de conversion
|
||||||
|
isConverting: false,
|
||||||
|
conversionProgress: 0,
|
||||||
|
conversionError: null,
|
||||||
|
conversionSuccess: false,
|
||||||
|
|
||||||
|
// Fichier en cours de traitement
|
||||||
|
currentFile: null,
|
||||||
|
convertedFile: null,
|
||||||
|
|
||||||
|
// Historique des conversions (optionnel)
|
||||||
|
conversionHistory: [],
|
||||||
|
|
||||||
|
// État de l'interface
|
||||||
|
isDragOver: false,
|
||||||
|
showSuccessMessage: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
/**
|
||||||
|
* Indique si une conversion est en cours
|
||||||
|
*/
|
||||||
|
isProcessing: (state) => state.isConverting,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si un fichier est sélectionné
|
||||||
|
*/
|
||||||
|
hasSelectedFile: (state) => state.currentFile !== null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si une conversion a réussi
|
||||||
|
*/
|
||||||
|
hasSucceeded: (state) => state.conversionSuccess && state.convertedFile !== null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si une erreur est présente
|
||||||
|
*/
|
||||||
|
hasError: (state) => state.conversionError !== null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient le nom du fichier actuel
|
||||||
|
*/
|
||||||
|
currentFileName: (state) => state.currentFile?.name || '',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient la taille formatée du fichier actuel
|
||||||
|
*/
|
||||||
|
currentFileSize: (state) => {
|
||||||
|
if (!state.currentFile) return '';
|
||||||
|
|
||||||
|
const bytes = state.currentFile.size;
|
||||||
|
const sizes = ['octets', 'Ko', 'Mo', 'Go'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient le nombre de conversions réussies
|
||||||
|
*/
|
||||||
|
conversionCount: (state) => state.conversionHistory.length,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/**
|
||||||
|
* Sélectionne un fichier pour la conversion
|
||||||
|
* @param {File} file - Le fichier sélectionné
|
||||||
|
*/
|
||||||
|
selectFile(file) {
|
||||||
|
// Validation du fichier
|
||||||
|
const validation = conversionRepository.validateFile(file);
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
this.setError(validation.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réinitialisation de l'état
|
||||||
|
this.clearError();
|
||||||
|
this.conversionSuccess = false;
|
||||||
|
this.convertedFile = null;
|
||||||
|
this.showSuccessMessage = false;
|
||||||
|
|
||||||
|
// Stockage du fichier
|
||||||
|
this.currentFile = file;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance la conversion du fichier sélectionné
|
||||||
|
*/
|
||||||
|
async convertCurrentFile() {
|
||||||
|
if (!this.currentFile) {
|
||||||
|
this.setError('Aucun fichier sélectionné');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isConverting = true;
|
||||||
|
this.conversionProgress = 0;
|
||||||
|
this.clearError();
|
||||||
|
|
||||||
|
// Simulation du progrès (l'API ne fournit pas de progrès en temps réel)
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
if (this.conversionProgress < 90) {
|
||||||
|
this.conversionProgress += Math.random() * 10;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Appel à l'API de conversion
|
||||||
|
const convertedFileBlob = await conversionRepository.convertFile(this.currentFile);
|
||||||
|
|
||||||
|
// Nettoyage de l'interval de progrès
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
this.conversionProgress = 100;
|
||||||
|
|
||||||
|
// Stockage du fichier converti
|
||||||
|
this.convertedFile = convertedFileBlob;
|
||||||
|
this.conversionSuccess = true;
|
||||||
|
this.showSuccessMessage = true;
|
||||||
|
|
||||||
|
// Ajout à l'historique
|
||||||
|
this.addToHistory({
|
||||||
|
originalName: this.currentFile.name,
|
||||||
|
originalSize: this.currentFile.size,
|
||||||
|
convertedSize: convertedFileBlob.size,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.setError(error.message || 'Erreur lors de la conversion');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.isConverting = false;
|
||||||
|
this.conversionProgress = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Télécharge le fichier converti
|
||||||
|
*/
|
||||||
|
downloadConvertedFile() {
|
||||||
|
if (!this.convertedFile || !this.currentFile) {
|
||||||
|
this.setError('Aucun fichier converti disponible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
conversionRepository.downloadConvertedFile(
|
||||||
|
this.convertedFile,
|
||||||
|
this.currentFile.name
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.setError(error.message || 'Erreur lors du téléchargement');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réinitialise l'état de conversion
|
||||||
|
*/
|
||||||
|
resetConversion() {
|
||||||
|
this.currentFile = null;
|
||||||
|
this.convertedFile = null;
|
||||||
|
this.conversionSuccess = false;
|
||||||
|
this.showSuccessMessage = false;
|
||||||
|
this.conversionProgress = 0;
|
||||||
|
this.clearError();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Définit une erreur
|
||||||
|
* @param {string} message - Message d'erreur
|
||||||
|
*/
|
||||||
|
setError(message) {
|
||||||
|
this.conversionError = message;
|
||||||
|
this.conversionSuccess = false;
|
||||||
|
this.showSuccessMessage = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Efface l'erreur actuelle
|
||||||
|
*/
|
||||||
|
clearError() {
|
||||||
|
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é
|
||||||
|
*/
|
||||||
|
setDragOver(isDragOver) {
|
||||||
|
this.isDragOver = isDragOver;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute une conversion à l'historique
|
||||||
|
* @param {Object} conversionData - Données de la conversion
|
||||||
|
*/
|
||||||
|
addToHistory(conversionData) {
|
||||||
|
this.conversionHistory.unshift(conversionData);
|
||||||
|
|
||||||
|
// Limiter l'historique à 10 éléments
|
||||||
|
if (this.conversionHistory.length > 10) {
|
||||||
|
this.conversionHistory = this.conversionHistory.slice(0, 10);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Efface l'historique des conversions
|
||||||
|
*/
|
||||||
|
clearHistory() {
|
||||||
|
this.conversionHistory = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide un fichier sans le sélectionner
|
||||||
|
* @param {File} file - Le fichier à valider
|
||||||
|
* @returns {Object} - Résultat de la validation
|
||||||
|
*/
|
||||||
|
validateFile(file) {
|
||||||
|
return conversionRepository.validateFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
export class ApiConversionRepository {
|
||||||
|
/**
|
||||||
|
* Convertit un fichier CBR/CBZ en CBZ
|
||||||
|
* @param {File} file - Le fichier à convertir
|
||||||
|
* @returns {Promise<Blob>} - Le fichier converti
|
||||||
|
*/
|
||||||
|
async convertFile(file) {
|
||||||
|
try {
|
||||||
|
// Validation du fichier
|
||||||
|
if (!file) {
|
||||||
|
throw new Error('Aucun fichier fourni');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation de la taille (150MB max selon l'API)
|
||||||
|
const maxSize = 150 * 1024 * 1024; // 150MB en bytes
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
throw new Error('Le fichier est trop volumineux (max 150MB)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation du type de fichier
|
||||||
|
const allowedTypes = ['.cbr', '.cbz'];
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const isValidType = allowedTypes.some(type => fileName.endsWith(type));
|
||||||
|
|
||||||
|
if (!isValidType) {
|
||||||
|
throw new Error('Type de fichier non supporté. Seuls les fichiers .cbr et .cbz sont acceptés');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Création du FormData pour l'envoi multipart
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
// Appel à l'API
|
||||||
|
const response = await fetch('/api/conversions/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
// On ne définit pas Content-Type pour laisser le navigateur gérer multipart/form-data
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Gestion des erreurs HTTP
|
||||||
|
let errorMessage = 'Erreur lors de la conversion';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
errorMessage = errorData.message || errorData.detail || errorMessage;
|
||||||
|
} catch {
|
||||||
|
// Si la réponse n'est pas du JSON, on utilise le status text
|
||||||
|
errorMessage = response.statusText || errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupération du fichier converti
|
||||||
|
const convertedFile = await response.blob();
|
||||||
|
|
||||||
|
// Vérification que le fichier n'est pas vide
|
||||||
|
if (convertedFile.size === 0) {
|
||||||
|
throw new Error('Le fichier converti est vide');
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertedFile;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la conversion:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Télécharge le fichier converti
|
||||||
|
* @param {Blob} fileBlob - Le fichier à télécharger
|
||||||
|
* @param {string} originalFileName - Nom original du fichier
|
||||||
|
*/
|
||||||
|
downloadConvertedFile(fileBlob, originalFileName) {
|
||||||
|
try {
|
||||||
|
// Génération du nom de fichier de sortie
|
||||||
|
const baseName = originalFileName.replace(/\.(cbr|cbz)$/i, '');
|
||||||
|
const outputFileName = `${baseName}.cbz`;
|
||||||
|
|
||||||
|
// Création d'un lien de téléchargement
|
||||||
|
const url = URL.createObjectURL(fileBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = outputFileName;
|
||||||
|
|
||||||
|
// Déclenchement du téléchargement
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
// Nettoyage
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du téléchargement:', error);
|
||||||
|
throw new Error('Impossible de télécharger le fichier converti');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide si un fichier peut être converti
|
||||||
|
* @param {File} file - Le fichier à valider
|
||||||
|
* @returns {Object} - Résultat de la validation {isValid: boolean, error?: string}
|
||||||
|
*/
|
||||||
|
validateFile(file) {
|
||||||
|
if (!file) {
|
||||||
|
return { isValid: false, error: 'Aucun fichier sélectionné' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérification de la taille
|
||||||
|
const maxSize = 150 * 1024 * 1024; // 150MB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: 'Le fichier est trop volumineux (maximum 150MB)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérification du type
|
||||||
|
const allowedTypes = ['.cbr', '.cbz'];
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const isValidType = allowedTypes.some(type => fileName.endsWith(type));
|
||||||
|
|
||||||
|
if (!isValidType) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: 'Type de fichier non supporté. Seuls les fichiers .cbr et .cbz sont acceptés'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Statut de la conversion -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<!-- Icône de statut -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<ArrowPathIcon
|
||||||
|
v-if="isConverting"
|
||||||
|
class="w-6 h-6 text-blue-500 animate-spin"
|
||||||
|
/>
|
||||||
|
<CheckCircleIcon
|
||||||
|
v-else-if="isSuccess"
|
||||||
|
class="w-6 h-6 text-green-500"
|
||||||
|
/>
|
||||||
|
<ExclamationTriangleIcon
|
||||||
|
v-else-if="hasError"
|
||||||
|
class="w-6 h-6 text-red-500"
|
||||||
|
/>
|
||||||
|
<ClockIcon
|
||||||
|
v-else
|
||||||
|
class="w-6 h-6 text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message de statut -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
{{ statusMessage }}
|
||||||
|
</p>
|
||||||
|
<p v-if="fileName" class="text-xs text-gray-500">
|
||||||
|
{{ fileName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barre de progression -->
|
||||||
|
<div v-if="showProgress" class="space-y-2">
|
||||||
|
<div class="flex justify-between text-xs text-gray-600">
|
||||||
|
<span>Progression</span>
|
||||||
|
<span>{{ Math.round(progress) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="bg-blue-500 h-2 rounded-full transition-all duration-300 ease-out"
|
||||||
|
:style="{ width: `${progress}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Détails de la conversion -->
|
||||||
|
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 space-y-1">
|
||||||
|
<div v-if="originalSize" class="flex justify-between">
|
||||||
|
<span>Taille originale:</span>
|
||||||
|
<span>{{ formatFileSize(originalSize) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="convertedSize" class="flex justify-between">
|
||||||
|
<span>Taille convertie:</span>
|
||||||
|
<span>{{ formatFileSize(convertedSize) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="originalSize && convertedSize" class="flex justify-between font-medium">
|
||||||
|
<span>Gain d'espace:</span>
|
||||||
|
<span :class="spaceSavingClass">{{ spaceSavingText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div v-if="showActions" class="flex space-x-3">
|
||||||
|
<button
|
||||||
|
v-if="canDownload"
|
||||||
|
@click="$emit('download')"
|
||||||
|
class="flex items-center space-x-2 px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon class="w-4 h-4" />
|
||||||
|
<span>Télécharger CBZ</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon class="w-4 h-4" />
|
||||||
|
<span>Convertir un autre fichier</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message d'erreur détaillé -->
|
||||||
|
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 border border-red-200 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">
|
||||||
|
Erreur de conversion
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ClockIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ConversionProgress',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
ArrowPathIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
ClockIcon,
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
isConverting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
isSuccess: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
hasError: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
originalSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
convertedSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
showActions: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
showDetails: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['download', 'reset'],
|
||||||
|
|
||||||
|
setup(props) {
|
||||||
|
// Message de statut calculé
|
||||||
|
const statusMessage = computed(() => {
|
||||||
|
if (props.isConverting) {
|
||||||
|
return 'Conversion en cours...';
|
||||||
|
}
|
||||||
|
if (props.isSuccess) {
|
||||||
|
return 'Conversion terminée avec succès !';
|
||||||
|
}
|
||||||
|
if (props.hasError) {
|
||||||
|
return 'Erreur lors de la conversion';
|
||||||
|
}
|
||||||
|
return 'En attente de fichier';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Affichage de la barre de progression
|
||||||
|
const showProgress = computed(() => {
|
||||||
|
return props.isConverting && props.progress > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actions disponibles
|
||||||
|
const canDownload = computed(() => {
|
||||||
|
return props.isSuccess && !props.isConverting;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canReset = computed(() => {
|
||||||
|
return (props.isSuccess || props.hasError) && !props.isConverting;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcul du gain d'espace
|
||||||
|
const spaceSaving = computed(() => {
|
||||||
|
if (!props.originalSize || !props.convertedSize) return 0;
|
||||||
|
return ((props.originalSize - props.convertedSize) / props.originalSize) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
const spaceSavingText = computed(() => {
|
||||||
|
const saving = spaceSaving.value;
|
||||||
|
if (saving > 0) {
|
||||||
|
return `-${saving.toFixed(1)}%`;
|
||||||
|
} else if (saving < 0) {
|
||||||
|
return `+${Math.abs(saving).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
return '0%';
|
||||||
|
});
|
||||||
|
|
||||||
|
const spaceSavingClass = computed(() => {
|
||||||
|
const saving = spaceSaving.value;
|
||||||
|
if (saving > 0) {
|
||||||
|
return 'text-green-600';
|
||||||
|
} else if (saving < 0) {
|
||||||
|
return 'text-red-600';
|
||||||
|
}
|
||||||
|
return 'text-gray-600';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formatage de la taille de fichier
|
||||||
|
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]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusMessage,
|
||||||
|
showProgress,
|
||||||
|
canDownload,
|
||||||
|
canReset,
|
||||||
|
spaceSavingText,
|
||||||
|
spaceSavingClass,
|
||||||
|
formatFileSize,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
: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'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- Zone d'upload -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Icône -->
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ArchiveBoxIcon
|
||||||
|
:class="[
|
||||||
|
'w-16 h-16 transition-colors duration-200',
|
||||||
|
isDragOver ? 'text-green-500' : 'text-gray-400'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message principal -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">
|
||||||
|
{{ isDragOver ? 'Déposez votre fichier ici' : 'Sélectionnez un fichier CBR ou CBZ' }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Glissez-déposez votre fichier ou cliquez pour le sélectionner
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Fichiers supportés: .cbr, .cbz (max. 150MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton de sélection -->
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<label
|
||||||
|
for="file-upload"
|
||||||
|
:class="[
|
||||||
|
'relative cursor-pointer rounded-md px-4 py-2 font-medium text-white transition-colors duration-200',
|
||||||
|
isDragOver
|
||||||
|
? 'bg-green-500 hover:bg-green-600'
|
||||||
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span>Sélectionner un fichier</span>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
name="file-upload"
|
||||||
|
type="file"
|
||||||
|
class="sr-only"
|
||||||
|
accept=".cbr,.cbz"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informations du fichier sélectionné -->
|
||||||
|
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<DocumentIcon class="w-8 h-8 text-gray-600" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{{ selectedFile.name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{{ formatFileSize(selectedFile.size) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="clearFile"
|
||||||
|
class="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
title="Supprimer le fichier"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay pendant le drag -->
|
||||||
|
<div
|
||||||
|
v-if="isDragOver"
|
||||||
|
class="absolute inset-0 bg-green-100 bg-opacity-50 rounded-lg flex items-center justify-center"
|
||||||
|
style="pointer-events: none;"
|
||||||
|
>
|
||||||
|
<div class="text-green-600 font-medium text-lg">
|
||||||
|
Déposez le fichier ici
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ArchiveBoxIcon, DocumentIcon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'FileUploadArea',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
ArchiveBoxIcon,
|
||||||
|
DocumentIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
selectedFile: {
|
||||||
|
type: File,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['file-selected', 'file-cleared'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const isDragOver = ref(false);
|
||||||
|
const dragCounter = ref(0);
|
||||||
|
|
||||||
|
// Handlers pour le drag & drop
|
||||||
|
const handleDragEnter = (event) => {
|
||||||
|
if (props.disabled) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
dragCounter.value++;
|
||||||
|
isDragOver.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event) => {
|
||||||
|
if (props.disabled) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event) => {
|
||||||
|
if (props.disabled) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
dragCounter.value--;
|
||||||
|
|
||||||
|
if (dragCounter.value === 0) {
|
||||||
|
isDragOver.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event) => {
|
||||||
|
if (props.disabled) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
isDragOver.value = false;
|
||||||
|
dragCounter.value = 0;
|
||||||
|
|
||||||
|
const files = event.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
emit('file-selected', file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler pour la sélection de fichier via input
|
||||||
|
const handleFileSelect = (event) => {
|
||||||
|
if (props.disabled) return;
|
||||||
|
|
||||||
|
const files = event.target.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
emit('file-selected', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réinitialiser l'input pour permettre la sélection du même fichier
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer le fichier sélectionné
|
||||||
|
const clearFile = () => {
|
||||||
|
emit('file-cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formater la taille du fichier
|
||||||
|
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]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDragOver,
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
handleFileSelect,
|
||||||
|
clearFile,
|
||||||
|
formatFileSize,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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';
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { useConversionStore } from '../../application/store/conversionStore';
|
||||||
|
import ConversionProgress from '../components/ConversionProgress.vue';
|
||||||
|
import FileUploadArea from '../components/FileUploadArea.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ConversionPage',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
FileUploadArea,
|
||||||
|
ConversionProgress,
|
||||||
|
ArrowPathIcon,
|
||||||
|
ArchiveBoxIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
},
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const conversionStore = useConversionStore();
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const showProgress = computed(() => {
|
||||||
|
return conversionStore.hasSelectedFile &&
|
||||||
|
(conversionStore.isProcessing || conversionStore.hasSucceeded || conversionStore.hasError);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 handleConvert = async () => {
|
||||||
|
if (!conversionStore.currentFile) return;
|
||||||
|
|
||||||
|
const success = await conversionStore.convertCurrentFile();
|
||||||
|
if (success) {
|
||||||
|
console.log('Conversion réussie');
|
||||||
|
} else {
|
||||||
|
console.error('Échec de la conversion');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Styles spécifiques si nécessaires */
|
||||||
|
</style>
|
||||||
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,228 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<!-- File Icon and Info -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Details -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 truncate">
|
||||||
|
{{ file.filename }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Status Badge -->
|
||||||
|
<div class="flex-shrink-0 ml-4">
|
||||||
|
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ file.getFormattedSize() }} • {{ file.getFileExtension().toUpperCase() }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Extracted Info -->
|
||||||
|
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
|
||||||
|
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 text-blue-700">
|
||||||
|
Chapitre {{ file.getExtractedChapterNumber() }}
|
||||||
|
</span>
|
||||||
|
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 text-purple-700">
|
||||||
|
Volume {{ file.getExtractedVolumeNumber() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Display -->
|
||||||
|
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<div class="flex">
|
||||||
|
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">Erreur</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700">{{ file.errorMessage }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manga Selection -->
|
||||||
|
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Matches Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
<MangaMatchCard
|
||||||
|
v-for="match in sortedMatches"
|
||||||
|
:key="match.id"
|
||||||
|
:match="match"
|
||||||
|
:is-selected="file.selectedManga?.id === match.id"
|
||||||
|
@select-match="handleMangaSelection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Manga Preview -->
|
||||||
|
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
|
<img
|
||||||
|
v-if="file.selectedManga.thumbnailUrl"
|
||||||
|
:src="file.selectedManga.thumbnailUrl"
|
||||||
|
:alt="file.selectedManga.title"
|
||||||
|
class="w-12 h-16 object-cover rounded"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-gray-900">{{ file.selectedManga.title }}</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ file.selectedManga.slug }}</p>
|
||||||
|
<p class="text-xs text-blue-600 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapter/Volume Number Inputs -->
|
||||||
|
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
|
||||||
|
<!-- Chapter Number -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Numéro de chapitre
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
:value="file.selectedChapterNumber ?? ''"
|
||||||
|
@input="handleChapterNumberInput"
|
||||||
|
:disabled="file.selectedVolumeNumber !== null"
|
||||||
|
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||||
|
placeholder="Ex: 1, 1.5, 2..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volume Number -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Numéro de volume
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
:value="file.selectedVolumeNumber ?? ''"
|
||||||
|
@input="handleVolumeNumberInput"
|
||||||
|
:disabled="file.selectedChapterNumber !== null"
|
||||||
|
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||||
|
placeholder="Ex: 1, 1.5, 2..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Matches Message -->
|
||||||
|
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||||
|
<div class="flex">
|
||||||
|
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800">Aucun manga trouvé</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700">
|
||||||
|
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="mt-6 flex justify-between items-center">
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<!-- Import Button -->
|
||||||
|
<button
|
||||||
|
v-if="file.isReadyForImport()"
|
||||||
|
@click="$emit('import-file')"
|
||||||
|
:disabled="isImporting"
|
||||||
|
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md text-sm font-medium flex items-center"
|
||||||
|
>
|
||||||
|
<svg v-if="isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ isImporting ? 'Import en cours...' : 'Importer' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Retry Button -->
|
||||||
|
<button
|
||||||
|
v-if="file.hasError()"
|
||||||
|
@click="$emit('retry-file')"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remove Button -->
|
||||||
|
<button
|
||||||
|
@click="$emit('remove-file')"
|
||||||
|
class="text-red-600 hover:text-red-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { 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'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Computed property to get sorted matches
|
||||||
|
const sortedMatches = computed(() => {
|
||||||
|
const matches = props.file.getMatches();
|
||||||
|
return matches.sort((a, b) => b.matchScore - a.matchScore);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMangaSelection = (selectedManga) => {
|
||||||
|
emit('manga-selected', selectedManga);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChapterNumberInput = (event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
const chapterNumber = value ? parseFloat(value) : null;
|
||||||
|
emit('chapter-number-selected', chapterNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeNumberInput = (event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
const volumeNumber = value ? parseFloat(value) : null;
|
||||||
|
emit('volume-number-selected', volumeNumber);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
|
||||||
|
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Import terminé</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Voici le résumé de votre session d'import
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics -->
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
|
||||||
|
<div class="text-sm text-gray-500">Importés</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
|
||||||
|
<div class="text-sm text-gray-500">Erreurs</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-gray-600">{{ totalCount }}</div>
|
||||||
|
<div class="text-sm text-gray-500">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Files List -->
|
||||||
|
<div v-if="importedFiles.length > 0" class="mb-6">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||||
|
Fichiers importés avec succès ({{ importedFiles.length }})
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="file in importedFiles"
|
||||||
|
:key="file.id"
|
||||||
|
class="flex items-center text-sm"
|
||||||
|
>
|
||||||
|
<svg class="flex-shrink-0 h-4 w-4 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900">{{ file.filename }}</span>
|
||||||
|
<span v-if="file.selectedManga" class="ml-2 text-gray-500">
|
||||||
|
→ {{ file.selectedManga.title }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Files List -->
|
||||||
|
<div v-if="errorFiles.length > 0" class="mb-6">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||||
|
Fichiers en erreur ({{ errorFiles.length }})
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="file in errorFiles"
|
||||||
|
:key="file.id"
|
||||||
|
class="flex items-start text-sm"
|
||||||
|
>
|
||||||
|
<svg class="flex-shrink-0 h-4 w-4 text-red-400 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-900">{{ file.filename }}</div>
|
||||||
|
<div class="text-red-600 text-xs mt-1">{{ file.errorMessage }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-center space-x-4 pt-6 border-t">
|
||||||
|
<button
|
||||||
|
@click="startNewImport"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||||
|
>
|
||||||
|
Nouvel import
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="goToLibrary"
|
||||||
|
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||||
|
>
|
||||||
|
Aller à la bibliothèque
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
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,116 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md"
|
||||||
|
:class="{
|
||||||
|
'border-blue-500 bg-blue-50': isSelected,
|
||||||
|
'border-gray-200 hover:border-gray-300': !isSelected
|
||||||
|
}"
|
||||||
|
@click="$emit('select-match', match)"
|
||||||
|
>
|
||||||
|
<!-- Match Header with Score -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-blue-500': isSelected,
|
||||||
|
'bg-gray-300': !isSelected
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
<span class="text-sm font-medium text-gray-700">Score: {{ match.matchScore }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="isSelected" class="text-blue-600">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manga Thumbnail -->
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="match.thumbnailUrl"
|
||||||
|
:src="match.thumbnailUrl"
|
||||||
|
:alt="match.title"
|
||||||
|
class="w-16 h-20 object-cover rounded border"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-16 h-20 bg-gray-200 rounded border flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manga Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 truncate" :title="match.title">
|
||||||
|
{{ match.title }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500 mt-1 truncate" :title="match.slug">
|
||||||
|
{{ match.slug }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Alternative Slugs -->
|
||||||
|
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
|
||||||
|
<p class="text-xs text-gray-400">Autres titres:</p>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<span
|
||||||
|
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
|
||||||
|
:key="altSlug"
|
||||||
|
class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
{{ altSlug }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="match.alternativeSlugs.length > 2"
|
||||||
|
class="text-xs text-gray-400"
|
||||||
|
>
|
||||||
|
+{{ match.alternativeSlugs.length - 2 }} autres
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Score Bar -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||||
|
<span>Correspondance</span>
|
||||||
|
<span>{{ match.matchScore }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full transition-all duration-300"
|
||||||
|
:class="{
|
||||||
|
'bg-blue-500': isSelected,
|
||||||
|
'bg-gray-400': !isSelected
|
||||||
|
}"
|
||||||
|
:style="{ width: match.matchScore + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
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 rounded-full text-xs font-medium';
|
||||||
|
|
||||||
|
if (props.isImporting || props.isAnalyzing) {
|
||||||
|
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (props.status) {
|
||||||
|
case 'pending':
|
||||||
|
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||||
|
case 'analyzed':
|
||||||
|
return `${baseClasses} bg-yellow-100 text-yellow-800`;
|
||||||
|
case 'importing':
|
||||||
|
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||||
|
case 'imported':
|
||||||
|
return `${baseClasses} bg-green-100 text-green-800`;
|
||||||
|
case 'error':
|
||||||
|
return `${baseClasses} bg-red-100 text-red-800`;
|
||||||
|
default:
|
||||||
|
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">Import de Bibliothèque</h1>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar (if files are being processed) -->
|
||||||
|
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8">
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm font-medium text-gray-700">Progression</span>
|
||||||
|
<span class="text-sm text-gray-500">{{ store.progressPercentage }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: store.progressPercentage + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||||
|
<span>{{ store.importedCount }} importés</span>
|
||||||
|
<span>{{ store.errorCount }} erreurs</span>
|
||||||
|
<span>{{ store.totalFiles }} total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Upload Zone -->
|
||||||
|
<div v-if="!store.hasFiles || store.allFilesProcessed" class="mb-8">
|
||||||
|
<FileUpload
|
||||||
|
label="Importer des fichiers CBZ/CBR"
|
||||||
|
accept=".cbz,.cbr"
|
||||||
|
:multiple="true"
|
||||||
|
description="Formats CBZ ou CBR uniquement"
|
||||||
|
@files-selected="handleFilesSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Files List -->
|
||||||
|
<div v-if="store.hasFiles" class="space-y-6">
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-4 mb-6">
|
||||||
|
<button
|
||||||
|
v-if="store.hasReadyFiles"
|
||||||
|
@click="importAllFiles"
|
||||||
|
:disabled="store.isLoading"
|
||||||
|
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md font-medium"
|
||||||
|
>
|
||||||
|
<LoadingSpinner v-if="store.isLoading" class="w-4 h-4 mr-2" />
|
||||||
|
Importer tous les fichiers prêts ({{ store.readyCount }})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="store.analyzedFiles.length > 0"
|
||||||
|
@click="autoSelectMatches"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Sélection automatique
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="clearAllFiles"
|
||||||
|
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Effacer tout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Files Grid -->
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<FileImportCard
|
||||||
|
v-for="file in store.files"
|
||||||
|
:key="file.id"
|
||||||
|
:file="file"
|
||||||
|
:is-analyzing="store.analyzingFiles.has(file.id)"
|
||||||
|
:is-importing="store.importingFiles.has(file.id)"
|
||||||
|
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
|
||||||
|
@chapter-number-selected="(chapterNumber) => store.setFileChapterNumber(file.id, chapterNumber)"
|
||||||
|
@volume-number-selected="(volumeNumber) => store.setFileVolumeNumber(file.id, volumeNumber)"
|
||||||
|
@import-file="() => importSingleFile(file.id)"
|
||||||
|
@retry-file="() => retryFile(file.id)"
|
||||||
|
@remove-file="() => store.removeFile(file.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Summary (when all files are processed) -->
|
||||||
|
<div v-if="store.allFilesProcessed" class="mt-8">
|
||||||
|
<ImportResults />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onUnmounted } from 'vue';
|
||||||
|
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
|
||||||
|
import LoadingSpinner from '../../../../shared/components/ui/LoadingSpinner.vue';
|
||||||
|
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||||
|
import FileImportCard from '../components/FileImportCard.vue';
|
||||||
|
import ImportResults from '../components/ImportResults.vue';
|
||||||
|
|
||||||
|
const store = useNewImportStore();
|
||||||
|
|
||||||
|
// === EVENT HANDLERS ===
|
||||||
|
|
||||||
|
const handleFilesSelected = (files) => {
|
||||||
|
store.addFiles(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoSelectMatches = () => {
|
||||||
|
store.autoSelectBestMatches();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllFiles = () => {
|
||||||
|
if (confirm('Êtes-vous sûr de vouloir effacer tous les fichiers ?')) {
|
||||||
|
store.clearFiles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// === LIFECYCLE ===
|
||||||
|
|
||||||
|
// Reset state when component unmounts
|
||||||
|
onUnmounted(() => {
|
||||||
|
store.resetGlobalState();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export class SearchMangas {
|
||||||
|
constructor(mangaRepository) {
|
||||||
|
this.mangaRepository = mangaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(query) {
|
||||||
|
if (!query || query.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.mangaRepository.searchMangas(query);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
288
assets/vue/app/domain/manga/application/store/mangaStore.js
Normal file
288
assets/vue/app/domain/manga/application/store/mangaStore.js
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
|
||||||
|
|
||||||
|
const mangaRepository = new ApiMangaRepository();
|
||||||
|
|
||||||
|
// Helper pour comparer la collection (peut être supprimé si non utilisé ailleurs)
|
||||||
|
const deepCompare = (obj1, obj2) => {
|
||||||
|
try {
|
||||||
|
if (obj1 == null && obj2 == null) return true;
|
||||||
|
if (obj1 == null || obj2 == null) return false;
|
||||||
|
return JSON.stringify(obj1) === JSON.stringify(obj2);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur lors de la comparaison d'objets:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMangaStore = defineStore('manga', {
|
||||||
|
state: () => ({
|
||||||
|
// --- Collection State ---
|
||||||
|
collection: null,
|
||||||
|
loadingCollection: false,
|
||||||
|
errorCollection: null,
|
||||||
|
isBackgroundLoadingCollection: false,
|
||||||
|
|
||||||
|
// --- Selected Manga State ---
|
||||||
|
// Gardé pour savoir quel manga est sélectionné dans l'UI,
|
||||||
|
// mais les données détaillées ne sont plus stockées ici.
|
||||||
|
currentMangaId: null,
|
||||||
|
|
||||||
|
// --- Manga Chapters State ---
|
||||||
|
mangaChapters: {},
|
||||||
|
loadingChapters: false,
|
||||||
|
chaptersError: null,
|
||||||
|
|
||||||
|
// --- Search State ---
|
||||||
|
searchResults: [],
|
||||||
|
loadingSearch: false,
|
||||||
|
searchError: null,
|
||||||
|
|
||||||
|
// --- Add Manga State ---
|
||||||
|
addingManga: false,
|
||||||
|
addMangaError: null
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
// Plus de getters spécifiques aux détails/chapitres ici
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// --- Collection Actions ---
|
||||||
|
async loadCollection() {
|
||||||
|
if (this.loadingCollection) return;
|
||||||
|
this.loadingCollection = true;
|
||||||
|
this.errorCollection = null;
|
||||||
|
try {
|
||||||
|
const newCollection = await mangaRepository.getCollection();
|
||||||
|
// On garde la comparaison pour éviter màj inutile de la collection
|
||||||
|
if (!deepCompare(this.collection, newCollection)) {
|
||||||
|
this.collection = newCollection;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.errorCollection = err.message;
|
||||||
|
} finally {
|
||||||
|
this.loadingCollection = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshCollectionInBackground() {
|
||||||
|
if (this.isBackgroundLoadingCollection) return;
|
||||||
|
this.isBackgroundLoadingCollection = true;
|
||||||
|
try {
|
||||||
|
const newCollection = await mangaRepository.getCollection();
|
||||||
|
if (!deepCompare(this.collection, newCollection)) {
|
||||||
|
this.collection = newCollection;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to refresh collection:', err);
|
||||||
|
} finally {
|
||||||
|
this.isBackgroundLoadingCollection = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Selected Manga Actions ---
|
||||||
|
setCurrentMangaId(mangaId) {
|
||||||
|
// Met simplement à jour l'ID sélectionné
|
||||||
|
this.currentMangaId = mangaId;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCurrentMangaFocus() {
|
||||||
|
this.currentMangaId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Chapters Actions ---
|
||||||
|
async loadChapters(mangaId) {
|
||||||
|
if (this.loadingChapters) return;
|
||||||
|
this.loadingChapters = true;
|
||||||
|
this.chaptersError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chaptersData = await mangaRepository.getChapters(mangaId);
|
||||||
|
this.mangaChapters[mangaId] = chaptersData;
|
||||||
|
} catch (err) {
|
||||||
|
this.chaptersError = err.message;
|
||||||
|
} finally {
|
||||||
|
this.loadingChapters = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateChapterAvailability(chapterId, isAvailable = true) {
|
||||||
|
console.log(`Mise à jour du chapitre ${chapterId}, disponible: ${isAvailable}`);
|
||||||
|
|
||||||
|
// Pour chaque manga dans notre store
|
||||||
|
Object.keys(this.mangaChapters).forEach(mangaId => {
|
||||||
|
const chaptersObj = this.mangaChapters[mangaId];
|
||||||
|
if (!chaptersObj || !chaptersObj.items) return;
|
||||||
|
|
||||||
|
const chapters = chaptersObj.items;
|
||||||
|
|
||||||
|
// Chercher le chapitre correspondant
|
||||||
|
const chapterIndex = chapters.findIndex(chapter => chapter.id === chapterId);
|
||||||
|
|
||||||
|
// Si on trouve le chapitre, mettre à jour son état
|
||||||
|
if (chapterIndex !== -1) {
|
||||||
|
console.log(`Chapitre trouvé dans le manga ${mangaId}, index: ${chapterIndex}`);
|
||||||
|
|
||||||
|
// Important: créer une nouvelle référence pour que Vue détecte le changement
|
||||||
|
const updatedChapter = {
|
||||||
|
...chapters[chapterIndex],
|
||||||
|
isAvailable: isAvailable
|
||||||
|
};
|
||||||
|
|
||||||
|
// Créer un nouveau tableau pour garantir la réactivité
|
||||||
|
const updatedChapters = [...chapters];
|
||||||
|
updatedChapters[chapterIndex] = updatedChapter;
|
||||||
|
|
||||||
|
// Mise à jour reactive du store
|
||||||
|
this.mangaChapters[mangaId] = {
|
||||||
|
...chaptersObj,
|
||||||
|
items: updatedChapters
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Chapitre mis à jour avec succès');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Search Actions ---
|
||||||
|
async searchMangaDex(query) {
|
||||||
|
if (this.loadingSearch) return;
|
||||||
|
|
||||||
|
this.loadingSearch = true;
|
||||||
|
this.searchError = null;
|
||||||
|
this.searchResults = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await mangaRepository.searchMangaDex(query);
|
||||||
|
this.searchResults = data.items || [];
|
||||||
|
} catch (error) {
|
||||||
|
this.searchError = error.message;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.loadingSearch = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSearchResults() {
|
||||||
|
this.searchResults = [];
|
||||||
|
this.searchError = null;
|
||||||
|
this.loadingSearch = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Add Manga Actions ---
|
||||||
|
async createFromMangaDex(externalId) {
|
||||||
|
if (this.addingManga) return;
|
||||||
|
|
||||||
|
this.addingManga = true;
|
||||||
|
this.addMangaError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mangaRepository.createFromMangaDex(externalId);
|
||||||
|
// Rafraîchir la collection après l'ajout
|
||||||
|
await this.loadCollection();
|
||||||
|
} catch (error) {
|
||||||
|
this.addMangaError = error.message;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.addingManga = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchMangaChapters(mangaId) {
|
||||||
|
if (this.loadingChapters) return;
|
||||||
|
this.loadingChapters = true;
|
||||||
|
this.chaptersError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Déclenche la récupération initiale des chapitres depuis la source externe
|
||||||
|
await mangaRepository.fetchMangaChapters(mangaId);
|
||||||
|
console.log('Récupération initiale des chapitres déclenchée avec succès');
|
||||||
|
|
||||||
|
// Note: Les nouveaux chapitres seront disponibles après traitement asynchrone
|
||||||
|
// Le MercureListener se chargera de mettre à jour l'interface
|
||||||
|
} catch (err) {
|
||||||
|
this.chaptersError = err.message;
|
||||||
|
console.error('Erreur lors de la récupération des chapitres:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.loadingChapters = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshMangaChapters(mangaId) {
|
||||||
|
if (this.loadingChapters) return;
|
||||||
|
this.loadingChapters = true;
|
||||||
|
this.chaptersError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Déclenche la synchronisation incrémentale avec scraping automatique
|
||||||
|
await mangaRepository.refreshMangaChapters(mangaId);
|
||||||
|
console.log('Synchronisation incrémentale déclenchée avec succès');
|
||||||
|
|
||||||
|
// Note: Les chapitres mis à jour seront disponibles après traitement asynchrone
|
||||||
|
// Le MercureListener se chargera de mettre à jour l'interface
|
||||||
|
} catch (err) {
|
||||||
|
this.chaptersError = err.message;
|
||||||
|
console.error('Erreur lors de la synchronisation des chapitres:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.loadingChapters = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Scrape Chapter Action ---
|
||||||
|
async searchChapter(chapterId) {
|
||||||
|
try {
|
||||||
|
await mangaRepository.searchChapter(chapterId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la recherche du chapitre:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Delete Chapter Action ---
|
||||||
|
async deleteChapter(chapterId) {
|
||||||
|
try {
|
||||||
|
await mangaRepository.deleteChapter(chapterId);
|
||||||
|
// Mettre à jour l'état du chapitre pour refléter qu'il n'est plus disponible
|
||||||
|
this.updateChapterAvailability(chapterId, false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression du chapitre:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Download Chapter Action ---
|
||||||
|
async downloadChapter(chapterId) {
|
||||||
|
try {
|
||||||
|
await mangaRepository.downloadChapter(chapterId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du téléchargement du chapitre:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Hide Chapter Action ---
|
||||||
|
async hideChapter(chapterId, mangaId) {
|
||||||
|
try {
|
||||||
|
await mangaRepository.hideChapter(chapterId);
|
||||||
|
// Recharger la liste des chapitres depuis l'API
|
||||||
|
await this.loadChapters(mangaId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du masquage du chapitre:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Download Volume Action ---
|
||||||
|
async downloadVolume(mangaId, volumeNumber) {
|
||||||
|
try {
|
||||||
|
await mangaRepository.downloadVolume(mangaId, volumeNumber);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du téléchargement du volume:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
44
assets/vue/app/domain/manga/domain/entities/manga.js
Normal file
44
assets/vue/app/domain/manga/domain/entities/manga.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export class Manga {
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
description = null,
|
||||||
|
authors = [],
|
||||||
|
imageUrl = null,
|
||||||
|
thumbnailUrl = null,
|
||||||
|
publicationYear = null,
|
||||||
|
status = null,
|
||||||
|
rating = null,
|
||||||
|
genres = [],
|
||||||
|
createdAt = new Date().toISOString()
|
||||||
|
}) {
|
||||||
|
this.id = id;
|
||||||
|
this.slug = slug;
|
||||||
|
this.title = title;
|
||||||
|
this.description = description;
|
||||||
|
this.authors = authors;
|
||||||
|
this.imageUrl = imageUrl;
|
||||||
|
this.thumbnailUrl = thumbnailUrl;
|
||||||
|
this.publicationYear = publicationYear;
|
||||||
|
this.status = status;
|
||||||
|
this.rating = rating;
|
||||||
|
this.genres = genres;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(data) {
|
||||||
|
return new Manga(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MangaCollection {
|
||||||
|
constructor(items, total, page, limit, hasNextPage, hasPreviousPage) {
|
||||||
|
this.items = items.map(item => Manga.create(item));
|
||||||
|
this.total = total;
|
||||||
|
this.page = page;
|
||||||
|
this.limit = limit;
|
||||||
|
this.hasNextPage = hasNextPage;
|
||||||
|
this.hasPreviousPage = hasPreviousPage;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { MangaCollection } from '../../domain/entities/manga';
|
||||||
|
|
||||||
|
export class ApiMangaRepository {
|
||||||
|
async getCollection() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/mangas');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch manga collection');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return new MangaCollection(
|
||||||
|
data.items,
|
||||||
|
data.total,
|
||||||
|
data.page,
|
||||||
|
data.limit,
|
||||||
|
data.hasNextPage,
|
||||||
|
data.hasPreviousPage
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMangaById(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/mangas/by-id/${id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch manga details');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChapters(mangaId) {
|
||||||
|
try {
|
||||||
|
let allChapters = [];
|
||||||
|
let page = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const response = await fetch(`/api/mangas/${mangaId}/chapters?limit=500&page=${page}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch manga chapters');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
allChapters = allChapters.concat(data.items);
|
||||||
|
hasMore = data.hasNextPage;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer pour ne garder que les chapitres visibles
|
||||||
|
const visibleChapters = allChapters.filter(chapter => chapter.isVisible === true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: visibleChapters,
|
||||||
|
total: visibleChapters.length
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMangaBySlug(slug) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/mangas/by-slug/${slug}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch manga details');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchMangas(query) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/manga-search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to search mangas');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchMangaDex(query) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/mangadex-search?title=${encodeURIComponent(query)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to search MangaDex');
|
||||||
|
}
|
||||||
|
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', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ externalId })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to create manga from MangaDex');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMangaChapters(mangaId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/manga/chapters/fetch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ mangaId })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch manga chapters');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshMangaChapters(mangaId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/manga/${mangaId}/chapters/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to refresh manga chapters');
|
||||||
|
}
|
||||||
|
// L'endpoint retourne 202 (Accepted), pas de contenu JSON à parser
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchChapter(chapterId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/scraping/chapters', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ chapterId })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Échec de la recherche du chapitre');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPreferredSources(mangaId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/mangas/${mangaId}/preferred-sources`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch preferred sources');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPreferredSources(mangaId, sourceIds) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/mangas/${mangaId}/preferred-sources`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ sourceIds })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to set preferred sources');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async editManga(mangaId, updateData) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/mangas/${mangaId}/edit`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updateData)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to edit manga');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChapter(chapterId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/manga/chapters/${chapterId}/cbz`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete chapter');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadChapter(chapterId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/manga/chapters/${chapterId}/download`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to download chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer le nom du fichier depuis les headers
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
let filename = `chapter-${chapterId}.cbz`;
|
||||||
|
|
||||||
|
if (contentDisposition) {
|
||||||
|
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/);
|
||||||
|
if (filenameMatch) {
|
||||||
|
filename = filenameMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer un blob à partir de la réponse
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Créer un lien de téléchargement temporaire
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
// Nettoyer
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hideChapter(chapterId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/manga/chapters/${chapterId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to hide chapter');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadVolume(mangaId, volumeNumber) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/mangas/${mangaId}/volumes/${volumeNumber}/download`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to download volume');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer le nom du fichier depuis les headers
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
let filename = `volume-${volumeNumber}.zip`;
|
||||||
|
|
||||||
|
if (contentDisposition) {
|
||||||
|
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/);
|
||||||
|
if (filenameMatch) {
|
||||||
|
filename = filenameMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer un blob à partir de la réponse
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Créer un lien de téléchargement temporaire
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
// Nettoyer
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleMonitoring(mangaId, enabled) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/manga/${mangaId}/monitoring/toggle`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Tenter de récupérer le message d'erreur détaillé de l'API
|
||||||
|
let errorMessage = 'Failed to toggle monitoring';
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.detail) {
|
||||||
|
errorMessage = errorData.detail;
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else if (errorData.violations && errorData.violations.length > 0) {
|
||||||
|
errorMessage = errorData.violations.map(v => v.message).join(', ');
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('Could not parse error response:', parseError);
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'endpoint retourne un statut 204 (No Content), donc pas de données à retourner
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteManga(mangaId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/mangas/${mangaId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete manga');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,906 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="isOpen" class="fixed inset-0 z-50 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">
|
||||||
|
<!-- Overlay avec effet de flou Material Design -->
|
||||||
|
<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">
|
||||||
|
<!-- 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="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">
|
||||||
|
Gérer les chapitres
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 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"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="h-5 w-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content avec style Material Design -->
|
||||||
|
<div class="bg-white 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>
|
||||||
|
<div class="absolute top-0 left-0 w-8 h-8 border-4 border-green-600 rounded-full border-t-transparent animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
||||||
|
<div 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>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 space-x-3">
|
||||||
|
<button
|
||||||
|
@click="showCreateVolumeModal = true"
|
||||||
|
class="bg-green-600 text-white px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-green-700 shadow-md hover:shadow-lg transition-all duration-200 flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<PlusIcon class="h-4 w-4" />
|
||||||
|
<span>Créer un volume</span>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{{ showUnassignedChapters ? 'Masquer' : 'Afficher' }} les chapitres non assignés
|
||||||
|
</button>
|
||||||
|
<!-- Bouton de séparation automatique du volume fourre-tout -->
|
||||||
|
<button
|
||||||
|
v-if="hasVolumeZero && canSplitVolumeZero"
|
||||||
|
@click="showSplitVolumeZeroModal = true"
|
||||||
|
class="bg-green-600 text-white px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-green-700 shadow-md hover:shadow-lg transition-all duration-200 flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon class="h-4 w-4" />
|
||||||
|
<span>Séparer le volume 00</span>
|
||||||
|
</button>
|
||||||
|
<!-- Actions de sélection multiple -->
|
||||||
|
<div v-if="selectedChapters.length > 0" class="flex items-center space-x-3 bg-green-50 px-4 py-2 rounded-xl border border-green-200">
|
||||||
|
<span class="text-sm font-medium text-green-700">{{ selectedChapters.length }} chapitre(s) sélectionné(s)</span>
|
||||||
|
<button
|
||||||
|
@click="showMoveToVolumeModal = true"
|
||||||
|
class="bg-green-600 text-white px-3 py-1.5 rounded-lg text-xs font-medium hover:bg-green-700 shadow-sm transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Déplacer vers un volume
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="clearSelection"
|
||||||
|
class="text-green-600 hover:text-green-800 text-xs font-medium hover:bg-green-100 px-2 py-1 rounded transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 bg-white px-3 py-1.5 rounded-lg border border-gray-200">
|
||||||
|
{{ 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">
|
||||||
|
<!-- 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 class="px-6 py-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-700 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>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="chapter in unassignedChapters"
|
||||||
|
:key="chapter.id"
|
||||||
|
class="flex items-center space-x-3 p-3 hover:bg-white rounded-lg transition-colors duration-200 border border-transparent hover:border-gray-200"
|
||||||
|
:class="{ 'bg-green-50 border-green-200 shadow-sm': isChapterSelected(chapter) }"
|
||||||
|
>
|
||||||
|
<!-- Checkbox de sélection Material Design -->
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isChapterSelected(chapter)"
|
||||||
|
@change="toggleChapterSelection(chapter)"
|
||||||
|
class="h-5 w-5 text-green-600 border-gray-300 rounded focus:ring-green-500 focus:ring-2 transition-colors duration-200"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<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"
|
||||||
|
@click="startEditingTitle(chapter)"
|
||||||
|
>
|
||||||
|
{{ chapter.title || 'Sans titre' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="startEditingTitle(chapter)"
|
||||||
|
class="ml-2 text-gray-400 hover:text-gray-600 p-1 rounded-full hover:bg-gray-100 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<PencilIcon class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
v-model="chapter.editingTitle"
|
||||||
|
type="text"
|
||||||
|
class="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
||||||
|
@keyup.enter="saveTitle(chapter)"
|
||||||
|
@keyup.esc="cancelEditingTitle(chapter)"
|
||||||
|
ref="titleInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="saveTitle(chapter)"
|
||||||
|
class="text-green-600 hover:text-green-800 p-1 rounded-full hover:bg-green-100 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<CheckIcon class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="cancelEditingTitle(chapter)"
|
||||||
|
class="text-red-600 hover:text-red-800 p-1 rounded-full hover:bg-red-100 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="assignToVolume(chapter)"
|
||||||
|
class="bg-green-600 text-white px-3 py-1.5 rounded-lg text-xs font-medium hover:bg-green-700 shadow-sm transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Assigner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="chapter.isModified" class="w-3 h-3 bg-yellow-400 rounded-full shadow-sm"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volumes avec style Material Design -->
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
<div
|
||||||
|
v-for="volume in volumes"
|
||||||
|
:key="volume.number"
|
||||||
|
class="bg-white"
|
||||||
|
>
|
||||||
|
<!-- 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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="toggleVolumeExpanded(volume)"
|
||||||
|
class="w-8 h-8 rounded-full bg-green-100 hover:bg-green-200 flex items-center justify-center transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon v-if="volume.isExpanded" class="h-4 w-4 text-green-600" />
|
||||||
|
<ChevronRightIcon v-else class="h-4 w-4 text-green-600" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteVolume(volume.number)"
|
||||||
|
class="text-red-600 hover:text-red-800 text-sm font-medium hover:bg-red-100 px-3 py-1.5 rounded-lg transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="chapter in volume.chapters"
|
||||||
|
:key="chapter.id"
|
||||||
|
class="flex items-center space-x-3 p-3 hover:bg-gray-50 rounded-lg transition-colors duration-200 border border-transparent hover:border-gray-200"
|
||||||
|
:class="{ 'bg-green-50 border-green-200 shadow-sm': isChapterSelected(chapter) }"
|
||||||
|
>
|
||||||
|
<!-- Checkbox de sélection -->
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isChapterSelected(chapter)"
|
||||||
|
@change="toggleChapterSelection(chapter)"
|
||||||
|
class="h-5 w-5 text-green-600 border-gray-300 rounded focus:ring-green-500 focus:ring-2 transition-colors duration-200"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<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"
|
||||||
|
@click="startEditingTitle(chapter)"
|
||||||
|
>
|
||||||
|
{{ chapter.title || 'Sans titre' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="startEditingTitle(chapter)"
|
||||||
|
class="ml-2 text-gray-400 hover:text-gray-600 p-1 rounded-full hover:bg-gray-100 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<PencilIcon class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
v-model="chapter.editingTitle"
|
||||||
|
type="text"
|
||||||
|
class="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
||||||
|
@keyup.enter="saveTitle(chapter)"
|
||||||
|
@keyup.esc="cancelEditingTitle(chapter)"
|
||||||
|
ref="titleInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="saveTitle(chapter)"
|
||||||
|
class="text-green-600 hover:text-green-800 p-1 rounded-full hover:bg-green-100 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<CheckIcon class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="cancelEditingTitle(chapter)"
|
||||||
|
class="text-red-600 hover:text-red-800 p-1 rounded-full hover:bg-red-100 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="removeFromVolume(chapter)"
|
||||||
|
class="text-red-600 hover:text-red-800 text-xs font-medium hover:bg-red-100 px-3 py-1.5 rounded-lg transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Retirer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="chapter.isModified" class="w-3 h-3 bg-yellow-400 rounded-full shadow-sm"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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="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"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleSave"
|
||||||
|
:disabled="isSaving || !hasChanges"
|
||||||
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<span v-if="isSaving" class="flex items-center space-x-2">
|
||||||
|
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<span>Sauvegarde...</span>
|
||||||
|
</span>
|
||||||
|
<span v-else>Sauvegarder</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de création de volume Material Design -->
|
||||||
|
<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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white 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>
|
||||||
|
<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"
|
||||||
|
placeholder="Ex: 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="volumeExists" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center space-x-2">
|
||||||
|
<XMarkIcon class="h-4 w-4 text-red-600" />
|
||||||
|
<span class="text-sm">Ce volume existe déjà.</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="newVolumeNumber && !isValidVolumeNumber" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center space-x-2">
|
||||||
|
<XMarkIcon class="h-4 w-4 text-red-600" />
|
||||||
|
<span class="text-sm">Le numéro de volume doit être entre 1 et 999.</span>
|
||||||
|
</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="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||||
|
<button
|
||||||
|
@click="showCreateVolumeModal = false"
|
||||||
|
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 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="createVolume"
|
||||||
|
:disabled="!isValidVolumeNumber || volumeExists"
|
||||||
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
|
||||||
|
>
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal d'assignation Material Design -->
|
||||||
|
<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="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" />
|
||||||
|
</div>
|
||||||
|
<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="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Volume</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedVolumeForAssignment"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un volume</option>
|
||||||
|
<option v-for="volume in volumes" :key="volume.number" :value="volume.number">
|
||||||
|
Volume {{ volume.number }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</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="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||||
|
<button
|
||||||
|
@click="showAssignModal = false"
|
||||||
|
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 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="confirmAssignToVolume"
|
||||||
|
:disabled="!selectedVolumeForAssignment"
|
||||||
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
|
||||||
|
>
|
||||||
|
Assigner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de déplacement multiple Material Design -->
|
||||||
|
<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="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" />
|
||||||
|
</div>
|
||||||
|
<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="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">
|
||||||
|
Chapitres sélectionnés : {{ selectedChapters.map(c => c.number).join(', ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Volume de destination</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedVolumeForMove"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un volume</option>
|
||||||
|
<option v-for="volume in volumes" :key="volume.number" :value="volume.number">
|
||||||
|
Volume {{ volume.number }}
|
||||||
|
</option>
|
||||||
|
<option value="unassigned">Chapitres non assignés</option>
|
||||||
|
</select>
|
||||||
|
</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="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||||
|
<button
|
||||||
|
@click="showMoveToVolumeModal = false"
|
||||||
|
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 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="confirmMoveToVolume"
|
||||||
|
:disabled="!selectedVolumeForMove"
|
||||||
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-md hover:shadow-lg"
|
||||||
|
>
|
||||||
|
Déplacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de séparation du volume fourre-tout Material Design -->
|
||||||
|
<div v-if="showSplitVolumeZeroModal" 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="showSplitVolumeZeroModal = 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-lg 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="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" />
|
||||||
|
</div>
|
||||||
|
<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="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">
|
||||||
|
Le volume 00 contient {{ volumeZeroChapters.length }} chapitres et sera séparé en {{ numberOfNewVolumes }} nouveaux volumes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||||
|
<p class="text-sm text-green-800">
|
||||||
|
<strong>Moyenne des autres volumes :</strong> {{ averageChaptersPerVolume.toFixed(1) }} chapitres par volume
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-green-800 mt-1">
|
||||||
|
<strong>Chapitres par nouveau volume :</strong> {{ chaptersPerNewVolume }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700">Répartition proposée :</h4>
|
||||||
|
<div class="space-y-2 max-h-32 overflow-y-auto">
|
||||||
|
<div v-for="(group, index) in proposedVolumeGroups" :key="index" class="text-sm text-gray-600 bg-gray-50 p-3 rounded-lg border border-gray-200">
|
||||||
|
<strong>Volume {{ group.volumeNumber }} :</strong>
|
||||||
|
Chapitres {{ group.chapters.map(c => c.number).join(', ') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||||
|
<button
|
||||||
|
@click="showSplitVolumeZeroModal = false"
|
||||||
|
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 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="confirmSplitVolumeZero"
|
||||||
|
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-transparent bg-green-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 shadow-md hover:shadow-lg"
|
||||||
|
>
|
||||||
|
Confirmer la séparation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ArrowPathIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
DocumentIcon,
|
||||||
|
FolderIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
XMarkIcon
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
manga: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
chapters: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isSaving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'save']);
|
||||||
|
|
||||||
|
// État local
|
||||||
|
const localChapters = ref([]);
|
||||||
|
const showCreateVolumeModal = ref(false);
|
||||||
|
const showAssignModal = ref(false);
|
||||||
|
const showMoveToVolumeModal = ref(false);
|
||||||
|
const showSplitVolumeZeroModal = ref(false);
|
||||||
|
const showUnassignedChapters = ref(true);
|
||||||
|
const newVolumeNumber = ref('');
|
||||||
|
const selectedChapter = ref(null);
|
||||||
|
const selectedVolumeForAssignment = ref('');
|
||||||
|
const selectedVolumeForMove = ref('');
|
||||||
|
const titleInput = ref(null);
|
||||||
|
const expandedVolumes = ref(new Set());
|
||||||
|
const selectedChapters = ref([]);
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const volumes = computed(() => {
|
||||||
|
const volumeMap = new Map();
|
||||||
|
|
||||||
|
// Ajouter les volumes existants avec leurs chapitres
|
||||||
|
localChapters.value.forEach(chapter => {
|
||||||
|
if (chapter.volume) {
|
||||||
|
if (!volumeMap.has(chapter.volume)) {
|
||||||
|
volumeMap.set(chapter.volume, {
|
||||||
|
number: chapter.volume,
|
||||||
|
chapters: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
volumeMap.get(chapter.volume).chapters.push(chapter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ajouter les volumes vides qui sont dans expandedVolumes
|
||||||
|
expandedVolumes.value.forEach(volumeNumber => {
|
||||||
|
if (!volumeMap.has(volumeNumber)) {
|
||||||
|
volumeMap.set(volumeNumber, {
|
||||||
|
number: volumeNumber,
|
||||||
|
chapters: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const volumesArray = Array.from(volumeMap.values())
|
||||||
|
.sort((a, b) => b.number - a.number) // Tri décroissant (derniers volumes en premier)
|
||||||
|
.map(volume => ({
|
||||||
|
...volume,
|
||||||
|
chapters: volume.chapters.sort((a, b) => b.number - a.number), // Chapitres décroissants
|
||||||
|
isExpanded: expandedVolumes.value.has(volume.number)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return volumesArray;
|
||||||
|
});
|
||||||
|
|
||||||
|
const unassignedChapters = computed(() => {
|
||||||
|
return localChapters.value
|
||||||
|
.filter(chapter => !chapter.volume)
|
||||||
|
.sort((a, b) => b.number - a.number); // Tri décroissant (derniers chapitres en premier)
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalChapters = computed(() => localChapters.value.length);
|
||||||
|
|
||||||
|
const volumeExists = computed(() => {
|
||||||
|
if (!newVolumeNumber.value) return false;
|
||||||
|
const volumeNumber = parseInt(newVolumeNumber.value);
|
||||||
|
return volumes.value.some(volume => volume.number === volumeNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValidVolumeNumber = computed(() => {
|
||||||
|
if (!newVolumeNumber.value) return false;
|
||||||
|
const volumeNumber = parseInt(newVolumeNumber.value);
|
||||||
|
return volumeNumber > 0 && volumeNumber <= 999;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasChanges = computed(() => {
|
||||||
|
return localChapters.value.some(chapter => chapter.isModified);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed properties pour la séparation du volume fourre-tout
|
||||||
|
const volumeZeroChapters = computed(() => {
|
||||||
|
return localChapters.value.filter(chapter => chapter.volume === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasVolumeZero = computed(() => {
|
||||||
|
return volumeZeroChapters.value.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const otherVolumes = computed(() => {
|
||||||
|
return volumes.value.filter(volume => volume.number !== 0 && volume.chapters.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const averageChaptersPerVolume = computed(() => {
|
||||||
|
if (otherVolumes.value.length === 0) return 10; // Valeur par défaut si aucun autre volume
|
||||||
|
const totalChapters = otherVolumes.value.reduce((sum, volume) => sum + volume.chapters.length, 0);
|
||||||
|
return totalChapters / otherVolumes.value.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSplitVolumeZero = computed(() => {
|
||||||
|
return hasVolumeZero.value && volumeZeroChapters.value.length > averageChaptersPerVolume.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const numberOfNewVolumes = computed(() => {
|
||||||
|
if (!canSplitVolumeZero.value) return 0;
|
||||||
|
return Math.ceil(volumeZeroChapters.value.length / averageChaptersPerVolume.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chaptersPerNewVolume = computed(() => {
|
||||||
|
if (!canSplitVolumeZero.value) return 0;
|
||||||
|
return Math.ceil(volumeZeroChapters.value.length / numberOfNewVolumes.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const proposedVolumeGroups = computed(() => {
|
||||||
|
if (!canSplitVolumeZero.value) return [];
|
||||||
|
|
||||||
|
const chapters = [...volumeZeroChapters.value].sort((a, b) => a.number - b.number); // Tri croissant par numéro
|
||||||
|
const groups = [];
|
||||||
|
const chaptersPerGroup = chaptersPerNewVolume.value;
|
||||||
|
|
||||||
|
// Trouver le prochain numéro de volume disponible
|
||||||
|
const existingVolumeNumbers = volumes.value.map(v => v.number).filter(n => n !== 0);
|
||||||
|
const maxVolumeNumber = existingVolumeNumbers.length > 0 ? Math.max(...existingVolumeNumbers) : 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < numberOfNewVolumes.value; i++) {
|
||||||
|
const startIndex = i * chaptersPerGroup;
|
||||||
|
const endIndex = Math.min(startIndex + chaptersPerGroup, chapters.length);
|
||||||
|
const groupChapters = chapters.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
if (groupChapters.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
volumeNumber: maxVolumeNumber + i + 1,
|
||||||
|
chapters: groupChapters
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Méthodes
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!props.isSaving) {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const modifiedChapters = localChapters.value
|
||||||
|
.filter(chapter => chapter.isModified)
|
||||||
|
.map(chapter => ({
|
||||||
|
id: chapter.id,
|
||||||
|
title: chapter.title,
|
||||||
|
volume: chapter.volume || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (modifiedChapters.length > 0) {
|
||||||
|
emit('save', modifiedChapters);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleVolumeExpanded = (volume) => {
|
||||||
|
if (expandedVolumes.value.has(volume.number)) {
|
||||||
|
expandedVolumes.value.delete(volume.number);
|
||||||
|
} else {
|
||||||
|
expandedVolumes.value.add(volume.number);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createVolume = () => {
|
||||||
|
if (newVolumeNumber.value && !volumeExists.value && isValidVolumeNumber.value) {
|
||||||
|
const volumeNumber = parseInt(newVolumeNumber.value);
|
||||||
|
|
||||||
|
// Ajouter le nouveau volume à la liste des volumes dépliés
|
||||||
|
expandedVolumes.value.add(volumeNumber);
|
||||||
|
|
||||||
|
// Fermer la modale et réinitialiser
|
||||||
|
showCreateVolumeModal.value = false;
|
||||||
|
newVolumeNumber.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteVolume = (volumeNumber) => {
|
||||||
|
localChapters.value.forEach(chapter => {
|
||||||
|
if (chapter.volume === volumeNumber) {
|
||||||
|
chapter.volume = null;
|
||||||
|
chapter.isModified = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignToVolume = (chapter) => {
|
||||||
|
selectedChapter.value = chapter;
|
||||||
|
selectedVolumeForAssignment.value = '';
|
||||||
|
showAssignModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAssignToVolume = () => {
|
||||||
|
if (selectedChapter.value && selectedVolumeForAssignment.value) {
|
||||||
|
selectedChapter.value.volume = parseInt(selectedVolumeForAssignment.value);
|
||||||
|
selectedChapter.value.isModified = true;
|
||||||
|
showAssignModal.value = false;
|
||||||
|
selectedChapter.value = null;
|
||||||
|
selectedVolumeForAssignment.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromVolume = (chapter) => {
|
||||||
|
chapter.volume = null;
|
||||||
|
chapter.isModified = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditingTitle = async (chapter) => {
|
||||||
|
chapter.isEditing = true;
|
||||||
|
chapter.editingTitle = chapter.title || '';
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
if (titleInput.value) {
|
||||||
|
titleInput.value.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTitle = (chapter) => {
|
||||||
|
if (chapter.editingTitle !== chapter.title) {
|
||||||
|
chapter.title = chapter.editingTitle;
|
||||||
|
chapter.isModified = true;
|
||||||
|
}
|
||||||
|
chapter.isEditing = false;
|
||||||
|
chapter.editingTitle = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditingTitle = (chapter) => {
|
||||||
|
chapter.isEditing = false;
|
||||||
|
chapter.editingTitle = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Méthodes de sélection multiple
|
||||||
|
const isChapterSelected = (chapter) => {
|
||||||
|
return selectedChapters.value.some(selected => selected.id === chapter.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleChapterSelection = (chapter) => {
|
||||||
|
const index = selectedChapters.value.findIndex(selected => selected.id === chapter.id);
|
||||||
|
if (index > -1) {
|
||||||
|
selectedChapters.value.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
selectedChapters.value.push(chapter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
selectedChapters.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmMoveToVolume = () => {
|
||||||
|
if (selectedVolumeForMove.value && selectedChapters.value.length > 0) {
|
||||||
|
const targetVolume = selectedVolumeForMove.value === 'unassigned' ? null : parseInt(selectedVolumeForMove.value);
|
||||||
|
|
||||||
|
selectedChapters.value.forEach(chapter => {
|
||||||
|
chapter.volume = targetVolume;
|
||||||
|
chapter.isModified = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vider la sélection et fermer la modale
|
||||||
|
selectedChapters.value = [];
|
||||||
|
showMoveToVolumeModal.value = false;
|
||||||
|
selectedVolumeForMove.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSplitVolumeZero = () => {
|
||||||
|
if (!canSplitVolumeZero.value) return;
|
||||||
|
|
||||||
|
// Appliquer la répartition proposée
|
||||||
|
proposedVolumeGroups.value.forEach(group => {
|
||||||
|
group.chapters.forEach(chapter => {
|
||||||
|
chapter.volume = group.volumeNumber;
|
||||||
|
chapter.isModified = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fermer la modale
|
||||||
|
showSplitVolumeZeroModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialiser les chapitres locaux quand les props changent
|
||||||
|
watch(() => props.chapters, (newChapters) => {
|
||||||
|
localChapters.value = newChapters.map(chapter => ({
|
||||||
|
...chapter,
|
||||||
|
isModified: false,
|
||||||
|
isEditing: false,
|
||||||
|
editingTitle: ''
|
||||||
|
}));
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Réinitialiser quand la modale s'ouvre
|
||||||
|
watch(() => props.isOpen, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
localChapters.value = props.chapters.map(chapter => ({
|
||||||
|
...chapter,
|
||||||
|
isModified: false,
|
||||||
|
isEditing: false,
|
||||||
|
editingTitle: ''
|
||||||
|
}));
|
||||||
|
showCreateVolumeModal.value = false;
|
||||||
|
showAssignModal.value = false;
|
||||||
|
showMoveToVolumeModal.value = false;
|
||||||
|
showSplitVolumeZeroModal.value = false;
|
||||||
|
showUnassignedChapters.value = true;
|
||||||
|
newVolumeNumber.value = '';
|
||||||
|
selectedChapter.value = null;
|
||||||
|
selectedVolumeForAssignment.value = '';
|
||||||
|
selectedVolumeForMove.value = '';
|
||||||
|
expandedVolumes.value.clear();
|
||||||
|
selectedChapters.value = [];
|
||||||
|
|
||||||
|
// S'assurer que le dernier volume est déplié après l'initialisation
|
||||||
|
nextTick(() => {
|
||||||
|
if (volumes.value.length > 0) {
|
||||||
|
expandedVolumes.value.add(volumes.value[0].number);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<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>
|
||||||
|
<div class="mt-1 text-sm text-gray-500"> Added: {{ formatDate(manga.createdAt) }} </div>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
manga: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = dateString => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<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') }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 w-full text-left">
|
||||||
|
<router-link
|
||||||
|
v-if="chapter.isAvailable"
|
||||||
|
:to="{
|
||||||
|
name: 'reader',
|
||||||
|
params: {
|
||||||
|
chapterId: chapter.id
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
{{ chapter.title || 'Sans titre' }}
|
||||||
|
</router-link>
|
||||||
|
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 flex justify-end gap-2">
|
||||||
|
<button v-if="!chapter.isAvailable" @click="handleSearch" :class="buttonClass">
|
||||||
|
<MagnifyingGlassIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button v-else @click="handleDelete" class="text-gray-500 hover:text-green-500">
|
||||||
|
<XMarkIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button @click="handleDownload" :class="downloadButtonClass" :disabled="isDownloading || !chapter.isAvailable">
|
||||||
|
<ArrowDownTrayIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button @click="handleHide" :class="hideButtonClass" :disabled="isHiding">
|
||||||
|
<TrashIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowDownTrayIcon, MagnifyingGlassIcon, TrashIcon, XMarkIcon } from '@heroicons/vue/24/solid';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
chapter: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mangaSlug: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mangaId: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useMangaStore();
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isDownloading = ref(false);
|
||||||
|
const isHiding = ref(false);
|
||||||
|
|
||||||
|
const buttonClass = computed(() => {
|
||||||
|
return isLoading.value ? 'text-yellow-500 cursor-wait' : 'text-gray-500 hover:text-green-500';
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadButtonClass = computed(() => {
|
||||||
|
if (isDownloading.value) {
|
||||||
|
return 'text-yellow-500 cursor-wait';
|
||||||
|
}
|
||||||
|
if (!props.chapter.isAvailable) {
|
||||||
|
return 'text-gray-300 cursor-not-allowed';
|
||||||
|
}
|
||||||
|
return 'text-gray-500 hover:text-green-500';
|
||||||
|
});
|
||||||
|
|
||||||
|
const hideButtonClass = computed(() => {
|
||||||
|
return isHiding.value ? 'text-yellow-500 cursor-wait' : 'text-gray-500 hover:text-green-500';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Surveiller les changements d'état du chapitre
|
||||||
|
watch(
|
||||||
|
() => props.chapter.isAvailable,
|
||||||
|
(newValue, oldValue) => {
|
||||||
|
console.log(
|
||||||
|
`MangaChapter: État du chapitre ${props.chapter.number} (ID: ${props.chapter.id}) modifié - ${oldValue} => ${newValue}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si le chapitre devient disponible, on arrête le chargement
|
||||||
|
if (newValue === true) {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
try {
|
||||||
|
console.log(`MangaChapter: Recherche du chapitre ${props.chapter.number} (ID: ${props.chapter.id})`);
|
||||||
|
// Montrer l'indicateur de chargement
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
// Lancer la recherche du chapitre - L'UI sera mise à jour par l'événement Mercure
|
||||||
|
await store.searchChapter(props.chapter.id);
|
||||||
|
} catch (error) {
|
||||||
|
// En cas d'erreur, on arrête le chargement
|
||||||
|
isLoading.value = false;
|
||||||
|
console.error('Erreur lors de la recherche du chapitre:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
console.log(`MangaChapter: Suppression du chapitre ${props.chapter.number} (ID: ${props.chapter.id})`);
|
||||||
|
await store.deleteChapter(props.chapter.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression du chapitre:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
console.log(`MangaChapter: Téléchargement du chapitre ${props.chapter.number} (ID: ${props.chapter.id})`);
|
||||||
|
// Montrer l'indicateur de chargement
|
||||||
|
isDownloading.value = true;
|
||||||
|
|
||||||
|
await store.downloadChapter(props.chapter.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du téléchargement du chapitre:', error);
|
||||||
|
} finally {
|
||||||
|
// Arrêter l'indicateur de chargement
|
||||||
|
isDownloading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHide = async () => {
|
||||||
|
try {
|
||||||
|
console.log(`MangaChapter: Masquage du chapitre ${props.chapter.number} (ID: ${props.chapter.id})`);
|
||||||
|
// Montrer l'indicateur de chargement
|
||||||
|
isHiding.value = true;
|
||||||
|
|
||||||
|
await store.hideChapter(props.chapter.id, props.mangaId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du masquage du chapitre:', error);
|
||||||
|
} finally {
|
||||||
|
// Arrêter l'indicateur de chargement
|
||||||
|
isHiding.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-2 border-t">
|
||||||
|
<table class="min-w-full table-auto">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<MangaChapter
|
||||||
|
v-for="chapter in chapters"
|
||||||
|
:key="chapter.id"
|
||||||
|
:chapter="chapter"
|
||||||
|
:manga-slug="mangaSlug"
|
||||||
|
:manga-id="mangaId" />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import MangaChapter from './MangaChapter.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
chapters: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mangaSlug: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mangaId: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<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 bg-opacity-75 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 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">
|
||||||
|
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">
|
||||||
|
{{ error.message || 'Une erreur est survenue lors de la suppression.' }}
|
||||||
|
</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">Action irréversible</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 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="flex">
|
||||||
|
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800">
|
||||||
|
Attention
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700">
|
||||||
|
<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>
|
||||||
|
<li>Tous les chapitres associés</li>
|
||||||
|
<li>Tous les fichiers CBZ téléchargés</li>
|
||||||
|
</ul>
|
||||||
|
</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 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"
|
||||||
|
@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
|
||||||
|
},
|
||||||
|
manga: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'confirm']);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
emit('confirm');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
<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 bg-opacity-75 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 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">
|
||||||
|
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">
|
||||||
|
{{ error.message || 'Une erreur est survenue lors de la sauvegarde.' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form @submit.prevent="saveChanges" class="space-y-6">
|
||||||
|
<!-- 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>
|
||||||
|
<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"
|
||||||
|
placeholder="Titre du manga"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="slug" class="block text-sm font-medium text-gray-700 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"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<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"
|
||||||
|
placeholder="2023"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-medium text-gray-700 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"
|
||||||
|
placeholder="Description du manga"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
<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"
|
||||||
|
placeholder="Auteur du manga"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="status" class="block text-sm font-medium text-gray-700 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"
|
||||||
|
placeholder="ongoing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note -->
|
||||||
|
<div>
|
||||||
|
<label for="rating" class="block text-sm font-medium text-gray-700 mb-2">Note</label>
|
||||||
|
<input
|
||||||
|
id="rating"
|
||||||
|
v-model.number="formData.rating"
|
||||||
|
type="number"
|
||||||
|
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"
|
||||||
|
placeholder="9.541"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slugs alternatifs -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 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"
|
||||||
|
>
|
||||||
|
{{ slug }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeAlternativeSlug(index)"
|
||||||
|
class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full text-green-400 hover:text-green-600"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showAlternativeSlugInput = !showAlternativeSlugInput"
|
||||||
|
class="text-green-600 hover:text-green-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
+ Ajouter un slug alternatif
|
||||||
|
</button>
|
||||||
|
<div v-if="showAlternativeSlugInput" class="flex gap-2">
|
||||||
|
<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"
|
||||||
|
placeholder="Nouveau slug alternatif"
|
||||||
|
@keyup.enter="addAlternativeSlug"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addAlternativeSlug"
|
||||||
|
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
|
||||||
|
>
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Genres -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 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"
|
||||||
|
>
|
||||||
|
{{ 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"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showGenreInput = !showGenreInput"
|
||||||
|
class="text-green-600 hover:text-green-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
+ Ajouter un genre
|
||||||
|
</button>
|
||||||
|
<div v-if="showGenreInput" class="flex gap-2">
|
||||||
|
<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"
|
||||||
|
placeholder="Nouveau genre"
|
||||||
|
@keyup.enter="addGenre"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addGenre"
|
||||||
|
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
|
||||||
|
>
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Boutons -->
|
||||||
|
<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"
|
||||||
|
@click="closeModal"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="saveChanges"
|
||||||
|
>
|
||||||
|
<div v-if="isSaving" class="flex items-center">
|
||||||
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Saving...
|
||||||
|
</div>
|
||||||
|
<span v-else>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TransitionRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
|
||||||
|
import { XMarkIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
manga: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
isSaving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'save']);
|
||||||
|
|
||||||
|
// Données du formulaire
|
||||||
|
const formData = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
author: '',
|
||||||
|
publicationYear: null,
|
||||||
|
status: '',
|
||||||
|
rating: null,
|
||||||
|
genres: [],
|
||||||
|
alternativeSlugs: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contrôle de l'affichage des inputs
|
||||||
|
const showGenreInput = ref(false);
|
||||||
|
const showAlternativeSlugInput = ref(false);
|
||||||
|
|
||||||
|
// Champs temporaires pour ajouter des genres et slugs
|
||||||
|
const newGenre = ref('');
|
||||||
|
const newAlternativeSlug = ref('');
|
||||||
|
|
||||||
|
// Initialiser le formulaire avec les données du manga
|
||||||
|
watch(() => props.manga, (newManga) => {
|
||||||
|
if (newManga) {
|
||||||
|
formData.value = {
|
||||||
|
title: newManga.title || '',
|
||||||
|
description: newManga.description || '',
|
||||||
|
author: newManga.author || '',
|
||||||
|
publicationYear: newManga.publicationYear || null,
|
||||||
|
status: newManga.status || '',
|
||||||
|
rating: newManga.rating || null,
|
||||||
|
genres: Array.isArray(newManga.genres) ? [...newManga.genres] : [],
|
||||||
|
alternativeSlugs: Array.isArray(newManga.alternativeSlugs) ? [...newManga.alternativeSlugs] : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
// Réinitialiser les états d'affichage
|
||||||
|
showGenreInput.value = false;
|
||||||
|
showAlternativeSlugInput.value = false;
|
||||||
|
newGenre.value = '';
|
||||||
|
newAlternativeSlug.value = '';
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveChanges = () => {
|
||||||
|
// Nettoyer les données avant de les envoyer
|
||||||
|
const dataToSave = {
|
||||||
|
title: formData.value.title || undefined,
|
||||||
|
description: formData.value.description || undefined,
|
||||||
|
author: formData.value.author || undefined,
|
||||||
|
publicationYear: formData.value.publicationYear || undefined,
|
||||||
|
status: formData.value.status || undefined,
|
||||||
|
rating: formData.value.rating || undefined,
|
||||||
|
genres: formData.value.genres.length > 0 ? formData.value.genres : undefined,
|
||||||
|
alternativeSlugs: formData.value.alternativeSlugs.length > 0 ? formData.value.alternativeSlugs : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer les valeurs undefined
|
||||||
|
Object.keys(dataToSave).forEach(key => {
|
||||||
|
if (dataToSave[key] === undefined) {
|
||||||
|
delete dataToSave[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
emit('save', dataToSave);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGenre = () => {
|
||||||
|
if (newGenre.value.trim() && !formData.value.genres.includes(newGenre.value.trim())) {
|
||||||
|
formData.value.genres.push(newGenre.value.trim());
|
||||||
|
newGenre.value = '';
|
||||||
|
showGenreInput.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGenre = (index) => {
|
||||||
|
formData.value.genres.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAlternativeSlug = () => {
|
||||||
|
if (newAlternativeSlug.value.trim() && !formData.value.alternativeSlugs.includes(newAlternativeSlug.value.trim())) {
|
||||||
|
formData.value.alternativeSlugs.push(newAlternativeSlug.value.trim());
|
||||||
|
newAlternativeSlug.value = '';
|
||||||
|
showAlternativeSlugInput.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAlternativeSlug = (index) => {
|
||||||
|
formData.value.alternativeSlugs.splice(index, 1);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import MangaCard from './MangaCard.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
mangas: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shadow-lg text-white">
|
||||||
|
<div class="relative h-64 sm:h-80 lg:h-96 bg-cover bg-center" :style="{ backgroundImage: `url('${manga.imageUrl}')` }">
|
||||||
|
<div class="absolute inset-0 bg-black opacity-50"></div>
|
||||||
|
<div class="absolute inset-0 flex flex-col lg:flex-row justify-center p-4 lg:p-6">
|
||||||
|
<!-- Image de couverture - cachée sur mobile, visible sur desktop -->
|
||||||
|
<div class="hidden lg:block mr-12">
|
||||||
|
<img :src="manga.thumbnailUrl" :alt="manga.title" class="max-w-48 lg:max-w-72 max-h-48 lg:max-h-72" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informations du manga -->
|
||||||
|
<div class="flex flex-col space-y-3 lg:space-y-4 flex-1 min-w-0">
|
||||||
|
<div class="flex items-start lg:items-center space-x-3">
|
||||||
|
<BookmarkIcon class="h-6 w-6 lg:h-8 lg:w-8 text-white flex-shrink-0 mt-1 lg:mt-0" />
|
||||||
|
<h1 class="text-xl sm:text-2xl lg:text-3xl font-bold leading-tight">{{ manga.title }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4 text-sm lg:text-base">
|
||||||
|
<span>{{ manga.year }}</span>
|
||||||
|
<span>Chapitres: {{ manga.totalChapters }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start lg:items-center space-x-2">
|
||||||
|
<FolderIcon class="h-5 w-5 lg:h-6 lg:w-6 text-gray-400 flex-shrink-0 mt-0.5 lg:mt-0" />
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:space-x-4 min-w-0 flex-1">
|
||||||
|
<span class="truncate text-sm lg:text-base">/media/mangas/{{ manga.title }} ({{ manga.year }})</span>
|
||||||
|
<span class="bg-green-600 py-1 px-2 rounded text-xs lg:text-sm self-start lg:self-auto">{{ manga.status || 'Terminé' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2" v-if="manga.tags?.length">
|
||||||
|
<template v-for="(tag, index) in manga.tags.slice(0, isMobile ? 3 : 5)" :key="index">
|
||||||
|
<span class="bg-gray-700 py-1 px-2 rounded-sm text-xs lg:text-sm">{{ tag }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-if="manga.tags.length > (isMobile ? 3 : 5)" class="bg-gray-700 py-1 px-2 rounded-sm text-xs lg:text-sm">...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<HeartIcon class="h-5 w-5 lg:h-6 lg:w-6 text-red-500" />
|
||||||
|
<span class="text-sm lg:text-base">{{ manga.rating }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm lg:text-base line-clamp-3 lg:line-clamp-5">{{ manga.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { BookmarkIcon, FolderIcon, HeartIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
manga: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Détection du mobile
|
||||||
|
const windowWidth = ref(window.innerWidth);
|
||||||
|
const isMobile = computed(() => windowWidth.value < 1024);
|
||||||
|
|
||||||
|
const updateWindowWidth = () => {
|
||||||
|
windowWidth.value = window.innerWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', updateWindowWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateWindowWidth);
|
||||||
|
});
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (-webkit-line-clamp: 5) {
|
||||||
|
.line-clamp-5 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 5;
|
||||||
|
line-clamp: 5;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<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,277 @@
|
|||||||
|
<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 bg-opacity-75 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 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">
|
||||||
|
Sources préférées
|
||||||
|
</DialogTitle>
|
||||||
|
<div class="mt-2">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Configurez l'ordre de priorité des sources pour ce manga. Glissez-déposez les sources pour les réorganiser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="isLoading" class="mt-5 flex justify-center items-center py-8">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</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">
|
||||||
|
{{ 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">
|
||||||
|
Aucune source disponible
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(source, index) in localSources"
|
||||||
|
:key="source.id"
|
||||||
|
: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,
|
||||||
|
'scale-105 shadow-lg border-blue-400': draggedIndex === index,
|
||||||
|
'opacity-50': dragOverIndex === index && draggedIndex !== index,
|
||||||
|
'scale-95 active:scale-95': isPressed === index
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleDragStart(index, $event)"
|
||||||
|
@dragover="handleDragOver(index, $event)"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop(index, $event)"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@mousedown="handleMouseDown(index)"
|
||||||
|
@mouseup="handleMouseUp"
|
||||||
|
@mouseleave="handleMouseUp"
|
||||||
|
>
|
||||||
|
<!-- Badge de priorité -->
|
||||||
|
<div class="absolute -top-2 -left-2 z-10">
|
||||||
|
<div :class="[
|
||||||
|
'inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold text-white shadow-lg',
|
||||||
|
{
|
||||||
|
'bg-gradient-to-r from-blue-500 to-indigo-600': index === 0,
|
||||||
|
'bg-gradient-to-r from-green-500 to-emerald-600': index === 1,
|
||||||
|
'bg-gradient-to-r from-yellow-500 to-amber-600': index === 2,
|
||||||
|
'bg-gradient-to-r from-gray-500 to-slate-600': index > 2
|
||||||
|
}
|
||||||
|
]">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Indicateur de priorité -->
|
||||||
|
<div class="mr-4">
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
]">
|
||||||
|
<span v-if="index === 0">🥇 Priorité haute</span>
|
||||||
|
<span v-else-if="index === 1">🥈 Priorité moyenne</span>
|
||||||
|
<span v-else-if="index === 2">🥉 Priorité basse</span>
|
||||||
|
<span v-else>Priorité {{ index + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 sm:col-start-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isSaving || isLoading"
|
||||||
|
@click="saveChanges"
|
||||||
|
>
|
||||||
|
<div v-if="isSaving" class="flex items-center">
|
||||||
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Sauvegarde...
|
||||||
|
</div>
|
||||||
|
<span v-else>Sauvegarder</span>
|
||||||
|
</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"
|
||||||
|
@click="closeModal"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TransitionRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
|
||||||
|
import { Cog6ToothIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
isSaving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'save']);
|
||||||
|
|
||||||
|
// Copie locale des sources pour le drag & drop
|
||||||
|
const localSources = ref([]);
|
||||||
|
|
||||||
|
// États pour le drag & drop
|
||||||
|
const draggedIndex = ref(null);
|
||||||
|
const dragOverIndex = ref(null);
|
||||||
|
|
||||||
|
// État pour l'effet de clic
|
||||||
|
const isPressed = ref(null);
|
||||||
|
|
||||||
|
// Watcher pour mettre à jour la copie locale quand les props changent
|
||||||
|
watch(
|
||||||
|
() => props.sources,
|
||||||
|
(newSources) => {
|
||||||
|
localSources.value = [...newSources];
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonctions pour l'effet de clic
|
||||||
|
const handleMouseDown = (index) => {
|
||||||
|
isPressed.value = index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
isPressed.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonctions pour le drag & drop
|
||||||
|
const handleDragStart = (index, event) => {
|
||||||
|
draggedIndex.value = index;
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/html', event.target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (index, event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
dragOverIndex.value = index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
dragOverIndex.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (dropIndex, event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (draggedIndex.value !== null && draggedIndex.value !== dropIndex) {
|
||||||
|
const sources = [...localSources.value];
|
||||||
|
const draggedItem = sources[draggedIndex.value];
|
||||||
|
|
||||||
|
// Supprimer l'élément de sa position actuelle
|
||||||
|
sources.splice(draggedIndex.value, 1);
|
||||||
|
|
||||||
|
// L'insérer à la nouvelle position
|
||||||
|
// Si on drop après l'élément original, on doit ajuster l'index
|
||||||
|
const insertIndex = draggedIndex.value < dropIndex ? dropIndex - 1 : dropIndex;
|
||||||
|
sources.splice(insertIndex, 0, draggedItem);
|
||||||
|
|
||||||
|
localSources.value = sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
draggedIndex.value = null;
|
||||||
|
dragOverIndex.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
draggedIndex.value = null;
|
||||||
|
dragOverIndex.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveChanges = () => {
|
||||||
|
// Extraire seulement les IDs dans l'ordre actuel
|
||||||
|
const sourceIds = localSources.value.map(source => source.id);
|
||||||
|
emit('save', sourceIds);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-sm shadow mb-2">
|
||||||
|
<!-- En-tête du volume -->
|
||||||
|
<div class="relative bg-white 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>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'px-2 py-1 text-xs sm:text-sm rounded text-center text-white min-w-[3rem] sm:min-w-[4rem]',
|
||||||
|
{
|
||||||
|
'bg-red-500': volume.downloadedChapter === 0,
|
||||||
|
'bg-yellow-500':
|
||||||
|
volume.downloadedChapter < volume.totalChapter && volume.downloadedChapter > 0,
|
||||||
|
'bg-green-500': volume.downloadedChapter === volume.totalChapter
|
||||||
|
}
|
||||||
|
]">
|
||||||
|
{{ volume.downloadedChapter }}/{{ volume.totalChapter }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions du volume -->
|
||||||
|
<div class="flex items-center space-x-1 sm:space-x-2">
|
||||||
|
<button
|
||||||
|
class="w-8 sm:w-10 h-8 sm:h-10 flex items-center justify-center"
|
||||||
|
@click="handleSearch"
|
||||||
|
:class="{
|
||||||
|
'text-yellow-500 cursor-wait': isSearching,
|
||||||
|
'text-gray-500 hover:text-green-500': !isSearching
|
||||||
|
}">
|
||||||
|
<MagnifyingGlassIcon class="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-8 sm:w-10 h-8 sm:h-10 flex items-center justify-center"
|
||||||
|
@click="handleDownload"
|
||||||
|
:class="{
|
||||||
|
'text-yellow-500 cursor-wait': isDownloading,
|
||||||
|
'text-gray-500 hover:text-green-500': !isDownloading
|
||||||
|
}"
|
||||||
|
:disabled="isDownloading">
|
||||||
|
<ArrowDownTrayIcon class="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton toggle centré -->
|
||||||
|
<div class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<button
|
||||||
|
@click="toggleVolume"
|
||||||
|
class="w-8 sm:w-10 h-8 sm:h-10 flex items-center justify-center">
|
||||||
|
<component
|
||||||
|
:is="isOpen ? ChevronUpIcon : ChevronDownIcon"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des chapitres -->
|
||||||
|
<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">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
BookmarkIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
MagnifyingGlassIcon
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
import MangaChapterList from './MangaChapterList.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
volume: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mangaSlug: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mangaId: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle']);
|
||||||
|
|
||||||
|
const store = useMangaStore();
|
||||||
|
const isOpen = ref(props.isOpen);
|
||||||
|
const isSearching = ref(false);
|
||||||
|
const isDownloading = ref(false);
|
||||||
|
|
||||||
|
// Synchroniser l'état local avec la prop
|
||||||
|
watch(() => props.isOpen, (newValue) => {
|
||||||
|
isOpen.value = newValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleVolume = () => {
|
||||||
|
isOpen.value = !isOpen.value;
|
||||||
|
emit('toggle', props.volume.number);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (isSearching.value) return; // Éviter les clicks multiples
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSearching.value = true;
|
||||||
|
console.log(
|
||||||
|
`Recherche du volume ${props.volume.number} - Lancement du scraping de ${props.volume.chapters.length} chapitres`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Récupérer tous les chapitres non disponibles du volume
|
||||||
|
const chaptersToSearch = props.volume.chapters
|
||||||
|
.filter(chapter => !chapter.isAvailable)
|
||||||
|
.map(chapter => chapter.id);
|
||||||
|
|
||||||
|
if (chaptersToSearch.length === 0) {
|
||||||
|
console.log('Tous les chapitres sont déjà disponibles !');
|
||||||
|
isSearching.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Chapitres à scraper: ${chaptersToSearch.length}`);
|
||||||
|
|
||||||
|
// Lancer le scraping de chaque chapitre non disponible en séquentiel
|
||||||
|
for (const chapterId of chaptersToSearch) {
|
||||||
|
console.log(`Scraping du chapitre ${chapterId}...`);
|
||||||
|
await store.searchChapter(chapterId);
|
||||||
|
// Petite pause entre chaque requête pour éviter de surcharger le serveur
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Scraping des chapitres du volume ${props.volume.number} terminé`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur lors du scraping du volume ${props.volume.number}:`, error);
|
||||||
|
} finally {
|
||||||
|
isSearching.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
console.log(`MangaVolume: Téléchargement du volume ${props.volume.number} (Manga ID: ${props.mangaId})`);
|
||||||
|
// Montrer l'indicateur de chargement
|
||||||
|
isDownloading.value = true;
|
||||||
|
|
||||||
|
await store.downloadVolume(props.mangaId, props.volume.number);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du téléchargement du volume:', error);
|
||||||
|
} finally {
|
||||||
|
// Arrêter l'indicateur de chargement
|
||||||
|
isDownloading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<MangaVolume
|
||||||
|
v-for="(volume, index) in volumes"
|
||||||
|
:key="volume.number"
|
||||||
|
:volume="volume"
|
||||||
|
:mangaSlug="mangaSlug"
|
||||||
|
:mangaId="mangaId"
|
||||||
|
:isOpen="expandedVolumes.has(volume.number)"
|
||||||
|
@toggle="handleVolumeToggle" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import MangaVolume from './MangaVolume.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
volumes: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mangaSlug: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mangaId: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
expandAll: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:expandAll']);
|
||||||
|
|
||||||
|
// Set pour stocker les numéros de volumes ouverts
|
||||||
|
const expandedVolumes = ref(new Set());
|
||||||
|
|
||||||
|
// Initialiser avec le premier volume ouvert par défaut
|
||||||
|
watch(() => props.volumes, (newVolumes) => {
|
||||||
|
if (newVolumes.length > 0 && expandedVolumes.value.size === 0) {
|
||||||
|
// Ouvrir le premier volume (volume 00) par défaut
|
||||||
|
expandedVolumes.value.add(newVolumes[0].number);
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Gérer l'expansion de tous les volumes
|
||||||
|
watch(() => props.expandAll, (shouldExpand) => {
|
||||||
|
if (shouldExpand) {
|
||||||
|
// Ouvrir tous les volumes
|
||||||
|
props.volumes.forEach(volume => {
|
||||||
|
expandedVolumes.value.add(volume.number);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fermer tous les volumes (y compris le volume 00)
|
||||||
|
expandedVolumes.value.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleVolumeToggle = (volumeNumber) => {
|
||||||
|
if (expandedVolumes.value.has(volumeNumber)) {
|
||||||
|
expandedVolumes.value.delete(volumeNumber);
|
||||||
|
} else {
|
||||||
|
expandedVolumes.value.add(volumeNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Émettre l'état d'expansion
|
||||||
|
const allExpanded = props.volumes.length > 0 &&
|
||||||
|
props.volumes.every(volume => expandedVolumes.value.has(volume.number));
|
||||||
|
emit('update:expandAll', allExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Méthode publique pour contrôler l'expansion
|
||||||
|
const expandAllVolumes = () => {
|
||||||
|
props.volumes.forEach(volume => {
|
||||||
|
expandedVolumes.value.add(volume.number);
|
||||||
|
});
|
||||||
|
emit('update:expandAll', true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapseAllVolumes = () => {
|
||||||
|
expandedVolumes.value.clear();
|
||||||
|
// Ne plus garder le premier volume ouvert, tous les volumes sont fermés
|
||||||
|
emit('update:expandAll', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exposer les méthodes pour le composant parent
|
||||||
|
defineExpose({
|
||||||
|
expandAllVolumes,
|
||||||
|
collapseAllVolumes
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Composant invisible qui écoute les événements Mercure -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch, inject } from 'vue';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mangaId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useMangaStore();
|
||||||
|
const eventSource = ref(null);
|
||||||
|
// On récupère le client de requête pour invalider le cache
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const setupMercureEventSource = () => {
|
||||||
|
if (eventSource.value) {
|
||||||
|
eventSource.value.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer les topics à écouter
|
||||||
|
const topics = [`manga/${props.mangaId}/chapters`, 'scraping/status'];
|
||||||
|
|
||||||
|
// Construire l'URL du hub Mercure avec les topics
|
||||||
|
const mercureHubUrl = new URL('/.well-known/mercure', window.location.origin);
|
||||||
|
topics.forEach(topic => {
|
||||||
|
mercureHubUrl.searchParams.append('topic', topic);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`MercureListener: Abonnement aux topics pour manga ${props.mangaId}`);
|
||||||
|
console.log(`MercureListener: URL Mercure - ${mercureHubUrl.toString()}`);
|
||||||
|
|
||||||
|
// Créer la source d'événements
|
||||||
|
eventSource.value = new EventSource(mercureHubUrl, { withCredentials: true });
|
||||||
|
|
||||||
|
// Définir les gestionnaires d'événements
|
||||||
|
eventSource.value.onmessage = event => {
|
||||||
|
try {
|
||||||
|
console.log('MercureListener: Événement reçu', event.data);
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleMercureEvent(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du traitement de l'événement Mercure:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.value.onerror = error => {
|
||||||
|
console.error('Erreur de connexion à Mercure:', error);
|
||||||
|
// Tenter de reconnecter après un délai
|
||||||
|
setTimeout(() => {
|
||||||
|
if (eventSource.value) {
|
||||||
|
setupMercureEventSource();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMercureEvent = data => {
|
||||||
|
if (!data || !data.type) {
|
||||||
|
console.warn('MercureListener: Événement sans type reçu', data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'chapter.scraped':
|
||||||
|
console.log(`MercureListener: Chapitre ${data.chapterNumber} scrappé avec succès!`, data);
|
||||||
|
|
||||||
|
// Vérifier que l'ID du chapitre est présent et au bon format
|
||||||
|
if (!data.chapterId) {
|
||||||
|
console.error("MercureListener: ID du chapitre manquant dans l'événement", data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour l'état du chapitre dans le store
|
||||||
|
try {
|
||||||
|
// Mettre à jour le store Pinia
|
||||||
|
store.updateChapterAvailability(data.chapterId, true);
|
||||||
|
console.log(
|
||||||
|
`MercureListener: Chapitre ${data.chapterNumber} (ID: ${data.chapterId}) marqué comme disponible`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalider le cache des requêtes pour forcer un rechargement frais
|
||||||
|
console.log(`MercureListener: Invalidation du cache pour les chapitres du manga ${props.mangaId}`);
|
||||||
|
queryClient.invalidateQueries(['manga', ref(props.mangaId), 'chapters']);
|
||||||
|
|
||||||
|
// Force le rechargement des chapitres via le store
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('MercureListener: Rechargement forcé des chapitres via le store');
|
||||||
|
store.loadChapters(props.mangaId);
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MercureListener: Erreur lors de la mise à jour du chapitre', error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'chapter.scraping.failed':
|
||||||
|
console.error(`MercureListener: Échec du scraping du chapitre ${data.chapterNumber}:`, data.reason);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('MercureListener: Événement Mercure non géré:', data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.mangaId,
|
||||||
|
(newMangaId, oldMangaId) => {
|
||||||
|
console.log(`MercureListener: MangaId changé de ${oldMangaId} à ${newMangaId}`);
|
||||||
|
if (newMangaId) {
|
||||||
|
setupMercureEventSource();
|
||||||
|
} else if (eventSource.value) {
|
||||||
|
eventSource.value.close();
|
||||||
|
eventSource.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupMercureEventSource();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (eventSource.value) {
|
||||||
|
eventSource.value.close();
|
||||||
|
eventSource.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
|
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
|
||||||
|
|
||||||
|
export function useMangaChapters(mangaId) {
|
||||||
|
const mangaRepository = new ApiMangaRepository();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['manga', mangaId, 'chapters'],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!mangaId.value) {
|
||||||
|
return Promise.resolve([]); // Retourne un tableau vide si pas d'ID
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`useMangaChapters: Chargement des chapitres pour le manga ${mangaId.value}`);
|
||||||
|
const response = await mangaRepository.getChapters(mangaId.value);
|
||||||
|
|
||||||
|
// Log pour déboguer
|
||||||
|
console.log(`useMangaChapters: ${response.items?.length || 0} chapitres chargés`);
|
||||||
|
|
||||||
|
// Assure de toujours retourner un tableau
|
||||||
|
return Array.isArray(response) ? response : response?.items ?? [];
|
||||||
|
},
|
||||||
|
// Refresh toutes les 30 secondes en arrière-plan
|
||||||
|
refetchInterval: 30000,
|
||||||
|
// S'assurer que si le composant est visible à nouveau, on récupère les données fraîches
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
// Query activée uniquement si mangaId est défini
|
||||||
|
enabled: computed(() => !!mangaId.value),
|
||||||
|
// Options pour conserver les données entre les requêtes
|
||||||
|
staleTime: 60000, // Considère les données comme "périmées" après 1 minute
|
||||||
|
cacheTime: 5 * 60 * 1000 // Garde les données en cache pendant 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retourne le résultat de useQuery (contenant data, isLoading, etc.)
|
||||||
|
return query;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
|
||||||
|
|
||||||
|
export function useMangaDelete() {
|
||||||
|
const mangaRepository = new ApiMangaRepository();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isDeleteModalOpen = ref(false);
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: ({ mangaId }) => {
|
||||||
|
return mangaRepository.deleteManga(mangaId);
|
||||||
|
},
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Invalider et refetch les listes de mangas
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mangas'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['manga-search'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const openDeleteModal = () => {
|
||||||
|
isDeleteModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeleteModal = () => {
|
||||||
|
isDeleteModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteManga = async (mangaId) => {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync({ mangaId });
|
||||||
|
closeDeleteModal();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression du manga:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDeleteModalOpen,
|
||||||
|
openDeleteModal,
|
||||||
|
closeDeleteModal,
|
||||||
|
deleteManga,
|
||||||
|
isLoading: deleteMutation.isPending,
|
||||||
|
error: deleteMutation.error,
|
||||||
|
isSuccess: deleteMutation.isSuccess
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
|
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
|
||||||
|
|
||||||
|
// Accepte un ID de manga (peut être une ref, un computed ref, ou une valeur simple)
|
||||||
|
export function useMangaDetails(mangaId) {
|
||||||
|
const mangaRepository = new ApiMangaRepository();
|
||||||
|
|
||||||
|
// Assure que mangaId est une ref ou une computed pour la réactivité de la queryKey
|
||||||
|
// Si ce n'est pas déjà le cas, mais généralement on passera une computed( () => route.params.id )
|
||||||
|
// const mangaIdRef = computed(() => unref(mangaId)); // unref est utile si on accepte des valeurs simples aussi
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
// La queryKey doit être réactive à mangaId
|
||||||
|
queryKey: ['manga', mangaId], // mangaId est déjà une computed ref, donc c'est bon
|
||||||
|
queryFn: () => {
|
||||||
|
// Vérifier que l'ID a une valeur avant d'appeler l'API
|
||||||
|
if (!mangaId.value) {
|
||||||
|
// Retourner null ou undefined si pas d'ID, pour éviter un appel API invalide
|
||||||
|
// TanStack Query gère aussi l'option 'enabled' pour cela
|
||||||
|
return Promise.resolve(null); // ou throw new Error("ID manquant");
|
||||||
|
}
|
||||||
|
return mangaRepository.getMangaById(mangaId.value);
|
||||||
|
},
|
||||||
|
// Activer la requête seulement si mangaId a une valeur truthy
|
||||||
|
enabled: computed(() => !!mangaId.value)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retourne tous les états et données fournis par useQuery
|
||||||
|
return query;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
|
||||||
|
|
||||||
|
export function useMangaEdit() {
|
||||||
|
const mangaRepository = new ApiMangaRepository();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEditModalOpen = ref(false);
|
||||||
|
|
||||||
|
const editMutation = useMutation({
|
||||||
|
mutationFn: ({ mangaId, updateData }) => {
|
||||||
|
return mangaRepository.editManga(mangaId, updateData);
|
||||||
|
},
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Invalider et refetch les données du manga
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['manga', variables.mangaId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mangas'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEditModal = () => {
|
||||||
|
isEditModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditModal = () => {
|
||||||
|
isEditModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editManga = async (mangaId, updateData) => {
|
||||||
|
try {
|
||||||
|
await editMutation.mutateAsync({ mangaId, updateData });
|
||||||
|
closeEditModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'édition du manga:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEditModalOpen,
|
||||||
|
openEditModal,
|
||||||
|
closeEditModal,
|
||||||
|
editManga,
|
||||||
|
isLoading: editMutation.isPending,
|
||||||
|
error: editMutation.error,
|
||||||
|
isSuccess: editMutation.isSuccess
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||||
|
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
|
||||||
|
|
||||||
|
const mangaRepository = new ApiMangaRepository();
|
||||||
|
|
||||||
|
export function useMangaMonitoring() {
|
||||||
|
const { showSuccess, showError } = useNotifications();
|
||||||
|
|
||||||
|
const isToggling = ref(false);
|
||||||
|
const toggleError = ref(null);
|
||||||
|
|
||||||
|
const toggleMonitoring = async (mangaId, enabled) => {
|
||||||
|
if (isToggling.value || !mangaId) return;
|
||||||
|
|
||||||
|
isToggling.value = true;
|
||||||
|
toggleError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`${enabled ? 'Activation' : 'Désactivation'} du monitoring pour le manga ${mangaId}`);
|
||||||
|
|
||||||
|
await mangaRepository.toggleMonitoring(mangaId, enabled);
|
||||||
|
|
||||||
|
const message = enabled
|
||||||
|
? 'Monitoring activé avec succès. Vous recevrez les nouveaux chapitres automatiquement.'
|
||||||
|
: 'Monitoring désactivé avec succès. Les nouveaux chapitres ne seront plus téléchargés automatiquement.';
|
||||||
|
|
||||||
|
showSuccess(message);
|
||||||
|
|
||||||
|
console.log(`Monitoring ${enabled ? 'activé' : 'désactivé'} avec succès`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du changement de monitoring:', error);
|
||||||
|
toggleError.value = error.message || 'Erreur lors du changement de monitoring';
|
||||||
|
|
||||||
|
const errorMessage = enabled
|
||||||
|
? `Erreur lors de l'activation du monitoring: ${error.message || 'Une erreur inattendue est survenue'}`
|
||||||
|
: `Erreur lors de la désactivation du monitoring: ${error.message || 'Une erreur inattendue est survenue'}`;
|
||||||
|
|
||||||
|
showError(errorMessage);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isToggling.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enableMonitoring = async (mangaId) => {
|
||||||
|
return await toggleMonitoring(mangaId, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableMonitoring = async (mangaId) => {
|
||||||
|
return await toggleMonitoring(mangaId, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearError = () => {
|
||||||
|
toggleError.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isToggling,
|
||||||
|
toggleError,
|
||||||
|
toggleMonitoring,
|
||||||
|
enableMonitoring,
|
||||||
|
disableMonitoring,
|
||||||
|
clearError
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
|
||||||
|
|
||||||
|
export function useMangaPreferredSources(mangaId) {
|
||||||
|
const mangaRepository = new ApiMangaRepository();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Query pour récupérer les sources préférées
|
||||||
|
const preferredSourcesQuery = useQuery({
|
||||||
|
queryKey: ['manga', mangaId, 'preferred-sources'],
|
||||||
|
queryFn: () => {
|
||||||
|
if (!mangaId.value) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return mangaRepository.getPreferredSources(mangaId.value);
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!mangaId.value)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation pour sauvegarder les sources préférées
|
||||||
|
const setPreferredSourcesMutation = useMutation({
|
||||||
|
mutationFn: ({ sourceIds }) => {
|
||||||
|
return mangaRepository.setPreferredSources(mangaId.value, sourceIds);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalider le cache pour refaire la requête de récupération
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['manga', mangaId.value, 'preferred-sources']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sources = computed(() => preferredSourcesQuery.data.value?.sources || []);
|
||||||
|
const hasPreferredSources = computed(() => preferredSourcesQuery.data.value?.hasPreferredSources || false);
|
||||||
|
const isLoading = computed(() => preferredSourcesQuery.isLoading.value);
|
||||||
|
const error = computed(() => preferredSourcesQuery.error.value);
|
||||||
|
const isSaving = computed(() => setPreferredSourcesMutation.isPending.value);
|
||||||
|
|
||||||
|
const savePreferredSources = (sourceIds) => {
|
||||||
|
return setPreferredSourcesMutation.mutate({ sourceIds });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources,
|
||||||
|
hasPreferredSources,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isSaving,
|
||||||
|
savePreferredSources
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
|
||||||
|
export function useMangaRefresh() {
|
||||||
|
const mangaStore = useMangaStore();
|
||||||
|
const { showSuccess, showError } = useNotifications();
|
||||||
|
|
||||||
|
const isRefreshing = ref(false);
|
||||||
|
const refreshError = ref(null);
|
||||||
|
|
||||||
|
const refreshMetadata = async (mangaId) => {
|
||||||
|
if (isRefreshing.value || !mangaId) return;
|
||||||
|
|
||||||
|
isRefreshing.value = true;
|
||||||
|
refreshError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Début du refresh des métadonnées pour le manga ${mangaId}`);
|
||||||
|
|
||||||
|
// Appel à l'endpoint de refresh des chapitres
|
||||||
|
await mangaStore.refreshMangaChapters(mangaId);
|
||||||
|
|
||||||
|
showSuccess('Refresh des métadonnées lancé avec succès. Les nouveaux chapitres apparaîtront sous peu.');
|
||||||
|
|
||||||
|
console.log('Refresh des métadonnées déclenché avec succès');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du refresh des métadonnées:', error);
|
||||||
|
refreshError.value = error.message || 'Erreur lors du refresh des métadonnées';
|
||||||
|
showError(`Erreur lors du refresh: ${error.message || 'Une erreur inattendue est survenue'}`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isRefreshing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearError = () => {
|
||||||
|
refreshError.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRefreshing,
|
||||||
|
refreshError,
|
||||||
|
refreshMetadata,
|
||||||
|
clearError
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
import { useMangaChapters } from './useMangaChapters'; // Importe le composable des chapitres
|
||||||
|
|
||||||
|
export function useMangaVolumes(mangaId) {
|
||||||
|
// Récupération du store pour avoir accès aux chapitres mis à jour en temps réel
|
||||||
|
const mangaStore = useMangaStore();
|
||||||
|
|
||||||
|
// Utilise le composable des chapitres pour récupérer les données brutes et les états
|
||||||
|
const { data: rawChaptersData, isLoading, isFetching, error, status, refetch } = useMangaChapters(mangaId);
|
||||||
|
|
||||||
|
// Fonction pour forcer le rechargement des données
|
||||||
|
const refresh = () => {
|
||||||
|
console.log('useMangaVolumes: Rechargement forcé des chapitres');
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Surveiller les changements dans le store pour les chapitres du manga actuel
|
||||||
|
watch(
|
||||||
|
() => mangaStore.mangaChapters[mangaId.value],
|
||||||
|
() => {
|
||||||
|
console.log('useMangaVolumes: Changement détecté dans les chapitres du store');
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calcule les volumes à partir des données des chapitres
|
||||||
|
const volumes = computed(() => {
|
||||||
|
console.log('useMangaVolumes: Recalcul des volumes');
|
||||||
|
const chaptersArray = rawChaptersData.value || []; // Utilise la data retournée par useMangaChapters
|
||||||
|
if (chaptersArray.length === 0) return [];
|
||||||
|
|
||||||
|
const volumeMap = new Map();
|
||||||
|
chaptersArray.forEach(chapter => {
|
||||||
|
const volumeNumber = chapter.volume || '00';
|
||||||
|
if (!volumeMap.has(volumeNumber)) {
|
||||||
|
volumeMap.set(volumeNumber, {
|
||||||
|
number: volumeNumber,
|
||||||
|
downloadedChapter: 0,
|
||||||
|
totalChapter: 0,
|
||||||
|
chapters: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const volumeEntry = volumeMap.get(volumeNumber);
|
||||||
|
volumeEntry.chapters.push({
|
||||||
|
...chapter,
|
||||||
|
isAvailable: Boolean(chapter.isAvailable)
|
||||||
|
});
|
||||||
|
volumeEntry.totalChapter++;
|
||||||
|
if (chapter.isAvailable) {
|
||||||
|
volumeEntry.downloadedChapter++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(volumeMap.values()).sort((a, b) => {
|
||||||
|
// Cas spécial pour le volume 00, qui doit apparaître en premier
|
||||||
|
if (a.number === '00') return -1;
|
||||||
|
if (b.number === '00') return 1;
|
||||||
|
|
||||||
|
// Pour tous les autres volumes, tri décroissant
|
||||||
|
const numA = Number(a.number);
|
||||||
|
const numB = Number(b.number);
|
||||||
|
return numB - numA;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retourne les volumes calculés et propage les états pertinents de useMangaChapters
|
||||||
|
return {
|
||||||
|
volumes, // Les données transformées
|
||||||
|
isLoading, // L'état de chargement initial des chapitres
|
||||||
|
isFetching, // L'état de rafraîchissement des chapitres
|
||||||
|
error, // L'erreur potentielle lors du fetch des chapitres
|
||||||
|
status, // L'état global ('pending', 'error', 'success')
|
||||||
|
refresh
|
||||||
|
// On pourrait aussi retourner rawChaptersData si nécessaire ailleurs
|
||||||
|
};
|
||||||
|
}
|
||||||
157
assets/vue/app/domain/manga/presentation/pages/AddManga.vue
Normal file
157
assets/vue/app/domain/manga/presentation/pages/AddManga.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<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>
|
||||||
|
</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 -->
|
||||||
|
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<DialogPanel class="w-full max-w-lg bg-white rounded-xl shadow-xl p-6">
|
||||||
|
<DialogTitle class="text-lg mb-4"> Ajouter à la bibliothèque </DialogTitle>
|
||||||
|
|
||||||
|
<div v-if="selectedManga">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<img
|
||||||
|
: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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 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">
|
||||||
|
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' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||||
|
import { ArrowPathIcon } from '@heroicons/vue/24/solid';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
import MangaList from '../components/MangaList.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const mangaStore = useMangaStore();
|
||||||
|
|
||||||
|
const searchQuery = ref('');
|
||||||
|
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 truncatedDescription = computed(() => {
|
||||||
|
if (!selectedManga.value?.description) return '';
|
||||||
|
return selectedManga.value.description.length > 500
|
||||||
|
? selectedManga.value.description.slice(0, 500) + '...'
|
||||||
|
: selectedManga.value.description;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nettoyer la recherche et les résultats lors du démontage du composant
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
searchQuery.value = '';
|
||||||
|
mangaStore.clearSearchResults();
|
||||||
|
});
|
||||||
|
|
||||||
|
const performSearch = async () => {
|
||||||
|
if (!searchQuery.value.trim()) return;
|
||||||
|
try {
|
||||||
|
await mangaStore.searchMangaDex(searchQuery.value);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur de recherche:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
101
assets/vue/app/domain/manga/presentation/pages/HomePage.vue
Normal file
101
assets/vue/app/domain/manga/presentation/pages/HomePage.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<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
|
||||||
|
v-else-if="viewMode === 'list'"
|
||||||
|
:mangas="collection?.items || []"
|
||||||
|
@manga-click="handleMangaClick" />
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ArrowPathIcon,
|
||||||
|
ArrowsUpDownIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
EyeIcon,
|
||||||
|
FunnelIcon,
|
||||||
|
MagnifyingGlassIcon
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
import MangaGrid from '../components/MangaGrid.vue';
|
||||||
|
import MangaList from '../components/MangaList.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const mangaStore = useMangaStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
collection,
|
||||||
|
loadingCollection: loading,
|
||||||
|
errorCollection: error,
|
||||||
|
isBackgroundLoadingCollection: isBackgroundLoading
|
||||||
|
} = storeToRefs(mangaStore);
|
||||||
|
|
||||||
|
const viewMode = ref('grid');
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mangaStore.loadCollection();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMangaClick = manga => {
|
||||||
|
router.push({ name: 'manga-details', params: { id: manga.id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolbarConfig = {
|
||||||
|
leftSection: [
|
||||||
|
{
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: 'Refresh',
|
||||||
|
type: 'button',
|
||||||
|
onClick: () => mangaStore.refreshCollectionInBackground(),
|
||||||
|
active: isBackgroundLoading.value
|
||||||
|
},
|
||||||
|
{ icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} }
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{ icon: Cog6ToothIcon, type: 'button', onClick: () => {} },
|
||||||
|
{
|
||||||
|
icon: EyeIcon,
|
||||||
|
type: 'dropdown',
|
||||||
|
label: 'View',
|
||||||
|
items: [
|
||||||
|
{ label: 'List', onClick: () => (viewMode.value = 'list') },
|
||||||
|
{ label: 'Grid', onClick: () => (viewMode.value = 'grid') }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ArrowsUpDownIcon,
|
||||||
|
type: 'dropdown',
|
||||||
|
label: 'Sort',
|
||||||
|
items: [
|
||||||
|
{ label: 'Title', onClick: () => {} },
|
||||||
|
{ label: 'Author', onClick: () => {} },
|
||||||
|
{ label: 'Status', onClick: () => {} },
|
||||||
|
{ label: 'Year', onClick: () => {} }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FunnelIcon,
|
||||||
|
type: 'dropdown',
|
||||||
|
label: 'Filter',
|
||||||
|
items: [
|
||||||
|
{ label: 'All', onClick: () => {} },
|
||||||
|
{ label: 'Completed', onClick: () => {} },
|
||||||
|
{ label: 'In Progress', onClick: () => {} }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
</script>
|
||||||
401
assets/vue/app/domain/manga/presentation/pages/MangaDetails.vue
Normal file
401
assets/vue/app/domain/manga/presentation/pages/MangaDetails.vue
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- 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">
|
||||||
|
{{ 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="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">
|
||||||
|
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MangaHeader :manga="currentManga" />
|
||||||
|
|
||||||
|
<!-- Section Volumes avec conteneur mobile -->
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 mt-8 pb-8">
|
||||||
|
<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">
|
||||||
|
{{ errorVolumes.message || 'Une erreur est survenue lors du chargement des volumes.' }}
|
||||||
|
</div>
|
||||||
|
<MangaVolumeList
|
||||||
|
v-else
|
||||||
|
ref="volumeListRef"
|
||||||
|
:volumes="volumes"
|
||||||
|
:manga-slug="currentManga.slug"
|
||||||
|
:manga-id="mangaId"
|
||||||
|
v-model:expand-all="isAllExpanded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale des sources préférées -->
|
||||||
|
<MangaPreferredSourcesModal
|
||||||
|
:is-open="isPreferredSourcesModalOpen"
|
||||||
|
:sources="preferredSources"
|
||||||
|
:is-loading="isLoadingSources"
|
||||||
|
:error="sourcesError"
|
||||||
|
:is-saving="isSavingSources"
|
||||||
|
@close="closePreferredSourcesModal"
|
||||||
|
@save="savePreferredSources"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modale d'édition du manga -->
|
||||||
|
<MangaEditModal
|
||||||
|
:is-open="isEditModalOpen"
|
||||||
|
:manga="currentManga"
|
||||||
|
:is-saving="isEditLoading"
|
||||||
|
:error="editError"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@save="saveMangaEdit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modale de gestion des chapitres -->
|
||||||
|
<ManageChaptersModal
|
||||||
|
:is-open="isManageChaptersModalOpen"
|
||||||
|
:manga="currentManga"
|
||||||
|
:chapters="mangaStore.mangaChapters[mangaId]?.items || []"
|
||||||
|
:is-loading="mangaStore.loadingChapters"
|
||||||
|
:is-saving="isSavingChapters"
|
||||||
|
:error="chaptersError"
|
||||||
|
@close="closeManageChaptersModal"
|
||||||
|
@save="saveChaptersChanges"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modale de suppression du manga -->
|
||||||
|
<MangaDeleteModal
|
||||||
|
:is-open="isDeleteModalOpen"
|
||||||
|
:manga="currentManga"
|
||||||
|
:is-loading="isDeleting"
|
||||||
|
:error="deleteError"
|
||||||
|
@close="closeDeleteModal"
|
||||||
|
@confirm="confirmDeleteManga"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="isLoadingDetails" class="flex justify-center items-center h-64">
|
||||||
|
<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">
|
||||||
|
Aucun manga sélectionné ou trouvé.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ArrowPathIcon,
|
||||||
|
BookmarkIcon,
|
||||||
|
BookmarkSlashIcon,
|
||||||
|
ChevronDoubleDownIcon,
|
||||||
|
ChevronDoubleUpIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
TrashIcon,
|
||||||
|
WrenchIcon
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { useMangaDelete } from '../composables/useMangaDelete';
|
||||||
|
import { useMangaDetails } from '../composables/useMangaDetails';
|
||||||
|
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||||
|
import { useMangaMonitoring } from '../composables/useMangaMonitoring';
|
||||||
|
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||||
|
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||||
|
import { useMangaVolumes } from '../composables/useMangaVolumes';
|
||||||
|
|
||||||
|
import ManageChaptersModal from '../components/ManageChaptersModal.vue';
|
||||||
|
import MangaDeleteModal from '../components/MangaDeleteModal.vue';
|
||||||
|
import MangaEditModal from '../components/MangaEditModal.vue';
|
||||||
|
import MangaHeader from '../components/MangaHeader.vue';
|
||||||
|
import MangaPreferredSourcesModal from '../components/MangaPreferredSourcesModal.vue';
|
||||||
|
import MangaVolumeList from '../components/MangaVolumeList.vue';
|
||||||
|
import MercureListener from '../components/MercureListener.vue';
|
||||||
|
|
||||||
|
import NotificationToast from '../../../../shared/components/ui/NotificationToast.vue';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
|
import { useMangaStore } from '../../application/store/mangaStore';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const mangaStore = useMangaStore();
|
||||||
|
|
||||||
|
const mangaId = computed(() => Number(route.params.id) || null);
|
||||||
|
|
||||||
|
// État de la modale
|
||||||
|
const isPreferredSourcesModalOpen = ref(false);
|
||||||
|
const isManageChaptersModalOpen = ref(false);
|
||||||
|
const isSavingChapters = ref(false);
|
||||||
|
const chaptersError = ref(null);
|
||||||
|
|
||||||
|
// État d'expansion des volumes
|
||||||
|
const isAllExpanded = ref(false);
|
||||||
|
const volumeListRef = ref(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: currentManga,
|
||||||
|
isLoading: isLoadingDetails,
|
||||||
|
isFetching: isRefreshingDetails,
|
||||||
|
error: errorDetails,
|
||||||
|
refetch: refetchMangaDetails
|
||||||
|
} = useMangaDetails(mangaId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
volumes,
|
||||||
|
isLoading: isLoadingVolumes,
|
||||||
|
isFetching: isRefreshingVolumes,
|
||||||
|
error: errorVolumes
|
||||||
|
} = useMangaVolumes(mangaId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
sources: preferredSources,
|
||||||
|
isLoading: isLoadingSources,
|
||||||
|
error: sourcesError,
|
||||||
|
isSaving: isSavingSources,
|
||||||
|
savePreferredSources: saveSourcesOrder
|
||||||
|
} = useMangaPreferredSources(mangaId);
|
||||||
|
|
||||||
|
// Composable pour l'édition des mangas
|
||||||
|
const {
|
||||||
|
isEditModalOpen,
|
||||||
|
openEditModal,
|
||||||
|
closeEditModal,
|
||||||
|
editManga,
|
||||||
|
isLoading: isEditLoading,
|
||||||
|
error: editError
|
||||||
|
} = useMangaEdit();
|
||||||
|
|
||||||
|
// Composable pour le refresh des métadonnées
|
||||||
|
const {
|
||||||
|
isRefreshing,
|
||||||
|
refreshMetadata
|
||||||
|
} = useMangaRefresh();
|
||||||
|
|
||||||
|
// Composable pour le monitoring
|
||||||
|
const {
|
||||||
|
isToggling: isTogglingMonitoring,
|
||||||
|
toggleMonitoring,
|
||||||
|
toggleError: monitoringError
|
||||||
|
} = useMangaMonitoring();
|
||||||
|
|
||||||
|
// Composable pour la suppression
|
||||||
|
const {
|
||||||
|
isDeleteModalOpen,
|
||||||
|
openDeleteModal,
|
||||||
|
closeDeleteModal,
|
||||||
|
deleteManga,
|
||||||
|
isLoading: isDeleting,
|
||||||
|
error: deleteError
|
||||||
|
} = useMangaDelete();
|
||||||
|
|
||||||
|
// Charger les chapitres dans le store quand le manga est chargé
|
||||||
|
watch(
|
||||||
|
mangaId,
|
||||||
|
newId => {
|
||||||
|
if (newId) {
|
||||||
|
mangaStore.loadChapters(newId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const openPreferredSourcesModal = () => {
|
||||||
|
isPreferredSourcesModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePreferredSourcesModal = () => {
|
||||||
|
isPreferredSourcesModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openManageChaptersModal = () => {
|
||||||
|
isManageChaptersModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeManageChaptersModal = () => {
|
||||||
|
isManageChaptersModalOpen.value = false;
|
||||||
|
chaptersError.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteManga = async () => {
|
||||||
|
if (!mangaId.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteManga(mangaId.value);
|
||||||
|
// Rediriger vers la liste des mangas après suppression
|
||||||
|
router.push('/manga');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression du manga:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePreferredSources = async (sourceIds) => {
|
||||||
|
try {
|
||||||
|
await saveSourcesOrder(sourceIds);
|
||||||
|
closePreferredSourcesModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la sauvegarde des sources préférées:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour sauvegarder l'édition du manga
|
||||||
|
const saveMangaEdit = async (updateData) => {
|
||||||
|
try {
|
||||||
|
await editManga(mangaId.value, updateData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'édition du manga:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour sauvegarder les changements des chapitres
|
||||||
|
const saveChaptersChanges = async (chaptersData) => {
|
||||||
|
if (!mangaId.value) return;
|
||||||
|
|
||||||
|
isSavingChapters.value = true;
|
||||||
|
chaptersError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/chapters/batch-edit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapters: chaptersData
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erreur lors de la sauvegarde des chapitres');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recharger les chapitres et les volumes
|
||||||
|
await mangaStore.loadChapters(mangaId.value);
|
||||||
|
closeManageChaptersModal();
|
||||||
|
} catch (error) {
|
||||||
|
chaptersError.value = error.message;
|
||||||
|
console.error('Erreur lors de la sauvegarde des chapitres:', error);
|
||||||
|
} finally {
|
||||||
|
isSavingChapters.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour le refresh des métadonnées
|
||||||
|
const handleRefreshMetadata = async () => {
|
||||||
|
if (!mangaId.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshMetadata(mangaId.value);
|
||||||
|
} catch (error) {
|
||||||
|
// L'erreur est déjà gérée dans le composable avec les notifications
|
||||||
|
console.error('Erreur lors du refresh:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour basculer le monitoring
|
||||||
|
const handleToggleMonitoring = async () => {
|
||||||
|
if (!mangaId.value || !currentManga.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newMonitoringState = !currentManga.value.monitored;
|
||||||
|
await toggleMonitoring(mangaId.value, newMonitoringState);
|
||||||
|
|
||||||
|
// Recharger les détails du manga pour mettre à jour l'état du monitoring
|
||||||
|
await refetchMangaDetails();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du changement de monitoring:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour étendre/réduire tous les volumes
|
||||||
|
const handleExpandAll = () => {
|
||||||
|
if (!volumeListRef.value) return;
|
||||||
|
|
||||||
|
if (isAllExpanded.value) {
|
||||||
|
volumeListRef.value.collapseAllVolumes();
|
||||||
|
} else {
|
||||||
|
volumeListRef.value.expandAllVolumes();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: 'Refresh metadata',
|
||||||
|
type: 'button',
|
||||||
|
onClick: handleRefreshMetadata,
|
||||||
|
loading: isRefreshing.value,
|
||||||
|
disabled: isRefreshing.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: PencilSquareIcon,
|
||||||
|
label: 'Manage chapters',
|
||||||
|
type: 'button',
|
||||||
|
onClick: openManageChaptersModal
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Cog6ToothIcon,
|
||||||
|
label: 'Preferred Sources',
|
||||||
|
type: 'button',
|
||||||
|
onClick: openPreferredSourcesModal
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{
|
||||||
|
icon: currentManga.value?.monitored ? BookmarkIcon : BookmarkSlashIcon,
|
||||||
|
label: currentManga.value?.monitored ? 'Désactiver monitoring' : 'Activer monitoring',
|
||||||
|
type: 'button',
|
||||||
|
onClick: handleToggleMonitoring,
|
||||||
|
loading: isTogglingMonitoring.value,
|
||||||
|
disabled: isTogglingMonitoring.value,
|
||||||
|
variant: currentManga.value?.monitored ? 'active' : 'default'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: WrenchIcon,
|
||||||
|
label: 'Edit',
|
||||||
|
type: 'button',
|
||||||
|
onClick: openEditModal
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: TrashIcon,
|
||||||
|
label: 'Delete',
|
||||||
|
type: 'button',
|
||||||
|
onClick: openDeleteModal
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: isAllExpanded.value ? ChevronDoubleUpIcon : ChevronDoubleDownIcon,
|
||||||
|
label: isAllExpanded.value ? 'Collapse all' : 'Expand all',
|
||||||
|
type: 'button',
|
||||||
|
onClick: handleExpandAll,
|
||||||
|
variant: isAllExpanded.value ? 'active' : 'default'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const loading = computed(() => isLoadingDetails.value || isLoadingVolumes.value);
|
||||||
|
const isRefreshingData = computed(() => isRefreshingDetails.value || isRefreshingVolumes.value || isRefreshing.value);
|
||||||
|
const error = computed(() => errorDetails.value || errorVolumes.value);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
mangaId,
|
||||||
|
newId => {
|
||||||
|
if (newId) {
|
||||||
|
mangaStore.setCurrentMangaId(newId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
mangaStore.clearCurrentMangaFocus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
293
assets/vue/app/domain/reader/application/store/readerStore.js
Normal file
293
assets/vue/app/domain/reader/application/store/readerStore.js
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { Chapter } from '../../domain/entities/Chapter';
|
||||||
|
import { ApiChapterRepository } from '../../infrastructure/repository/ApiChapterRepository';
|
||||||
|
|
||||||
|
export const useReaderStore = defineStore('reader', {
|
||||||
|
state: () => ({
|
||||||
|
currentChapter: null,
|
||||||
|
currentPage: 0,
|
||||||
|
readingMode: 'single', // 'single' ou 'infinite'
|
||||||
|
readingDirection: 'ltr', // 'ltr' ou 'rtl'
|
||||||
|
zoom: 1,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
pages: [],
|
||||||
|
totalPages: 0,
|
||||||
|
loadedPages: new Set(), // Garder une trace des pages déjà chargées
|
||||||
|
|
||||||
|
// Paramètres pour les doubles pages
|
||||||
|
doublePageSettings: {
|
||||||
|
autoDetect: true,
|
||||||
|
mobileMode: 'rotate', // 'rotate', 'scroll', 'normal'
|
||||||
|
detectionThreshold: 1.2 // Ratio largeur/hauteur pour détecter une double page
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isFirstPage: state => state.currentPage === 0,
|
||||||
|
isLastPage: state => state.currentPage === state.totalPages - 1,
|
||||||
|
currentPageData: state => state.pages[state.currentPage],
|
||||||
|
hasPreviousChapter: state => Boolean(state.currentChapter?.navigation?.previousChapter),
|
||||||
|
hasNextChapter: state => Boolean(state.currentChapter?.navigation?.nextChapter),
|
||||||
|
|
||||||
|
// Getters pour les doubles pages
|
||||||
|
effectiveDoublePageMode: (state) => {
|
||||||
|
// Si la détection automatique est désactivée, retourner 'normal'
|
||||||
|
if (!state.doublePageSettings.autoDetect) {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
return state.doublePageSettings.mobileMode;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Préférences sauvegardées dans localStorage
|
||||||
|
savedPreferences: (state) => ({
|
||||||
|
readingMode: state.readingMode,
|
||||||
|
readingDirection: state.readingDirection,
|
||||||
|
zoom: state.zoom,
|
||||||
|
doublePageSettings: state.doublePageSettings
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async loadChapter(chapterId) {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const repository = new ApiChapterRepository();
|
||||||
|
|
||||||
|
// Charger les informations du chapitre
|
||||||
|
const chapterData = await repository.getChapter(chapterId);
|
||||||
|
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.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (pageIndex !== this.currentPage) {
|
||||||
|
this.currentPage = pageIndex;
|
||||||
|
// Précharger les pages suivantes
|
||||||
|
if (this.readingMode === 'infinite') {
|
||||||
|
await this.preloadNextPages(pageIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async nextPage() {
|
||||||
|
if (!this.isLastPage) {
|
||||||
|
this.currentPage++;
|
||||||
|
await this.loadPageData(this.currentPage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async 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) {
|
||||||
|
this.readingDirection = direction;
|
||||||
|
this.savePreferences();
|
||||||
|
},
|
||||||
|
|
||||||
|
setZoom(zoom) {
|
||||||
|
this.zoom = Math.max(0.5, Math.min(2, zoom));
|
||||||
|
this.savePreferences();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Nouvelles actions pour les doubles pages
|
||||||
|
setDoublePageMode(mode) {
|
||||||
|
if (['rotate', 'scroll', 'normal'].includes(mode)) {
|
||||||
|
this.doublePageSettings.mobileMode = mode;
|
||||||
|
this.savePreferences();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setDoublePageAutoDetect(enabled) {
|
||||||
|
this.doublePageSettings.autoDetect = enabled;
|
||||||
|
this.savePreferences();
|
||||||
|
},
|
||||||
|
|
||||||
|
setDoublePageDetectionThreshold(threshold) {
|
||||||
|
this.doublePageSettings.detectionThreshold = Math.max(1.0, Math.min(3.0, threshold));
|
||||||
|
this.savePreferences();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateDoublePageSettings(settings) {
|
||||||
|
this.doublePageSettings = {
|
||||||
|
...this.doublePageSettings,
|
||||||
|
...settings
|
||||||
|
};
|
||||||
|
this.savePreferences();
|
||||||
|
},
|
||||||
|
|
||||||
|
async goToNextChapter() {
|
||||||
|
if (this.currentChapter?.navigation?.nextChapter) {
|
||||||
|
await this.loadChapter(this.currentChapter.navigation.nextChapter);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
readingMode: this.readingMode,
|
||||||
|
readingDirection: this.readingDirection,
|
||||||
|
zoom: this.zoom,
|
||||||
|
doublePageSettings: this.doublePageSettings
|
||||||
|
};
|
||||||
|
localStorage.setItem('mangarr-reader-preferences', JSON.stringify(preferences));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la sauvegarde des préférences:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPreferences() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('mangarr-reader-preferences');
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Migration: si l'ancien doublePageMode existe, le migrer vers mobileMode
|
||||||
|
if (preferences.doublePageMode && ['rotate', 'scroll', 'normal'].includes(preferences.doublePageMode)) {
|
||||||
|
this.doublePageSettings.mobileMode = preferences.doublePageMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.doublePageSettings) {
|
||||||
|
this.doublePageSettings = {
|
||||||
|
...this.doublePageSettings,
|
||||||
|
...preferences.doublePageSettings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des préférences:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Réinitialiser les préférences
|
||||||
|
resetPreferences() {
|
||||||
|
this.readingMode = 'single';
|
||||||
|
this.readingDirection = 'ltr';
|
||||||
|
this.zoom = 1;
|
||||||
|
this.doublePageSettings = {
|
||||||
|
autoDetect: true,
|
||||||
|
mobileMode: 'rotate',
|
||||||
|
detectionThreshold: 1.2
|
||||||
|
};
|
||||||
|
this.savePreferences();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
34
assets/vue/app/domain/reader/domain/entities/Chapter.js
Normal file
34
assets/vue/app/domain/reader/domain/entities/Chapter.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export class Chapter {
|
||||||
|
constructor({ id, mangaId, number, title, pages = [], read = false, lastReadPage = 0, navigation = {} }) {
|
||||||
|
this.id = id;
|
||||||
|
this.mangaId = mangaId;
|
||||||
|
this.number = number;
|
||||||
|
this.title = title;
|
||||||
|
this.pages = pages;
|
||||||
|
this.read = read;
|
||||||
|
this.lastReadPage = lastReadPage;
|
||||||
|
this.navigation = {
|
||||||
|
previousChapter: navigation.previousChapter || null,
|
||||||
|
nextChapter: navigation.nextChapter || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(data) {
|
||||||
|
return new Chapter(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
markAsRead() {
|
||||||
|
this.read = true;
|
||||||
|
this.lastReadPage = this.pages.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
markAsUnread() {
|
||||||
|
this.read = false;
|
||||||
|
this.lastReadPage = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLastReadPage(pageNumber) {
|
||||||
|
this.lastReadPage = pageNumber;
|
||||||
|
this.read = pageNumber === this.pages.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export class ChapterRepositoryInterface {
|
||||||
|
/**
|
||||||
|
* Récupère les informations d'un chapitre
|
||||||
|
* @param {string} chapterId - L'identifiant du chapitre
|
||||||
|
* @returns {Promise<Object>} Les informations du chapitre
|
||||||
|
*/
|
||||||
|
async getChapter(chapterId) {
|
||||||
|
throw new Error('Method not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la liste des pages d'un chapitre
|
||||||
|
* @param {string} chapterId - L'identifiant du chapitre
|
||||||
|
* @param {number} page - Le numéro de page
|
||||||
|
* @param {number} itemsPerPage - Le nombre d'éléments par page
|
||||||
|
* @returns {Promise<Object>} La liste des pages avec leurs métadonnées
|
||||||
|
*/
|
||||||
|
async getChapterPages(chapterId, page = 1, itemsPerPage = 20) {
|
||||||
|
throw new Error('Method not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère une page spécifique d'un chapitre
|
||||||
|
* @param {string} chapterId - L'identifiant du chapitre
|
||||||
|
* @param {number} pageNumber - Le numéro de la page
|
||||||
|
* @returns {Promise<Object>} Les données de la page
|
||||||
|
*/
|
||||||
|
async getChapterPage(chapterId, pageNumber) {
|
||||||
|
throw new Error('Method not implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ChapterRepositoryInterface } from '../../domain/repository/ChapterRepositoryInterface';
|
||||||
|
|
||||||
|
export class ApiChapterRepository extends ChapterRepositoryInterface {
|
||||||
|
async getChapter(chapterId) {
|
||||||
|
const response = await fetch(`/api/reader/chapter/${chapterId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch chapter');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChapterPages(chapterId, page = 1, itemsPerPage = 20) {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/reader/chapter/${chapterId}/pages?page=${page}&itemsPerPage=${itemsPerPage}`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch chapter pages');
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chapter-navigation">
|
||||||
|
<button
|
||||||
|
v-if="hasPreviousChapter"
|
||||||
|
@click="goToPreviousChapter"
|
||||||
|
class="nav-button nav-button-previous"
|
||||||
|
:disabled="isLoading"
|
||||||
|
title="Chapitre précédent"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 mr-2" 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>
|
||||||
|
<span class="hidden sm:inline">Chapitre précédent</span>
|
||||||
|
<span class="sm:hidden">Précédent</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasNextChapter"
|
||||||
|
@click="goToNextChapter"
|
||||||
|
class="nav-button nav-button-next"
|
||||||
|
:disabled="isLoading"
|
||||||
|
title="Chapitre suivant"
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">Chapitre suivant</span>
|
||||||
|
<span class="sm:hidden">Suivant</span>
|
||||||
|
<svg class="w-5 h-5 ml-2" 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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useReaderStore } from '../../application/store/readerStore';
|
||||||
|
|
||||||
|
const store = useReaderStore();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
position: {
|
||||||
|
type: String,
|
||||||
|
default: 'top', // 'top' ou 'bottom'
|
||||||
|
validator: (value) => ['top', 'bottom'].includes(value)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasPreviousChapter = computed(() => store.hasPreviousChapter);
|
||||||
|
const hasNextChapter = computed(() => store.hasNextChapter);
|
||||||
|
const isLoading = computed(() => store.isLoading);
|
||||||
|
|
||||||
|
const goToPreviousChapter = async () => {
|
||||||
|
await store.goToPreviousChapter();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextChapter = async () => {
|
||||||
|
await store.goToNextChapter();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.chapter-navigation {
|
||||||
|
@apply flex items-center justify-between w-full px-4 py-3;
|
||||||
|
@apply bg-gray-800/80 backdrop-blur-sm border border-gray-700/50;
|
||||||
|
@apply rounded-lg shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
@apply flex items-center px-4 py-2;
|
||||||
|
@apply bg-blue-600 hover:bg-blue-700 text-white;
|
||||||
|
@apply rounded-md transition-all duration-200;
|
||||||
|
@apply disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
|
||||||
|
@apply font-medium text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:disabled {
|
||||||
|
@apply hover:bg-blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button-previous {
|
||||||
|
@apply mr-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button-next {
|
||||||
|
@apply ml-auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chapter-reader" :class="{ rtl: store.readingDirection === 'rtl' }">
|
||||||
|
<div v-if="store.isLoading" class="loading">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="store.error" class="error">
|
||||||
|
{{ store.error }}
|
||||||
|
</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"
|
||||||
|
:page-number="store.currentPage + 1"
|
||||||
|
:zoom="store.zoom"
|
||||||
|
:double-page-mode="store.effectiveDoublePageMode"
|
||||||
|
@button-click="showButtonsWithTimer" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<InfiniteReader
|
||||||
|
:pages="store.pages"
|
||||||
|
:zoom="store.zoom"
|
||||||
|
:double-page-mode="store.effectiveDoublePageMode"
|
||||||
|
@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>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||||
|
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({
|
||||||
|
chapterId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
availableChapters: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useReaderStore();
|
||||||
|
const headerStore = useHeaderStore();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
} else {
|
||||||
|
// En mode infini : utiliser la logique d'InfiniteReader
|
||||||
|
showButtonsWithTimer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleReadingDirection = () => {
|
||||||
|
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
|
||||||
|
resetButtonsTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomIn = () => {
|
||||||
|
store.setZoom(Math.min(store.zoom + 0.1, 2));
|
||||||
|
resetButtonsTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomOut = () => {
|
||||||
|
store.setZoom(Math.max(store.zoom - 0.1, 0.5));
|
||||||
|
resetButtonsTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomChange = (zoom) => {
|
||||||
|
store.setZoom(zoom);
|
||||||
|
resetButtonsTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
if (newId) {
|
||||||
|
store.loadChapter(newId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Charger les préférences sauvegardées
|
||||||
|
store.loadPreferences();
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyPress);
|
||||||
|
|
||||||
|
// Afficher les boutons au démarrage
|
||||||
|
showButtonsWithTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
@apply flex items-center justify-center h-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
@apply text-red-500 text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content {
|
||||||
|
@apply w-full h-full flex flex-col;
|
||||||
|
@apply p-0 sm:p-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rtl {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chapter-selector">
|
||||||
|
<label for="chapter-select" class="sr-only">Sélectionner un chapitre</label>
|
||||||
|
<select
|
||||||
|
id="chapter-select"
|
||||||
|
v-model="selectedChapterId"
|
||||||
|
@change="handleChapterChange"
|
||||||
|
class="chapter-select"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="chapter in availableChapters"
|
||||||
|
:key="chapter.id"
|
||||||
|
:value="chapter.id"
|
||||||
|
:selected="chapter.id === currentChapterId"
|
||||||
|
>
|
||||||
|
Chapitre {{ chapter.number }} - {{ chapter.title }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useReaderStore } from '../../application/store/readerStore';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
availableChapters: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['chapter-selected']);
|
||||||
|
|
||||||
|
const store = useReaderStore();
|
||||||
|
|
||||||
|
const selectedChapterId = ref(null);
|
||||||
|
|
||||||
|
const currentChapterId = computed(() => store.currentChapter?.id);
|
||||||
|
const isLoading = computed(() => store.isLoading);
|
||||||
|
|
||||||
|
// Synchroniser la sélection avec le chapitre actuel
|
||||||
|
watch(currentChapterId, (newId) => {
|
||||||
|
selectedChapterId.value = newId;
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Gérer le changement de chapitre
|
||||||
|
const handleChapterChange = async () => {
|
||||||
|
if (selectedChapterId.value && selectedChapterId.value !== currentChapterId.value) {
|
||||||
|
await store.loadChapter(selectedChapterId.value);
|
||||||
|
emit('chapter-selected', selectedChapterId.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.chapter-selector {
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-select {
|
||||||
|
@apply w-full px-3 py-2 text-sm;
|
||||||
|
@apply bg-gray-800 text-white border border-gray-600;
|
||||||
|
@apply rounded-md shadow-sm;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
|
||||||
|
@apply disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
@apply transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-select:hover:not(:disabled) {
|
||||||
|
@apply border-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style personnalisé pour la flèche du select */
|
||||||
|
.chapter-select {
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||||
|
background-position: right 0.5rem center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 1.5em 1.5em;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
<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">
|
||||||
|
<div v-if="page?.loading" 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" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton flottant pour revenir en haut -->
|
||||||
|
<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="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"
|
||||||
|
title="Revenir en haut"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<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="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||||
|
import ChapterNavigation from './ChapterNavigation.vue';
|
||||||
|
import ReaderPage from './ReaderPage.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
pages: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
doublePageMode: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['pageVisible', 'buttonsVisibilityChange']);
|
||||||
|
|
||||||
|
const headerStore = useHeaderStore();
|
||||||
|
const containerRef = ref(null);
|
||||||
|
const observer = ref(null);
|
||||||
|
const windowWidth = ref(window.innerWidth);
|
||||||
|
|
||||||
|
// État unique pour tous les boutons flottants avec timer de 3 secondes
|
||||||
|
const showFloatingButtons = ref(false);
|
||||||
|
let buttonsTimer = null;
|
||||||
|
|
||||||
|
// Variables pour détecter la direction du scroll
|
||||||
|
let lastScrollTop = 0;
|
||||||
|
let scrollDirection = 'down';
|
||||||
|
|
||||||
|
const observeIntersection = entries => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const pageIndex = parseInt(entry.target.getAttribute('data-page-index'));
|
||||||
|
emit('pageVisible', pageIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupIntersectionObserver = () => {
|
||||||
|
if (observer.value) {
|
||||||
|
observer.value.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
observer.value = new IntersectionObserver(observeIntersection, {
|
||||||
|
root: null,
|
||||||
|
threshold: 0.5
|
||||||
|
});
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
|
||||||
|
if (pageElements) {
|
||||||
|
pageElements.forEach((element, index) => {
|
||||||
|
element.setAttribute('data-page-index', index);
|
||||||
|
observer.value.observe(element);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction unique pour gérer la visibilité de tous les boutons flottants
|
||||||
|
const showButtonsWithTimer = () => {
|
||||||
|
showFloatingButtons.value = true;
|
||||||
|
emit('buttonsVisibilityChange', true);
|
||||||
|
// Réinitialiser le timer à chaque fois
|
||||||
|
clearTimeout(buttonsTimer);
|
||||||
|
buttonsTimer = setTimeout(() => {
|
||||||
|
showFloatingButtons.value = false;
|
||||||
|
emit('buttonsVisibilityChange', false);
|
||||||
|
}, 3000); // 3 secondes
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideButtonsImmediately = () => {
|
||||||
|
showFloatingButtons.value = false;
|
||||||
|
emit('buttonsVisibilityChange', false);
|
||||||
|
clearTimeout(buttonsTimer);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction exposée pour réinitialiser le timer depuis l'extérieur
|
||||||
|
const resetButtonsTimer = () => {
|
||||||
|
if (showFloatingButtons.value) {
|
||||||
|
clearTimeout(buttonsTimer);
|
||||||
|
buttonsTimer = setTimeout(() => {
|
||||||
|
showFloatingButtons.value = false;
|
||||||
|
emit('buttonsVisibilityChange', false);
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
showButtonsWithTimer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gestion du scroll pour tous les boutons flottants et le header
|
||||||
|
const handleScroll = () => {
|
||||||
|
let scrollTop = 0;
|
||||||
|
|
||||||
|
// Vérifier le scroll sur le conteneur direct
|
||||||
|
if (containerRef.value && containerRef.value.scrollTop > 0) {
|
||||||
|
scrollTop = containerRef.value.scrollTop;
|
||||||
|
} else {
|
||||||
|
// Vérifier le scroll sur les conteneurs parents
|
||||||
|
let currentElement = containerRef.value?.parentElement;
|
||||||
|
while (currentElement && scrollTop === 0) {
|
||||||
|
if (currentElement.scrollTop > 0) {
|
||||||
|
scrollTop = currentElement.scrollTop;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentElement = currentElement.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le scroll sur la fenêtre
|
||||||
|
if (scrollTop === 0) {
|
||||||
|
scrollTop = window.scrollY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détecter la direction du scroll
|
||||||
|
if (scrollTop > lastScrollTop) {
|
||||||
|
scrollDirection = 'down';
|
||||||
|
} else if (scrollTop < lastScrollTop) {
|
||||||
|
scrollDirection = 'up';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion du header auto-hide (seulement si largeur < 1200px)
|
||||||
|
if (windowWidth.value < 1200) {
|
||||||
|
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
|
||||||
|
if (scrollTop > 300) {
|
||||||
|
showButtonsWithTimer();
|
||||||
|
} else if (scrollTop <= 100) {
|
||||||
|
// Masquer immédiatement si on est en haut de page
|
||||||
|
hideButtonsImmediately();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sauvegarder la position actuelle pour la prochaine comparaison
|
||||||
|
lastScrollTop = scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stratégie 2: Chercher le conteneur parent avec scroll
|
||||||
|
let currentElement = containerRef.value?.parentElement;
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentElement = currentElement.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exposer la fonction pour le parent
|
||||||
|
defineExpose({
|
||||||
|
resetButtonsTimer,
|
||||||
|
showButtonsWithTimer
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.pages,
|
||||||
|
() => {
|
||||||
|
setupIntersectionObserver();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gestion du redimensionnement de la fenêtre
|
||||||
|
const handleResize = () => {
|
||||||
|
const newWidth = window.innerWidth;
|
||||||
|
windowWidth.value = newWidth;
|
||||||
|
|
||||||
|
// Activer/désactiver l'auto-hide selon la largeur
|
||||||
|
if (newWidth < 1200) {
|
||||||
|
headerStore.enableAutoHide();
|
||||||
|
} else {
|
||||||
|
headerStore.disableAutoHide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupIntersectionObserver();
|
||||||
|
|
||||||
|
// Activer l'auto-hide du header si la largeur < 1200px
|
||||||
|
if (windowWidth.value < 1200) {
|
||||||
|
headerStore.enableAutoHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter l'écouteur de scroll sur le conteneur
|
||||||
|
if (containerRef.value) {
|
||||||
|
containerRef.value.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter l'écouteur de scroll sur la fenêtre
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
// Ajouter l'écouteur de redimensionnement
|
||||||
|
window.addEventListener('resize', handleResize, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (observer.value) {
|
||||||
|
observer.value.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Désactiver l'auto-hide du header en quittant
|
||||||
|
headerStore.disableAutoHide();
|
||||||
|
|
||||||
|
// Nettoyer les timers
|
||||||
|
clearTimeout(buttonsTimer);
|
||||||
|
|
||||||
|
// Nettoyer l'écouteur de scroll du conteneur
|
||||||
|
if (containerRef.value) {
|
||||||
|
containerRef.value.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nettoyer l'écouteur de scroll de la fenêtre
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
// Nettoyer l'écouteur de redimensionnement
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.infinite-reader {
|
||||||
|
@apply flex-1 flex flex-col items-center overflow-y-auto relative;
|
||||||
|
/* 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 mb-2 sm:mb-4 px-1 sm:px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
@apply flex items-center justify-center min-h-[400px];
|
||||||
|
/* Largeur adaptative selon la taille d'écran */
|
||||||
|
width: 95vw; /* Mobile : 95% de la largeur */
|
||||||
|
}
|
||||||
|
|
||||||
|
@screen sm {
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
width: 80vw; /* Tablette : 80% de la largeur */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@screen lg {
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
width: 70vw; /* Desktop : 70% de la largeur */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
@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>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div class="reader-controls">
|
||||||
|
<button @click="onPrevious" :disabled="isFirstPage" class="nav-button">
|
||||||
|
<ChevronLeftIcon class="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="controls-center">
|
||||||
|
<div class="page-info"> {{ currentPage + 1 }} / {{ totalPages }} </div>
|
||||||
|
<div class="chapter-selector-wrapper" v-if="availableChapters.length > 0">
|
||||||
|
<ChapterSelector
|
||||||
|
:available-chapters="availableChapters"
|
||||||
|
@chapter-selected="onChapterSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls-right">
|
||||||
|
<!-- Bouton paramètres intégré -->
|
||||||
|
<button @click="onToggleSettings" class="settings-button" :class="{ 'active': settingsOpen }" title="Paramètres">
|
||||||
|
<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="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>
|
||||||
|
|
||||||
|
<button @click="onNext" :disabled="isLastPage" class="nav-button">
|
||||||
|
<ChevronRightIcon class="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import ChapterSelector from './ChapterSelector.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
currentPage: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
totalPages: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isFirstPage: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isLastPage: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
availableChapters: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
settingsOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['previous', 'next', 'chapter-selected', 'toggle-settings']);
|
||||||
|
|
||||||
|
const onPrevious = () => emit('previous');
|
||||||
|
const onNext = () => emit('next');
|
||||||
|
const onChapterSelected = (chapterId) => emit('chapter-selected', chapterId);
|
||||||
|
const onToggleSettings = () => emit('toggle-settings');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.reader-controls {
|
||||||
|
@apply flex items-center justify-between p-4 bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive pour reader-controls */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.reader-controls {
|
||||||
|
padding: 0.5rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-center {
|
||||||
|
@apply flex flex-col items-center space-y-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive pour controls-center */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.controls-center {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-right {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive pour controls-right */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.controls-right {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-selector-wrapper {
|
||||||
|
@apply min-w-[120px] max-w-[200px];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive pour chapter-selector-wrapper */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.chapter-selector-wrapper {
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 60vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
@apply px-4 py-2 bg-gray-700 rounded hover:bg-gray-600 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive pour nav-button */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.nav-button {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:disabled {
|
||||||
|
@apply opacity-50 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button {
|
||||||
|
@apply px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded transition-colors duration-200 flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive pour settings-button */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.settings-button {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button.active {
|
||||||
|
@apply bg-blue-600 hover:bg-blue-700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container" :style="{ transform: `scale(${zoom})` }">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Affichage spécial pour les doubles pages sur mobile -->
|
||||||
|
<div v-else-if="isDoublePage && isMobile && doublePageMode !== 'normal'" class="double-page-mobile">
|
||||||
|
<!-- Mode rotation automatique -->
|
||||||
|
<div v-if="doublePageMode === 'rotate'" class="double-page-rotated">
|
||||||
|
<img
|
||||||
|
:src="imageSource"
|
||||||
|
:alt="`Page ${pageNumber} (Double page)`"
|
||||||
|
class="page-image rotated"
|
||||||
|
:style="doublePageRotatedStyle"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
ref="imageRef" />
|
||||||
|
<div class="rotation-hint">
|
||||||
|
<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 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>
|
||||||
|
<span>Tournez votre appareil pour une meilleure lecture</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode défilement horizontal -->
|
||||||
|
<div v-else-if="doublePageMode === 'scroll'" class="double-page-scroll">
|
||||||
|
<div class="scroll-container" ref="scrollContainerRef">
|
||||||
|
<img
|
||||||
|
:src="imageSource"
|
||||||
|
:alt="`Page ${pageNumber} (Double page)`"
|
||||||
|
class="page-image scrollable"
|
||||||
|
:style="doublePageScrollStyle"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
ref="imageRef" />
|
||||||
|
</div>
|
||||||
|
<div class="scroll-hint">
|
||||||
|
<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>
|
||||||
|
<span>Glissez horizontalement (commence par la droite)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Affichage normal pour les pages simples, sur desktop, ou mode normal forcé -->
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="imageSource"
|
||||||
|
:alt="`Page ${pageNumber}`"
|
||||||
|
class="page-image"
|
||||||
|
:style="imageStyle"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
ref="imageRef" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useReaderStore } from '../../application/store/readerStore';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
pageData: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
pageNumber: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
doublePageMode: {
|
||||||
|
type: String,
|
||||||
|
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
||||||
|
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useReaderStore();
|
||||||
|
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 imageLoaded = ref(false);
|
||||||
|
|
||||||
|
const imageSource = computed(() => {
|
||||||
|
if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Détection des doubles pages basée sur le ratio largeur/hauteur et les dimensions API
|
||||||
|
const isDoublePage = computed(() => {
|
||||||
|
// Ne pas détecter si la détection automatique est désactivée
|
||||||
|
if (!store.doublePageSettings.autoDetect) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = store.doublePageSettings.detectionThreshold || 1.2;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
if (imageRef.value) {
|
||||||
|
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) {
|
||||||
|
nextTick(() => {
|
||||||
|
scrollContainerRef.value.scrollLeft = scrollContainerRef.value.scrollWidth - scrollContainerRef.value.clientWidth;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Réinitialiser les dimensions quand on change de page
|
||||||
|
const resetDimensions = () => {
|
||||||
|
naturalWidth.value = 0;
|
||||||
|
naturalHeight.value = 0;
|
||||||
|
imageLoaded.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watcher pour détecter les changements de page
|
||||||
|
watch(
|
||||||
|
() => props.pageData,
|
||||||
|
(newPageData, oldPageData) => {
|
||||||
|
// Réinitialiser les dimensions si c'est une nouvelle page
|
||||||
|
if (newPageData?.id !== oldPageData?.id) {
|
||||||
|
resetDimensions();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Watcher pour détecter les changements de numéro de page
|
||||||
|
watch(
|
||||||
|
() => props.pageNumber,
|
||||||
|
() => {
|
||||||
|
resetDimensions();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculer la largeur maximale en fonction de la largeur disponible
|
||||||
|
const maxWidth = computed(() => {
|
||||||
|
// Utiliser les dimensions API en priorité
|
||||||
|
let width = naturalWidth.value;
|
||||||
|
let height = naturalHeight.value;
|
||||||
|
|
||||||
|
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
||||||
|
width = props.pageData.dimensions.width;
|
||||||
|
height = props.pageData.dimensions.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!width || !height) return null;
|
||||||
|
|
||||||
|
const availableWidth = windowWidth.value;
|
||||||
|
|
||||||
|
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur
|
||||||
|
if (availableWidth < 1200) {
|
||||||
|
return Math.min(width, availableWidth * 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si la largeur disponible est >= 1200px : limiter à 1200px maximum
|
||||||
|
return Math.min(width, 1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageStyle = computed(() => {
|
||||||
|
if (!maxWidth.value) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${maxWidth.value}px`,
|
||||||
|
height: 'auto',
|
||||||
|
maxWidth: '100%'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Styles spéciaux pour les doubles pages
|
||||||
|
const doublePageRotatedStyle = computed(() => {
|
||||||
|
let width = naturalWidth.value;
|
||||||
|
let height = naturalHeight.value;
|
||||||
|
|
||||||
|
// Utiliser les dimensions API si disponibles
|
||||||
|
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
||||||
|
width = props.pageData.dimensions.width;
|
||||||
|
height = props.pageData.dimensions.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!width || !height) return {};
|
||||||
|
|
||||||
|
// En mode rotation : maximiser l'utilisation de l'espace
|
||||||
|
const availableWidth = windowWidth.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
|
||||||
|
// et la hauteur originale devient la largeur affichée
|
||||||
|
const rotatedWidth = height; // Hauteur de l'image devient largeur après rotation
|
||||||
|
const rotatedHeight = width; // Largeur de l'image devient hauteur après rotation
|
||||||
|
|
||||||
|
// Calculer le facteur d'échelle pour remplir l'écran
|
||||||
|
const scaleByWidth = availableWidth * 0.98 / rotatedWidth;
|
||||||
|
const scaleByHeight = availableHeight * 0.95 / rotatedHeight;
|
||||||
|
const scaleFactor = Math.min(scaleByWidth, scaleByHeight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${width * scaleFactor}px`,
|
||||||
|
height: `${height * scaleFactor}px`,
|
||||||
|
maxWidth: 'none',
|
||||||
|
maxHeight: 'none',
|
||||||
|
transform: 'rotate(90deg)',
|
||||||
|
transformOrigin: 'center center'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const doublePageScrollStyle = computed(() => {
|
||||||
|
let width = naturalWidth.value;
|
||||||
|
let height = naturalHeight.value;
|
||||||
|
|
||||||
|
// Utiliser les dimensions API si disponibles
|
||||||
|
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
||||||
|
width = props.pageData.dimensions.width;
|
||||||
|
height = props.pageData.dimensions.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!width || !height) return {};
|
||||||
|
|
||||||
|
// Mode scroll : remplir la hauteur, permettre le défilement horizontal
|
||||||
|
const availableHeight = window.innerHeight - 80; // Espace pour les contrôles
|
||||||
|
|
||||||
|
// Échelle basée sur la hauteur pour remplir l'écran verticalement
|
||||||
|
const scaleFactor = (availableHeight * 0.95) / height;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${width * scaleFactor}px`,
|
||||||
|
height: `${height * scaleFactor}px`,
|
||||||
|
maxWidth: 'none',
|
||||||
|
maxHeight: 'none',
|
||||||
|
minWidth: '150vw' // Assurer un débordement horizontal pour le scroll
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gestion du redimensionnement de la fenêtre
|
||||||
|
const handleResize = () => {
|
||||||
|
windowWidth.value = window.innerWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (imageRef.value && imageRef.value.complete) {
|
||||||
|
handleImageLoad();
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.page-container {
|
||||||
|
@apply flex-1 flex items-center justify-center overflow-hidden;
|
||||||
|
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 */
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour les doubles pages sur mobile */
|
||||||
|
.double-page-mobile {
|
||||||
|
@apply w-full h-full flex flex-col items-center justify-center relative;
|
||||||
|
/* Utiliser tout l'espace disponible */
|
||||||
|
min-height: 100vh;
|
||||||
|
min-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.double-page-rotated {
|
||||||
|
@apply relative flex items-center justify-center;
|
||||||
|
/* Espace pour la rotation - utiliser tout l'espace */
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.double-page-rotated .page-image.rotated {
|
||||||
|
@apply origin-center;
|
||||||
|
/* Animation fluide pour la rotation */
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
/* Permettre le débordement contrôlé */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.double-page-scroll {
|
||||||
|
@apply w-full h-full flex flex-col items-center;
|
||||||
|
/* Utiliser toute la hauteur disponible */
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container {
|
||||||
|
@apply overflow-x-auto overflow-y-hidden w-full flex items-center justify-start;
|
||||||
|
/* Utiliser toute la hauteur et largeur */
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
/* Barres de défilement personnalisées */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #4F46E5 #E5E7EB;
|
||||||
|
/* Défilement fluide */
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container::-webkit-scrollbar {
|
||||||
|
height: 12px; /* Plus visible sur mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container::-webkit-scrollbar-track {
|
||||||
|
@apply bg-gray-200 rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-blue-600 rounded hover:bg-blue-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.double-page-scroll .page-image.scrollable {
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
/* Centrer verticalement */
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hints pour guider l'utilisateur - ajuster la position */
|
||||||
|
.rotation-hint,
|
||||||
|
.scroll-hint {
|
||||||
|
@apply absolute top-8 left-4 bg-black bg-opacity-70 text-white px-3 py-2 rounded-lg text-sm flex items-center gap-2 z-10;
|
||||||
|
/* Animation de disparition */
|
||||||
|
animation: fadeOut 4s ease-in-out 2s forwards;
|
||||||
|
/* S'assurer qu'ils sont visibles par-dessus tout */
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
0%, 50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
@apply text-red-500 text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive ajustements */
|
||||||
|
@media (orientation: landscape) and (max-width: 768px) {
|
||||||
|
.double-page-rotated .page-image.rotated {
|
||||||
|
/* En mode paysage mobile, ajuster la rotation pour optimiser l'espace */
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotation-hint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* En paysage, utiliser le mode adaptatif automatiquement */
|
||||||
|
.double-page-rotated {
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spécifique pour les écrans très petits */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.rotation-hint,
|
||||||
|
.scroll-hint {
|
||||||
|
@apply text-xs px-2 py-1;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
<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"
|
||||||
|
enter-from-class="opacity-0 translate-y-4 scale-95"
|
||||||
|
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-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>
|
||||||
|
|
||||||
|
<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 -->
|
||||||
|
<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">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Doubles pages (Mobile)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Activation/désactivation -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<label class="setting-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="doublePageSettings.autoDetect"
|
||||||
|
@change="onDoublePageAutoDetectChange($event.target.checked)"
|
||||||
|
class="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Détection automatique</span>
|
||||||
|
</label>
|
||||||
|
<p class="setting-description">
|
||||||
|
Détecter et optimiser automatiquement l'affichage des doubles pages sur mobile
|
||||||
|
</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
|
||||||
|
:value="doublePageMode"
|
||||||
|
@change="onDoublePageModeChange($event.target.value)"
|
||||||
|
class="setting-select"
|
||||||
|
>
|
||||||
|
<option value="rotate">Rotation suggérée</option>
|
||||||
|
<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>
|
||||||
|
</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) }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="doublePageSettings.detectionThreshold"
|
||||||
|
@input="onDetectionThresholdChange($event.target.value)"
|
||||||
|
min="1.0"
|
||||||
|
max="2.5"
|
||||||
|
step="0.1"
|
||||||
|
class="setting-slider"
|
||||||
|
/>
|
||||||
|
<p class="setting-description">
|
||||||
|
Plus la valeur est faible, plus la détection est sensible (1.4 recommandé)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } 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'
|
||||||
|
},
|
||||||
|
doublePageSettings: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
autoDetect: true,
|
||||||
|
mobileOnly: true,
|
||||||
|
detectionThreshold: 1.4
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// Visibilité contrôlée par le parent
|
||||||
|
visible: {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'toggleReadingMode',
|
||||||
|
'toggleReadingDirection',
|
||||||
|
'zoomIn',
|
||||||
|
'zoomOut',
|
||||||
|
'zoomChange',
|
||||||
|
'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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 onResetPreferences = () => {
|
||||||
|
emit('resetPreferences');
|
||||||
|
emit('buttonClick');
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.reader-settings {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive pour settings-panel */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.settings-panel {
|
||||||
|
width: 90vw;
|
||||||
|
max-width: calc(100vw - 1rem);
|
||||||
|
right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
@apply flex items-center gap-2 text-white font-medium text-sm mb-2 cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-checkbox {
|
||||||
|
@apply w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-select {
|
||||||
|
@apply w-full bg-gray-700 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-slider {
|
||||||
|
@apply w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-slider::-webkit-slider-thumb {
|
||||||
|
@apply appearance-none w-4 h-4 bg-blue-600 rounded-full cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-slider::-moz-range-thumb {
|
||||||
|
@apply w-4 h-4 bg-blue-600 rounded-full cursor-pointer border-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
@apply text-gray-400 text-xs leading-relaxed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.setting-actions {
|
||||||
|
@apply flex gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
@apply flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.reset {
|
||||||
|
@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,231 @@
|
|||||||
|
<template>
|
||||||
|
<div class="single-mode-reader">
|
||||||
|
<!-- Zone cliquable pour navigation -->
|
||||||
|
<div class="page-navigation-wrapper" @click="handlePageClick">
|
||||||
|
<!-- Zone de navigation gauche (invisible) -->
|
||||||
|
<div
|
||||||
|
class="navigation-zone left-zone"
|
||||||
|
@click.stop="goToPrevious"
|
||||||
|
@mouseenter="showLeftHint"
|
||||||
|
@mouseleave="hideLeftHint"
|
||||||
|
title="Page précédente"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Page centrale -->
|
||||||
|
<div class="page-content">
|
||||||
|
<ReaderPage
|
||||||
|
:page-data="pageData"
|
||||||
|
:page-number="pageNumber"
|
||||||
|
:zoom="zoom"
|
||||||
|
:double-page-mode="doublePageMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zone de navigation droite (invisible) -->
|
||||||
|
<div
|
||||||
|
class="navigation-zone right-zone"
|
||||||
|
@click.stop="goToNext"
|
||||||
|
@mouseenter="showRightHint"
|
||||||
|
@mouseleave="hideRightHint"
|
||||||
|
title="Page suivante"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Indicateurs visuels de navigation -->
|
||||||
|
<div class="navigation-hints">
|
||||||
|
<div class="hint left-hint" v-if="canGoToPrevious && (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)">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useReaderStore } from '../../application/store/readerStore';
|
||||||
|
import ReaderPage from './ReaderPage.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
pageData: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
pageNumber: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
doublePageMode: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['buttonClick']);
|
||||||
|
|
||||||
|
const store = useReaderStore();
|
||||||
|
|
||||||
|
// État pour afficher les indicateurs de navigation
|
||||||
|
const showNavigationHints = ref(false);
|
||||||
|
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 canGoToNext = computed(() => {
|
||||||
|
return !store.isLastPage || store.hasNextChapter;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation vers la page/chapitre précédent
|
||||||
|
const goToPrevious = async () => {
|
||||||
|
if (!store.isFirstPage) {
|
||||||
|
// Page précédente dans le même chapitre
|
||||||
|
await store.previousPage();
|
||||||
|
} else if (store.hasPreviousChapter) {
|
||||||
|
// Chapitre précédent (le store gère automatiquement la navigation vers la dernière page)
|
||||||
|
await store.goToPreviousChapter();
|
||||||
|
}
|
||||||
|
showNavigationHints.value = true;
|
||||||
|
emit('buttonClick'); // Signaler l'interaction pour afficher les boutons
|
||||||
|
clearTimeout(hintTimeout);
|
||||||
|
hintTimeout = setTimeout(() => {
|
||||||
|
showNavigationHints.value = false;
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigation vers la page/chapitre suivant
|
||||||
|
const goToNext = async () => {
|
||||||
|
if (!store.isLastPage) {
|
||||||
|
// Page suivante dans le même chapitre
|
||||||
|
await store.nextPage();
|
||||||
|
} else if (store.hasNextChapter) {
|
||||||
|
// Première page du chapitre suivant
|
||||||
|
await store.goToNextChapter();
|
||||||
|
// Le store va charger le chapitre suivant et se positionner automatiquement à la première page
|
||||||
|
}
|
||||||
|
showNavigationHints.value = true;
|
||||||
|
emit('buttonClick'); // Signaler l'interaction pour afficher les boutons
|
||||||
|
clearTimeout(hintTimeout);
|
||||||
|
hintTimeout = setTimeout(() => {
|
||||||
|
showNavigationHints.value = false;
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gestion du clic général sur la page (fallback)
|
||||||
|
const handlePageClick = (event) => {
|
||||||
|
// Si le clic n'a pas été intercepté par les zones, on navigue vers la page suivante
|
||||||
|
goToNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gestion des hints au hover
|
||||||
|
const showLeftHint = () => {
|
||||||
|
showLeftHintHover.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideLeftHint = () => {
|
||||||
|
showLeftHintHover.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showRightHint = () => {
|
||||||
|
showRightHintHover.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideRightHint = () => {
|
||||||
|
showRightHintHover.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-navigation-wrapper {
|
||||||
|
@apply relative w-full h-full flex items-center justify-center 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation-zone {
|
||||||
|
@apply absolute top-0 bottom-0 z-10;
|
||||||
|
width: 33%; /* 1/3 de la largeur pour chaque zone */
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-zone {
|
||||||
|
@apply left-0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-zone {
|
||||||
|
@apply right-0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indicateurs visuels de navigation */
|
||||||
|
.navigation-hints {
|
||||||
|
@apply absolute inset-0 pointer-events-none z-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
@apply absolute top-1/2 transform -translate-y-1/2;
|
||||||
|
@apply bg-black/50 text-white p-2 rounded-full;
|
||||||
|
@apply transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-hint {
|
||||||
|
@apply left-4;
|
||||||
|
animation: slideInLeft 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-hint {
|
||||||
|
@apply right-4;
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInLeft {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50%) translateX(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-50%) translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50%) translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-50%) translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pas d'effet hover background - les flèches apparaissent à la place */
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reader-container">
|
||||||
|
<ChapterReader :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 ChapterReader from '../components/ChapterReader.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useReaderStore();
|
||||||
|
|
||||||
|
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 } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.chapter-page {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-title-section {
|
||||||
|
@apply space-y-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-meta {
|
||||||
|
@apply flex flex-wrap items-center gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-container {
|
||||||
|
@apply flex-1 overflow-hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
|
||||||
|
|
||||||
|
const contentSourceRepository = new ApiContentSourceRepository();
|
||||||
|
|
||||||
|
export const useContentSourceStore = defineStore('contentSource', {
|
||||||
|
state: () => ({
|
||||||
|
// Collection state
|
||||||
|
sources: [],
|
||||||
|
loadingSources: false,
|
||||||
|
sourcesError: null,
|
||||||
|
|
||||||
|
// Current source state
|
||||||
|
currentSource: null,
|
||||||
|
loadingCurrentSource: false,
|
||||||
|
currentSourceError: null,
|
||||||
|
|
||||||
|
// Create/Update state
|
||||||
|
saving: false,
|
||||||
|
saveError: null,
|
||||||
|
|
||||||
|
// Import/Export state
|
||||||
|
importing: false,
|
||||||
|
exporting: false,
|
||||||
|
importError: null,
|
||||||
|
exportError: null
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
getSourceById: (state) => (id) => {
|
||||||
|
return state.sources.find(source => source.id === id);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSourcesByType: (state) => (scrapingType) => {
|
||||||
|
return state.sources.filter(source => source.scrapingType === scrapingType);
|
||||||
|
},
|
||||||
|
|
||||||
|
htmlSources: (state) => {
|
||||||
|
return state.sources.filter(source => source.scrapingType === 'HTML');
|
||||||
|
},
|
||||||
|
|
||||||
|
javascriptSources: (state) => {
|
||||||
|
return state.sources.filter(source => source.scrapingType === 'Javascript');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// Load all sources
|
||||||
|
async loadSources() {
|
||||||
|
if (this.loadingSources) return;
|
||||||
|
|
||||||
|
this.loadingSources = true;
|
||||||
|
this.sourcesError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.sources = await contentSourceRepository.getAll();
|
||||||
|
} catch (error) {
|
||||||
|
this.sourcesError = error.message;
|
||||||
|
console.error('Erreur lors du chargement des sources:', error);
|
||||||
|
} finally {
|
||||||
|
this.loadingSources = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Load specific source by ID
|
||||||
|
async loadSource(id) {
|
||||||
|
if (this.loadingCurrentSource) return;
|
||||||
|
|
||||||
|
this.loadingCurrentSource = true;
|
||||||
|
this.currentSourceError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.currentSource = await contentSourceRepository.getById(id);
|
||||||
|
} catch (error) {
|
||||||
|
this.currentSourceError = error.message;
|
||||||
|
console.error('Erreur lors du chargement de la source:', error);
|
||||||
|
} finally {
|
||||||
|
this.loadingCurrentSource = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create new source
|
||||||
|
async createSource(sourceData) {
|
||||||
|
if (this.saving) return;
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
this.saveError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newSource = await contentSourceRepository.create(sourceData);
|
||||||
|
this.sources.push(newSource);
|
||||||
|
return newSource;
|
||||||
|
} catch (error) {
|
||||||
|
this.saveError = error.message;
|
||||||
|
console.error('Erreur lors de la création de la source:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update existing source
|
||||||
|
async updateSource(id, sourceData) {
|
||||||
|
if (this.saving) return;
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
this.saveError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedSource = await contentSourceRepository.update(id, sourceData);
|
||||||
|
|
||||||
|
// Update in sources array
|
||||||
|
const index = this.sources.findIndex(source => source.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.sources[index] = updatedSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current source if it's the same
|
||||||
|
if (this.currentSource && this.currentSource.id === id) {
|
||||||
|
this.currentSource = updatedSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedSource;
|
||||||
|
} catch (error) {
|
||||||
|
this.saveError = error.message;
|
||||||
|
console.error('Erreur lors de la mise à jour de la source:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Export sources
|
||||||
|
async exportSources() {
|
||||||
|
if (this.exporting) return;
|
||||||
|
|
||||||
|
this.exporting = true;
|
||||||
|
this.exportError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await contentSourceRepository.export();
|
||||||
|
} catch (error) {
|
||||||
|
this.exportError = error.message;
|
||||||
|
console.error('Erreur lors de l\'export:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.exporting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Import sources
|
||||||
|
async importSources(sourcesData) {
|
||||||
|
if (this.importing) return;
|
||||||
|
|
||||||
|
this.importing = true;
|
||||||
|
this.importError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await contentSourceRepository.import(sourcesData);
|
||||||
|
// Reload sources after import
|
||||||
|
await this.loadSources();
|
||||||
|
} catch (error) {
|
||||||
|
this.importError = error.message;
|
||||||
|
console.error('Erreur lors de l\'import:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.importing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear current source
|
||||||
|
clearCurrentSource() {
|
||||||
|
this.currentSource = null;
|
||||||
|
this.currentSourceError = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear errors
|
||||||
|
clearErrors() {
|
||||||
|
this.sourcesError = null;
|
||||||
|
this.currentSourceError = null;
|
||||||
|
this.saveError = null;
|
||||||
|
this.importError = null;
|
||||||
|
this.exportError = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Types de scraping disponibles
|
||||||
|
*/
|
||||||
|
export const SCRAPING_TYPES = {
|
||||||
|
HTML: 'html',
|
||||||
|
JAVASCRIPT: 'javascript'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orientations de lecture
|
||||||
|
*/
|
||||||
|
export const READING_ORIENTATIONS = {
|
||||||
|
VERTICAL: 'vertical',
|
||||||
|
HORIZONTAL: 'horizontal'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* États des sources
|
||||||
|
*/
|
||||||
|
export const SOURCE_STATUS = {
|
||||||
|
ACTIVE: 'active',
|
||||||
|
INACTIVE: 'inactive',
|
||||||
|
ERROR: 'error'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration par défaut pour une nouvelle source
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SOURCE_CONFIG = {
|
||||||
|
baseUrl: '',
|
||||||
|
chapterUrlFormat: '',
|
||||||
|
scrapingType: SCRAPING_TYPES.HTML,
|
||||||
|
imageSelector: '',
|
||||||
|
nextPageSelector: '',
|
||||||
|
chapterSelector: '',
|
||||||
|
token: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validateurs pour les champs
|
||||||
|
*/
|
||||||
|
export const VALIDATORS = {
|
||||||
|
URL_PATTERN: /^https?:\/\/.+/,
|
||||||
|
SELECTOR_PATTERN: /^[.#]?[\w\-\s.#\[\]=\"':()>+~,]+$/
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages d'erreur
|
||||||
|
*/
|
||||||
|
export const ERROR_MESSAGES = {
|
||||||
|
INVALID_URL: 'L\'URL doit commencer par http:// ou https://',
|
||||||
|
INVALID_SELECTOR: 'Le sélecteur CSS n\'est pas valide',
|
||||||
|
REQUIRED_FIELD: 'Ce champ est obligatoire',
|
||||||
|
CHAPTER_URL_FORMAT_REQUIRED: 'Le format d\'URL des chapitres est obligatoire'
|
||||||
|
};
|
||||||
116
assets/vue/app/domain/setting/domain/models/ContentSource.js
Normal file
116
assets/vue/app/domain/setting/domain/models/ContentSource.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Modèle représentant une source de contenu pour le scraping
|
||||||
|
*/
|
||||||
|
export class ContentSource {
|
||||||
|
constructor({
|
||||||
|
id = null,
|
||||||
|
baseUrl = '',
|
||||||
|
chapterUrlFormat = '',
|
||||||
|
scrapingType = 'HTML',
|
||||||
|
imageSelector = null,
|
||||||
|
nextPageSelector = null,
|
||||||
|
chapterSelector = null,
|
||||||
|
cleanBaseUrl = '',
|
||||||
|
token = null
|
||||||
|
} = {}) {
|
||||||
|
this.id = id;
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.chapterUrlFormat = chapterUrlFormat;
|
||||||
|
this.scrapingType = scrapingType;
|
||||||
|
this.imageSelector = imageSelector;
|
||||||
|
this.nextPageSelector = nextPageSelector;
|
||||||
|
this.chapterSelector = chapterSelector;
|
||||||
|
this.cleanBaseUrl = cleanBaseUrl || this.extractCleanBaseUrl(baseUrl);
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait une URL propre à partir de l'URL de base
|
||||||
|
*/
|
||||||
|
extractCleanBaseUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return urlObj.hostname;
|
||||||
|
} catch (error) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si la source est valide
|
||||||
|
*/
|
||||||
|
isValid() {
|
||||||
|
return !!(this.baseUrl && this.chapterUrlFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si la source est de type JavaScript
|
||||||
|
*/
|
||||||
|
isJavascriptSource() {
|
||||||
|
return this.scrapingType === 'Javascript';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si la source est de type HTML
|
||||||
|
*/
|
||||||
|
isHtmlSource() {
|
||||||
|
return this.scrapingType === 'HTML';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine l'orientation basée sur les sélecteurs
|
||||||
|
*/
|
||||||
|
getOrientation() {
|
||||||
|
return this.nextPageSelector ? 'vertical' : 'horizontal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère une URL de chapitre basée sur le format
|
||||||
|
*/
|
||||||
|
generateChapterUrl(slug, chapterNumber) {
|
||||||
|
return this.chapterUrlFormat
|
||||||
|
.replace('{slug}', slug)
|
||||||
|
.replace('{chapterNumber}', chapterNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit l'objet en format API
|
||||||
|
*/
|
||||||
|
toApiFormat() {
|
||||||
|
return {
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
chapterUrlFormat: this.chapterUrlFormat,
|
||||||
|
scrapingType: this.scrapingType,
|
||||||
|
imageSelector: this.imageSelector,
|
||||||
|
nextPageSelector: this.nextPageSelector,
|
||||||
|
chapterSelector: this.chapterSelector,
|
||||||
|
token: this.token
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une instance depuis les données API
|
||||||
|
*/
|
||||||
|
static fromApiData(data) {
|
||||||
|
return new ContentSource(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone l'instance
|
||||||
|
*/
|
||||||
|
clone() {
|
||||||
|
return new ContentSource({
|
||||||
|
id: this.id,
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
chapterUrlFormat: this.chapterUrlFormat,
|
||||||
|
scrapingType: this.scrapingType,
|
||||||
|
imageSelector: this.imageSelector,
|
||||||
|
nextPageSelector: this.nextPageSelector,
|
||||||
|
chapterSelector: this.chapterSelector,
|
||||||
|
cleanBaseUrl: this.cleanBaseUrl,
|
||||||
|
token: this.token
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export class ApiContentSourceRepository {
|
||||||
|
constructor() {
|
||||||
|
this.apiClient = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère toutes les sources de contenu
|
||||||
|
*/
|
||||||
|
async getAll() {
|
||||||
|
try {
|
||||||
|
const response = await this.apiClient.get('/content-sources');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors de la récupération des sources');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère une source de contenu par son ID
|
||||||
|
*/
|
||||||
|
async getById(id) {
|
||||||
|
try {
|
||||||
|
const response = await this.apiClient.get(`/content-sources/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors de la récupération de la source');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une nouvelle source de contenu
|
||||||
|
*/
|
||||||
|
async create(contentSource) {
|
||||||
|
try {
|
||||||
|
const response = await this.apiClient.post('/content-sources', contentSource);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors de la création de la source');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour une source de contenu
|
||||||
|
*/
|
||||||
|
async update(id, contentSource) {
|
||||||
|
try {
|
||||||
|
const response = await this.apiClient.put(`/content-sources/${id}`, contentSource);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors de la mise à jour de la source');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporte toutes les sources de contenu
|
||||||
|
*/
|
||||||
|
async export() {
|
||||||
|
try {
|
||||||
|
const response = await this.apiClient.get('/content-sources/export');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors de l\'export des sources');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importe des sources de contenu
|
||||||
|
*/
|
||||||
|
async import(contentSources) {
|
||||||
|
try {
|
||||||
|
const response = await this.apiClient.post('/content-sources/import', contentSources);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors de l\'import des sources');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teste une configuration de scraper
|
||||||
|
*/
|
||||||
|
async testConfiguration(configuration) {
|
||||||
|
try {
|
||||||
|
const response = await this.apiClient.post('/scraping/test-configuration', configuration);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
// Gestion spécifique des erreurs de validation
|
||||||
|
if (error.response?.status === 422) {
|
||||||
|
const validationErrors = error.response.data?.violations || [];
|
||||||
|
const errorMessage = validationErrors.map(violation =>
|
||||||
|
`${violation.propertyPath}: ${violation.message}`
|
||||||
|
).join(', ') || 'Erreur de validation';
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
throw new Error(error.response?.data?.message || 'Erreur lors du test de la configuration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<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">
|
||||||
|
<!-- 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">
|
||||||
|
{{ truncateUrl(source.cleanBaseUrl) }}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click.stop="$emit('openLink', source.baseUrl)"
|
||||||
|
class="p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
title="Ouvrir le site">
|
||||||
|
<ArrowTopRightOnSquareIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<!-- Badge type de scraping -->
|
||||||
|
<span
|
||||||
|
:class="getScrapingTypeBadgeClass(source.scrapingType)"
|
||||||
|
class="px-2 py-1 text-xs font-medium rounded-md">
|
||||||
|
{{ 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">
|
||||||
|
{{ getOrientation(source) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
source: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['edit', 'openLink']);
|
||||||
|
|
||||||
|
// Fonction pour tronquer l'URL si elle est trop longue
|
||||||
|
const truncateUrl = (url) => {
|
||||||
|
if (!url) return '';
|
||||||
|
const maxLength = 25; // Ajustez selon vos besoins
|
||||||
|
return url.length > maxLength ? url.substring(0, maxLength) + '...' : url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScrapingTypeBadgeClass = (type) => {
|
||||||
|
switch (type?.toLowerCase()) {
|
||||||
|
case 'html':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||||
|
case 'javascript':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrientation = (source) => {
|
||||||
|
// Logic pour déterminer l'orientation basée sur les sélecteurs ou autre logique métier
|
||||||
|
if (source.nextPageSelector) {
|
||||||
|
return 'vertical';
|
||||||
|
}
|
||||||
|
return 'horizontal';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrientationBadgeClass = (source) => {
|
||||||
|
const orientation = getOrientation(source);
|
||||||
|
switch (orientation) {
|
||||||
|
case 'vertical':
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
|
||||||
|
case 'horizontal':
|
||||||
|
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600 rounded-t-lg">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Cog6ToothIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ isEditing ? 'Edit Scrapper Configuration' : 'New Scrapper Configuration' }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form @submit.prevent="handleSubmit" class="p-6 space-y-6">
|
||||||
|
<!-- Base URL -->
|
||||||
|
<div>
|
||||||
|
<label for="baseUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Base URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="baseUrl"
|
||||||
|
v-model="form.baseUrl"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="https://example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Selector -->
|
||||||
|
<div>
|
||||||
|
<label for="imageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Image Selector
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="imageSelector"
|
||||||
|
v-model="form.imageSelector"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder=".reading-content .page-break img" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapter URL Format -->
|
||||||
|
<div>
|
||||||
|
<label for="chapterUrlFormat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Chapter URL Format <span class="text-gray-500">({slug}, {chapterNumber})</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="chapterUrlFormat"
|
||||||
|
v-model="form.chapterUrlFormat"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="https://example.com/manga/{slug}-{chapterNumber}/" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next Page Selector -->
|
||||||
|
<div>
|
||||||
|
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Next Page Selector <span class="text-gray-500">(let empty if vertical reader)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="nextPageSelector"
|
||||||
|
v-model="form.nextPageSelector"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder=".next-page" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapter Selector -->
|
||||||
|
<div>
|
||||||
|
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Chapter Selector <span class="text-gray-500">(required for Javascript scraping)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="chapterSelector"
|
||||||
|
v-model="form.chapterSelector"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder=".chapter-selector" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scraping Type -->
|
||||||
|
<div>
|
||||||
|
<label for="scrapingType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Scraping Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="scrapingType"
|
||||||
|
v-model="form.scrapingType"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white">
|
||||||
|
<option value="html">HTML</option>
|
||||||
|
<option value="javascript">Javascript</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token (optionnel) -->
|
||||||
|
<div>
|
||||||
|
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Token
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="token"
|
||||||
|
v-model="form.token"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Optional authentication token" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="saving"
|
||||||
|
class="px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center space-x-2">
|
||||||
|
<ArrowPathIcon v-if="saving" class="w-4 h-4 animate-spin" />
|
||||||
|
<span>{{ isEditing ? 'Update Configuration' : 'Create Configuration' }}</span>
|
||||||
|
<PencilSquareIcon v-if="!saving" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<div v-if="error" class="text-red-600 dark:text-red-400 text-sm">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Test Configuration Section -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-600 p-6 bg-gray-50 dark:bg-gray-700 rounded-b-lg">
|
||||||
|
<div class="flex items-center space-x-2 mb-4">
|
||||||
|
<WrenchScrewdriverIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Test Configuration</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label for="testMangaSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Manga Slug
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="testMangaSlug"
|
||||||
|
v-model="testData.mangaSlug"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="manga-slug" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="testChapterNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Chapter Number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="testChapterNumber"
|
||||||
|
v-model="testData.chapterNumber"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview de l'URL qui sera testée -->
|
||||||
|
<div v-if="generatedTestUrl" class="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md">
|
||||||
|
<div class="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<strong>URL qui sera testée :</strong>
|
||||||
|
<div class="mt-1 font-mono text-xs break-all">{{ generatedTestUrl }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="testConfiguration"
|
||||||
|
:disabled="testing || !canTest"
|
||||||
|
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center justify-center space-x-2">
|
||||||
|
<ArrowPathIcon v-if="testing" class="w-4 h-4 animate-spin" />
|
||||||
|
<PlayIcon v-else class="w-4 h-4" />
|
||||||
|
<span>Test Configuration</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ArrowPathIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
PlayIcon,
|
||||||
|
WrenchScrewdriverIcon
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
source: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
saving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit', 'test']);
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.source);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
baseUrl: '',
|
||||||
|
imageSelector: '',
|
||||||
|
chapterUrlFormat: '',
|
||||||
|
nextPageSelector: '',
|
||||||
|
chapterSelector: '',
|
||||||
|
scrapingType: 'html',
|
||||||
|
token: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const testData = ref({
|
||||||
|
mangaSlug: '',
|
||||||
|
chapterNumber: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const testing = ref(false);
|
||||||
|
|
||||||
|
const canTest = computed(() => {
|
||||||
|
return form.value.baseUrl &&
|
||||||
|
form.value.chapterUrlFormat &&
|
||||||
|
testData.value.mangaSlug &&
|
||||||
|
testData.value.chapterNumber;
|
||||||
|
});
|
||||||
|
|
||||||
|
const generatedTestUrl = computed(() => {
|
||||||
|
if (!form.value.chapterUrlFormat || !testData.value.mangaSlug || !testData.value.chapterNumber) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return form.value.chapterUrlFormat
|
||||||
|
.replace('{slug}', testData.value.mangaSlug)
|
||||||
|
.replace('{chapterNumber}', testData.value.chapterNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize form with source data if editing, clear if creating new
|
||||||
|
watch(() => props.source, (newSource) => {
|
||||||
|
if (newSource) {
|
||||||
|
form.value = {
|
||||||
|
baseUrl: newSource.baseUrl || '',
|
||||||
|
imageSelector: newSource.imageSelector || '',
|
||||||
|
chapterUrlFormat: newSource.chapterUrlFormat || '',
|
||||||
|
nextPageSelector: newSource.nextPageSelector || '',
|
||||||
|
chapterSelector: newSource.chapterSelector || '',
|
||||||
|
scrapingType: (newSource.scrapingType || 'html').toLowerCase(),
|
||||||
|
token: newSource.token || ''
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Reset form when no source (creating new)
|
||||||
|
form.value = {
|
||||||
|
baseUrl: '',
|
||||||
|
imageSelector: '',
|
||||||
|
chapterUrlFormat: '',
|
||||||
|
nextPageSelector: '',
|
||||||
|
chapterSelector: '',
|
||||||
|
scrapingType: 'html',
|
||||||
|
token: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit('submit', { ...form.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConfiguration = async () => {
|
||||||
|
testing.value = true;
|
||||||
|
try {
|
||||||
|
await emit('test', {
|
||||||
|
configuration: { ...form.value },
|
||||||
|
testData: {
|
||||||
|
...testData.value,
|
||||||
|
testUrl: generatedTestUrl.value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
testing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
||||||
|
|
||||||
|
export function useContentSources() {
|
||||||
|
const store = useContentSourceStore();
|
||||||
|
|
||||||
|
// Computed properties pour un accès facile aux données
|
||||||
|
const sources = computed(() => store.sources);
|
||||||
|
const currentSource = computed(() => store.currentSource);
|
||||||
|
const isLoading = computed(() => store.loadingSources || store.loadingCurrentSource);
|
||||||
|
const isSaving = computed(() => store.saving);
|
||||||
|
const hasError = computed(() => store.sourcesError || store.currentSourceError || store.saveError);
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const getSourceById = (id) => store.getSourceById(id);
|
||||||
|
const getSourcesByType = (type) => store.getSourcesByType(type);
|
||||||
|
const htmlSources = computed(() => store.htmlSources);
|
||||||
|
const javascriptSources = computed(() => store.javascriptSources);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const loadSources = () => store.loadSources();
|
||||||
|
const loadSource = (id) => store.loadSource(id);
|
||||||
|
const createSource = (data) => store.createSource(data);
|
||||||
|
const updateSource = (id, data) => store.updateSource(id, data);
|
||||||
|
const exportSources = () => store.exportSources();
|
||||||
|
const importSources = (data) => store.importSources(data);
|
||||||
|
const clearCurrentSource = () => store.clearCurrentSource();
|
||||||
|
const clearErrors = () => store.clearErrors();
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Data
|
||||||
|
sources,
|
||||||
|
currentSource,
|
||||||
|
|
||||||
|
// States
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
hasError,
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
getSourceById,
|
||||||
|
getSourcesByType,
|
||||||
|
htmlSources,
|
||||||
|
javascriptSources,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadSources,
|
||||||
|
loadSource,
|
||||||
|
createSource,
|
||||||
|
updateSource,
|
||||||
|
exportSources,
|
||||||
|
importSources,
|
||||||
|
clearCurrentSource,
|
||||||
|
clearErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Scrapper Configurations
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
Gérez les configurations de scraping pour les différentes sources de manga
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loadingSources" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="sourcesError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
||||||
|
<p class="text-red-800 dark:text-red-200">{{ sourcesError }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="contentSourceStore.loadSources()"
|
||||||
|
class="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug Info (temporary) -->
|
||||||
|
<div v-if="!loadingSources && !sourcesError && sources.length === 0" class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6">
|
||||||
|
<p class="text-blue-800 dark:text-blue-200">Aucune source trouvée. Rechargement en cours...</p>
|
||||||
|
<button
|
||||||
|
@click="contentSourceStore.loadSources()"
|
||||||
|
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sources Grid -->
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Existing Sources -->
|
||||||
|
<ContentSourceCard
|
||||||
|
v-for="source in sources"
|
||||||
|
:key="source.id"
|
||||||
|
:source="source"
|
||||||
|
@edit="editSource"
|
||||||
|
@open-link="openSourceLink" />
|
||||||
|
|
||||||
|
<!-- Add New Configuration Card -->
|
||||||
|
<div
|
||||||
|
@click="addNewSource"
|
||||||
|
class="bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer flex flex-col items-center justify-center h-full">
|
||||||
|
<PlusIcon class="w-8 h-8 text-gray-400 dark:text-gray-500 mb-3" />
|
||||||
|
<span class="text-lg font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Add New Configuration
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import/Export Success Messages -->
|
||||||
|
<div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||||
|
Configuration importée avec succès !
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showExportSuccess" class="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||||
|
Configuration exportée !
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Modal -->
|
||||||
|
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Importer des configurations</h3>
|
||||||
|
<textarea
|
||||||
|
v-model="importData"
|
||||||
|
class="w-full h-40 p-3 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Collez ici le JSON des configurations à importer..."></textarea>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 mt-4">
|
||||||
|
<button
|
||||||
|
@click="showImportModal = false"
|
||||||
|
class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleImport"
|
||||||
|
:disabled="importing || !importData.trim()"
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md">
|
||||||
|
{{ importing ? 'Import...' : 'Importer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
ArrowUpTrayIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
PlusIcon
|
||||||
|
} 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 { useContentSourceStore } from '../../application/store/contentSourceStore';
|
||||||
|
import ContentSourceCard from '../components/ContentSourceCard.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const contentSourceStore = useContentSourceStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
sources,
|
||||||
|
loadingSources,
|
||||||
|
sourcesError,
|
||||||
|
importing,
|
||||||
|
exporting
|
||||||
|
} = storeToRefs(contentSourceStore);
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const showImportModal = ref(false);
|
||||||
|
const showExportSuccess = ref(false);
|
||||||
|
const showImportSuccess = ref(false);
|
||||||
|
const importData = ref('');
|
||||||
|
|
||||||
|
// Load sources on mount and clear current source
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
contentSourceStore.clearCurrentSource(); // Clear any previously loaded source
|
||||||
|
contentSourceStore.clearErrors(); // Clear any previous errors
|
||||||
|
await contentSourceStore.loadSources();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des sources:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toolbar configuration
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
leftSection: [
|
||||||
|
{
|
||||||
|
icon: ArrowPathIcon,
|
||||||
|
label: 'Actualiser',
|
||||||
|
type: 'button',
|
||||||
|
onClick: () => contentSourceStore.loadSources(),
|
||||||
|
active: loadingSources.value
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rightSection: [
|
||||||
|
{
|
||||||
|
icon: ArrowDownTrayIcon,
|
||||||
|
label: 'Exporter',
|
||||||
|
type: 'button',
|
||||||
|
onClick: handleExport,
|
||||||
|
disabled: exporting.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ArrowUpTrayIcon,
|
||||||
|
label: 'Importer',
|
||||||
|
type: 'button',
|
||||||
|
onClick: () => showImportModal.value = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const editSource = (source) => {
|
||||||
|
router.push({
|
||||||
|
name: 'scrapper-edit',
|
||||||
|
params: { id: source.id }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNewSource = () => {
|
||||||
|
router.push({ name: 'scrapper-new' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSourceLink = (url) => {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
try {
|
||||||
|
const exportData = await contentSourceStore.exportSources();
|
||||||
|
|
||||||
|
// Create and download file
|
||||||
|
const dataStr = JSON.stringify(exportData, null, 2);
|
||||||
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(dataBlob);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `scrapper-configurations-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
showExportSuccess.value = true;
|
||||||
|
setTimeout(() => showExportSuccess.value = false, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'export:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(importData.value);
|
||||||
|
await contentSourceStore.importSources(data);
|
||||||
|
|
||||||
|
showImportModal.value = false;
|
||||||
|
importData.value = '';
|
||||||
|
showImportSuccess.value = true;
|
||||||
|
setTimeout(() => showImportSuccess.value = false, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'import:', error);
|
||||||
|
alert('Erreur: Format JSON invalide ou erreur serveur');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<!-- Back Navigation -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<button
|
||||||
|
@click="goBack"
|
||||||
|
class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
||||||
|
<ArrowLeftIcon class="w-5 h-5" />
|
||||||
|
<span>Retour aux configurations</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
||||||
|
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<div v-else class="max-w-4xl mx-auto">
|
||||||
|
<ContentSourceForm
|
||||||
|
:source="currentSource"
|
||||||
|
:saving="saving"
|
||||||
|
:error="saveError"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@test="handleTest" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Results Modal -->
|
||||||
|
<div v-if="showTestResults" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold">Résultats du test</h3>
|
||||||
|
<button
|
||||||
|
@click="showTestResults = false"
|
||||||
|
class="text-gray-400 hover:text-gray-600">
|
||||||
|
<XMarkIcon class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 overflow-y-auto">
|
||||||
|
<!-- Loading state during test -->
|
||||||
|
<div v-if="testingConfiguration" class="flex items-center justify-center py-8">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||||
|
<span class="text-gray-600">Test en cours...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Results -->
|
||||||
|
<div v-else-if="testResults.success" class="space-y-4">
|
||||||
|
<div class="flex items-center text-green-600 mb-4">
|
||||||
|
<CheckCircleIcon class="w-5 h-5 mr-2" />
|
||||||
|
<span class="font-medium">Test réussi !</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
|
||||||
|
<div class="text-green-700 dark:text-green-300 break-all">{{ testResults.testedUrl }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-green-800 dark:text-green-200">Type de scraping:</span>
|
||||||
|
<div class="text-green-700 dark:text-green-300">{{ testResults.scrapingType }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-green-800 dark:text-green-200">Images trouvées:</span>
|
||||||
|
<div class="text-green-700 dark:text-green-300">{{ testResults.totalImages || testResults.imageUrls?.length || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="testResults.imageUrls && testResults.imageUrls.length > 0">
|
||||||
|
<h4 class="font-medium mb-3">Aperçu des images trouvées :</h4>
|
||||||
|
<div class="grid grid-cols-3 gap-3 max-h-96 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="(imageUrl, index) in testResults.imageUrls.slice(0, 12)"
|
||||||
|
:key="index"
|
||||||
|
class="relative group">
|
||||||
|
<img
|
||||||
|
:src="imageUrl"
|
||||||
|
:alt="`Image ${index + 1}`"
|
||||||
|
class="w-full h-32 object-cover rounded border border-gray-200 dark:border-gray-600"
|
||||||
|
@error="handleImageError"
|
||||||
|
@load="handleImageLoad" />
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity rounded flex items-center justify-center">
|
||||||
|
<span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium">
|
||||||
|
Page {{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="testResults.imageUrls.length > 12" class="text-sm text-gray-500 mt-3 text-center">
|
||||||
|
Et {{ testResults.imageUrls.length - 12 }} autres images...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ExclamationTriangleIcon class="w-5 h-5 text-yellow-400 mr-2" />
|
||||||
|
<p class="text-yellow-800 dark:text-yellow-200">
|
||||||
|
Le test s'est déroulé sans erreur mais aucune image n'a été trouvée.
|
||||||
|
Vérifiez vos sélecteurs CSS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Results -->
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="flex items-center text-red-600 mb-4">
|
||||||
|
<XCircleIcon class="w-5 h-5 mr-2" />
|
||||||
|
<span class="font-medium">Test échoué</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-4">
|
||||||
|
<div class="text-sm text-red-800 dark:text-red-200">
|
||||||
|
<div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div>
|
||||||
|
<div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Errors -->
|
||||||
|
<div v-if="testResults.errors && testResults.errors.length > 0" class="space-y-3">
|
||||||
|
<h4 class="font-medium text-red-800 dark:text-red-200">Erreurs détaillées :</h4>
|
||||||
|
<div
|
||||||
|
v-for="(error, index) in testResults.errors"
|
||||||
|
:key="index"
|
||||||
|
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4 rounded">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<ExclamationTriangleIcon class="w-5 h-5 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1">
|
||||||
|
<div class="flex items-center mb-1">
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
|
||||||
|
{{ formatErrorType(error.type) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
|
{{ error.field }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300 mb-2">
|
||||||
|
{{ error.message }}
|
||||||
|
</p>
|
||||||
|
<div class="bg-red-50 dark:bg-red-900 rounded p-2">
|
||||||
|
<p class="text-xs text-red-600 dark:text-red-400">
|
||||||
|
<strong>Suggestion :</strong> {{ error.suggestion }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generic Error -->
|
||||||
|
<div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded p-3">
|
||||||
|
<code class="text-sm text-red-800 dark:text-red-200">
|
||||||
|
{{ testResults.error }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Message -->
|
||||||
|
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||||
|
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
XMarkIcon
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||||
|
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
||||||
|
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
|
||||||
|
import ContentSourceForm from '../components/ContentSourceForm.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const contentSourceStore = useContentSourceStore();
|
||||||
|
const contentSourceRepository = new ApiContentSourceRepository();
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentSource,
|
||||||
|
loadingCurrentSource,
|
||||||
|
currentSourceError,
|
||||||
|
saving,
|
||||||
|
saveError
|
||||||
|
} = storeToRefs(contentSourceStore);
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const showTestResults = ref(false);
|
||||||
|
const showSuccessMessage = ref(false);
|
||||||
|
const testResults = ref({});
|
||||||
|
const testingConfiguration = ref(false);
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!route.params.id);
|
||||||
|
|
||||||
|
// Load source if editing, clear if creating new
|
||||||
|
onMounted(async () => {
|
||||||
|
if (isEditing.value) {
|
||||||
|
await contentSourceStore.loadSource(route.params.id);
|
||||||
|
} else {
|
||||||
|
// Clear current source immediately when creating new
|
||||||
|
contentSourceStore.clearCurrentSource();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toolbar configuration
|
||||||
|
const toolbarConfig = {
|
||||||
|
leftSection: [],
|
||||||
|
rightSection: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const goBack = () => {
|
||||||
|
router.push({ name: 'scrapper-configurations' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (formData) => {
|
||||||
|
try {
|
||||||
|
if (isEditing.value) {
|
||||||
|
await contentSourceStore.updateSource(route.params.id, formData);
|
||||||
|
} else {
|
||||||
|
await contentSourceStore.createSource(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear current source and errors before redirecting
|
||||||
|
contentSourceStore.clearCurrentSource();
|
||||||
|
contentSourceStore.clearErrors();
|
||||||
|
|
||||||
|
// Show success message briefly then redirect
|
||||||
|
showSuccessMessage.value = true;
|
||||||
|
|
||||||
|
// Use nextTick to ensure the DOM is updated before redirecting
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
// Navigate back to list
|
||||||
|
await router.push({ name: 'scrapper-configurations' });
|
||||||
|
|
||||||
|
// Hide success message after navigation
|
||||||
|
showSuccessMessage.value = false;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la sauvegarde:', error);
|
||||||
|
// Don't redirect if there's an error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async ({ configuration, testData }) => {
|
||||||
|
testingConfiguration.value = true;
|
||||||
|
showTestResults.value = true;
|
||||||
|
testResults.value = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Préparer les données selon le format de l'API
|
||||||
|
const testConfiguration = {
|
||||||
|
baseUrl: configuration.baseUrl,
|
||||||
|
chapterUrlFormat: configuration.chapterUrlFormat,
|
||||||
|
scrapingType: configuration.scrapingType?.toLowerCase() || 'html',
|
||||||
|
testUrl: testData.testUrl,
|
||||||
|
mangaSlug: testData.mangaSlug,
|
||||||
|
chapterNumber: parseFloat(testData.chapterNumber),
|
||||||
|
imageSelector: configuration.imageSelector || null,
|
||||||
|
nextPageSelector: configuration.nextPageSelector || null,
|
||||||
|
chapterSelector: configuration.chapterSelector || null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Envoi de la configuration de test:', testConfiguration);
|
||||||
|
|
||||||
|
const response = await contentSourceRepository.testConfiguration(testConfiguration);
|
||||||
|
|
||||||
|
testResults.value = response;
|
||||||
|
console.log('Résultats du test:', response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du test:', error);
|
||||||
|
testResults.value = {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
testedUrl: testData.testUrl,
|
||||||
|
scrapingType: configuration.scrapingType?.toLowerCase() || 'html',
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
testingConfiguration.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageError = (event) => {
|
||||||
|
// Hide broken images
|
||||||
|
event.target.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageLoad = (event) => {
|
||||||
|
// Ensure loaded images are visible
|
||||||
|
event.target.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatErrorType = (type) => {
|
||||||
|
const typeMap = {
|
||||||
|
'selector_error': 'Erreur sélecteur',
|
||||||
|
'url_error': 'Erreur URL',
|
||||||
|
'general_error': 'Erreur générale'
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
18
assets/vue/app/index.js
Normal file
18
assets/vue/app/index.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import App from './App.vue';
|
||||||
|
import { router } from './router';
|
||||||
|
import '../../styles/app.scss';
|
||||||
|
import { installVueQuery } from './shared/plugin/vueQuery';
|
||||||
|
// Création du store
|
||||||
|
const pinia = createPinia();
|
||||||
|
|
||||||
|
// Création de l'application
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
// Installation des plugins
|
||||||
|
app.use(router);
|
||||||
|
app.use(pinia);
|
||||||
|
app.use(installVueQuery);
|
||||||
|
// Montage de l'application
|
||||||
|
app.mount('#vue-app');
|
||||||
173
assets/vue/app/router/index.js
Normal file
173
assets/vue/app/router/index.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
|
||||||
|
import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue';
|
||||||
|
import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue';
|
||||||
|
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
|
||||||
|
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
|
||||||
|
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
||||||
|
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
||||||
|
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
|
||||||
|
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
|
||||||
|
import Layout from '../shared/components/layout/Layout.vue';
|
||||||
|
|
||||||
|
// Placeholder component for new routes
|
||||||
|
const PlaceholderComponent = {
|
||||||
|
props: {
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<h1 class="text-2xl font-bold mb-4">{{ title }}</h1>
|
||||||
|
<p class="text-gray-600">Cette fonctionnalité sera bientôt disponible.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: Layout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'home',
|
||||||
|
redirect: '/manga'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/manga',
|
||||||
|
name: 'manga',
|
||||||
|
component: HomePage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/manga/details/:id',
|
||||||
|
name: 'manga-details',
|
||||||
|
component: MangaDetails
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/manga/add',
|
||||||
|
name: 'add-manga',
|
||||||
|
component: AddManga
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/manga/reader/:chapterId',
|
||||||
|
name: 'reader',
|
||||||
|
component: ChapterPage,
|
||||||
|
props: { title: 'Lecteur' }
|
||||||
|
},
|
||||||
|
// Import routes
|
||||||
|
{
|
||||||
|
path: '/import',
|
||||||
|
name: 'import',
|
||||||
|
component: NewImportPage
|
||||||
|
},
|
||||||
|
// Pages placeholder avec chargement différé
|
||||||
|
{
|
||||||
|
path: '/manga/import',
|
||||||
|
name: 'manga-import',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Import de bibliothèque' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/manga/discover',
|
||||||
|
name: 'discover',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Découvrir' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/convert',
|
||||||
|
name: 'convert',
|
||||||
|
component: ConversionPage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/calendar',
|
||||||
|
name: 'calendar',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Calendrier' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/activity',
|
||||||
|
name: 'activity',
|
||||||
|
component: ActivityPage
|
||||||
|
},
|
||||||
|
// Paramètres
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Paramètres' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings/general',
|
||||||
|
name: 'settings-general',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Paramètres généraux' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings/folders',
|
||||||
|
name: 'settings-folders',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Gestion des dossiers' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings/scrappers',
|
||||||
|
name: 'scrapper-configurations',
|
||||||
|
component: ScrapperConfigurations
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings/scrappers/new',
|
||||||
|
name: 'scrapper-new',
|
||||||
|
component: ScrapperEdit
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings/scrappers/edit/:id',
|
||||||
|
name: 'scrapper-edit',
|
||||||
|
component: ScrapperEdit
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings/ui',
|
||||||
|
name: 'settings-ui',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: "Paramètres de l'interface" }
|
||||||
|
},
|
||||||
|
// Système
|
||||||
|
{
|
||||||
|
path: '/system',
|
||||||
|
name: 'system',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Système' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/system/status',
|
||||||
|
name: 'system-status',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Status du système' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/system/backup',
|
||||||
|
name: 'system-backup',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Sauvegarde' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/system/logs',
|
||||||
|
name: 'system-logs',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Journaux système' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/system/updates',
|
||||||
|
name: 'system-updates',
|
||||||
|
component: PlaceholderComponent,
|
||||||
|
props: { title: 'Mises à jour' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory('/vue/'),
|
||||||
|
routes
|
||||||
|
});
|
||||||
41
assets/vue/app/shared/components/layout/Header.vue
Normal file
41
assets/vue/app/shared/components/layout/Header.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
:class="[
|
||||||
|
'bg-green-600 h-16 flex items-center fixed w-full z-50 transition-transform duration-300 ease-in-out',
|
||||||
|
headerStore.shouldShowHeader ? 'translate-y-0' : '-translate-y-full'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="$emit('menu-click')"
|
||||||
|
:class="[
|
||||||
|
'ml-4 text-white p-2',
|
||||||
|
showMenuButton ? '' : 'md:hidden'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Bars3Icon class="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
<router-link to="/" class="text-white text-2xl font-bold ml-4">
|
||||||
|
Mangarr
|
||||||
|
</router-link>
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Bars3Icon } from '@heroicons/vue/24/outline';
|
||||||
|
import { useHeaderStore } from '../../stores/headerStore';
|
||||||
|
import SearchBar from './SearchBar.vue';
|
||||||
|
|
||||||
|
const headerStore = useHeaderStore();
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
showMenuButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['menu-click']);
|
||||||
|
</script>
|
||||||
46
assets/vue/app/shared/components/layout/Layout.vue
Normal file
46
assets/vue/app/shared/components/layout/Layout.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 flex">
|
||||||
|
<Header
|
||||||
|
:show-menu-button="isReaderMode"
|
||||||
|
@menu-click="toggleSidebar"
|
||||||
|
@manga-click="$emit('manga-click', $event)"
|
||||||
|
@add-manga-click="$emit('add-manga-click', $event)" />
|
||||||
|
<Sidebar
|
||||||
|
:is-open="isSidebarOpen"
|
||||||
|
:force-mobile-behavior="isReaderMode"
|
||||||
|
@close="closeSidebar"
|
||||||
|
@add-manga-click="$emit('add-manga-click', $event)" />
|
||||||
|
|
||||||
|
<main :class="[
|
||||||
|
'flex-1 pt-16',
|
||||||
|
isReaderMode ? '' : 'md:ml-60'
|
||||||
|
]">
|
||||||
|
<RouterView></RouterView>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import Header from './Header.vue';
|
||||||
|
import Sidebar from './Sidebar.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const isSidebarOpen = ref(false);
|
||||||
|
|
||||||
|
// Détecte si on est en mode Reader
|
||||||
|
const isReaderMode = computed(() => {
|
||||||
|
return route.name === 'reader';
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
isSidebarOpen.value = !isSidebarOpen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSidebar = () => {
|
||||||
|
isSidebarOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineEmits(['manga-click', 'add-manga-click']);
|
||||||
|
</script>
|
||||||
122
assets/vue/app/shared/components/layout/SearchBar.vue
Normal file
122
assets/vue/app/shared/components/layout/SearchBar.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="searchRef" class="relative flex-1 max-w-xl mx-4">
|
||||||
|
<div class="flex items-center py-1">
|
||||||
|
<MagnifyingGlassIcon class="h-5 w-5 text-white" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="query"
|
||||||
|
@input="handleInput"
|
||||||
|
@focus="isOpen = true"
|
||||||
|
placeholder="Rechercher"
|
||||||
|
class="appearance-none outline-none ml-2 pl-0 bg-transparent border-b border-white w-full placeholder:text-white text-white py-1 px-2 leading-tight transition-all duration-500 ease-in-out focus:placeholder:text-opacity-0 focus:border-opacity-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isOpen && query.trim()"
|
||||||
|
class="absolute w-full mt-2 bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-700 max-h-96 overflow-y-auto z-50">
|
||||||
|
<div v-if="loading" class="p-4 text-center text-gray-400"> Chargement... </div>
|
||||||
|
|
||||||
|
<template v-else-if="results.length > 0">
|
||||||
|
<div class="py-2">
|
||||||
|
<h3 class="px-4 py-2 text-sm font-semibold text-gray-400"> Mangas existants </h3>
|
||||||
|
<RouterLink
|
||||||
|
v-for="manga in results"
|
||||||
|
:key="manga.id"
|
||||||
|
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
isOpen = false;
|
||||||
|
query = '';
|
||||||
|
hasSearched = false;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
class="w-full px-4 py-2 flex items-center gap-3 hover:bg-gray-700/50 text-white">
|
||||||
|
<img :src="manga.thumbnailUrl" :alt="manga.title" class="w-10 h-14 object-cover rounded" />
|
||||||
|
<div class="text-left">
|
||||||
|
<div class="font-medium">{{ manga.title }}</div>
|
||||||
|
<div class="text-sm text-gray-400">{{ manga.author }} ({{ manga.publicationYear }})</div>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="hasSearched">
|
||||||
|
<div class="py-2">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'add-manga', query: query ? { q: query } : undefined }"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
isOpen = false;
|
||||||
|
query = '';
|
||||||
|
hasSearched = false;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
class="w-full px-4 py-2 flex items-center gap-2 text-green-400 hover:bg-gray-700/50">
|
||||||
|
<PlusIcon class="h-5 w-5" />
|
||||||
|
<span>Ajouter "{{ query }}"</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { MagnifyingGlassIcon, PlusIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { ApiMangaRepository } from '../../../domain/manga/infrastructure/api/apiMangaRepository';
|
||||||
|
import { SearchMangas } from '../../../domain/manga/application/queries/searchMangas';
|
||||||
|
|
||||||
|
const mangaRepository = new ApiMangaRepository();
|
||||||
|
const searchMangas = new SearchMangas(mangaRepository);
|
||||||
|
|
||||||
|
const searchRef = ref(null);
|
||||||
|
const query = ref('');
|
||||||
|
const results = ref([]);
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const hasSearched = ref(false);
|
||||||
|
|
||||||
|
let searchTimeout;
|
||||||
|
|
||||||
|
const handleInput = () => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(searchManga, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchManga = async () => {
|
||||||
|
if (!query.value.trim()) {
|
||||||
|
results.value = [];
|
||||||
|
hasSearched.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await searchMangas.execute(query.value);
|
||||||
|
results.value = Array.isArray(response) ? response : response.items || [];
|
||||||
|
hasSearched.value = true;
|
||||||
|
console.log('Résultats de recherche:', results.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
results.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickOutside = event => {
|
||||||
|
if (searchRef.value && !searchRef.value.contains(event.target)) {
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
110
assets/vue/app/shared/components/layout/Sidebar.vue
Normal file
110
assets/vue/app/shared/components/layout/Sidebar.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
:class="[
|
||||||
|
'fixed top-16 left-0 w-60 bg-gray-600 text-white transform transition-transform duration-300 ease-in-out z-40 h-full',
|
||||||
|
isOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
|
!forceMobileBehavior ? 'md:translate-x-0' : ''
|
||||||
|
]"
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Menu principal">
|
||||||
|
<nav class="h-full overflow-y-auto">
|
||||||
|
<ul class="h-full flex flex-col">
|
||||||
|
<li v-for="(item, index) in menuItems" :key="index" class="mb-2">
|
||||||
|
<MenuGroup
|
||||||
|
:id="item.id"
|
||||||
|
:icon="item.icon"
|
||||||
|
:text="item.text"
|
||||||
|
:sub-items="item.subItems"
|
||||||
|
:to="item.to" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
ArrowsRightLeftIcon,
|
||||||
|
BookOpenIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
ClockIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
ComputerDesktopIcon,
|
||||||
|
GlobeAltIcon,
|
||||||
|
PlusIcon
|
||||||
|
} from '@heroicons/vue/24/solid';
|
||||||
|
import MenuGroup from './sidebar/MenuGroup.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
forceMobileBehavior: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
icon: BookOpenIcon,
|
||||||
|
text: 'Mangas',
|
||||||
|
to: '/manga',
|
||||||
|
id: 'manga',
|
||||||
|
subItems: [
|
||||||
|
{ icon: PlusIcon.render, text: 'Ajouter un nouveau', to: '/manga/add' },
|
||||||
|
{
|
||||||
|
icon: ArrowDownTrayIcon,
|
||||||
|
text: 'Import bibliothèque',
|
||||||
|
to: '/import'
|
||||||
|
},
|
||||||
|
{ icon: GlobeAltIcon, text: 'Découvrir', to: '/manga/discover' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ArrowsRightLeftIcon,
|
||||||
|
text: 'Convertir CBR en CBZ',
|
||||||
|
to: '/convert',
|
||||||
|
id: 'convert'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CalendarIcon,
|
||||||
|
text: 'Calendrier',
|
||||||
|
to: '/calendar',
|
||||||
|
id: 'calendar'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ClockIcon,
|
||||||
|
text: 'Activité',
|
||||||
|
to: '/activity',
|
||||||
|
id: 'activity',
|
||||||
|
badge: '3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Cog6ToothIcon,
|
||||||
|
text: 'Paramètres',
|
||||||
|
to: '/settings',
|
||||||
|
id: 'settings',
|
||||||
|
subItems: [
|
||||||
|
{ icon: null, text: 'Général', to: '/settings/general' },
|
||||||
|
{ icon: null, text: 'Dossiers', to: '/settings/folders' },
|
||||||
|
{ icon: null, text: 'Scrappers', to: '/settings/scrappers' },
|
||||||
|
{ icon: null, text: 'UI', to: '/settings/ui' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ComputerDesktopIcon,
|
||||||
|
text: 'Système',
|
||||||
|
to: '/system',
|
||||||
|
id: 'system',
|
||||||
|
subItems: [
|
||||||
|
{ icon: null, text: 'Status', to: '/system/status' },
|
||||||
|
{ icon: null, text: 'Backup', to: '/system/backup' },
|
||||||
|
{ icon: null, text: 'Logs', to: '/system/logs' },
|
||||||
|
{ icon: null, text: 'Updates', to: '/system/updates' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
109
assets/vue/app/shared/components/layout/sidebar/MenuGroup.vue
Normal file
109
assets/vue/app/shared/components/layout/sidebar/MenuGroup.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="border-l-4"
|
||||||
|
:class="{
|
||||||
|
'border-green-600': isActive,
|
||||||
|
'hover:bg-gray-700 border-transparent': !isActive
|
||||||
|
}">
|
||||||
|
<div class="flex w-full" @click="toggleExpanded">
|
||||||
|
<RouterLink
|
||||||
|
:to="to"
|
||||||
|
class="flex-grow px-4 py-2 flex items-center"
|
||||||
|
:class="{
|
||||||
|
'text-green-600 bg-gray-800': isActive
|
||||||
|
}">
|
||||||
|
<div class="flex items-center flex-grow">
|
||||||
|
<component :is="icon" class="w-5 h-5 mr-3" />
|
||||||
|
<span class="px-2">{{ text }}</span>
|
||||||
|
</div>
|
||||||
|
<component
|
||||||
|
v-if="subItems.length > 0"
|
||||||
|
:is="expanded ? ChevronUpIcon : ChevronDownIcon"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="subItems.length > 0" class="ml-8 mt-2 space-y-4" v-show="expanded">
|
||||||
|
<SubMenuItem
|
||||||
|
v-for="(subItem, index) in subItems"
|
||||||
|
:key="index"
|
||||||
|
:text="subItem.text"
|
||||||
|
:to="subItem.to"
|
||||||
|
@click="subItem.onClick" />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import SubMenuItem from './SubMenuItem.vue';
|
||||||
|
import { useMenuStore } from '../../../stores/menuStore';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
subItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuStore = useMenuStore();
|
||||||
|
const expanded = ref(false);
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
|
||||||
|
const isActive = computed(() => {
|
||||||
|
if (!props.to) {
|
||||||
|
return props.subItems?.some(subItem => route.path === subItem.to) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.to === '/') {
|
||||||
|
return route.path === props.to || props.subItems.map(item => item.to).includes(route.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.path.startsWith(props.to);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isRouteMatching = path => {
|
||||||
|
return props.subItems.some(item => path.startsWith(item.to)) || path === props.to;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => route.path, () => menuStore.activeMenuId],
|
||||||
|
([newPath, newMenuId]) => {
|
||||||
|
if (isRouteMatching(newPath)) {
|
||||||
|
expanded.value = true;
|
||||||
|
menuStore.setActiveMenu(props.id);
|
||||||
|
} else if (newMenuId !== props.id) {
|
||||||
|
expanded.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
if (expanded.value) {
|
||||||
|
menuStore.setActiveMenu(null);
|
||||||
|
} else {
|
||||||
|
menuStore.setActiveMenu(props.id);
|
||||||
|
}
|
||||||
|
expanded.value = !expanded.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<li>
|
||||||
|
<RouterLink v-if="to" :to="to" class="block hover:text-green-600" role="menuitem">
|
||||||
|
{{ text }}
|
||||||
|
</RouterLink>
|
||||||
|
<button v-else @click="$emit('click')" class="w-full text-left hover:text-green-600" role="menuitem">
|
||||||
|
{{ text }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['click']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.router-link-exact-active {
|
||||||
|
@apply text-green-600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
assets/vue/app/shared/components/ui/Divider.vue
Normal file
7
assets/vue/app/shared/components/ui/Divider.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-14 mx-4 border-r opacity-50 border-green-500"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// Pas de script nécessaire pour ce composant purement visuel
|
||||||
|
</script>
|
||||||
127
assets/vue/app/shared/components/ui/FileUpload.vue
Normal file
127
assets/vue/app/shared/components/ui/FileUpload.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div class="file-upload">
|
||||||
|
<label :for="inputId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
|
||||||
|
:class="{ 'border-green-500 bg-green-50': isDragOver, 'hover:border-gray-400': !isDragOver }"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
@dragover.prevent="isDragOver = true"
|
||||||
|
@dragleave.prevent="isDragOver = false"
|
||||||
|
>
|
||||||
|
<div class="space-y-1 text-center">
|
||||||
|
<svg
|
||||||
|
class="mx-auto h-12 w-12 text-gray-400"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="flex text-sm text-gray-600">
|
||||||
|
<label
|
||||||
|
:for="inputId"
|
||||||
|
class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"
|
||||||
|
>
|
||||||
|
<span>Sélectionner des fichiers</span>
|
||||||
|
<input
|
||||||
|
:id="inputId"
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
class="sr-only"
|
||||||
|
:accept="accept"
|
||||||
|
:multiple="multiple"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<p class="pl-1">ou glisser-déposer</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="selectedFiles.length > 0" class="mt-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Fichiers sélectionnés :</h4>
|
||||||
|
<ul class="text-xs text-gray-600 space-y-1">
|
||||||
|
<li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
|
||||||
|
<span class="truncate">{{ file.name }}</span>
|
||||||
|
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Choisir des fichiers'
|
||||||
|
},
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: '.cbz,.cbr'
|
||||||
|
},
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: 'CBZ ou CBR jusqu\'à 100MB chacun'
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'files-selected']);
|
||||||
|
|
||||||
|
const fileInput = ref(null);
|
||||||
|
const isDragOver = ref(false);
|
||||||
|
const selectedFiles = ref([]);
|
||||||
|
|
||||||
|
const inputId = computed(() => `file-upload-${Math.random().toString(36).substr(2, 9)}`);
|
||||||
|
|
||||||
|
const formatFileSize = (bytes) => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (event) => {
|
||||||
|
const files = Array.from(event.target.files);
|
||||||
|
selectedFiles.value = files;
|
||||||
|
emit('update:modelValue', files);
|
||||||
|
emit('files-selected', files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event) => {
|
||||||
|
isDragOver.value = false;
|
||||||
|
const files = Array.from(event.dataTransfer.files);
|
||||||
|
selectedFiles.value = files;
|
||||||
|
emit('update:modelValue', files);
|
||||||
|
emit('files-selected', files);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for external changes to modelValue
|
||||||
|
watch(() => props.modelValue, (newFiles) => {
|
||||||
|
selectedFiles.value = newFiles;
|
||||||
|
}, { deep: true });
|
||||||
|
</script>
|
||||||
46
assets/vue/app/shared/components/ui/LoadingSpinner.vue
Normal file
46
assets/vue/app/shared/components/ui/LoadingSpinner.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="animate-spin"
|
||||||
|
:class="sizeClasses"
|
||||||
|
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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator: (value) => ['sm', 'md', 'lg', 'xl'].includes(value)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sizeClasses = computed(() => {
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-8 w-8',
|
||||||
|
lg: 'h-12 w-12',
|
||||||
|
xl: 'h-16 w-16'
|
||||||
|
};
|
||||||
|
return sizes[props.size];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user