Compare commits
53 Commits
c311cfe80c
...
style/simp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc27fc4564 | ||
|
|
e1909b9804 | ||
|
|
07d3b56d1b | ||
|
|
ac19cc53ca | ||
|
|
15cb59e420 | ||
|
|
d4e456961a | ||
|
|
465a05c13b | ||
|
|
2ffe559832 | ||
|
|
5eb650df6f | ||
| b60a68cbd7 | |||
|
|
ec1ef8fe68 | ||
| 48d819ba72 | |||
| 156d2eea37 | |||
|
|
e5c319db79 | ||
|
|
41ca08f20e | ||
| 13653b4ced | |||
| e9b56b80e6 | |||
|
|
95f224d69a | ||
|
|
ff8b945014 | ||
|
|
2a8b6bc397 | ||
|
|
eb25d2c34e | ||
|
|
c981ce27c5 | ||
|
|
6f3efab0fc | ||
|
|
ed86c9074d | ||
|
|
1becbe9254 | ||
|
|
aea4e57b9e | ||
|
|
19395b4869 | ||
|
|
f418b36167 | ||
|
|
c085c3453a | ||
|
|
d299e0b9ae | ||
|
|
e78a6230b5 | ||
|
|
9d61e4231a | ||
|
|
027f795bc0 | ||
|
|
19f1633c7a | ||
|
|
751fb1e74b | ||
|
|
c60301d1ca | ||
|
|
944994b7d7 | ||
|
|
08e005a0d3 | ||
|
|
566b62450e | ||
|
|
16f87d5f06 | ||
|
|
78971a7e2b | ||
|
|
b1feb6a83f | ||
|
|
8b41626894 | ||
|
|
4e7a277d49 | ||
|
|
01428cbdeb | ||
|
|
5f5271e1b5 | ||
|
|
939f6da0c4 | ||
|
|
0756460fbc | ||
|
|
3941cb4b8f | ||
|
|
3507349167 | ||
| 487f400418 | |||
|
|
322c396165 | ||
| 6875ad4222 |
251
.claude/skills/vue-frontend/SKILL.md
Normal file
251
.claude/skills/vue-frontend/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
name: vue-frontend
|
||||
description: Architecture Vue.js du projet Mangarr — structure DDD front (domain/application/infrastructure/presentation), patterns Pinia store, TanStack Query composables, API repositories, conventions de nommage. Utiliser quand on crée ou modifie un composant Vue, une page, un store Pinia, un composable, ou un repository API dans assets/vue/app/.
|
||||
allowed-tools: Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Architecture Vue.js — Mangarr Frontend
|
||||
|
||||
## Structure des dossiers
|
||||
|
||||
```
|
||||
assets/vue/app/
|
||||
index.js # Point d'entrée : Vue + Pinia + Router + VueQuery
|
||||
App.vue # Root : <router-view> + <NotificationToast>
|
||||
router/index.js # Routes imbriquées sous Layout, base /vue/
|
||||
domain/
|
||||
{DomainName}/
|
||||
domain/
|
||||
entities/ # Classes entités JS
|
||||
constants/ # Constantes du domaine
|
||||
application/
|
||||
store/ # Stores Pinia
|
||||
infrastructure/
|
||||
api/ # Clients HTTP (ApiXxxRepository)
|
||||
presentation/
|
||||
pages/ # Composants pleine page
|
||||
components/ # Composants réutilisables
|
||||
composables/ # Logique Vue (useXxx)
|
||||
shared/
|
||||
components/
|
||||
layout/ # Layout, Header, Sidebar
|
||||
ui/ # Composants UI génériques
|
||||
composables/ # useNotifications, etc.
|
||||
stores/ # headerStore, menuStore
|
||||
plugin/ # vueQuery.js config
|
||||
```
|
||||
|
||||
**Domaines existants :** `manga`, `reader`, `import`, `conversion`, `activity`, `setting`
|
||||
|
||||
## Conventions de nommage
|
||||
|
||||
| Couche | Pattern | Exemple |
|
||||
|--------|---------|---------|
|
||||
| Entité | `PascalCase` | `Manga`, `ImportFile`, `Job` |
|
||||
| Store Pinia | `use{Domain}Store()` | `useMangaStore()` |
|
||||
| Composable | `use{Feature}()` | `useMangaDetails()`, `useNotifications()` |
|
||||
| Repository API | `Api{Domain}Repository` | `ApiMangaRepository` |
|
||||
| Page | `{Domain}{Action}.vue` | `MangaDetails.vue`, `NewImportPage.vue` |
|
||||
| Composant | `{Domain}{Feature}.vue` | `MangaCard.vue`, `StatusBadge.vue` |
|
||||
| Modal | `{Feature}Modal.vue` | `MangaDeleteModal.vue` |
|
||||
|
||||
## Pattern Store Pinia
|
||||
|
||||
```javascript
|
||||
// application/store/xyzStore.js
|
||||
export const useXyzStore = defineStore('xyz', {
|
||||
state: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isReady: (state) => state.data && !state.isLoading,
|
||||
},
|
||||
|
||||
actions: {
|
||||
async load() {
|
||||
this.isLoading = true
|
||||
try {
|
||||
const repo = new ApiXyzRepository()
|
||||
this.data = await repo.getAll()
|
||||
} catch (err) {
|
||||
this.error = err.message
|
||||
throw err
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Pattern Composable avec TanStack Query
|
||||
|
||||
Préférer TanStack Query pour les lectures (queries), le store Pinia pour les mutations et l'état global.
|
||||
|
||||
```javascript
|
||||
// presentation/composables/useXyzDetails.js
|
||||
export function useXyzDetails(xyzId) {
|
||||
const repo = new ApiXyzRepository()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['xyz', xyzId],
|
||||
queryFn: () => repo.getById(xyzId.value),
|
||||
enabled: computed(() => !!xyzId.value),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Mutation
|
||||
export function useXyzEdit() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data) => new ApiXyzRepository().edit(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['xyz'] }),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Repository API
|
||||
|
||||
```javascript
|
||||
// infrastructure/api/apiXyzRepository.js
|
||||
export class ApiXyzRepository {
|
||||
async getAll() {
|
||||
const response = await fetch('/api/xyz')
|
||||
if (!response.ok) throw new Error(await this.#extractError(response))
|
||||
const data = await response.json()
|
||||
return data.items.map(Xyz.fromApiData)
|
||||
}
|
||||
|
||||
async getById(id) {
|
||||
const response = await fetch(`/api/xyz/${id}`)
|
||||
if (!response.ok) throw new Error(await this.#extractError(response))
|
||||
return Xyz.fromApiData(await response.json())
|
||||
}
|
||||
|
||||
async create(payload) {
|
||||
const response = await fetch('/api/xyz', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) throw new Error(await this.#extractError(response))
|
||||
return Xyz.fromApiData(await response.json())
|
||||
}
|
||||
|
||||
async #extractError(response) {
|
||||
try {
|
||||
const data = await response.json()
|
||||
return data.error || data.detail || `HTTP ${response.status}`
|
||||
} catch {
|
||||
return `HTTP ${response.status}`
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Entité
|
||||
|
||||
```javascript
|
||||
// domain/entities/xyz.js
|
||||
export class Xyz {
|
||||
constructor({ id, name, status }) {
|
||||
this.id = id
|
||||
this.name = name
|
||||
this.status = status
|
||||
}
|
||||
|
||||
static fromApiData(data) {
|
||||
return new Xyz(data)
|
||||
}
|
||||
|
||||
isActive() { return this.status === 'active' }
|
||||
isCompleted() { return this.status === 'completed' }
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Page
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
<LoadingSpinner v-if="isLoading" />
|
||||
<div v-else-if="error">{{ error }}</div>
|
||||
<ChildComponent v-else :data="data" @action="handleAction" />
|
||||
<FeatureModal :is-open="isModalOpen" @close="closeModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useFeatureComposable } from '../composables/useFeature'
|
||||
|
||||
const route = useRoute()
|
||||
const { data, isLoading, error } = useFeatureComposable(
|
||||
computed(() => route.params.id)
|
||||
)
|
||||
|
||||
const isModalOpen = ref(false)
|
||||
const closeModal = () => (isModalOpen.value = false)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Système de notifications (global)
|
||||
|
||||
```javascript
|
||||
import { useNotifications } from '@/shared/composables/useNotifications'
|
||||
|
||||
const { showSuccess, showError, showWarning, showInfo } = useNotifications()
|
||||
|
||||
showSuccess('Manga ajouté avec succès')
|
||||
showError('Erreur lors du chargement')
|
||||
```
|
||||
|
||||
## Configuration VueQuery (shared/plugin/vueQuery.js)
|
||||
|
||||
- `staleTime`: 5 minutes
|
||||
- `gcTime`: 10 minutes
|
||||
- `retry`: 1
|
||||
- `refetchOnWindowFocus`: true
|
||||
|
||||
## Upload de fichiers (FormData)
|
||||
|
||||
Ne pas définir `Content-Type` manuellement — le navigateur le gère automatiquement avec le boundary correct.
|
||||
|
||||
```javascript
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('mangaId', mangaId)
|
||||
|
||||
const response = await fetch('/api/xyz/import', {
|
||||
method: 'POST',
|
||||
body: formData, // pas de Content-Type header
|
||||
})
|
||||
```
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
make npm-run # Build dev one-shot — vérifie qu'il n'y a pas d'erreur de compilation
|
||||
make npm-watch # Watch + rebuild automatique pendant le développement
|
||||
make npm-add p=pkg # Ajouter une dépendance npm
|
||||
```
|
||||
|
||||
Après toute modification de composants Vue, stores ou repositories, lancer `make npm-run` pour valider le build.
|
||||
|
||||
## Règles à respecter
|
||||
|
||||
- **Domain** : entités JS pures, aucune dépendance Vue/fetch
|
||||
- **Application** : stores Pinia uniquement, pas d'appels fetch directs (passer par Infrastructure)
|
||||
- **Infrastructure** : repositories API, aucune logique Vue
|
||||
- **Presentation** : composants + composables, import uniquement depuis Application et Infrastructure
|
||||
- **Shared** : composants/composables transversaux, pas de dépendances vers les domaines
|
||||
- Préférer `useQuery`/`useMutation` (TanStack) pour les données serveur, Pinia pour l'état UI global
|
||||
- Un composable = une responsabilité, nommé `use{FeatureVerb}` (ex: `useMangaDelete`, `useMangaEdit`)
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build and Deploy
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -9,63 +9,34 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install tools
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
apt-get update && apt-get install -y docker.io curl jq
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Build production image
|
||||
run: |
|
||||
docker build --target frankenphp_prod -t mangarr:latest .
|
||||
|
||||
- name: Redeploy via Portainer API
|
||||
- name: Deploy via Deployer
|
||||
env:
|
||||
PORTAINER_USER: ${{ secrets.PORTAINER_USER }}
|
||||
PORTAINER_PASSWORD: ${{ secrets.PORTAINER_PASSWORD }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
JWT=$(curl -s -X POST http://portainer:9000/api/auth \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"Username\":\"$PORTAINER_USER\",\"Password\":\"$PORTAINER_PASSWORD\"}" | jq -r '.jwt')
|
||||
# Créer le container sans le démarrer (évite le problème DinD avec les volumes)
|
||||
CONTAINER=$(docker create \
|
||||
-e DEPLOY_HOST \
|
||||
-e GITEA_TOKEN \
|
||||
-w /app \
|
||||
deployphp/deployer:v7 \
|
||||
-f /app/deploy.php deploy production -vvv)
|
||||
|
||||
if [ -z "$JWT" ] || [ "$JWT" = "null" ]; then
|
||||
echo "Erreur: authentification Portainer echouee"
|
||||
exit 1
|
||||
fi
|
||||
# Copier les sources et les clés SSH dans le container
|
||||
docker cp "$PWD/." "$CONTAINER:/app/"
|
||||
docker cp "$HOME/.ssh/." "$CONTAINER:/root/.ssh/"
|
||||
|
||||
STACK_INFO=$(curl -s http://portainer:9000/api/stacks \
|
||||
-H "Authorization: Bearer $JWT")
|
||||
STACK_ID=$(echo "$STACK_INFO" | jq '.[] | select(.Name=="mangarr") | .Id')
|
||||
ENDPOINT_ID=$(echo "$STACK_INFO" | jq '.[] | select(.Name=="mangarr") | .EndpointId')
|
||||
|
||||
if [ -z "$STACK_ID" ] || [ "$STACK_ID" = "null" ]; then
|
||||
echo "Erreur: stack mangarr non trouvee"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stack ID: $STACK_ID, Endpoint ID: $ENDPOINT_ID"
|
||||
|
||||
STACK_FILE=$(curl -s "http://portainer:9000/api/stacks/$STACK_ID/file" \
|
||||
-H "Authorization: Bearer $JWT" | jq -r '.StackFileContent')
|
||||
STACK_ENV=$(curl -s "http://portainer:9000/api/stacks/$STACK_ID" \
|
||||
-H "Authorization: Bearer $JWT" | jq '.Env')
|
||||
|
||||
HTTP_CODE=$(curl -s -o /tmp/deploy_result.json -w "%{http_code}" -X PUT \
|
||||
"http://portainer:9000/api/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"stackFileContent\":$(echo "$STACK_FILE" | jq -Rs .),\"env\":$STACK_ENV,\"prune\":true,\"pullImage\":false}")
|
||||
|
||||
echo "Portainer redeploy: HTTP $HTTP_CODE"
|
||||
if [ "$HTTP_CODE" -ge 300 ]; then
|
||||
cat /tmp/deploy_result.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run migrations
|
||||
run: |
|
||||
echo "Attente du demarrage de Mangarr..."
|
||||
sleep 15
|
||||
docker exec mangarr php bin/console doctrine:migrations:migrate --no-interaction || echo "Rien a migrer"
|
||||
docker exec mangarr php bin/console cache:clear --env=prod || true
|
||||
echo "Deploy termine avec succes"
|
||||
# Démarrer et attendre la fin
|
||||
docker start -a "$CONTAINER"
|
||||
EXIT_CODE=$?
|
||||
docker rm "$CONTAINER" || true
|
||||
exit $EXIT_CODE
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,3 +38,4 @@ yarn-error.log
|
||||
/public/images/
|
||||
src/Controller/TestController.php
|
||||
.phpunit.cache/test-results
|
||||
/tests/Fixtures/pages/
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -68,6 +68,19 @@ ENTRYPOINT ["docker-entrypoint"]
|
||||
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
|
||||
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
|
||||
|
||||
# Runtime FrankenPHP image (sans code baked-in)
|
||||
# Le code vient du bind mount /srv/mangarr/current:/app (géré par Deployer)
|
||||
# Builder une seule fois : docker build --target frankenphp_runtime -t mangarr:runtime .
|
||||
FROM frankenphp_base AS frankenphp_runtime
|
||||
|
||||
ENV APP_ENV=prod
|
||||
ENV FRANKENPHP_CONFIG="import worker.Caddyfile"
|
||||
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
|
||||
COPY --link frankenphp/conf.d/app.prod.ini $PHP_INI_DIR/conf.d/
|
||||
COPY --link frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile
|
||||
|
||||
# Dev FrankenPHP image
|
||||
FROM frankenphp_base AS frankenphp_dev
|
||||
|
||||
|
||||
7
Makefile
7
Makefile
@@ -145,6 +145,13 @@ twig-extension: ## Create a new twig extension
|
||||
stimulus: ## Create a new stimulus controller
|
||||
@$(SYMFONY) make:stimulus-controller
|
||||
|
||||
notify-test: ## Envoie les 4 types de notifications de test avec 2s d'intervalle
|
||||
@for type in info success error warning; do \
|
||||
$(SYMFONY) app:notify:test --type=$$type --message="Test $$type depuis Mangarr"; \
|
||||
echo "[$$type] envoyé"; \
|
||||
sleep 2; \
|
||||
done
|
||||
|
||||
consume-commands: ## Consume commands messages
|
||||
@$(SYMFONY) messenger:consume commands -vv
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
html, body {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: white;
|
||||
}
|
||||
@@ -82,6 +87,33 @@ body {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
|
||||
/* Firefox uniquement — évite le conflit avec les pseudo-éléments webkit sur Chrome 121+ */
|
||||
@supports (-moz-appearance: none) {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #16a34a transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: #16a34a #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode — webkit track */
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
/* Supprime les flèches de la scrollbar */
|
||||
::-webkit-scrollbar-button:start:decrement,
|
||||
::-webkit-scrollbar-button:end:increment,
|
||||
::-webkit-scrollbar-button:start:increment,
|
||||
::-webkit-scrollbar-button:end:decrement {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
///* Custom styles for the scrollbar buttons */
|
||||
//::-webkit-scrollbar-button {
|
||||
// @apply bg-gray-700;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
<script setup>
|
||||
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
||||
import { useMercureNotifications } from './shared/composables/useMercureNotifications';
|
||||
|
||||
useMercureNotifications();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -7,8 +7,12 @@ export class Job {
|
||||
payload = {},
|
||||
result = null,
|
||||
error = null,
|
||||
failureReason = null,
|
||||
createdAt = new Date().toISOString(),
|
||||
updatedAt = new Date().toISOString()
|
||||
updatedAt = new Date().toISOString(),
|
||||
attempts = 0,
|
||||
maxAttempts = 1,
|
||||
context = {}
|
||||
}) {
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
@@ -16,9 +20,12 @@ export class Job {
|
||||
this.progress = progress;
|
||||
this.payload = payload;
|
||||
this.result = result;
|
||||
this.error = error;
|
||||
this.error = failureReason ?? error;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
this.attempts = attempts;
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
static create(data) {
|
||||
|
||||
@@ -23,8 +23,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
url += `&status=${status.join(',')}`;
|
||||
}
|
||||
|
||||
console.log('Fetching jobs from URL:', url);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -32,7 +30,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('API Response:', data);
|
||||
|
||||
// Gérer différents formats de réponse API
|
||||
let jobs, total, currentPage, limit_returned, hasNext, hasPrev;
|
||||
@@ -63,15 +60,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
hasPrev = !!data.hasPreviousPage;
|
||||
}
|
||||
|
||||
console.log('Processed data:', {
|
||||
jobs: jobs.length,
|
||||
total,
|
||||
currentPage,
|
||||
limit_returned,
|
||||
hasNext,
|
||||
hasPrev
|
||||
});
|
||||
|
||||
return new JobCollection(
|
||||
jobs,
|
||||
total,
|
||||
@@ -81,7 +69,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
hasPrev
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -102,7 +89,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
const data = await response.json();
|
||||
return Job.create(data);
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -124,7 +110,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -158,7 +143,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
const data = await response.json();
|
||||
return data.deleted || 0;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,56 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out"
|
||||
class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition duration-150 ease-in-out"
|
||||
:class="{
|
||||
'bg-yellow-50': job.status === 'pending',
|
||||
'bg-blue-50': job.status === 'in_progress',
|
||||
'bg-green-50': job.status === 'completed',
|
||||
'bg-red-50': job.status === 'failed'
|
||||
'bg-yellow-50 dark:bg-yellow-900/20': job.status === 'pending',
|
||||
'bg-blue-50 dark:bg-blue-900/20': job.status === 'in_progress',
|
||||
'bg-green-50 dark:bg-green-900/20': job.status === 'completed',
|
||||
'bg-red-50 dark:bg-red-900/20': job.status === 'failed'
|
||||
}">
|
||||
<td class="py-4 px-4 text-center">
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" />
|
||||
</td>
|
||||
<td class="py-4 px-4 font-medium">{{ job.type }}</td>
|
||||
<td class="py-4 px-4 font-medium">
|
||||
<div>{{ jobTypeLabel }}</div>
|
||||
<div v-if="job.context?.mangaTitle" class="text-xs text-gray-500 mt-0.5">
|
||||
{{ job.context.mangaTitle }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-800': job.status === 'pending',
|
||||
'bg-blue-100 text-blue-800': job.status === 'in_progress',
|
||||
'bg-green-100 text-green-800': job.status === 'completed',
|
||||
'bg-red-100 text-red-800': job.status === 'failed'
|
||||
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': job.status === 'pending',
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': job.status === 'in_progress',
|
||||
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': job.status === 'completed',
|
||||
'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300': job.status === 'failed'
|
||||
}">
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div v-if="job.error" class="text-sm text-red-600">
|
||||
<div v-if="job.error" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ job.error }}
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-600">
|
||||
<div v-else-if="job.context?.mangaTitle || job.context?.chapterNumber !== undefined || job.context?.sourceId"
|
||||
class="text-sm text-gray-700 dark:text-gray-300 space-y-0.5">
|
||||
<div v-if="job.context.mangaTitle" class="font-medium">
|
||||
{{ job.context.mangaTitle }}
|
||||
</div>
|
||||
<div v-if="job.context.chapterNumber !== undefined" class="text-gray-500 dark:text-gray-400">
|
||||
Chapitre {{ job.context.chapterNumber }}
|
||||
</div>
|
||||
<div v-if="job.context.sourceId" class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Source : {{ job.context.sourceId }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ formatDate(job.createdAt) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div v-if="job.status === 'in_progress'" class="mt-2">
|
||||
<div class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||
:style="{ width: `${job.progress}%` }"></div>
|
||||
@@ -42,7 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else-if="job.status === 'completed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||
style="width: 100%"></div>
|
||||
@@ -50,7 +67,7 @@
|
||||
100%
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else-if="job.status === 'failed'" class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-red-400 transition-all duration-300 ease-out"
|
||||
style="width: 100%"></div>
|
||||
@@ -58,14 +75,19 @@
|
||||
Erreur
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div v-else class="relative bg-gray-200 dark:bg-gray-700 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-yellow-400 transition-all duration-300 ease-out"
|
||||
style="width: 0%"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600">
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||
En attente
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="job.maxAttempts > 1 || job.attempts > 0"
|
||||
class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">
|
||||
{{ job.attempts }} / {{ job.maxAttempts }} tentative{{ job.maxAttempts > 1 ? 's' : '' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<button
|
||||
@@ -79,24 +101,33 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { defineEmits, defineProps } from 'vue';
|
||||
import { TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, defineEmits, defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
job: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
const props = defineProps({
|
||||
job: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
function onDelete() {
|
||||
emit('delete', props.job.id);
|
||||
}
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const JOB_TYPE_LABELS = {
|
||||
scraping_job: 'Scraping',
|
||||
conversion_job: 'Conversion',
|
||||
};
|
||||
|
||||
const jobTypeLabel = computed(() =>
|
||||
JOB_TYPE_LABELS[props.job.type] ?? props.job.type
|
||||
);
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
emit('delete', props.job.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="overflow-y-auto h-full">
|
||||
<Toolbar :config="toolbarConfig" class="mb-6" />
|
||||
|
||||
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activityStore.error" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<div v-else-if="activityStore.error" class="bg-red-100 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-400 p-4 mb-6">
|
||||
<p>{{ activityStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="container mx-auto p-2">
|
||||
<!-- 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="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white">
|
||||
<table class="min-w-full bg-white dark:bg-gray-800">
|
||||
<thead>
|
||||
<tr class="bg-gray-200 text-gray-800">
|
||||
<tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
|
||||
<th class="w-1/12 py-3 px-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -39,14 +29,14 @@
|
||||
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-700">
|
||||
<tbody class="text-gray-700 dark:text-gray-300">
|
||||
<template v-if="activityStore.jobs.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="py-8 px-4 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<ClockIcon class="h-12 w-12 text-gray-300 mb-4" />
|
||||
<p class="text-lg font-medium">Aucune activité trouvée</p>
|
||||
<p class="text-sm">Aucune activité ne correspond aux filtres actuels.</p>
|
||||
<ClockIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p class="text-lg font-medium dark:text-gray-300">Aucune activité trouvée</p>
|
||||
<p class="text-sm dark:text-gray-400">Aucune activité ne correspond aux filtres actuels.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
|
||||
<!-- Message de statut -->
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
<p v-if="fileName" class="text-xs text-gray-500">
|
||||
<p v-if="fileName" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ fileName }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -35,11 +35,11 @@
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div v-if="showProgress" class="space-y-2">
|
||||
<div class="flex justify-between text-xs text-gray-600">
|
||||
<div class="flex justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>Progression</span>
|
||||
<span>{{ Math.round(progress) }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300 ease-out"
|
||||
:style="{ width: `${progress}%` }"
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Détails de la conversion -->
|
||||
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 space-y-1">
|
||||
<div v-if="showDetails && (originalSize || convertedSize)" class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<div v-if="originalSize" class="flex justify-between">
|
||||
<span>Taille originale:</span>
|
||||
<span>{{ formatFileSize(originalSize) }}</span>
|
||||
@@ -77,7 +77,7 @@
|
||||
<button
|
||||
v-if="canReset"
|
||||
@click="$emit('reset')"
|
||||
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
class="flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
>
|
||||
<ArrowPathIcon class="w-4 h-4" />
|
||||
<span>Convertir un autre fichier</span>
|
||||
@@ -85,14 +85,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur détaillé -->
|
||||
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div v-if="hasError && errorMessage" class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div class="flex">
|
||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">
|
||||
Erreur de conversion
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-red-700">
|
||||
<p class="mt-1 text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
:class="[
|
||||
'border-2 border-dashed rounded-lg p-8 text-center transition-all duration-200',
|
||||
isDragOver
|
||||
? 'border-green-400 bg-green-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
? 'border-green-400 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
]"
|
||||
>
|
||||
<!-- Zone d'upload -->
|
||||
@@ -28,13 +28,13 @@
|
||||
|
||||
<!-- Message principal -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ isDragOver ? 'Déposez votre fichier ici' : 'Sélectionnez un fichier CBR ou CBZ' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Glissez-déposez votre fichier ou cliquez pour le sélectionner
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Fichiers supportés: .cbr, .cbz (max. 150MB)
|
||||
</p>
|
||||
</div>
|
||||
@@ -63,20 +63,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Informations du fichier sélectionné -->
|
||||
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div v-if="selectedFile" class="mt-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<DocumentIcon class="w-8 h-8 text-gray-600" />
|
||||
<DocumentIcon class="w-8 h-8 text-gray-600 dark:text-gray-400" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ selectedFile.name }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatFileSize(selectedFile.size) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="clearFile"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
class="p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
title="Supprimer le fichier"
|
||||
>
|
||||
<XMarkIcon class="w-5 h-5" />
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div class="overflow-y-auto h-full"><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">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Convertir CBR en CBZ
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-lg text-gray-600">
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400">
|
||||
Convertissez vos fichiers CBR (Comic Book RAR) en CBZ (Comic Book ZIP) pour une meilleure compatibilité.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Zone principale -->
|
||||
<div class="bg-white shadow-lg rounded-lg overflow-hidden">
|
||||
<div class="bg-white dark:bg-gray-800 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">
|
||||
@@ -75,14 +75,14 @@
|
||||
/>
|
||||
|
||||
<!-- Message d'information -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<InformationCircleIcon class="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
À propos de la conversion
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 space-y-1">
|
||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-400 space-y-1">
|
||||
<p>• Les fichiers CBZ sont plus largement supportés par les lecteurs de bandes dessinées</p>
|
||||
<p>• La compression ZIP permet généralement une meilleure accessibilité</p>
|
||||
<p>• Aucune perte de qualité lors de la conversion</p>
|
||||
@@ -95,34 +95,34 @@
|
||||
<!-- 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">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Historique des conversions
|
||||
</h3>
|
||||
<button
|
||||
@click="handleClearHistory"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Effacer l'historique
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(conversion, index) in conversionStore.conversionHistory"
|
||||
:key="index"
|
||||
class="flex items-center justify-between py-2 border-b border-gray-200 last:border-b-0"
|
||||
class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-600 last:border-b-0"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ conversion.originalName }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatDate(conversion.timestamp) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-600">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ formatFileSize(conversion.originalSize) }} → {{ formatFileSize(conversion.convertedSize) }}
|
||||
</p>
|
||||
<p class="text-xs text-green-600">
|
||||
@@ -150,7 +150,7 @@
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 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">
|
||||
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- 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">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ file.filename }}
|
||||
</h3>
|
||||
|
||||
@@ -23,29 +23,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 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">
|
||||
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
||||
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">
|
||||
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
||||
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 v-if="file.hasError()" class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<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>
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-300">Erreur</h3>
|
||||
<div class="mt-2 text-sm text-red-700 dark:text-red-400">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@
|
||||
<!-- 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">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
|
||||
</label>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</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">
|
||||
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
|
||||
<img
|
||||
v-if="file.selectedManga.thumbnailUrl"
|
||||
:src="file.selectedManga.thumbnailUrl"
|
||||
@@ -78,9 +78,9 @@
|
||||
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>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ file.selectedManga.title }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ file.selectedManga.slug }}</p>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<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">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Numéro de chapitre
|
||||
</label>
|
||||
<input
|
||||
@@ -97,14 +97,14 @@
|
||||
: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"
|
||||
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Volume Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Numéro de volume
|
||||
</label>
|
||||
<input
|
||||
@@ -113,7 +113,7 @@
|
||||
: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"
|
||||
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
</div>
|
||||
@@ -121,14 +121,14 @@
|
||||
</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 v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Aucun manga trouvé</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Aucun manga trouvé</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<div class="mt-6 flex justify-between items-center border-t dark:border-gray-700 pt-4">
|
||||
<div class="flex space-x-3">
|
||||
<!-- Import Button -->
|
||||
<button
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border dark:border-gray-700 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">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/40 mb-4">
|
||||
<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">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Import terminé</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Voici le résumé de votre session d'import
|
||||
</p>
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
<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 class="text-sm text-gray-500 dark:text-gray-400">Importés</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- Success Files List -->
|
||||
<div v-if="importedFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Fichiers importés avec succès ({{ importedFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
@@ -42,8 +42,8 @@
|
||||
<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">
|
||||
<span class="text-gray-900 dark:text-gray-100">{{ file.filename }}</span>
|
||||
<span v-if="file.selectedManga" class="ml-2 text-gray-500 dark:text-gray-400">
|
||||
→ {{ file.selectedManga.title }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
<!-- Error Files List -->
|
||||
<div v-if="errorFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Fichiers en erreur ({{ errorFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
@@ -65,15 +65,15 @@
|
||||
<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 class="text-gray-900 dark:text-gray-100">{{ file.filename }}</div>
|
||||
<div class="text-red-600 dark:text-red-400 text-xs mt-1">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-center space-x-4 pt-6 border-t">
|
||||
<div class="flex justify-center space-x-4 pt-6 border-t dark:border-gray-700">
|
||||
<button
|
||||
@click="startNewImport"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<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
|
||||
'border-blue-500 bg-blue-50 dark:bg-blue-900/20': isSelected,
|
||||
'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-500': !isSelected
|
||||
}"
|
||||
@click="$emit('select-match', match)"
|
||||
>
|
||||
@@ -17,7 +17,7 @@
|
||||
'bg-gray-300': !isSelected
|
||||
}"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-gray-700">Score: {{ match.matchScore }}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Score: {{ match.matchScore }}</span>
|
||||
</div>
|
||||
<div v-if="isSelected" class="text-blue-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -37,9 +37,9 @@
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-16 h-20 bg-gray-200 rounded border flex items-center justify-center"
|
||||
class="w-16 h-20 bg-gray-200 dark:bg-gray-700 rounded border dark:border-gray-600 flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -47,27 +47,27 @@
|
||||
|
||||
<!-- Manga Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 truncate" :title="match.title">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" :title="match.title">
|
||||
{{ match.title }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 mt-1 truncate" :title="match.slug">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate" :title="match.slug">
|
||||
{{ match.slug }}
|
||||
</p>
|
||||
|
||||
<!-- Alternative Slugs -->
|
||||
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
|
||||
<p class="text-xs text-gray-400">Autres titres:</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">Autres titres:</p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span
|
||||
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
|
||||
:key="altSlug"
|
||||
class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"
|
||||
class="text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded"
|
||||
>
|
||||
{{ altSlug }}
|
||||
</span>
|
||||
<span
|
||||
v-if="match.alternativeSlugs.length > 2"
|
||||
class="text-xs text-gray-400"
|
||||
class="text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
+{{ match.alternativeSlugs.length - 2 }} autres
|
||||
</span>
|
||||
@@ -78,11 +78,11 @@
|
||||
|
||||
<!-- Score Bar -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<span>Correspondance</span>
|
||||
<span>{{ match.matchScore }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
|
||||
@@ -49,22 +49,22 @@ 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`;
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300`;
|
||||
}
|
||||
|
||||
switch (props.status) {
|
||||
case 'pending':
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
|
||||
case 'analyzed':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`;
|
||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300`;
|
||||
case 'importing':
|
||||
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300`;
|
||||
case 'imported':
|
||||
return `${baseClasses} bg-green-100 text-green-800`;
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300`;
|
||||
case 'error':
|
||||
return `${baseClasses} bg-red-100 text-red-800`;
|
||||
return `${baseClasses} bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="overflow-y-auto h-full"><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">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">Import de Bibliothèque</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
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="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">Progression</span>
|
||||
<span class="text-sm text-gray-500">{{ store.progressPercentage }}%</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Progression</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ store.progressPercentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: store.progressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
<span>{{ store.importedCount }} importés</span>
|
||||
<span>{{ store.errorCount }} erreurs</span>
|
||||
<span>{{ store.totalFiles }} total</span>
|
||||
@@ -92,7 +92,7 @@
|
||||
<div v-if="store.allFilesProcessed" class="mt-8">
|
||||
<ImportResults />
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="handleClose"></div>
|
||||
|
||||
<!-- Modal avec style Material Design -->
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<!-- Header Material Design -->
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<FolderIcon class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-medium text-gray-900 leading-6">
|
||||
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100 leading-6">
|
||||
Gérer les chapitres
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ manga?.title }}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ manga?.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
|
||||
class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center transition-colors duration-200"
|
||||
>
|
||||
<XMarkIcon class="h-5 w-5 text-gray-600" />
|
||||
<XMarkIcon class="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content avec style Material Design -->
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-8">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-8">
|
||||
<div v-if="isLoading" class="flex justify-center items-center h-32">
|
||||
<div class="relative">
|
||||
<div class="w-8 h-8 border-4 border-green-200 rounded-full"></div>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
||||
<div v-else-if="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded-xl mb-6 flex items-center space-x-2">
|
||||
<div class="w-5 h-5 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<XMarkIcon class="h-3 w-3 text-red-600" />
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Actions avec style Material Design -->
|
||||
<div class="flex items-center justify-between bg-gray-50 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
@click="showCreateVolumeModal = true"
|
||||
@@ -58,7 +58,7 @@
|
||||
</button>
|
||||
<button
|
||||
@click="showUnassignedChapters = !showUnassignedChapters"
|
||||
class="text-gray-600 hover:text-gray-800 text-sm font-medium hover:bg-gray-100 px-3 py-2 rounded-lg transition-colors duration-200"
|
||||
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700 px-3 py-2 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{{ showUnassignedChapters ? 'Masquer' : 'Afficher' }} les chapitres non assignés
|
||||
</button>
|
||||
@@ -88,17 +88,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 bg-white px-3 py-1.5 rounded-lg border border-gray-200">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-700 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
{{ totalChapters }} chapitres, {{ volumes.length }} volumes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arborescence avec style Material Design -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden shadow-sm">
|
||||
<!-- Chapitres non assignés -->
|
||||
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
|
||||
<div v-if="showUnassignedChapters && unassignedChapters.length > 0" class="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700/50 dark:to-gray-700/30 border-b border-gray-200 dark:border-gray-600">
|
||||
<div class="px-6 py-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center space-x-2">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center space-x-2">
|
||||
<DocumentIcon class="h-4 w-4 text-gray-500" />
|
||||
<span>Chapitres non assignés ({{ unassignedChapters.length }})</span>
|
||||
</h4>
|
||||
@@ -119,11 +119,11 @@
|
||||
/>
|
||||
</div>
|
||||
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
||||
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<div class="flex-1">
|
||||
<div v-if="!chapter.isEditing" class="flex items-center">
|
||||
<span
|
||||
class="text-sm text-gray-900 cursor-pointer hover:text-green-600 transition-colors duration-200"
|
||||
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
||||
@click="startEditingTitle(chapter)"
|
||||
>
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
@@ -173,22 +173,22 @@
|
||||
</div>
|
||||
|
||||
<!-- Volumes avec style Material Design -->
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<div
|
||||
v-for="volume in volumes"
|
||||
:key="volume.number"
|
||||
class="bg-white"
|
||||
class="bg-white dark:bg-gray-800"
|
||||
>
|
||||
<!-- En-tête du volume Material Design -->
|
||||
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 border-b border-green-100">
|
||||
<div class="px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-b border-green-100 dark:border-green-900/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<FolderIcon class="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-green-900">Volume {{ volume.number }}</span>
|
||||
<span class="text-xs text-green-600 ml-2">({{ volume.chapters.length }} chapitres)</span>
|
||||
<span class="text-sm font-semibold text-green-900 dark:text-green-300">Volume {{ volume.number }}</span>
|
||||
<span class="text-xs text-green-600 dark:text-green-400 ml-2">({{ volume.chapters.length }} chapitres)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -211,10 +211,10 @@
|
||||
|
||||
<!-- Chapitres du volume -->
|
||||
<div v-if="volume.isExpanded" class="px-6 py-4">
|
||||
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500">
|
||||
<DocumentIcon class="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<div v-if="volume.chapters.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<DocumentIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
|
||||
<p class="text-sm">Aucun chapitre assigné à ce volume.</p>
|
||||
<p class="text-xs text-gray-400 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Utilisez le bouton "Assigner" sur les chapitres non assignés pour les ajouter.</p>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
@@ -233,11 +233,11 @@
|
||||
/>
|
||||
</div>
|
||||
<DocumentIcon class="h-5 w-5 text-gray-400" />
|
||||
<span class="text-sm font-medium text-gray-700 w-12 bg-gray-100 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-center">{{ chapter.number }}</span>
|
||||
<div class="flex-1">
|
||||
<div v-if="!chapter.isEditing" class="flex items-center">
|
||||
<span
|
||||
class="text-sm text-gray-900 cursor-pointer hover:text-green-600 transition-colors duration-200"
|
||||
class="text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
||||
@click="startEditingTitle(chapter)"
|
||||
>
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
@@ -291,12 +291,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer Material Design -->
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="handleClose"
|
||||
:disabled="isSaving"
|
||||
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
class="w-full sm:w-auto inline-flex justify-center items-center rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
@@ -320,24 +320,24 @@
|
||||
<div v-if="showCreateVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showCreateVolumeModal = false"></div>
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<PlusIcon class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900">Créer un nouveau volume</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Créer un nouveau volume</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Numéro du volume</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Numéro du volume</label>
|
||||
<input
|
||||
v-model="newVolumeNumber"
|
||||
type="number"
|
||||
min="1"
|
||||
class="block w-full border border-gray-300 rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
||||
class="block w-full border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-3 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors duration-200"
|
||||
placeholder="Ex: 1"
|
||||
/>
|
||||
</div>
|
||||
@@ -351,7 +351,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showCreateVolumeModal = false"
|
||||
@@ -376,8 +376,8 @@
|
||||
<div v-if="showAssignModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showAssignModal = false"></div>
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<DocumentIcon class="h-5 w-5 text-green-600" />
|
||||
@@ -385,7 +385,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Assigner le chapitre {{ selectedChapter?.number }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Volume</label>
|
||||
@@ -401,7 +401,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showAssignModal = false"
|
||||
@@ -426,8 +426,8 @@
|
||||
<div v-if="showMoveToVolumeModal" class="fixed inset-0 z-60 overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" @click="showMoveToVolumeModal = false"></div>
|
||||
<div class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100">
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full border border-gray-100 dark:border-gray-700">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 px-6 pt-6 pb-4 sm:px-8 sm:pb-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<ArrowPathIcon class="h-5 w-5 text-green-600" />
|
||||
@@ -435,7 +435,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Déplacer {{ selectedChapters.length }} chapitre(s)</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<p class="text-sm text-green-800 font-medium">
|
||||
@@ -457,7 +457,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showMoveToVolumeModal = false"
|
||||
@@ -491,7 +491,7 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Séparer le volume 00</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 py-6 sm:px-8 sm:py-6">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<p class="text-sm text-green-800 font-medium">
|
||||
@@ -517,7 +517,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 sm:px-8 sm:py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-end sm:space-x-3 space-y-3 sm:space-y-0">
|
||||
<button
|
||||
@click="showSplitVolumeZeroModal = false"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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">
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105 block">
|
||||
<div class="relative pb-[150%]">
|
||||
<img
|
||||
:src="manga.thumbnailUrl || 'https://via.placeholder.com/300x400'"
|
||||
@@ -9,11 +9,11 @@
|
||||
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>
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">{{ manga.title }}</h3>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-500">{{ manga.publicationYear }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500"> Added: {{ formatDate(manga.createdAt) }} </div>
|
||||
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400"> Added: {{ formatDate(manga.createdAt) }} </div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<tr class="border-t hover:bg-green-100">
|
||||
<td class="px-4 py-2" :class="{ 'text-green-500': chapter.isAvailable }">
|
||||
<tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20">
|
||||
<td class="px-4 py-2 text-gray-900 dark:text-gray-100" :class="{ 'text-green-500 dark:text-green-400': chapter.isAvailable }">
|
||||
{{ String(chapter.number).padStart(2, '0') }}
|
||||
</td>
|
||||
<td class="px-4 py-2 w-full text-left">
|
||||
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
|
||||
<router-link
|
||||
v-if="chapter.isAvailable"
|
||||
class="hover:text-green-500 dark:hover:text-green-400"
|
||||
:to="{
|
||||
name: 'reader',
|
||||
params: {
|
||||
@@ -14,7 +15,7 @@
|
||||
}">
|
||||
{{ chapter.title || 'Sans titre' }}
|
||||
</router-link>
|
||||
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">{{ 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">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="p-2 border-t">
|
||||
<div class="p-2 border-t dark:border-gray-700">
|
||||
<table class="min-w-full table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<tr class="text-gray-700 dark:text-gray-300">
|
||||
<th class="px-4 py-2 text-left">#</th>
|
||||
<th class="px-4 py-2 text-left">Titre</th>
|
||||
<th class="px-4 py-2 text-right">Actions</th>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,15 +24,15 @@
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div class="mb-6">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Supprimer le manga
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ error.message || 'Une erreur est survenue lors de la suppression.' }}
|
||||
</div>
|
||||
|
||||
@@ -40,19 +40,19 @@
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
|
||||
<span class="text-sm font-medium text-gray-900">Action irréversible</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Action irréversible</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Êtes-vous sûr de vouloir supprimer le manga <strong>"{{ manga?.title }}"</strong> ?
|
||||
</p>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">
|
||||
Attention
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<p>Cette action supprimera définitivement :</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Le manga et toutes ses métadonnées</li>
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="closeModal"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,15 +24,15 @@
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
||||
<div class="mb-6">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Edit Manga
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ error.message || 'Une erreur est survenue lors de la sauvegarde.' }}
|
||||
</div>
|
||||
|
||||
@@ -41,49 +41,49 @@
|
||||
<!-- Titre et Slug -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Titre</label>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="formData.title"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Titre du manga"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700 mb-2">Slug</label>
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slug</label>
|
||||
<input
|
||||
id="slug"
|
||||
:value="manga?.slug || ''"
|
||||
type="text"
|
||||
disabled
|
||||
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm text-gray-500"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-600 shadow-sm sm:text-sm text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Année de publication -->
|
||||
<div>
|
||||
<label for="publicationYear" class="block text-sm font-medium text-gray-700 mb-2">Année de publication</label>
|
||||
<label for="publicationYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Année de publication</label>
|
||||
<input
|
||||
id="publicationYear"
|
||||
v-model.number="formData.publicationYear"
|
||||
type="number"
|
||||
min="1900"
|
||||
:max="new Date().getFullYear()"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="2023"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
rows="4"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Description du manga"
|
||||
/>
|
||||
</div>
|
||||
@@ -91,22 +91,22 @@
|
||||
<!-- Auteur et Statut -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="author" class="block text-sm font-medium text-gray-700 mb-2">Auteur</label>
|
||||
<label for="author" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Auteur</label>
|
||||
<input
|
||||
id="author"
|
||||
v-model="formData.author"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Auteur du manga"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-2">Statut</label>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Statut</label>
|
||||
<input
|
||||
id="status"
|
||||
v-model="formData.status"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="ongoing"
|
||||
/>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
<!-- Note -->
|
||||
<div>
|
||||
<label for="rating" class="block text-sm font-medium text-gray-700 mb-2">Note</label>
|
||||
<label for="rating" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Note</label>
|
||||
<input
|
||||
id="rating"
|
||||
v-model.number="formData.rating"
|
||||
@@ -122,20 +122,20 @@
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.001"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="9.541"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Slugs alternatifs -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Slugs alternatifs</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Slugs alternatifs</label>
|
||||
<div class="space-y-2">
|
||||
<div v-if="formData.alternativeSlugs.length > 0" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(slug, index) in formData.alternativeSlugs"
|
||||
:key="index"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300"
|
||||
>
|
||||
{{ slug }}
|
||||
<button
|
||||
@@ -158,7 +158,7 @@
|
||||
<input
|
||||
v-model="newAlternativeSlug"
|
||||
type="text"
|
||||
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Nouveau slug alternatif"
|
||||
@keyup.enter="addAlternativeSlug"
|
||||
/>
|
||||
@@ -175,19 +175,19 @@
|
||||
|
||||
<!-- Genres -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Genres</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Genres</label>
|
||||
<div class="space-y-3">
|
||||
<div v-if="formData.genres.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<span
|
||||
v-for="(genre, index) in formData.genres"
|
||||
:key="index"
|
||||
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 text-gray-800"
|
||||
class="inline-flex items-center justify-between px-3 py-1 rounded-md text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{{ genre }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeGenre(index)"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 hover:text-gray-600"
|
||||
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMarkIcon class="w-3 h-3" />
|
||||
</button>
|
||||
@@ -204,7 +204,7 @@
|
||||
<input
|
||||
v-model="newGenre"
|
||||
type="text"
|
||||
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Nouveau genre"
|
||||
@keyup.enter="addGenre"
|
||||
/>
|
||||
@@ -224,7 +224,7 @@
|
||||
<div class="mt-8 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
@click="closeModal"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@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" />
|
||||
<img :src="manga.imageUrl || '/placeholder-cover.png'" alt="" class="h-48 w-32 object-cover rounded" referrerpolicy="no-referrer" />
|
||||
<!-- TODO: Add placeholder image -->
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
@@ -24,17 +24,17 @@
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<div>
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
||||
<Cog6ToothIcon class="h-6 w-6 text-blue-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900">
|
||||
<DialogTitle as="h3" class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Sources préférées
|
||||
</DialogTitle>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configurez l'ordre de priorité des sources pour ce manga. Glissez-déposez les sources pour les réorganiser.
|
||||
</p>
|
||||
</div>
|
||||
@@ -47,13 +47,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="mt-5 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-else-if="error" class="mt-5 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ error.message || 'Une erreur est survenue lors du chargement des sources.' }}
|
||||
</div>
|
||||
|
||||
<!-- Sources list -->
|
||||
<div v-else class="mt-5">
|
||||
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500">
|
||||
<div v-if="localSources.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Aucune source disponible
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
@@ -63,10 +63,10 @@
|
||||
:class="[
|
||||
'group relative flex items-center p-4 rounded-lg border-2 transition-all duration-200 cursor-grab active:cursor-grabbing select-none',
|
||||
{
|
||||
'bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-300 shadow-md': index === 0,
|
||||
'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300': index === 1,
|
||||
'bg-gradient-to-r from-yellow-50 to-amber-50 border-yellow-300': index === 2,
|
||||
'bg-gray-50 border-gray-200': index > 2,
|
||||
'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-300 dark:border-blue-700 shadow-md': index === 0,
|
||||
'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border-green-300 dark:border-green-700': index === 1,
|
||||
'bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border-yellow-300 dark:border-yellow-700': index === 2,
|
||||
'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600': index > 2,
|
||||
'scale-105 shadow-lg border-blue-400': draggedIndex === index,
|
||||
'opacity-50': dragOverIndex === index && draggedIndex !== index,
|
||||
'scale-95 active:scale-95': isPressed === index
|
||||
@@ -102,10 +102,10 @@
|
||||
<div :class="[
|
||||
'flex items-center space-x-1 px-3 py-1 rounded-full text-xs font-semibold',
|
||||
{
|
||||
'bg-blue-100 text-blue-800': index === 0,
|
||||
'bg-green-100 text-green-800': index === 1,
|
||||
'bg-yellow-100 text-yellow-800': index === 2,
|
||||
'bg-gray-100 text-gray-600': index > 2
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300': index === 0,
|
||||
'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300': index === 1,
|
||||
'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300': index === 2,
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300': index > 2
|
||||
}
|
||||
]">
|
||||
<span v-if="index === 0">🥇 Priorité haute</span>
|
||||
@@ -117,14 +117,14 @@
|
||||
|
||||
<!-- Informations de la source -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-gray-900 truncate">{{ source.name }}</div>
|
||||
<div class="text-sm text-gray-600 truncate">
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100 truncate">{{ source.name }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
<a :href="source.baseUrl" target="_blank" class="hover:text-blue-600 hover:underline">{{ source.baseUrl }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur de drag -->
|
||||
<div class="ml-4 text-gray-400 group-hover:text-gray-600 transition-colors duration-200">
|
||||
<div class="ml-4 text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors duration-200">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9h8M8 15h8" />
|
||||
</svg>
|
||||
@@ -148,7 +148,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:col-start-1 sm:mt-0"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 sm:col-start-1 sm:mt-0"
|
||||
@click="closeModal"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<th class="w-10 px-4 py-3"></th>
|
||||
<th class="py-3 pr-4 text-left font-medium">Titre</th>
|
||||
<th class="py-3 pr-4 text-left font-medium w-44">Source préférée</th>
|
||||
<th class="py-3 pr-4 text-left font-medium w-44">Chapitres</th>
|
||||
<th class="py-3 px-4 text-right font-medium w-28">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<tr
|
||||
v-for="manga in mangas"
|
||||
:key="manga.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
|
||||
|
||||
<!-- Monitoring -->
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button
|
||||
:title="manga.monitored ? 'Monitoring actif — cliquer pour désactiver' : 'Monitoring inactif — cliquer pour activer'"
|
||||
:class="manga.monitored
|
||||
? 'text-green-500 hover:text-green-600'
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500'"
|
||||
class="transition-colors"
|
||||
@click="doToggleMonitoring(manga)">
|
||||
<component
|
||||
:is="manga.monitored ? BookmarkIcon : BookmarkSlashIcon"
|
||||
class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<!-- Titre -->
|
||||
<td class="py-3 pr-4">
|
||||
<RouterLink
|
||||
:to="{ name: 'manga-details', params: { id: manga.id } }"
|
||||
class="font-medium text-gray-900 dark:text-gray-100 hover:text-green-500 dark:hover:text-green-400 transition-colors">
|
||||
{{ manga.title }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
|
||||
<!-- Source préférée -->
|
||||
<td class="py-3 pr-4">
|
||||
<MangaPreferredSourceCell :manga-id="manga.id" />
|
||||
</td>
|
||||
|
||||
<!-- Chapitres — barre de progression -->
|
||||
<td class="py-3 pr-4">
|
||||
<div v-if="manga.chaptersTotal > 0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
|
||||
{{ manga.chaptersScraped }} / {{ manga.chaptersTotal }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ progressPercent(manga) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 dark:bg-gray-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="progressPercent(manga) >= 100
|
||||
? 'bg-green-500'
|
||||
: 'bg-blue-500'"
|
||||
:style="{ width: progressPercent(manga) + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400 dark:text-gray-600 text-xs">—</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center justify-end gap-0.5">
|
||||
<button
|
||||
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="Éditer"
|
||||
@click="openEdit(manga)">
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="Sources préférées"
|
||||
@click="openSources(manga)">
|
||||
<Cog6ToothIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md transition-colors"
|
||||
:class="refreshingId === manga.id
|
||||
? 'text-blue-400 cursor-not-allowed'
|
||||
: 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600'"
|
||||
title="Rafraîchir"
|
||||
:disabled="refreshingId === manga.id"
|
||||
@click="doRefresh(manga)">
|
||||
<ArrowPathIcon
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin': refreshingId === manga.id }" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modales -->
|
||||
<MangaEditModal
|
||||
:is-open="isEditModalOpen"
|
||||
:manga="selectedManga"
|
||||
:is-saving="editIsLoading"
|
||||
:error="editError"
|
||||
@close="closeEditModal"
|
||||
@save="handleSaveEdit" />
|
||||
|
||||
<MangaPreferredSourcesModal
|
||||
:is-open="isSourcesModalOpen"
|
||||
:sources="preferredSources"
|
||||
:is-loading="sourcesIsLoading"
|
||||
:error="sourcesError"
|
||||
:is-saving="sourcesIsSaving"
|
||||
@close="isSourcesModalOpen = false"
|
||||
@save="handleSaveSources" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowPathIcon, BookmarkIcon, BookmarkSlashIcon, Cog6ToothIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useMangaEdit } from '../composables/useMangaEdit';
|
||||
import { useMangaMonitoring } from '../composables/useMangaMonitoring';
|
||||
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
|
||||
import { useMangaRefresh } from '../composables/useMangaRefresh';
|
||||
import MangaEditModal from './MangaEditModal.vue';
|
||||
import MangaPreferredSourceCell from './MangaPreferredSourceCell.vue';
|
||||
import MangaPreferredSourcesModal from './MangaPreferredSourcesModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mangas: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
function progressPercent(manga) {
|
||||
if (!manga.chaptersTotal) return 0;
|
||||
return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100);
|
||||
}
|
||||
|
||||
// ── Monitoring ────────────────────────────────────────────
|
||||
const { toggleMonitoring } = useMangaMonitoring();
|
||||
|
||||
async function doToggleMonitoring(manga) {
|
||||
await toggleMonitoring(manga.id, !manga.monitored);
|
||||
manga.monitored = !manga.monitored;
|
||||
}
|
||||
|
||||
// ── Selected manga ────────────────────────────────────────
|
||||
const selectedManga = ref(null);
|
||||
const isSourcesModalOpen = ref(false);
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────
|
||||
const { isEditModalOpen, openEditModal, closeEditModal, editManga, isLoading: editIsLoading, error: editError } = useMangaEdit();
|
||||
|
||||
function openEdit(manga) {
|
||||
selectedManga.value = manga;
|
||||
openEditModal();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(data) {
|
||||
if (!selectedManga.value) return;
|
||||
await editManga(selectedManga.value.id, data);
|
||||
}
|
||||
|
||||
// ── Sources préférées ─────────────────────────────────────
|
||||
const selectedMangaId = computed(() => selectedManga.value?.id ?? null);
|
||||
const {
|
||||
sources: preferredSources,
|
||||
isLoading: sourcesIsLoading,
|
||||
error: sourcesError,
|
||||
isSaving: sourcesIsSaving,
|
||||
savePreferredSources
|
||||
} = useMangaPreferredSources(selectedMangaId);
|
||||
|
||||
function openSources(manga) {
|
||||
selectedManga.value = manga;
|
||||
isSourcesModalOpen.value = true;
|
||||
}
|
||||
|
||||
function handleSaveSources(sourceIds) {
|
||||
savePreferredSources(sourceIds);
|
||||
isSourcesModalOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────
|
||||
const { refreshMetadata } = useMangaRefresh();
|
||||
const refreshingId = ref(null);
|
||||
|
||||
async function doRefresh(manga) {
|
||||
if (refreshingId.value) return;
|
||||
refreshingId.value = manga.id;
|
||||
try {
|
||||
await refreshMetadata(manga.id);
|
||||
} finally {
|
||||
refreshingId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-sm shadow mb-2">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-sm shadow mb-2">
|
||||
<!-- En-tête du volume -->
|
||||
<div class="relative bg-white p-3 sm:p-4 rounded-t-sm">
|
||||
<div class="relative bg-white dark:bg-gray-800 p-3 sm:p-4 rounded-t-sm">
|
||||
<!-- Layout mobile/desktop -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Partie gauche -->
|
||||
<div class="flex items-center space-x-1 sm:space-x-4 flex-1 min-w-0">
|
||||
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 flex-shrink-0" />
|
||||
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
|
||||
<BookmarkIcon class="h-6 w-6 sm:h-8 sm:w-8 text-gray-500 dark:text-gray-400 flex-shrink-0" />
|
||||
<h2 class="text-lg sm:text-xl font-semibold w-20 sm:w-28 flex-shrink-0 dark:text-gray-100">Vol {{ String(volume.number).padStart(2, '0') }}</h2>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
:class="[
|
||||
@@ -65,7 +65,7 @@
|
||||
<MangaChapterList v-show="isOpen" :chapters="volume.chapters" :manga-slug="mangaSlug" :manga-id="mangaId" />
|
||||
|
||||
<!-- Chevron de fermeture -->
|
||||
<div v-show="isOpen" class="flex justify-center p-2 bg-white rounded-b-sm">
|
||||
<div v-show="isOpen" class="flex justify-center p-2 bg-white dark:bg-gray-800 rounded-b-sm">
|
||||
<button @click="toggleVolume" class="w-8 h-8 flex items-center justify-center">
|
||||
<ChevronUpIcon
|
||||
class="h-5 w-5 sm:h-6 sm:w-6 bg-gray-400 rounded-full p-1 text-white hover:bg-green-500 cursor-pointer"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
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" />
|
||||
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
|
||||
<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">
|
||||
@@ -20,27 +20,27 @@
|
||||
<!-- É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>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-400">Recherche en cours...</p>
|
||||
</div>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
<div v-if="error" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded relative mb-6">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Résultats de recherche -->
|
||||
<div class="max-w-full overflow-hidden">
|
||||
<MangaList v-if="searchResults.length > 0" :mangas="searchResults" @manga-click="openMangaModal" />
|
||||
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600">Aucun résultat trouvé</p>
|
||||
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600 dark:text-gray-400">Aucun résultat trouvé</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal de confirmation -->
|
||||
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" />
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" aria-hidden="true" />
|
||||
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel class="w-full max-w-lg bg-white rounded-xl shadow-xl p-6">
|
||||
<DialogTitle class="text-lg mb-4"> Ajouter à la bibliothèque </DialogTitle>
|
||||
<DialogPanel class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6">
|
||||
<DialogTitle class="text-lg mb-4 text-gray-900 dark:text-gray-100"> Ajouter à la bibliothèque </DialogTitle>
|
||||
|
||||
<div v-if="selectedManga">
|
||||
<div class="flex gap-4">
|
||||
@@ -49,8 +49,8 @@
|
||||
: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">
|
||||
<h4 class="text-lg text-gray-900 dark:text-gray-100">{{ selectedManga.title }}</h4>
|
||||
<p class="mt-2 text-gray-700 dark:text-gray-300">
|
||||
{{ truncatedDescription }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50">
|
||||
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 dark:bg-gray-800">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="container mx-auto px-4">
|
||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="collection?.items || []" />
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="w-full">
|
||||
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
|
||||
<MangaList
|
||||
v-else-if="viewMode === 'list'"
|
||||
:mangas="collection?.items || []"
|
||||
:mangas="pagedItems"
|
||||
@manga-click="handleMangaClick" />
|
||||
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" />
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
:total="sortedCollection.length"
|
||||
:limit="prefs.itemsPerPage"
|
||||
:has-next-page="currentPage < totalPages"
|
||||
:has-previous-page="currentPage > 1"
|
||||
@page-change="currentPage = $event" />
|
||||
<div
|
||||
v-if="isBackgroundLoading"
|
||||
class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Mise à jour en cours...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,15 +38,19 @@
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaGrid from '../components/MangaGrid.vue';
|
||||
import MangaList from '../components/MangaList.vue';
|
||||
import MangaTable from '../components/MangaTable.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
const prefs = useUserPreferencesStore();
|
||||
|
||||
const {
|
||||
collection,
|
||||
@@ -43,7 +59,8 @@ import MangaList from '../components/MangaList.vue';
|
||||
isBackgroundLoadingCollection: isBackgroundLoading
|
||||
} = storeToRefs(mangaStore);
|
||||
|
||||
const viewMode = ref('grid');
|
||||
const viewMode = ref(prefs.defaultView);
|
||||
const currentPage = ref(1);
|
||||
|
||||
onMounted(() => {
|
||||
mangaStore.loadCollection();
|
||||
@@ -53,6 +70,27 @@ import MangaList from '../components/MangaList.vue';
|
||||
router.push({ name: 'manga-details', params: { id: manga.id } });
|
||||
};
|
||||
|
||||
const sortedCollection = computed(() => {
|
||||
const items = [...(collection.value?.items || [])];
|
||||
if (prefs.sortBy === 'title') {
|
||||
items.sort((a, b) => a.title.localeCompare(b.title));
|
||||
} else if (prefs.sortBy === 'addedAt') {
|
||||
items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const pagedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * prefs.itemsPerPage;
|
||||
return sortedCollection.value.slice(start, start + prefs.itemsPerPage);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.ceil(sortedCollection.value.length / prefs.itemsPerPage));
|
||||
|
||||
watch(() => prefs.itemsPerPage, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
const toolbarConfig = {
|
||||
leftSection: [
|
||||
{
|
||||
@@ -71,8 +109,9 @@ import MangaList from '../components/MangaList.vue';
|
||||
type: 'dropdown',
|
||||
label: 'View',
|
||||
items: [
|
||||
{ label: 'List', onClick: () => (viewMode.value = 'list') },
|
||||
{ label: 'Grid', onClick: () => (viewMode.value = 'grid') }
|
||||
{ label: 'Overview', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
|
||||
{ label: 'Grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
|
||||
{ label: 'Table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -80,10 +119,9 @@ import MangaList from '../components/MangaList.vue';
|
||||
type: 'dropdown',
|
||||
label: 'Sort',
|
||||
items: [
|
||||
{ label: 'Title', onClick: () => {} },
|
||||
{ label: 'Author', onClick: () => {} },
|
||||
{ label: 'Status', onClick: () => {} },
|
||||
{ label: 'Year', onClick: () => {} }
|
||||
{ label: 'Title', onClick: () => prefs.setSortBy('title') },
|
||||
{ label: "Date d'ajout", onClick: () => prefs.setSortBy('addedAt') },
|
||||
{ label: 'Progression', onClick: () => prefs.setSortBy('progress') }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Notifications Toast -->
|
||||
<NotificationToast />
|
||||
|
||||
<div v-if="errorDetails" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mx-4 mt-4">
|
||||
<Toolbar v-if="currentManga" :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
|
||||
<div v-if="errorDetails" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded mx-4 mt-4">
|
||||
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
|
||||
</div>
|
||||
|
||||
@@ -11,9 +15,7 @@
|
||||
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
||||
<MercureListener :manga-id="String(mangaId)" />
|
||||
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
|
||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 z-20">
|
||||
<div v-if="isRefreshingDetails" class="absolute top-2 right-2 text-gray-500 dark:text-gray-400 z-20">
|
||||
<ArrowPathIcon class="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
|
||||
@@ -24,7 +26,7 @@
|
||||
<div v-if="isLoadingVolumes" class="flex justify-center items-center h-32">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
<div v-else-if="errorVolumes" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<div v-else-if="errorVolumes" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ errorVolumes.message || 'Une erreur est survenue lors du chargement des volumes.' }}
|
||||
</div>
|
||||
<MangaVolumeList
|
||||
@@ -84,9 +86,11 @@
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-gray-500 py-10 px-4">
|
||||
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-10 px-4">
|
||||
Aucun manga sélectionné ou trouvé.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useUserPreferencesStore } from '../../../setting/application/store/userPreferencesStore';
|
||||
import { Chapter } from '../../domain/entities/Chapter';
|
||||
import { ApiChapterRepository } from '../../infrastructure/repository/ApiChapterRepository';
|
||||
|
||||
@@ -13,7 +14,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
error: null,
|
||||
pages: [],
|
||||
totalPages: 0,
|
||||
loadedPages: new Set(), // Garder une trace des pages déjà chargées
|
||||
|
||||
// Paramètres pour les doubles pages
|
||||
doublePageSettings: {
|
||||
@@ -32,7 +32,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
|
||||
// Getters pour les doubles pages
|
||||
effectiveDoublePageMode: (state) => {
|
||||
// Si la détection automatique est désactivée, retourner 'normal'
|
||||
if (!state.doublePageSettings.autoDetect) {
|
||||
return 'normal';
|
||||
}
|
||||
@@ -55,28 +54,20 @@ export const useReaderStore = defineStore('reader', {
|
||||
try {
|
||||
const repository = new ApiChapterRepository();
|
||||
|
||||
// Charger les informations du chapitre
|
||||
const chapterData = await repository.getChapter(chapterId);
|
||||
const [chapterData, pagesData] = await Promise.all([
|
||||
repository.getChapter(chapterId),
|
||||
repository.getChapterPages(chapterId, 1, 9999),
|
||||
]);
|
||||
|
||||
this.currentChapter = Chapter.create(chapterData);
|
||||
|
||||
// Charger la liste des pages
|
||||
const pagesData = await repository.getChapterPages(chapterId);
|
||||
|
||||
// Initialiser le tableau avec des placeholders
|
||||
this.pages = new Array(pagesData.totalItems).fill(null);
|
||||
this.pages = pagesData.pages.map(p => ({
|
||||
id: p.id,
|
||||
pageNumber: p.pageNumber,
|
||||
url: p.url,
|
||||
dimensions: p.dimensions,
|
||||
}));
|
||||
this.totalPages = pagesData.totalItems;
|
||||
this.loadedPages.clear();
|
||||
|
||||
// Charger la première page
|
||||
if (this.totalPages > 0) {
|
||||
this.currentPage = 0;
|
||||
await this.loadPageData(0);
|
||||
|
||||
// En mode infini, précharger les premières pages
|
||||
if (this.readingMode === 'infinite') {
|
||||
await this.preloadNextPages(0);
|
||||
}
|
||||
}
|
||||
this.currentPage = 0;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
@@ -84,100 +75,28 @@ export const useReaderStore = defineStore('reader', {
|
||||
}
|
||||
},
|
||||
|
||||
async loadPageData(pageIndex) {
|
||||
if (!this.currentChapter || pageIndex < 0 || pageIndex >= this.totalPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si la page est déjà chargée, ne rien faire
|
||||
if (this.loadedPages.has(pageIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageNumber = pageIndex + 1; // Convertir en 1-based pour l'API
|
||||
|
||||
// Marquer la page comme en cours de chargement
|
||||
const newPages = [...this.pages];
|
||||
newPages[pageIndex] = { loading: true };
|
||||
this.pages = newPages;
|
||||
|
||||
try {
|
||||
const repository = new ApiChapterRepository();
|
||||
const pageData = await repository.getChapterPage(this.currentChapter.id, pageNumber);
|
||||
|
||||
// Vérifier que les données sont valides
|
||||
if (!pageData || !pageData.base64Content) {
|
||||
throw new Error("Données de page invalides reçues de l'API");
|
||||
}
|
||||
|
||||
// Mettre à jour la page
|
||||
const updatedPages = [...this.pages];
|
||||
updatedPages[pageIndex] = {
|
||||
id: pageData.id,
|
||||
pageNumber: pageData.pageNumber,
|
||||
base64Content: pageData.base64Content,
|
||||
mimeType: pageData.mimeType,
|
||||
dimensions: pageData.dimensions
|
||||
};
|
||||
this.pages = updatedPages;
|
||||
this.loadedPages.add(pageIndex);
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors du chargement de la page ${pageNumber}:`, error);
|
||||
// Marquer la page comme en erreur
|
||||
const errorPages = [...this.pages];
|
||||
errorPages[pageIndex] = { error: error.message };
|
||||
this.pages = errorPages;
|
||||
}
|
||||
},
|
||||
|
||||
async preloadNextPages(startIndex, count = 3) {
|
||||
const promises = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const pageIndex = startIndex + i;
|
||||
if (pageIndex < this.totalPages) {
|
||||
promises.push(this.loadPageData(pageIndex));
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
},
|
||||
|
||||
async handlePageVisible(pageIndex) {
|
||||
handlePageVisible(pageIndex) {
|
||||
if (pageIndex !== this.currentPage) {
|
||||
this.currentPage = pageIndex;
|
||||
// Précharger les pages suivantes
|
||||
if (this.readingMode === 'infinite') {
|
||||
await this.preloadNextPages(pageIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async nextPage() {
|
||||
nextPage() {
|
||||
if (!this.isLastPage) {
|
||||
this.currentPage++;
|
||||
await this.loadPageData(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
async previousPage() {
|
||||
previousPage() {
|
||||
if (!this.isFirstPage) {
|
||||
this.currentPage--;
|
||||
await this.loadPageData(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
async setReadingMode(mode) {
|
||||
if (mode === this.readingMode) return;
|
||||
|
||||
this.readingMode = mode;
|
||||
this.savePreferences();
|
||||
|
||||
// S'assurer que la page courante est chargée
|
||||
await this.loadPageData(this.currentPage);
|
||||
|
||||
// Si on passe en mode infini, précharger les pages suivantes
|
||||
if (mode === 'infinite') {
|
||||
await this.preloadNextPages(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
setReadingDirection(direction) {
|
||||
@@ -190,7 +109,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
// Nouvelles actions pour les doubles pages
|
||||
setDoublePageMode(mode) {
|
||||
if (['rotate', 'scroll', 'normal'].includes(mode)) {
|
||||
this.doublePageSettings.mobileMode = mode;
|
||||
@@ -225,16 +143,10 @@ export const useReaderStore = defineStore('reader', {
|
||||
async goToPreviousChapter() {
|
||||
if (this.currentChapter?.navigation?.previousChapter) {
|
||||
await this.loadChapter(this.currentChapter.navigation.previousChapter);
|
||||
// Aller à la dernière page du chapitre précédent
|
||||
this.currentPage = Math.max(0, this.totalPages - 1);
|
||||
// S'assurer que la page est chargée
|
||||
if (this.totalPages > 0) {
|
||||
await this.loadPageData(this.currentPage);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Gestion de la persistance des préférences
|
||||
savePreferences() {
|
||||
try {
|
||||
const preferences = {
|
||||
@@ -252,10 +164,19 @@ export const useReaderStore = defineStore('reader', {
|
||||
loadPreferences() {
|
||||
try {
|
||||
const stored = localStorage.getItem('mangarr-reader-preferences');
|
||||
if (!stored) {
|
||||
const userPrefs = useUserPreferencesStore();
|
||||
this.readingDirection = userPrefs.readingDirection;
|
||||
const modeMap = { scroll: 'infinite', single: 'single', double: 'single' };
|
||||
this.readingMode = modeMap[userPrefs.readingMode] ?? 'single';
|
||||
if (userPrefs.readingMode === 'double') {
|
||||
this.doublePageSettings.autoDetect = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stored) {
|
||||
const preferences = JSON.parse(stored);
|
||||
|
||||
// Appliquer les préférences sauvegardées
|
||||
if (preferences.readingMode) this.readingMode = preferences.readingMode;
|
||||
if (preferences.readingDirection) this.readingDirection = preferences.readingDirection;
|
||||
if (typeof preferences.zoom === 'number') this.zoom = preferences.zoom;
|
||||
@@ -277,7 +198,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
}
|
||||
},
|
||||
|
||||
// Réinitialiser les préférences
|
||||
resetPreferences() {
|
||||
this.readingMode = 'single';
|
||||
this.readingDirection = 'ltr';
|
||||
|
||||
@@ -9,7 +9,7 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getChapterPages(chapterId, page = 1, itemsPerPage = 20) {
|
||||
async getChapterPages(chapterId, page = 1, itemsPerPage = 9999) {
|
||||
const response = await fetch(
|
||||
`/api/reader/chapter/${chapterId}/pages?page=${page}&itemsPerPage=${itemsPerPage}`
|
||||
);
|
||||
@@ -18,12 +18,4 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getChapterPage(chapterId, pageNumber) {
|
||||
const response = await fetch(`/api/reader/chapter/${chapterId}/page/${pageNumber}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch chapter page');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||
import { useUserPreferencesStore } from '../../../../domain/setting/application/store/userPreferencesStore';
|
||||
import { useReaderStore } from '../../application/store/readerStore';
|
||||
import InfiniteReader from './InfiniteReader.vue';
|
||||
import ReaderControls from './ReaderControls.vue';
|
||||
@@ -84,6 +85,7 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
|
||||
const store = useReaderStore();
|
||||
const headerStore = useHeaderStore();
|
||||
const prefs = useUserPreferencesStore();
|
||||
|
||||
// Référence vers InfiniteReader pour accéder à ses méthodes
|
||||
const infiniteReaderRef = ref(null);
|
||||
@@ -97,6 +99,7 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
const toggleReadingMode = () => {
|
||||
const newMode = store.readingMode === 'single' ? 'infinite' : 'single';
|
||||
store.setReadingMode(newMode);
|
||||
prefs.setReadingMode(newMode === 'infinite' ? 'scroll' : 'single');
|
||||
|
||||
// Gérer la visibilité selon le mode
|
||||
if (newMode === 'single') {
|
||||
@@ -111,7 +114,9 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
};
|
||||
|
||||
const toggleReadingDirection = () => {
|
||||
store.setReadingDirection(store.readingDirection === 'ltr' ? 'rtl' : 'ltr');
|
||||
const newDir = store.readingDirection === 'ltr' ? 'rtl' : 'ltr';
|
||||
store.setReadingDirection(newDir);
|
||||
prefs.setReadingDirection(newDir);
|
||||
resetButtonsTimer();
|
||||
};
|
||||
|
||||
@@ -222,6 +227,16 @@ import SingleModeReader from './SingleModeReader.vue';
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
// Auto-hide header si activé dans les préférences
|
||||
if (prefs.autoHideHeaderReader) {
|
||||
headerStore.enableAutoHide();
|
||||
}
|
||||
|
||||
// Auto-fullscreen si activé dans les préférences
|
||||
if (prefs.autoFullscreen && document.documentElement.requestFullscreen) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
// Afficher les boutons au démarrage
|
||||
showButtonsWithTimer();
|
||||
});
|
||||
|
||||
@@ -6,13 +6,10 @@
|
||||
</div>
|
||||
|
||||
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
|
||||
<div v-if="page?.loading" class="loading">
|
||||
<div v-if="!page?.url" class="loading">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
<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" />
|
||||
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<!-- Navigation en bas -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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>
|
||||
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
|
||||
|
||||
<!-- Affichage spécial pour les doubles pages sur mobile -->
|
||||
<div v-else-if="isDoublePage && isMobile && doublePageMode !== 'normal'" class="double-page-mobile">
|
||||
@@ -88,10 +88,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
const imageLoaded = ref(false);
|
||||
|
||||
const imageSource = computed(() => {
|
||||
if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
|
||||
return '';
|
||||
}
|
||||
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`;
|
||||
return props.pageData?.url ?? '';
|
||||
});
|
||||
|
||||
// Détection des doubles pages basée sur le ratio largeur/hauteur et les dimensions API
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
const STORAGE_KEY = 'mangarr_preferences';
|
||||
|
||||
const defaultState = {
|
||||
theme: 'system',
|
||||
language: 'fr',
|
||||
defaultView: 'grid',
|
||||
itemsPerPage: 20,
|
||||
sortBy: 'title',
|
||||
readingDirection: 'ltr',
|
||||
readingMode: 'scroll',
|
||||
autoFullscreen: false,
|
||||
autoHideHeaderReader: true,
|
||||
toastDuration: 5000,
|
||||
};
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return { ...defaultState, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return { ...defaultState };
|
||||
}
|
||||
|
||||
let mediaQueryUnsubscribe = null;
|
||||
|
||||
export const useUserPreferencesStore = defineStore('userPreferences', {
|
||||
state: () => loadFromStorage(),
|
||||
|
||||
actions: {
|
||||
applyTheme() {
|
||||
// Nettoyer le listener précédent
|
||||
if (mediaQueryUnsubscribe) {
|
||||
mediaQueryUnsubscribe();
|
||||
mediaQueryUnsubscribe = null;
|
||||
}
|
||||
|
||||
const html = document.documentElement;
|
||||
|
||||
if (this.theme === 'dark') {
|
||||
html.classList.add('dark');
|
||||
} else if (this.theme === 'light') {
|
||||
html.classList.remove('dark');
|
||||
} else {
|
||||
// mode 'system'
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e) => {
|
||||
if (e.matches) {
|
||||
html.classList.add('dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
handler(mq);
|
||||
mq.addEventListener('change', handler);
|
||||
mediaQueryUnsubscribe = () => mq.removeEventListener('change', handler);
|
||||
}
|
||||
},
|
||||
|
||||
setTheme(theme) {
|
||||
this.theme = theme;
|
||||
this.persist();
|
||||
this.applyTheme();
|
||||
},
|
||||
|
||||
setLanguage(language) {
|
||||
this.language = language;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setDefaultView(view) {
|
||||
this.defaultView = view;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setItemsPerPage(count) {
|
||||
this.itemsPerPage = count;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setSortBy(sort) {
|
||||
this.sortBy = sort;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setReadingDirection(direction) {
|
||||
this.readingDirection = direction;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setReadingMode(mode) {
|
||||
this.readingMode = mode;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setAutoFullscreen(value) {
|
||||
this.autoFullscreen = value;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setAutoHideHeaderReader(value) {
|
||||
this.autoHideHeaderReader = value;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
setToastDuration(duration) {
|
||||
this.toastDuration = duration;
|
||||
this.persist();
|
||||
},
|
||||
|
||||
resetToDefaults() {
|
||||
Object.assign(this, defaultState);
|
||||
this.persist();
|
||||
this.applyTheme();
|
||||
},
|
||||
|
||||
persist() {
|
||||
try {
|
||||
const data = {
|
||||
theme: this.theme,
|
||||
language: this.language,
|
||||
defaultView: this.defaultView,
|
||||
itemsPerPage: this.itemsPerPage,
|
||||
sortBy: this.sortBy,
|
||||
readingDirection: this.readingDirection,
|
||||
readingMode: this.readingMode,
|
||||
autoFullscreen: this.autoFullscreen,
|
||||
autoHideHeaderReader: this.autoHideHeaderReader,
|
||||
toastDuration: this.toastDuration,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
@@ -71,6 +72,7 @@
|
||||
Configuration exportée !
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Back Navigation -->
|
||||
<div class="mb-6">
|
||||
@@ -180,6 +181,7 @@
|
||||
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto h-full"><div class="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('preferences.title') }}</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('preferences.subtitle') }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
@click="handleReset">
|
||||
{{ t('preferences.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Apparence -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.appearance') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Thème -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.theme.label') }}</label>
|
||||
<select
|
||||
:value="store.theme"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setTheme($event.target.value)">
|
||||
<option value="light">{{ t('preferences.theme.light') }}</option>
|
||||
<option value="dark">{{ t('preferences.theme.dark') }}</option>
|
||||
<option value="system">{{ t('preferences.theme.system') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Langue -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.language.label') }}</label>
|
||||
<select
|
||||
:value="store.language"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="handleLanguageChange($event.target.value)">
|
||||
<option value="fr">{{ t('preferences.language.fr') }}</option>
|
||||
<option value="en">{{ t('preferences.language.en') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Affichage collection -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.collection') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Vue par défaut -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.defaultView.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
:class="viewButtonClass('grid')"
|
||||
@click="store.setDefaultView('grid')">
|
||||
{{ t('preferences.defaultView.grid') }}
|
||||
</button>
|
||||
<button
|
||||
:class="viewButtonClass('list')"
|
||||
@click="store.setDefaultView('list')">
|
||||
{{ t('preferences.defaultView.list') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mangas par page -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.itemsPerPage.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="n in [12, 20, 40]"
|
||||
:key="n"
|
||||
:class="countButtonClass(n)"
|
||||
@click="store.setItemsPerPage(n)">
|
||||
{{ n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tri par défaut -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.sortBy.label') }}</label>
|
||||
<select
|
||||
:value="store.sortBy"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setSortBy($event.target.value)">
|
||||
<option value="title">{{ t('preferences.sortBy.title') }}</option>
|
||||
<option value="addedAt">{{ t('preferences.sortBy.addedAt') }}</option>
|
||||
<option value="progress">{{ t('preferences.sortBy.progress') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lecture -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.reading') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Direction de lecture -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingDirection.label') }}</label>
|
||||
<select
|
||||
:value="store.readingDirection"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setReadingDirection($event.target.value)">
|
||||
<option value="ltr">{{ t('preferences.readingDirection.ltr') }}</option>
|
||||
<option value="rtl">{{ t('preferences.readingDirection.rtl') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Mode d'affichage -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.readingMode.label') }}</label>
|
||||
<select
|
||||
:value="store.readingMode"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="store.setReadingMode($event.target.value)">
|
||||
<option value="scroll">{{ t('preferences.readingMode.scroll') }}</option>
|
||||
<option value="single">{{ t('preferences.readingMode.single') }}</option>
|
||||
<option value="double">{{ t('preferences.readingMode.double') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Auto plein écran -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoFullscreen.label') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoFullscreen.description') }}</p>
|
||||
</div>
|
||||
<button
|
||||
:class="toggleClass(store.autoFullscreen)"
|
||||
role="switch"
|
||||
:aria-checked="store.autoFullscreen"
|
||||
@click="store.setAutoFullscreen(!store.autoFullscreen)">
|
||||
<span :class="toggleKnobClass(store.autoFullscreen)" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Auto-hide header -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.autoHideHeaderReader.label') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('preferences.autoHideHeaderReader.description') }}</p>
|
||||
</div>
|
||||
<button
|
||||
:class="toggleClass(store.autoHideHeaderReader)"
|
||||
role="switch"
|
||||
:aria-checked="store.autoHideHeaderReader"
|
||||
@click="store.setAutoHideHeaderReader(!store.autoHideHeaderReader)">
|
||||
<span :class="toggleKnobClass(store.autoHideHeaderReader)" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6 mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||||
{{ t('preferences.sections.notifications') }}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<!-- Durée des toasts -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('preferences.toastDuration.label') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="[val, label] in toastOptions"
|
||||
:key="val"
|
||||
:class="countButtonClass(val, store.toastDuration)"
|
||||
@click="store.setToastDuration(val)">
|
||||
{{ t(label) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUserPreferencesStore } from '../../application/store/userPreferencesStore';
|
||||
import { i18n } from '../../../../shared/i18n';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const store = useUserPreferencesStore();
|
||||
|
||||
const toastOptions = [
|
||||
[3000, 'preferences.toastDuration.3s'],
|
||||
[5000, 'preferences.toastDuration.5s'],
|
||||
[10000, 'preferences.toastDuration.10s'],
|
||||
];
|
||||
|
||||
function handleLanguageChange(lang) {
|
||||
store.setLanguage(lang);
|
||||
i18n.global.locale.value = lang;
|
||||
locale.value = lang;
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (confirm(t('preferences.resetConfirm'))) {
|
||||
store.resetToDefaults();
|
||||
i18n.global.locale.value = store.language;
|
||||
locale.value = store.language;
|
||||
}
|
||||
}
|
||||
|
||||
function viewButtonClass(view) {
|
||||
const active = store.defaultView === view;
|
||||
return [
|
||||
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
|
||||
active
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
];
|
||||
}
|
||||
|
||||
function countButtonClass(val, current = store.itemsPerPage) {
|
||||
const active = current === val;
|
||||
return [
|
||||
'px-3 py-1.5 text-sm rounded-lg border transition-colors',
|
||||
active
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
];
|
||||
}
|
||||
|
||||
function toggleClass(active) {
|
||||
return [
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
active ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600',
|
||||
];
|
||||
}
|
||||
|
||||
function toggleKnobClass(active) {
|
||||
return [
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
active ? 'translate-x-6' : 'translate-x-1',
|
||||
];
|
||||
}
|
||||
</script>
|
||||
@@ -4,6 +4,9 @@ import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import '../../styles/app.scss';
|
||||
import { installVueQuery } from './shared/plugin/vueQuery';
|
||||
import { i18n } from './shared/i18n';
|
||||
import { useUserPreferencesStore } from './domain/setting/application/store/userPreferencesStore';
|
||||
|
||||
// Création du store
|
||||
const pinia = createPinia();
|
||||
|
||||
@@ -14,5 +17,12 @@ const app = createApp(App);
|
||||
app.use(router);
|
||||
app.use(pinia);
|
||||
app.use(installVueQuery);
|
||||
app.use(i18n);
|
||||
|
||||
// Appliquer le thème et la langue sauvegardés
|
||||
const prefs = useUserPreferencesStore();
|
||||
prefs.applyTheme();
|
||||
i18n.global.locale.value = prefs.language;
|
||||
|
||||
// Montage de l'application
|
||||
app.mount('#vue-app');
|
||||
|
||||
@@ -8,6 +8,7 @@ import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
||||
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
||||
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
|
||||
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
|
||||
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
|
||||
import Layout from '../shared/components/layout/Layout.vue';
|
||||
|
||||
// Placeholder component for new routes
|
||||
@@ -129,8 +130,7 @@ const routes = [
|
||||
{
|
||||
path: '/settings/ui',
|
||||
name: 'settings-ui',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: "Paramètres de l'interface" }
|
||||
component: UserPreferencesPage
|
||||
},
|
||||
// Système
|
||||
{
|
||||
@@ -168,6 +168,6 @@ const routes = [
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory('/vue/'),
|
||||
history: createWebHistory('/'),
|
||||
routes
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex">
|
||||
<div class="h-screen overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
|
||||
<Header
|
||||
:show-menu-button="isReaderMode"
|
||||
@menu-click="toggleSidebar"
|
||||
@@ -12,7 +12,7 @@
|
||||
@add-manga-click="$emit('add-manga-click', $event)" />
|
||||
|
||||
<main :class="[
|
||||
'flex-1 pt-16',
|
||||
'flex-1 mt-16 flex flex-col overflow-hidden',
|
||||
isReaderMode ? '' : 'md:ml-60'
|
||||
]">
|
||||
<RouterView></RouterView>
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
<template>
|
||||
<div class="fixed top-4 right-4 z-50 space-y-2">
|
||||
<div class="fixed bottom-4 left-4 z-50 flex flex-col-reverse gap-2">
|
||||
<TransitionGroup
|
||||
name="notification"
|
||||
tag="div"
|
||||
class="space-y-2"
|
||||
class="flex flex-col-reverse gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:class="[
|
||||
'max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden',
|
||||
'max-w-md w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden',
|
||||
getNotificationClass(notification.type)
|
||||
]"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<component :is="getIcon(notification.type)" :class="[
|
||||
'h-6 w-6',
|
||||
getIconClass(notification.type)
|
||||
]" />
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0 flex">
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<button
|
||||
@click="removeNotification(notification.id)"
|
||||
class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
class="bg-white dark:bg-gray-800 rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<XMarkIcon class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 pt-0.5 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 break-words">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-3">
|
||||
<component :is="getIcon(notification.type)" :class="[
|
||||
'h-6 w-6',
|
||||
getIconClass(notification.type)
|
||||
]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,10 +66,10 @@ const getIcon = (type) => {
|
||||
|
||||
const getNotificationClass = (type) => {
|
||||
const classes = {
|
||||
success: 'border-l-4 border-green-400',
|
||||
error: 'border-l-4 border-red-400',
|
||||
warning: 'border-l-4 border-yellow-400',
|
||||
info: 'border-l-4 border-blue-400'
|
||||
success: 'border-r-4 border-green-400',
|
||||
error: 'border-r-4 border-red-400',
|
||||
warning: 'border-r-4 border-yellow-400',
|
||||
info: 'border-r-4 border-blue-400'
|
||||
};
|
||||
return classes[type] || classes.info;
|
||||
};
|
||||
@@ -93,11 +93,11 @@ const getIconClass = (type) => {
|
||||
|
||||
.notification-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.notification-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Informations de pagination -->
|
||||
<div class="flex items-center text-sm text-gray-700">
|
||||
<div class="flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>
|
||||
Affichage de
|
||||
<span class="font-medium">{{ startItem }}</span>
|
||||
@@ -22,8 +22,8 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-2 py-2 text-sm font-medium rounded-md',
|
||||
hasPreviousPage
|
||||
? 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
|
||||
: 'text-gray-300 bg-gray-100 border border-gray-200 cursor-not-allowed'
|
||||
? 'text-gray-500 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
: 'text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 cursor-not-allowed'
|
||||
]">
|
||||
<span class="sr-only">Précédent</span>
|
||||
<ChevronLeftIcon class="h-5 w-5" />
|
||||
@@ -38,14 +38,14 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
|
||||
currentPage === 1
|
||||
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
]">
|
||||
1
|
||||
</button>
|
||||
|
||||
<!-- Points de suspension gauche -->
|
||||
<span v-if="showLeftDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700">
|
||||
<span v-if="showLeftDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
...
|
||||
</span>
|
||||
|
||||
@@ -57,14 +57,14 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
|
||||
currentPage === page
|
||||
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
]">
|
||||
{{ page }}
|
||||
</button>
|
||||
|
||||
<!-- Points de suspension droite -->
|
||||
<span v-if="showRightDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700">
|
||||
<span v-if="showRightDots" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
...
|
||||
</span>
|
||||
|
||||
@@ -75,8 +75,8 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-3 py-2 text-sm font-medium rounded-md',
|
||||
currentPage === totalPages
|
||||
? 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
? 'z-10 bg-indigo-50 dark:bg-indigo-900/30 border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
]">
|
||||
{{ totalPages }}
|
||||
</button>
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
<!-- Pagination mobile -->
|
||||
<div class="md:hidden flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ currentPage }} / {{ totalPages }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -96,8 +96,8 @@
|
||||
:class="[
|
||||
'relative inline-flex items-center px-2 py-2 text-sm font-medium rounded-md',
|
||||
hasNextPage
|
||||
? 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
|
||||
: 'text-gray-300 bg-gray-100 border border-gray-200 cursor-not-allowed'
|
||||
? 'text-gray-500 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
: 'text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 cursor-not-allowed'
|
||||
]">
|
||||
<span class="sr-only">Suivant</span>
|
||||
<ChevronRightIcon class="h-5 w-5" />
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: value => {
|
||||
// Vérifie que leftSection et rightSection sont des tableaux
|
||||
return Array.isArray(value.leftSection) && Array.isArray(value.rightSection);
|
||||
}
|
||||
}
|
||||
|
||||
45
assets/vue/app/shared/composables/useMercureNotifications.js
Normal file
45
assets/vue/app/shared/composables/useMercureNotifications.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useNotifications } from './useNotifications';
|
||||
|
||||
export function useMercureNotifications() {
|
||||
const { showSuccess, showError, showInfo, showWarning } = useNotifications();
|
||||
let eventSource = null;
|
||||
|
||||
const handleNotification = data => {
|
||||
const message = data.message ?? 'Notification';
|
||||
switch (data.status) {
|
||||
case 'success': showSuccess(message); break;
|
||||
case 'error': showError(message); break;
|
||||
case 'warning': showWarning(message); break;
|
||||
default: showInfo(message);
|
||||
}
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
const url = new URL('/.well-known/mercure', window.location.origin);
|
||||
url.searchParams.append('topic', 'notifications');
|
||||
|
||||
eventSource = new EventSource(url, { withCredentials: true });
|
||||
|
||||
eventSource.onmessage = event => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleNotification(data);
|
||||
} catch (e) {
|
||||
console.error('useMercureNotifications: erreur de parsing', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource?.close();
|
||||
setTimeout(setup, 5000);
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(setup);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref } from 'vue';
|
||||
import { useUserPreferencesStore } from '../../domain/setting/application/store/userPreferencesStore';
|
||||
|
||||
const notifications = ref([]);
|
||||
let nextId = 1;
|
||||
@@ -36,20 +37,24 @@ export function useNotifications() {
|
||||
notifications.value = [];
|
||||
};
|
||||
|
||||
const showSuccess = (message, duration = 4000) => {
|
||||
return addNotification(message, 'success', duration);
|
||||
const showSuccess = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'success', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
const showError = (message, duration = 6000) => {
|
||||
return addNotification(message, 'error', duration);
|
||||
const showError = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'error', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
const showWarning = (message, duration = 5000) => {
|
||||
return addNotification(message, 'warning', duration);
|
||||
const showWarning = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'warning', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
const showInfo = (message, duration = 4000) => {
|
||||
return addNotification(message, 'info', duration);
|
||||
const showInfo = (message, duration) => {
|
||||
const prefs = useUserPreferencesStore();
|
||||
return addNotification(message, 'info', duration ?? prefs.toastDuration);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
10
assets/vue/app/shared/i18n/index.js
Normal file
10
assets/vue/app/shared/i18n/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import fr from './locales/fr.json';
|
||||
import en from './locales/en.json';
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'fr',
|
||||
fallbackLocale: 'fr',
|
||||
messages: { fr, en },
|
||||
});
|
||||
67
assets/vue/app/shared/i18n/locales/en.json
Normal file
67
assets/vue/app/shared/i18n/locales/en.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"nav": {
|
||||
"preferences": "Preferences"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"subtitle": "Customize the interface to your liking",
|
||||
"reset": "Reset",
|
||||
"resetConfirm": "Reset to default values?",
|
||||
"sections": {
|
||||
"appearance": "Appearance",
|
||||
"collection": "Collection display",
|
||||
"reading": "Reading",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"theme": {
|
||||
"label": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System (automatic)"
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"fr": "Français",
|
||||
"en": "English"
|
||||
},
|
||||
"defaultView": {
|
||||
"label": "Default view",
|
||||
"grid": "Grid",
|
||||
"list": "List"
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"label": "Mangas per page"
|
||||
},
|
||||
"sortBy": {
|
||||
"label": "Default sort",
|
||||
"title": "Title",
|
||||
"addedAt": "Date added",
|
||||
"progress": "Progress"
|
||||
},
|
||||
"readingDirection": {
|
||||
"label": "Reading direction",
|
||||
"ltr": "Left → Right (western)",
|
||||
"rtl": "Right → Left (manga)"
|
||||
},
|
||||
"readingMode": {
|
||||
"label": "Display mode",
|
||||
"scroll": "Vertical scroll",
|
||||
"single": "Single page",
|
||||
"double": "Double page"
|
||||
},
|
||||
"autoFullscreen": {
|
||||
"label": "Auto fullscreen",
|
||||
"description": "Enter fullscreen when starting the reader"
|
||||
},
|
||||
"autoHideHeaderReader": {
|
||||
"label": "Auto-hide header",
|
||||
"description": "Hide the navigation bar in reading mode"
|
||||
},
|
||||
"toastDuration": {
|
||||
"label": "Notification duration",
|
||||
"3s": "3 seconds",
|
||||
"5s": "5 seconds",
|
||||
"10s": "10 seconds"
|
||||
}
|
||||
}
|
||||
}
|
||||
67
assets/vue/app/shared/i18n/locales/fr.json
Normal file
67
assets/vue/app/shared/i18n/locales/fr.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"nav": {
|
||||
"preferences": "Préférences"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Préférences",
|
||||
"subtitle": "Personnalisez l'interface selon vos goûts",
|
||||
"reset": "Réinitialiser",
|
||||
"resetConfirm": "Remettre les valeurs par défaut ?",
|
||||
"sections": {
|
||||
"appearance": "Apparence",
|
||||
"collection": "Affichage de la collection",
|
||||
"reading": "Lecture",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"theme": {
|
||||
"label": "Thème",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système (automatique)"
|
||||
},
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"fr": "Français",
|
||||
"en": "English"
|
||||
},
|
||||
"defaultView": {
|
||||
"label": "Vue par défaut",
|
||||
"grid": "Grille",
|
||||
"list": "Liste"
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"label": "Mangas par page"
|
||||
},
|
||||
"sortBy": {
|
||||
"label": "Tri par défaut",
|
||||
"title": "Titre",
|
||||
"addedAt": "Date d'ajout",
|
||||
"progress": "Progression"
|
||||
},
|
||||
"readingDirection": {
|
||||
"label": "Direction de lecture",
|
||||
"ltr": "Gauche → Droite (occidental)",
|
||||
"rtl": "Droite → Gauche (manga)"
|
||||
},
|
||||
"readingMode": {
|
||||
"label": "Mode d'affichage",
|
||||
"scroll": "Défilement vertical",
|
||||
"single": "Page unique",
|
||||
"double": "Double page"
|
||||
},
|
||||
"autoFullscreen": {
|
||||
"label": "Plein écran automatique",
|
||||
"description": "Passer en plein écran au démarrage du lecteur"
|
||||
},
|
||||
"autoHideHeaderReader": {
|
||||
"label": "Masquer automatiquement l'en-tête",
|
||||
"description": "Masquer la barre de navigation en mode lecture"
|
||||
},
|
||||
"toastDuration": {
|
||||
"label": "Durée des notifications",
|
||||
"3s": "3 secondes",
|
||||
"5s": "5 secondes",
|
||||
"10s": "10 secondes"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"runtime/frankenphp-symfony": "^0.2.0",
|
||||
"symfony/asset": "7.0.*",
|
||||
"symfony/console": "7.0.*",
|
||||
"symfony/css-selector": "7.0.*",
|
||||
"symfony/doctrine-messenger": "7.0.*",
|
||||
"symfony/dotenv": "7.0.*",
|
||||
"symfony/expression-language": "7.0.*",
|
||||
@@ -117,7 +118,6 @@
|
||||
"phpmd/phpmd": "^2.15",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"symfony/browser-kit": "7.0.*",
|
||||
"symfony/css-selector": "7.0.*",
|
||||
"symfony/maker-bundle": "^1.52",
|
||||
"symfony/phpunit-bridge": "^7.0",
|
||||
"symfony/stopwatch": "7.0.*",
|
||||
|
||||
198
composer.lock
generated
198
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "1ec83e325be6f57ff404050f1ad58e2d",
|
||||
"content-hash": "0fc13b604085c0e8bcdf062505a21389",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/core",
|
||||
@@ -196,16 +196,16 @@
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.7",
|
||||
"version": "0.14.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/brick/math.git",
|
||||
"reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50"
|
||||
"reference": "63422359a44b7f06cae63c3b429b59e8efcc0629"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/brick/math/zipball/07ff363b16ef8aca9692bba3be9e73fe63f34e50",
|
||||
"reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50",
|
||||
"url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629",
|
||||
"reference": "63422359a44b7f06cae63c3b429b59e8efcc0629",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -244,7 +244,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/brick/math/issues",
|
||||
"source": "https://github.com/brick/math/tree/0.14.7"
|
||||
"source": "https://github.com/brick/math/tree/0.14.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -252,7 +252,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-07T10:57:35+00:00"
|
||||
"time": "2026-02-10T14:33:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/cache",
|
||||
@@ -527,16 +527,16 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/dbal",
|
||||
"version": "3.10.4",
|
||||
"version": "3.10.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/dbal.git",
|
||||
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868"
|
||||
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868",
|
||||
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868",
|
||||
"url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef",
|
||||
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -557,9 +557,9 @@
|
||||
"jetbrains/phpstorm-stubs": "2023.1",
|
||||
"phpstan/phpstan": "2.1.30",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "9.6.29",
|
||||
"slevomat/coding-standard": "8.24.0",
|
||||
"squizlabs/php_codesniffer": "4.0.0",
|
||||
"phpunit/phpunit": "9.6.34",
|
||||
"slevomat/coding-standard": "8.27.1",
|
||||
"squizlabs/php_codesniffer": "4.0.1",
|
||||
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
|
||||
},
|
||||
@@ -621,7 +621,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/dbal/issues",
|
||||
"source": "https://github.com/doctrine/dbal/tree/3.10.4"
|
||||
"source": "https://github.com/doctrine/dbal/tree/3.10.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -637,7 +637,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-29T10:46:08+00:00"
|
||||
"time": "2026-02-24T08:03:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
@@ -1223,16 +1223,16 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/migrations",
|
||||
"version": "3.9.5",
|
||||
"version": "3.9.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/migrations.git",
|
||||
"reference": "1b823afbc40f932dae8272574faee53f2755eac5"
|
||||
"reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/migrations/zipball/1b823afbc40f932dae8272574faee53f2755eac5",
|
||||
"reference": "1b823afbc40f932dae8272574faee53f2755eac5",
|
||||
"url": "https://api.github.com/repos/doctrine/migrations/zipball/ffd8355cdd8505fc650d9604f058bf62aedd80a1",
|
||||
"reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1306,7 +1306,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/migrations/issues",
|
||||
"source": "https://github.com/doctrine/migrations/tree/3.9.5"
|
||||
"source": "https://github.com/doctrine/migrations/tree/3.9.6"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1322,7 +1322,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-20T11:15:36+00:00"
|
||||
"time": "2026-02-11T06:46:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/orm",
|
||||
@@ -1971,16 +1971,16 @@
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
"version": "3.11.6",
|
||||
"version": "3.11.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image.git",
|
||||
"reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1"
|
||||
"reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/5f6d27d9fd56312c47f347929e7ac15345c605a1",
|
||||
"reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/2159bcccff18f09d2a392679b81a82c5a003f9bb",
|
||||
"reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2027,7 +2027,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image/issues",
|
||||
"source": "https://github.com/Intervention/image/tree/3.11.6"
|
||||
"source": "https://github.com/Intervention/image/tree/3.11.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2043,7 +2043,7 @@
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-17T13:38:29+00:00"
|
||||
"time": "2026-02-19T13:11:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jms/metadata",
|
||||
@@ -3972,6 +3972,71 @@
|
||||
],
|
||||
"time": "2024-07-26T12:31:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/css-selector",
|
||||
"version": "v7.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/css-selector.git",
|
||||
"reference": "63b9f8c9b3c28c43ad06764c67fe092af2576d17"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/css-selector/zipball/63b9f8c9b3c28c43ad06764c67fe092af2576d17",
|
||||
"reference": "63b9f8c9b3c28c43ad06764c67fe092af2576d17",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\CssSelector\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Jean-François Simon",
|
||||
"email": "jeanfrancois.simon@sensiolabs.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Converts CSS selectors to XPath expressions",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/css-selector/tree/v7.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-05-31T14:55:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dependency-injection",
|
||||
"version": "v7.0.10",
|
||||
@@ -13041,71 +13106,6 @@
|
||||
],
|
||||
"time": "2023-02-07T11:34:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/css-selector",
|
||||
"version": "v7.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/css-selector.git",
|
||||
"reference": "63b9f8c9b3c28c43ad06764c67fe092af2576d17"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/css-selector/zipball/63b9f8c9b3c28c43ad06764c67fe092af2576d17",
|
||||
"reference": "63b9f8c9b3c28c43ad06764c67fe092af2576d17",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\CssSelector\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Jean-François Simon",
|
||||
"email": "jeanfrancois.simon@sensiolabs.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Converts CSS selectors to XPath expressions",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/css-selector/tree/v7.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-05-31T14:55:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/maker-bundle",
|
||||
"version": "v1.62.1",
|
||||
@@ -13200,16 +13200,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/phpunit-bridge",
|
||||
"version": "v7.4.3",
|
||||
"version": "v7.4.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/phpunit-bridge.git",
|
||||
"reference": "f933e68bb9df29d08077a37e1515a23fea8562ab"
|
||||
"reference": "53c5a606cb4ae19c9466a5f8ffe60f61b0c93b5f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/f933e68bb9df29d08077a37e1515a23fea8562ab",
|
||||
"reference": "f933e68bb9df29d08077a37e1515a23fea8562ab",
|
||||
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/53c5a606cb4ae19c9466a5f8ffe60f61b0c93b5f",
|
||||
"reference": "53c5a606cb4ae19c9466a5f8ffe60f61b0c93b5f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -13261,7 +13261,7 @@
|
||||
"testing"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.3"
|
||||
"source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -13281,7 +13281,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-09T15:33:45+00:00"
|
||||
"time": "2026-03-04T13:54:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/web-profiler-bundle",
|
||||
|
||||
@@ -29,7 +29,7 @@ framework:
|
||||
'App\Domain\Manga\Application\Command\RefreshMangaChapters': commands
|
||||
|
||||
# Events spécifiques (pour compatibilité, peuvent être supprimés si tous implémentent AsyncDomainEvent)
|
||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events
|
||||
# ChapterScrapingStarted est synchrone pour que la notif "démarrage" arrive AVANT le scraping
|
||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events
|
||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
|
||||
'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events
|
||||
|
||||
@@ -34,11 +34,11 @@ framework:
|
||||
assets:
|
||||
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
|
||||
|
||||
#when@prod:
|
||||
# webpack_encore:
|
||||
# # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
|
||||
# # Available in version 1.2
|
||||
# cache: true
|
||||
when@prod:
|
||||
webpack_encore:
|
||||
# Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
|
||||
# Available in version 1.2
|
||||
cache: true
|
||||
|
||||
#when@test:
|
||||
# webpack_encore:
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
controllers:
|
||||
resource:
|
||||
path: ../src/Controller/
|
||||
namespace: App\Controller
|
||||
type: attribute
|
||||
|
||||
vue_app:
|
||||
path: /vue/{req}
|
||||
path: /{req}
|
||||
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
|
||||
defaults:
|
||||
template: 'vue/index.html.twig'
|
||||
req: ''
|
||||
requirements:
|
||||
req: ".*"
|
||||
req: "^(?!api/|legacy).*"
|
||||
|
||||
controllers:
|
||||
resource:
|
||||
path: ../src/Controller/
|
||||
namespace: App\Controller
|
||||
type: attribute
|
||||
|
||||
@@ -131,7 +131,7 @@ services:
|
||||
|
||||
App\Domain\Scraping\Infrastructure\Service\LocalImageStorage:
|
||||
arguments:
|
||||
$storagePath: '%env(MANGA_DATA_PATH)%'
|
||||
$storagePath: '%kernel.project_dir%/public/images'
|
||||
|
||||
# Shared Manga Path/File Manager
|
||||
App\Domain\Shared\Domain\Contract\MangaPathManagerInterface:
|
||||
|
||||
151
deploy.php
151
deploy.php
@@ -2,38 +2,135 @@
|
||||
namespace Deployer;
|
||||
|
||||
require 'recipe/symfony.php';
|
||||
// require 'contrib/webpack_encore.php';
|
||||
require 'contrib/npm.php';
|
||||
|
||||
// Config
|
||||
set('nodejs_version', 'node_22.x');
|
||||
set('keep_releases', '3');
|
||||
set('repository', 'gitea@git.test.nestor-server.fr:Colgora/Mangarr.git');
|
||||
set('webpack_encore/env', 'production');
|
||||
set('composer_options', '--verbose --prefer-dist --no-progress --no-interaction --optimize-autoloader');
|
||||
// GITEA_TOKEN injecté depuis le secret Gitea (scope: read:repository)
|
||||
$giteaToken = getenv('GITEA_TOKEN') ?: throw new \RuntimeException('GITEA_TOKEN secret is required');
|
||||
set('repository', "https://{$giteaToken}@git.homelab.nestor-server.fr/colgora/Mangarr.git");
|
||||
set('keep_releases', 3);
|
||||
set('composer_options', '--no-dev --optimize-autoloader --no-interaction --prefer-dist --ignore-platform-reqs --no-scripts');
|
||||
|
||||
set('shared_files', ['.env.local','var/log/prod.log']);
|
||||
set('shared_dirs', ['config/secrets','public/cbz','public/tmp','public/images']);
|
||||
// add('writable_dirs', []);
|
||||
// Copier vendor/ depuis la release précédente (hard links, quasi instantané)
|
||||
// node_modules est géré par le shared mount /srv/mangarr/shared/node_modules
|
||||
set('copy_dirs', ['vendor']);
|
||||
|
||||
desc('Runs webpack encore build');
|
||||
task('webpack_encore:build', function () {
|
||||
run("cd {{release_path}} && npm run build");
|
||||
});
|
||||
// Pas de shared_files ni shared_dirs : tout est géré par les volumes Docker
|
||||
set('shared_files', []);
|
||||
set('shared_dirs', []);
|
||||
set('writable_dirs', []);
|
||||
|
||||
desc('Run messenger consume');
|
||||
task('messenger:consume', function () {
|
||||
run("sudo supervisorctl restart messenger-consume:*");
|
||||
});
|
||||
|
||||
host('mangarr.test.nestor-server.fr')
|
||||
->set('remote_user', 'colgora')
|
||||
->set('deploy_path', '/var/www/mangarr')
|
||||
host('production')
|
||||
->set('hostname', getenv('DEPLOY_HOST')) // Injecté depuis le secret Gitea
|
||||
->set('remote_user', 'deploy') // User avec accès docker group
|
||||
->set('deploy_path', '/srv/mangarr')
|
||||
->set('branch', 'main');
|
||||
|
||||
// Créer les dossiers que Docker doit monter comme volumes (gitignorés, absents de la release)
|
||||
task('deploy:prepare_dirs', function () {
|
||||
run('mkdir -p {{release_path}}/var {{release_path}}/public/images {{release_path}}/public/cbz {{release_path}}/public/tmp');
|
||||
});
|
||||
|
||||
// composer install via container éphémère (pas de PHP sur l'hôte requis)
|
||||
// --user assure que vendor/ appartient au user deploy et non root
|
||||
// Skip si composer.lock inchangé et vendor/ déjà populé (hard-linké depuis la release précédente)
|
||||
task('deploy:vendors', function () {
|
||||
$releaseDir = get('release_path');
|
||||
$previousDir = get('previous_release');
|
||||
|
||||
if ($previousDir !== null) {
|
||||
$lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1");
|
||||
$vendorPopulated = test("[ -d $releaseDir/vendor/composer ]");
|
||||
|
||||
if ($lockUnchanged && $vendorPopulated) {
|
||||
writeln('<info>deploy:vendors skipped — composer.lock unchanged</info>');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
run('docker run --rm --user $(id -u):$(id -g) -v {{release_path}}:/app -w /app composer:2 install {{composer_options}}');
|
||||
});
|
||||
|
||||
// Build assets via container node éphémère
|
||||
// 3 couches d'optimisation :
|
||||
// 1. Skip total si aucun fichier front-end n'a changé (hard-link public/build/)
|
||||
// 2. Skip npm install si package-lock.json inchangé (node_modules partagé persistant)
|
||||
// 3. Cache npm et webpack persistants entre les releases
|
||||
desc('Build Webpack Encore assets');
|
||||
task('webpack_encore:build', function () {
|
||||
$sharedDir = '/srv/mangarr/shared';
|
||||
$sharedWebpackCache = "$sharedDir/webpack_cache";
|
||||
$sharedNodeModules = "$sharedDir/node_modules";
|
||||
$sharedNpmCache = "$sharedDir/npm_cache";
|
||||
|
||||
run("mkdir -p $sharedWebpackCache $sharedNodeModules $sharedNpmCache");
|
||||
|
||||
$releaseDir = get('release_path');
|
||||
$previousDir = get('previous_release'); // null au 1er déploiement
|
||||
|
||||
// --- COUCHE 1 : skip total si aucun fichier front-end n'a changé ---
|
||||
if ($previousDir !== null) {
|
||||
$watchList = ['assets', 'templates', 'package.json', 'package-lock.json',
|
||||
'webpack.config.js', 'postcss.config.js', 'tailwind.config.js'];
|
||||
|
||||
$diffChecks = implode(' && ', array_map(
|
||||
fn($p) => "diff -rq --no-dereference $previousDir/$p $releaseDir/$p > /dev/null 2>&1",
|
||||
$watchList
|
||||
));
|
||||
|
||||
$hasPreviousBuild = test("[ -d $previousDir/public/build ] && [ -f $previousDir/public/build/manifest.json ]");
|
||||
|
||||
if ($hasPreviousBuild && test("($diffChecks)")) {
|
||||
run("cp -al $previousDir/public/build $releaseDir/public/build");
|
||||
writeln('<info>webpack_encore:build skipped — no front-end files changed</info>');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- COUCHE 2 : skip npm install si package-lock.json inchangé ---
|
||||
$needsNpmInstall = true;
|
||||
if ($previousDir !== null) {
|
||||
$lockUnchanged = test("diff -q $previousDir/package-lock.json $releaseDir/package-lock.json > /dev/null 2>&1");
|
||||
$nmPopulated = test("[ -d $sharedNodeModules/.bin ]");
|
||||
if ($lockUnchanged && $nmPopulated) {
|
||||
$needsNpmInstall = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- COUCHE 3 : build docker avec caches persistants ---
|
||||
$installCmd = $needsNpmInstall
|
||||
? 'npm install --prefer-offline && npm run build'
|
||||
: 'npm run build';
|
||||
|
||||
run("docker run --rm \
|
||||
--user \$(id -u):\$(id -g) \
|
||||
-v $releaseDir:/app \
|
||||
-v $sharedNodeModules:/app/node_modules \
|
||||
-v $sharedWebpackCache:/app/node_modules/.cache \
|
||||
-v $sharedNpmCache:/npm_cache \
|
||||
-e npm_config_cache=/npm_cache \
|
||||
-e PUPPETEER_SKIP_DOWNLOAD=1 \
|
||||
-w /app \
|
||||
node:22-alpine \
|
||||
sh -c '$installCmd'");
|
||||
});
|
||||
|
||||
// Restart Docker containers (entrypoint gère les migrations automatiquement)
|
||||
// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage
|
||||
// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release.
|
||||
desc('Restart Docker containers');
|
||||
task('docker:restart', function () {
|
||||
run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler');
|
||||
run('docker restart mangarr');
|
||||
run('docker exec mangarr php bin/console cache:clear --env=prod');
|
||||
});
|
||||
|
||||
// Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin
|
||||
// Le cache et les migrations sont gérés par l'entrypoint.sh au démarrage du container
|
||||
task('deploy:cache:clear', function () {});
|
||||
task('deploy:cache:warmup', function () {});
|
||||
|
||||
// Hooks
|
||||
after('deploy:vendors', 'npm:install');
|
||||
after('npm:install', 'webpack_encore:build');
|
||||
after('deploy:vendors', 'database:migrate');
|
||||
after('deploy:symlink', 'messenger:consume');
|
||||
after('deploy:update_code', 'deploy:prepare_dirs');
|
||||
after('deploy:prepare_dirs', 'deploy:copy_dirs');
|
||||
after('deploy:vendors', 'webpack_encore:build');
|
||||
after('deploy:symlink', 'docker:restart');
|
||||
after('deploy:failed', 'deploy:unlock');
|
||||
|
||||
1965
package-lock.json
generated
1965
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@
|
||||
"react-router-dom": "^7.1.5",
|
||||
"sortablejs": "^1.15.2",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vuedraggable": "^2.24.3"
|
||||
}
|
||||
}
|
||||
|
||||
55
src/Command/SendTestNotificationCommand.php
Normal file
55
src/Command/SendTestNotificationCommand.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:notify:test',
|
||||
description: 'Envoie une notification de test via Mercure (utile en dev/prod pour vérifier le système)',
|
||||
)]
|
||||
class SendTestNotificationCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationInterface $notification
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('type', 't', InputOption::VALUE_REQUIRED, 'Type de notification : info, success, error, warning', 'info')
|
||||
->addOption('message', 'm', InputOption::VALUE_REQUIRED, 'Message à envoyer', 'Notification de test depuis Mangarr');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$type = $input->getOption('type');
|
||||
$message = $input->getOption('message');
|
||||
|
||||
$allowed = ['info', 'success', 'error', 'warning'];
|
||||
if (!in_array($type, $allowed, true)) {
|
||||
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
match ($type) {
|
||||
'success' => $this->notification->sendSuccess($message),
|
||||
'error' => $this->notification->sendError($message),
|
||||
'warning' => $this->notification->sendWarning($message),
|
||||
default => $this->notification->sendInfo($message),
|
||||
};
|
||||
|
||||
$output->writeln(sprintf('<info>[%s] Notification envoyée : %s</info>', strtoupper($type), $message));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class MangaController extends AbstractController
|
||||
$this->imageManager = new ImageManager(new Driver());
|
||||
}
|
||||
|
||||
#[Route('/', name: 'app_manga')]
|
||||
#[Route('/legacy', name: 'app_legacy')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$sort = $request->query->get('sort', 'title');
|
||||
|
||||
@@ -48,6 +48,7 @@ final class ConvertFileController extends AbstractController
|
||||
|
||||
// Retourner le fichier converti
|
||||
$fileContent = file_get_contents($response->convertedFilePath);
|
||||
@unlink($response->convertedFilePath);
|
||||
|
||||
return new Response(
|
||||
content: $fileContent,
|
||||
|
||||
@@ -58,7 +58,12 @@ readonly class MangadexProvider implements MangaProviderInterface
|
||||
{
|
||||
try {
|
||||
$attributes = $result['attributes'];
|
||||
$title = $attributes['title']['en'] ?? null;
|
||||
$title = $attributes['title']['en']
|
||||
?? $attributes['title']['fr']
|
||||
?? $attributes['title']['ja-ro']
|
||||
?? $attributes['title']['ko-ro']
|
||||
?? $attributes['title']['zh-ro']
|
||||
?? (!empty($attributes['title']) ? reset($attributes['title']) : null);
|
||||
|
||||
if (!$title) {
|
||||
return null;
|
||||
@@ -77,7 +82,7 @@ readonly class MangadexProvider implements MangaProviderInterface
|
||||
}
|
||||
if ($relationship['type'] === 'cover_art') {
|
||||
$imageUrl = sprintf(
|
||||
'https://mangadex.org/covers/%s/%s',
|
||||
'https://uploads.mangadex.org/covers/%s/%s.512.jpg',
|
||||
$result['id'],
|
||||
$relationship['attributes']['fileName']
|
||||
);
|
||||
|
||||
@@ -204,8 +204,9 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz
|
||||
}
|
||||
}
|
||||
|
||||
// Si on a trouvé un volume précédent et que le suivant est le même ou null, alors utilise le précédent
|
||||
if ($prevVolume !== null && ($nextVolume === null || $nextVolume === $prevVolume)) {
|
||||
// Priorité au volume précédent : le chapitre appartient à la fin du volume en cours
|
||||
// Couvre les cas : milieu de volume (prev=next), transition entre deux volumes (prev≠next)
|
||||
if ($prevVolume !== null) {
|
||||
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||
new ChapterId($currentChapter->getId()),
|
||||
$currentChapter->getMangaId(),
|
||||
@@ -218,8 +219,8 @@ readonly class MangadxChapterSynchronizationService implements ChapterSynchroniz
|
||||
$currentChapter->getCreatedAt()
|
||||
);
|
||||
}
|
||||
// Si on a trouvé un volume suivant mais pas de précédent, utilise le suivant
|
||||
elseif ($nextVolume !== null && $prevVolume === null) {
|
||||
// Sinon utilise le volume suivant (chapitres en début de série)
|
||||
elseif ($nextVolume !== null) {
|
||||
$chaptersByNumber[$currentChapterNum] = new Chapter(
|
||||
new ChapterId($currentChapter->getId()),
|
||||
$currentChapter->getMangaId(),
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Application\Query;
|
||||
|
||||
final readonly class GetChapterPage
|
||||
{
|
||||
public function __construct(
|
||||
private string $chapterId,
|
||||
private int $pageNumber
|
||||
) {
|
||||
}
|
||||
|
||||
public function getChapterId(): string
|
||||
{
|
||||
return $this->chapterId;
|
||||
}
|
||||
|
||||
public function getPageNumber(): int
|
||||
{
|
||||
return $this->pageNumber;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Reader\Application\Query\GetChapterPage;
|
||||
use App\Domain\Reader\Application\Response\ChapterPageResponse;
|
||||
use App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Reader\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Reader\Domain\Exception\PageNotFoundException;
|
||||
use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
final readonly class GetChapterPageHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ChapterRepositoryInterface $chapterRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(GetChapterPage $query): ChapterPageResponse
|
||||
{
|
||||
$chapterId = new ChapterId($query->getChapterId());
|
||||
$pageNumber = new PageNumber($query->getPageNumber());
|
||||
|
||||
$totalPages = $this->chapterRepository->getTotalPagesForChapter($chapterId);
|
||||
|
||||
if ($totalPages === 0) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
if ($pageNumber->getValue() > $totalPages) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$page = $this->chapterRepository->getPageContent($chapterId, $pageNumber);
|
||||
|
||||
return new ChapterPageResponse(
|
||||
$page->getId(),
|
||||
$page->getPageNumber()->getValue(),
|
||||
$page->getBase64Content(),
|
||||
$page->getMimeType(),
|
||||
$page->getDimensions()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Application\Response;
|
||||
|
||||
final readonly class ChapterPageResponse
|
||||
{
|
||||
public function __construct(
|
||||
private string $id,
|
||||
private int $pageNumber,
|
||||
private string $base64Content,
|
||||
private string $mimeType,
|
||||
private array $dimensions
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getPageNumber(): int
|
||||
{
|
||||
return $this->pageNumber;
|
||||
}
|
||||
|
||||
public function getBase64Content(): string
|
||||
{
|
||||
return $this->base64Content;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
public function getDimensions(): array
|
||||
{
|
||||
return $this->dimensions;
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@ namespace App\Domain\Reader\Domain\Contract\Repository;
|
||||
|
||||
use App\Domain\Reader\Domain\Model\ChapterContext;
|
||||
use App\Domain\Reader\Domain\Model\Page;
|
||||
use App\Domain\Reader\Domain\Model\PageContent;
|
||||
use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
interface ChapterRepositoryInterface
|
||||
{
|
||||
@@ -24,6 +22,4 @@ interface ChapterRepositoryInterface
|
||||
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId;
|
||||
|
||||
public function getNextChapterId(ChapterId $chapterId): ?ChapterId;
|
||||
|
||||
public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent;
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Domain\Model;
|
||||
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
final readonly class PageContent
|
||||
{
|
||||
public function __construct(
|
||||
private string $id,
|
||||
private PageNumber $pageNumber,
|
||||
private string $base64Content,
|
||||
private string $mimeType,
|
||||
private int $width,
|
||||
private int $height
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getPageNumber(): PageNumber
|
||||
{
|
||||
return $this->pageNumber;
|
||||
}
|
||||
|
||||
public function getBase64Content(): string
|
||||
{
|
||||
return $this->base64Content;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
public function getDimensions(): array
|
||||
{
|
||||
return [
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider\ChapterPageProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'Reader',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/reader/chapter/{chapterId}/page/{pageNumber}',
|
||||
openapiContext: [
|
||||
'summary' => 'Récupère une page spécifique d\'un chapitre',
|
||||
'description' => 'Retourne le contenu d\'une page en base64 avec ses métadonnées',
|
||||
'parameters' => [
|
||||
[
|
||||
'name' => 'chapterId',
|
||||
'in' => 'path',
|
||||
'required' => true,
|
||||
'schema' => ['type' => 'string'],
|
||||
'description' => 'L\'identifiant du chapitre'
|
||||
],
|
||||
[
|
||||
'name' => 'pageNumber',
|
||||
'in' => 'path',
|
||||
'required' => true,
|
||||
'schema' => ['type' => 'integer', 'minimum' => 1],
|
||||
'description' => 'Le numéro de la page à récupérer'
|
||||
],
|
||||
],
|
||||
'responses' => [
|
||||
'200' => [
|
||||
'description' => 'Page du chapitre',
|
||||
'content' => [
|
||||
'application/json' => [
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'string'],
|
||||
'pageNumber' => ['type' => 'integer'],
|
||||
'base64Content' => ['type' => 'string', 'description' => 'Contenu de l\'image en base64'],
|
||||
'mimeType' => ['type' => 'string', 'example' => 'image/jpeg'],
|
||||
'dimensions' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'width' => ['type' => 'integer'],
|
||||
'height' => ['type' => 'integer']
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'404' => [
|
||||
'description' => 'Chapitre ou page non trouvé'
|
||||
]
|
||||
]
|
||||
],
|
||||
provider: ChapterPageProvider::class
|
||||
),
|
||||
],
|
||||
)]
|
||||
class ChapterPageResource
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Domain\Reader\Application\Query\GetChapterPage;
|
||||
use App\Domain\Reader\Application\QueryHandler\GetChapterPageHandler;
|
||||
use App\Domain\Reader\Application\Response\ChapterPageResponse;
|
||||
|
||||
final readonly class ChapterPageProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetChapterPageHandler $handler
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ChapterPageResponse
|
||||
{
|
||||
return $this->handler->handle(
|
||||
new GetChapterPage(
|
||||
$uriVariables['chapterId'],
|
||||
(int) $uriVariables['pageNumber']
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,6 @@ use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
use App\Entity\Chapter as ChapterEntity;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use ZipArchive;
|
||||
use App\Domain\Reader\Domain\Exception\PageNotFoundException;
|
||||
use App\Domain\Reader\Domain\Model\PageContent;
|
||||
|
||||
readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
{
|
||||
@@ -34,12 +31,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
return $this->getPagesFromDirectory($chapterId, $pagesDirectory, $page, $itemsPerPage);
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->getPagesFromCbz($chapterId, $cbzPath, $page, $itemsPerPage);
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getChapterContext(ChapterId $chapterId): ChapterContext
|
||||
@@ -84,17 +76,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
return count($this->getImageFiles($pagesDirectory));
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($cbzPath);
|
||||
$count = $zip->numFiles;
|
||||
$zip->close();
|
||||
|
||||
return $count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId
|
||||
@@ -147,29 +129,6 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
return $nextChapter ? new ChapterId((string) $nextChapter->getId()) : null;
|
||||
}
|
||||
|
||||
public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
||||
'id' => $chapterId->getValue()
|
||||
]);
|
||||
|
||||
if (!$chapter) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
$pagesDirectory = $chapter->getPagesDirectory();
|
||||
if ($pagesDirectory && is_dir($pagesDirectory)) {
|
||||
return $this->getPageContentFromDirectory($chapterId, $pagesDirectory, $pageNumber);
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath || !file_exists($cbzPath)) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
return $this->getPageContentFromCbz($chapterId, $cbzPath, $pageNumber);
|
||||
}
|
||||
|
||||
private function getImageFiles(string $pagesDirectory): array
|
||||
{
|
||||
$files = glob($pagesDirectory . '/*.{jpg,jpeg,png,webp,gif}', GLOB_BRACE) ?: [];
|
||||
@@ -181,17 +140,12 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
private function getPagesFromDirectory(ChapterId $chapterId, string $pagesDirectory, int $page, int $itemsPerPage): array
|
||||
{
|
||||
$files = $this->getImageFiles($pagesDirectory);
|
||||
$start = ($page - 1) * $itemsPerPage;
|
||||
$start = max(0, ($page - 1) * $itemsPerPage);
|
||||
$end = min($start + $itemsPerPage, count($files));
|
||||
$pages = [];
|
||||
|
||||
for ($i = $start; $i < $end; $i++) {
|
||||
$imageContent = file_get_contents($files[$i]);
|
||||
if ($imageContent === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
$imageSize = @getimagesize($files[$i]);
|
||||
if ($imageSize === false) {
|
||||
continue;
|
||||
}
|
||||
@@ -199,7 +153,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
$pages[] = new Page(
|
||||
basename($files[$i]),
|
||||
new PageNumber($i + 1),
|
||||
sprintf('/api/chapters/%s/pages/%d', $chapterId->getValue(), $i + 1),
|
||||
sprintf('/images/pages/%s/%s', $chapterId->getValue(), basename($files[$i])),
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
@@ -207,120 +161,4 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
private function getPagesFromCbz(ChapterId $chapterId, string $cbzPath, int $page, int $itemsPerPage): array
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($cbzPath);
|
||||
|
||||
$pages = [];
|
||||
$start = ($page - 1) * $itemsPerPage;
|
||||
$end = min($start + $itemsPerPage, $zip->numFiles);
|
||||
|
||||
for ($i = $start; $i < $end; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
if ($stat === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageContent = $zip->getFromIndex($i);
|
||||
if ($imageContent === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
if ($imageSize === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pages[] = new Page(
|
||||
$stat['name'],
|
||||
new PageNumber($i + 1),
|
||||
sprintf('/api/chapters/%s/pages/%d', $chapterId->getValue(), $i + 1),
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
private function getPageContentFromDirectory(ChapterId $chapterId, string $pagesDirectory, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
$files = $this->getImageFiles($pagesDirectory);
|
||||
|
||||
if (!$files || $pageNumber->getValue() > count($files)) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$filePath = $files[$pageNumber->getValue() - 1];
|
||||
$imageContent = file_get_contents($filePath);
|
||||
|
||||
if ($imageContent === false) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
if ($imageSize === false) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$mimeType = $imageSize['mime'] ?? 'image/jpeg';
|
||||
|
||||
return new PageContent(
|
||||
basename($filePath),
|
||||
$pageNumber,
|
||||
base64_encode($imageContent),
|
||||
$mimeType,
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
}
|
||||
|
||||
private function getPageContentFromCbz(ChapterId $chapterId, string $cbzPath, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($cbzPath);
|
||||
|
||||
if ($pageNumber->getValue() > $zip->numFiles) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$index = $pageNumber->getValue() - 1;
|
||||
$stat = $zip->statIndex($index);
|
||||
|
||||
if ($stat === false) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$imageContent = $zip->getFromIndex($index);
|
||||
|
||||
if ($imageContent === false) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
|
||||
if ($imageSize === false) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$mimeType = $imageSize['mime'] ?? 'image/jpeg';
|
||||
$zip->close();
|
||||
|
||||
return new PageContent(
|
||||
$stat['name'],
|
||||
$pageNumber,
|
||||
base64_encode($imageContent),
|
||||
$mimeType,
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
||||
use App\Domain\Scraping\Domain\Model\Chapter;
|
||||
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
||||
use App\Domain\Scraping\Domain\Model\Source;
|
||||
@@ -53,13 +54,16 @@ readonly class ScrapeChapterHandler
|
||||
throw new \InvalidArgumentException("Manga not found with ID: {$chapter->mangaId}");
|
||||
}
|
||||
|
||||
// 3. Détermination des sources à utiliser
|
||||
// 3. Dispatch de l'événement de démarrage
|
||||
$this->eventBus->dispatch(new ChapterScrapingStarted($manga->getTitle(), $chapter->chapterNumber));
|
||||
|
||||
// 4. Détermination des sources à utiliser
|
||||
$sources = $this->getSourcesToTry($manga);
|
||||
if (empty($sources)) {
|
||||
throw new \InvalidArgumentException("No sources available for scraping");
|
||||
}
|
||||
|
||||
// 4. Essai de scraping sur chaque source jusqu'à succès
|
||||
// 5. Essai de scraping sur chaque source jusqu'à succès
|
||||
$success = false;
|
||||
$lastException = null;
|
||||
|
||||
@@ -78,6 +82,7 @@ readonly class ScrapeChapterHandler
|
||||
// Ajout de l'ID du chapitre et du slug dans le contexte du job
|
||||
$job->context['chapterId'] = $command->chapterId;
|
||||
$job->context['slug'] = $slug;
|
||||
$job->context['mangaTitle'] = $manga->getTitle();
|
||||
|
||||
$job->start();
|
||||
$this->jobRepository->save($job);
|
||||
|
||||
@@ -64,7 +64,7 @@ readonly class GetMangaPreferredSourcesHandler
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Si pas de sources préférées, retourner toutes les sources actives
|
||||
// 4. Si pas de sources préférées, retourner toutes les sources active
|
||||
return new GetMangaPreferredSourcesResponse(
|
||||
$query->mangaId,
|
||||
$allActiveSources,
|
||||
|
||||
@@ -5,12 +5,18 @@ namespace App\Domain\Scraping\Domain\Event;
|
||||
class ChapterScrapingStarted
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $jobId
|
||||
private readonly string $mangaTitle,
|
||||
private readonly float $chapterNumber,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getJobId(): string
|
||||
public function getMangaTitle(): string
|
||||
{
|
||||
return $this->jobId;
|
||||
return $this->mangaTitle;
|
||||
}
|
||||
|
||||
public function getChapterNumber(): float
|
||||
{
|
||||
return $this->chapterNumber;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Domain\Scraping\Infrastructure\EventSubscriber;
|
||||
|
||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
|
||||
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
@@ -18,15 +20,25 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
private readonly HubInterface $hub,
|
||||
private readonly ChapterRepositoryInterface $chapterRepository,
|
||||
private readonly JobRepositoryInterface $jobRepository,
|
||||
private readonly NotificationInterface $notification,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// Les événements sont capturés via le système de message handlers
|
||||
];
|
||||
return [];
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
public function onChapterScrapingStarted(ChapterScrapingStarted $event): void
|
||||
{
|
||||
$chapterNumber = $event->getChapterNumber();
|
||||
$mangaTitle = $event->getMangaTitle();
|
||||
|
||||
$this->notification->sendInfo(
|
||||
sprintf('Scraping du chapitre %s de "%s" démarré', $chapterNumber, $mangaTitle)
|
||||
);
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
@@ -42,7 +54,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer le chapitre associé au job
|
||||
$chapterId = $job->context['chapterId'] ?? null;
|
||||
$this->logger->info('ChapterId extrait du job: ' . $chapterId);
|
||||
|
||||
@@ -55,7 +66,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
|
||||
$this->logger->info('Chapitre trouvé - ID: ' . $chapter->id . ', MangaId: ' . $chapter->mangaId . ', Number: ' . $chapter->chapterNumber);
|
||||
|
||||
// Préparer les données à envoyer au front
|
||||
$data = [
|
||||
'type' => 'chapter.scraped',
|
||||
'chapterId' => $chapter->id,
|
||||
@@ -65,21 +75,19 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
'timestamp' => (new \DateTimeImmutable())->format('c')
|
||||
];
|
||||
|
||||
$this->logger->info('Données préparées pour Mercure: ' . json_encode($data));
|
||||
|
||||
// Publier une mise à jour sur le hub Mercure
|
||||
$topics = [
|
||||
'manga/chapter/' . $chapter->id, // Topic spécifique au chapitre
|
||||
'manga/' . $chapter->mangaId . '/chapters', // Topic pour tous les chapitres d'un manga
|
||||
'scraping/status' // Topic général pour les événements de scraping
|
||||
'manga/chapter/' . $chapter->id,
|
||||
'manga/' . $chapter->mangaId . '/chapters',
|
||||
'scraping/status'
|
||||
];
|
||||
|
||||
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
|
||||
|
||||
$update = new Update($topics, json_encode($data));
|
||||
$this->hub->publish($update);
|
||||
|
||||
$this->logger->info('Mise à jour publiée sur Mercure');
|
||||
$mangaTitle = $job->context['mangaTitle'] ?? 'manga inconnu';
|
||||
$this->notification->sendSuccess(
|
||||
sprintf('Chapitre %s de "%s" scrappé avec succès', $chapter->chapterNumber, $mangaTitle)
|
||||
);
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
@@ -87,7 +95,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
$this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber());
|
||||
|
||||
// Préparer les données à envoyer au front
|
||||
$data = [
|
||||
'type' => 'chapter.scraping.failed',
|
||||
'mangaId' => $event->getMangaId(),
|
||||
@@ -96,19 +103,16 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
||||
'timestamp' => (new \DateTimeImmutable())->format('c')
|
||||
];
|
||||
|
||||
$this->logger->info('Données préparées pour Mercure: ' . json_encode($data));
|
||||
|
||||
// Publier une mise à jour sur le hub Mercure
|
||||
$topics = [
|
||||
'manga/' . $event->getMangaId() . '/chapters', // Topic pour tous les chapitres d'un manga
|
||||
'scraping/status' // Topic général pour les événements de scraping
|
||||
'manga/' . $event->getMangaId() . '/chapters',
|
||||
'scraping/status'
|
||||
];
|
||||
|
||||
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
|
||||
|
||||
$update = new Update($topics, json_encode($data));
|
||||
$this->hub->publish($update);
|
||||
|
||||
$this->logger->info('Mise à jour publiée sur Mercure');
|
||||
$this->notification->sendError(
|
||||
sprintf('Échec du scraping du chapitre %s : %s', $event->getChapterNumber(), $event->getReason())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
15
src/Domain/Shared/Application/Command/DeleteJob.php
Normal file
15
src/Domain/Shared/Application/Command/DeleteJob.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Application\Command;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\CommandInterface;
|
||||
|
||||
readonly class DeleteJob implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
public string $id
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Application\Command;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\CommandInterface;
|
||||
use App\Domain\Shared\Domain\Model\JobStatus;
|
||||
|
||||
readonly class DeleteJobsByCriteria implements CommandInterface
|
||||
{
|
||||
/**
|
||||
* @param JobStatus[]|null $statuses
|
||||
*/
|
||||
public function __construct(
|
||||
public ?array $statuses = null,
|
||||
public ?string $type = null,
|
||||
public ?\DateTimeImmutable $createdAfter = null,
|
||||
public ?\DateTimeImmutable $createdBefore = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Application\CommandHandler;
|
||||
|
||||
use App\Domain\Shared\Application\Command\DeleteJob;
|
||||
use App\Domain\Shared\Domain\Contract\CommandHandlerInterface;
|
||||
use App\Domain\Shared\Domain\Contract\CommandInterface;
|
||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||
|
||||
readonly class DeleteJobHandler implements CommandHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private JobRepositoryInterface $jobRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(CommandInterface $command): void
|
||||
{
|
||||
if (!$command instanceof DeleteJob) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Command must be instance of %s, %s given',
|
||||
DeleteJob::class,
|
||||
get_class($command)
|
||||
));
|
||||
}
|
||||
|
||||
$this->jobRepository->get($command->id);
|
||||
$this->jobRepository->delete($command->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Application\CommandHandler;
|
||||
|
||||
use App\Domain\Shared\Application\Command\DeleteJobsByCriteria;
|
||||
use App\Domain\Shared\Domain\Contract\CommandHandlerInterface;
|
||||
use App\Domain\Shared\Domain\Contract\CommandInterface;
|
||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||
|
||||
readonly class DeleteJobsByCriteriaHandler implements CommandHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private JobRepositoryInterface $jobRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(CommandInterface $command): void
|
||||
{
|
||||
if (!$command instanceof DeleteJobsByCriteria) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Command must be instance of %s, %s given',
|
||||
DeleteJobsByCriteria::class,
|
||||
get_class($command)
|
||||
));
|
||||
}
|
||||
|
||||
$criteria = [];
|
||||
|
||||
if (null !== $command->statuses) {
|
||||
$criteria['statuses'] = $command->statuses;
|
||||
}
|
||||
|
||||
if (null !== $command->type) {
|
||||
$criteria['type'] = $command->type;
|
||||
}
|
||||
|
||||
if (null !== $command->createdAfter) {
|
||||
$criteria['createdAfter'] = $command->createdAfter;
|
||||
}
|
||||
|
||||
if (null !== $command->createdBefore) {
|
||||
$criteria['createdBefore'] = $command->createdBefore;
|
||||
}
|
||||
|
||||
$this->jobRepository->deleteByCriteria($criteria);
|
||||
}
|
||||
}
|
||||
15
src/Domain/Shared/Application/Query/GetJobById.php
Normal file
15
src/Domain/Shared/Application/Query/GetJobById.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Application\Query;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\QueryInterface;
|
||||
|
||||
readonly class GetJobById implements QueryInterface
|
||||
{
|
||||
public function __construct(
|
||||
public string $id
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Shared\Application\Query\GetJobById;
|
||||
use App\Domain\Shared\Application\Response\JobResponse;
|
||||
use App\Domain\Shared\Domain\Contract\QueryHandlerInterface;
|
||||
use App\Domain\Shared\Domain\Contract\QueryInterface;
|
||||
use App\Domain\Shared\Domain\Contract\ResponseInterface;
|
||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||
|
||||
readonly class GetJobByIdHandler implements QueryHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private JobRepositoryInterface $jobRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(QueryInterface $query): ResponseInterface
|
||||
{
|
||||
if (!$query instanceof GetJobById) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Query must be instance of %s, %s given',
|
||||
GetJobById::class,
|
||||
get_class($query)
|
||||
));
|
||||
}
|
||||
|
||||
$job = $this->jobRepository->get($query->id);
|
||||
|
||||
return JobResponse::fromJob($job);
|
||||
}
|
||||
}
|
||||
41
src/Domain/Shared/Application/Response/JobResponse.php
Normal file
41
src/Domain/Shared/Application/Response/JobResponse.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Application\Response;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\ResponseInterface;
|
||||
use App\Domain\Shared\Domain\Model\Job;
|
||||
|
||||
readonly class JobResponse implements ResponseInterface
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $type,
|
||||
public string $status,
|
||||
public \DateTimeImmutable $createdAt,
|
||||
public ?\DateTimeImmutable $startedAt,
|
||||
public ?\DateTimeImmutable $completedAt,
|
||||
public ?string $failureReason,
|
||||
public int $attempts,
|
||||
public int $maxAttempts,
|
||||
public array $context
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromJob(Job $job): self
|
||||
{
|
||||
return new self(
|
||||
id: $job->id,
|
||||
type: $job->type,
|
||||
status: $job->status->value,
|
||||
createdAt: $job->createdAt,
|
||||
startedAt: $job->startedAt,
|
||||
completedAt: $job->completedAt,
|
||||
failureReason: $job->failureReason,
|
||||
attempts: $job->attempts,
|
||||
maxAttempts: $job->maxAttempts,
|
||||
context: $job->context
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,4 +41,17 @@ interface JobRepositoryInterface
|
||||
* } $criteria
|
||||
*/
|
||||
public function countByCriteria(array $criteria): int;
|
||||
|
||||
public function delete(string $id): void;
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* statuses?: ?array<JobStatus>,
|
||||
* type?: ?string,
|
||||
* createdAfter?: ?\DateTimeImmutable,
|
||||
* createdBefore?: ?\DateTimeImmutable
|
||||
* } $criteria
|
||||
* @return int Nombre de jobs supprimés
|
||||
*/
|
||||
public function deleteByCriteria(array $criteria): int;
|
||||
}
|
||||
|
||||
@@ -6,18 +6,13 @@ namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
interface NotificationInterface
|
||||
{
|
||||
/**
|
||||
* Envoie une notification de succès
|
||||
*/
|
||||
public function sendSuccess(string $message): void;
|
||||
|
||||
/**
|
||||
* Envoie une notification d'erreur
|
||||
*/
|
||||
public function sendError(string $message): void;
|
||||
|
||||
/**
|
||||
* Envoie une notification avec un statut personnalisé
|
||||
*/
|
||||
public function sendInfo(string $message): void;
|
||||
|
||||
public function sendWarning(string $message): void;
|
||||
|
||||
public function sendUpdate(array $data): void;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,16 @@ namespace App\Domain\Shared\Infrastructure\ApiPlatform\Resource;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Domain\Shared\Application\Response\JobResponse;
|
||||
use App\Domain\Shared\Domain\Model\JobStatus;
|
||||
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Processor\DeleteJobProcessor;
|
||||
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Processor\DeleteJobsByCriteriaProcessor;
|
||||
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider\DeleteJobProvider;
|
||||
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider\GetJobListStateProvider;
|
||||
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider\GetJobStateProvider;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
@@ -105,7 +112,56 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
]
|
||||
]
|
||||
]
|
||||
)
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/jobs/{id}',
|
||||
provider: GetJobStateProvider::class,
|
||||
output: GetJobListResource::class,
|
||||
description: 'Récupère un job par son identifiant',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/jobs/{id}',
|
||||
provider: DeleteJobProvider::class,
|
||||
processor: DeleteJobProcessor::class,
|
||||
description: 'Supprime un job par son identifiant',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/jobs',
|
||||
processor: DeleteJobsByCriteriaProcessor::class,
|
||||
description: 'Supprime les jobs correspondant aux critères',
|
||||
openapiContext: [
|
||||
'parameters' => [
|
||||
[
|
||||
'name' => 'status',
|
||||
'in' => 'query',
|
||||
'description' => 'Filtrer par statut(s) (virgule-séparés ou tableau)',
|
||||
'required' => false,
|
||||
'schema' => ['type' => 'string'],
|
||||
],
|
||||
[
|
||||
'name' => 'type',
|
||||
'in' => 'query',
|
||||
'description' => 'Filtrer par type de job',
|
||||
'required' => false,
|
||||
'schema' => ['type' => 'string'],
|
||||
],
|
||||
[
|
||||
'name' => 'createdAfter',
|
||||
'in' => 'query',
|
||||
'description' => 'Date de création minimum (ISO8601)',
|
||||
'required' => false,
|
||||
'schema' => ['type' => 'string', 'format' => 'date-time'],
|
||||
],
|
||||
[
|
||||
'name' => 'createdBefore',
|
||||
'in' => 'query',
|
||||
'description' => 'Date de création maximum (ISO8601)',
|
||||
'required' => false,
|
||||
'schema' => ['type' => 'string', 'format' => 'date-time'],
|
||||
],
|
||||
]
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
class GetJobListResource
|
||||
@@ -157,4 +213,20 @@ class GetJobListResource
|
||||
context: $job->context
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromJobResponse(JobResponse $response): self
|
||||
{
|
||||
return new self(
|
||||
id: $response->id,
|
||||
type: $response->type,
|
||||
status: $response->status,
|
||||
createdAt: $response->createdAt,
|
||||
startedAt: $response->startedAt,
|
||||
completedAt: $response->completedAt,
|
||||
failureReason: $response->failureReason,
|
||||
attempts: $response->attempts,
|
||||
maxAttempts: $response->maxAttempts,
|
||||
context: $response->context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Domain\Shared\Application\Command\DeleteJob;
|
||||
use App\Domain\Shared\Application\CommandHandler\DeleteJobHandler;
|
||||
use App\Domain\Shared\Domain\Exception\JobNotFoundException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
readonly class DeleteJobProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private DeleteJobHandler $handler
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
|
||||
{
|
||||
try {
|
||||
$this->handler->handle(new DeleteJob($uriVariables['id']));
|
||||
} catch (JobNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage(), $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Domain\Shared\Application\Command\DeleteJobsByCriteria;
|
||||
use App\Domain\Shared\Application\CommandHandler\DeleteJobsByCriteriaHandler;
|
||||
use App\Domain\Shared\Domain\Model\JobStatus;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
readonly class DeleteJobsByCriteriaProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private DeleteJobsByCriteriaHandler $handler,
|
||||
private RequestStack $requestStack
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$filters = $request ? $request->query->all() : [];
|
||||
|
||||
$statuses = null;
|
||||
if (isset($filters['status'])) {
|
||||
$statusValues = is_array($filters['status'])
|
||||
? $filters['status']
|
||||
: explode(',', $filters['status']);
|
||||
|
||||
$statuses = [];
|
||||
foreach ($statusValues as $value) {
|
||||
try {
|
||||
$statuses[] = JobStatus::from($value);
|
||||
} catch (\ValueError) {
|
||||
// ignore invalid values
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($statuses)) {
|
||||
$statuses = null;
|
||||
}
|
||||
}
|
||||
|
||||
$this->handler->handle(new DeleteJobsByCriteria(
|
||||
statuses: $statuses,
|
||||
type: $filters['type'] ?? null,
|
||||
createdAfter: isset($filters['createdAfter']) ? new \DateTimeImmutable($filters['createdAfter']) : null,
|
||||
createdBefore: isset($filters['createdBefore']) ? new \DateTimeImmutable($filters['createdBefore']) : null,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Domain\Shared\Application\Query\GetJobById;
|
||||
use App\Domain\Shared\Application\QueryHandler\GetJobByIdHandler;
|
||||
use App\Domain\Shared\Domain\Exception\JobNotFoundException;
|
||||
use App\Domain\Shared\Infrastructure\ApiPlatform\Resource\GetJobListResource;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
readonly class DeleteJobProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetJobByIdHandler $handler
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GetJobListResource
|
||||
{
|
||||
try {
|
||||
$response = $this->handler->handle(new GetJobById($uriVariables['id']));
|
||||
} catch (JobNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
return GetJobListResource::fromJobResponse($response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Domain\Shared\Application\Query\GetJobById;
|
||||
use App\Domain\Shared\Application\QueryHandler\GetJobByIdHandler;
|
||||
use App\Domain\Shared\Domain\Exception\JobNotFoundException;
|
||||
use App\Domain\Shared\Infrastructure\ApiPlatform\Resource\GetJobListResource;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
readonly class GetJobStateProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetJobByIdHandler $handler
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GetJobListResource
|
||||
{
|
||||
try {
|
||||
$response = $this->handler->handle(new GetJobById($uriVariables['id']));
|
||||
} catch (JobNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
return GetJobListResource::fromJobResponse($response);
|
||||
}
|
||||
}
|
||||
@@ -188,4 +188,52 @@ readonly class DoctrineJobRepository implements JobRepositoryInterface
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function delete(string $id): void
|
||||
{
|
||||
$entity = $this->entityManager->find(JobEntity::class, $id);
|
||||
|
||||
if (null === $entity) {
|
||||
throw JobNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
$this->entityManager->remove($entity);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function deleteByCriteria(array $criteria): int
|
||||
{
|
||||
$qb = $this->entityManager->createQueryBuilder()
|
||||
->delete(JobEntity::class, 'j');
|
||||
|
||||
if (isset($criteria['statuses']) && is_array($criteria['statuses']) && !empty($criteria['statuses'])) {
|
||||
$expr = $qb->expr()->orX();
|
||||
foreach ($criteria['statuses'] as $key => $status) {
|
||||
$paramName = 'status' . $key;
|
||||
$expr->add($qb->expr()->eq('j.status', ':' . $paramName));
|
||||
$qb->setParameter($paramName, $status->value);
|
||||
}
|
||||
$qb->andWhere($expr);
|
||||
} elseif (isset($criteria['status'])) {
|
||||
$qb->andWhere('j.status = :status')
|
||||
->setParameter('status', $criteria['status']->value);
|
||||
}
|
||||
|
||||
if (isset($criteria['type'])) {
|
||||
$qb->andWhere('j.type = :type')
|
||||
->setParameter('type', $criteria['type']);
|
||||
}
|
||||
|
||||
if (isset($criteria['createdAfter'])) {
|
||||
$qb->andWhere('j.createdAt >= :createdAfter')
|
||||
->setParameter('createdAfter', $criteria['createdAfter']);
|
||||
}
|
||||
|
||||
if (isset($criteria['createdBefore'])) {
|
||||
$qb->andWhere('j.createdAt <= :createdBefore')
|
||||
->setParameter('createdBefore', $criteria['createdBefore']);
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,22 @@ readonly class SymfonyNotification implements NotificationInterface
|
||||
|
||||
public function sendSuccess(string $message): void
|
||||
{
|
||||
$this->sendUpdate([
|
||||
'status' => 'success',
|
||||
'message' => $message
|
||||
]);
|
||||
$this->sendUpdate(['status' => 'success', 'message' => $message]);
|
||||
}
|
||||
|
||||
public function sendError(string $message): void
|
||||
{
|
||||
$this->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => $message
|
||||
]);
|
||||
$this->sendUpdate(['status' => 'error', 'message' => $message]);
|
||||
}
|
||||
|
||||
public function sendInfo(string $message): void
|
||||
{
|
||||
$this->sendUpdate(['status' => 'info', 'message' => $message]);
|
||||
}
|
||||
|
||||
public function sendWarning(string $message): void
|
||||
{
|
||||
$this->sendUpdate(['status' => 'warning', 'message' => $message]);
|
||||
}
|
||||
|
||||
public function sendUpdate(array $data): void
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<!-- Header -->
|
||||
<header class="bg-green-600 h-16 flex items-center fixed w-full z-50">
|
||||
<div class="flex justify-center ml-4">
|
||||
<a class="flex flex-row justify-start" href="{{ path('app_manga') }}">
|
||||
<a class="flex flex-row justify-start" href="{{ path('app_legacy') }}">
|
||||
{# <div class="flex items-center"> #}
|
||||
{# <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" #}
|
||||
{# x="0px" y="0px" width="50" height="50" viewBox="-40 -90 500 500"> #}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<nav>
|
||||
<ul>
|
||||
<li class="{{ app.request.get('_route') starts with 'app_manga' ? 'border-l-4 border-green-600' : '' }}">
|
||||
<a href="{{ path('app_manga') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') starts with 'app_manga' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}">
|
||||
<a href="{{ path('app_legacy') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') starts with 'app_manga' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}">
|
||||
<i class="fas fa-book mr-2"></i>
|
||||
<span>Mangas</span>
|
||||
</a>
|
||||
|
||||
@@ -267,10 +267,14 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter(
|
||||
$this->chapters[$mangaId],
|
||||
fn (Chapter $chapter) => in_array($chapter->getNumber(), $chapterNumbers)
|
||||
);
|
||||
$result = [];
|
||||
foreach ($this->chapters[$mangaId] as $chapter) {
|
||||
if (in_array($chapter->getNumber(), $chapterNumbers)) {
|
||||
$result[$chapter->getNumber()] = $chapter;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Domain\Manga\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Manga\Domain\Model\Chapter;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Domain\Manga\Infrastructure\Service\MangadxChapterSynchronizationService;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangadexClient;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class MangadxChapterSynchronizationServiceTest extends TestCase
|
||||
{
|
||||
private InMemoryMangadexClient $client;
|
||||
private InMemoryMangaRepository $repository;
|
||||
private MangadxChapterSynchronizationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = new InMemoryMangadexClient();
|
||||
$this->repository = new InMemoryMangaRepository();
|
||||
$this->service = new MangadxChapterSynchronizationService($this->client, $this->repository);
|
||||
}
|
||||
|
||||
private function makeManga(string $externalId = 'manga-123'): Manga
|
||||
{
|
||||
return new Manga(
|
||||
new MangaId('manga-id'),
|
||||
new MangaTitle('Test'),
|
||||
new MangaSlug('test'),
|
||||
'Desc',
|
||||
'Author',
|
||||
2024,
|
||||
[],
|
||||
'ongoing',
|
||||
new ExternalId($externalId)
|
||||
);
|
||||
}
|
||||
|
||||
private function chapterEntry(string $number, string $lang, ?string $volume): array
|
||||
{
|
||||
return [
|
||||
'attributes' => [
|
||||
'chapter' => $number,
|
||||
'translatedLanguage' => $lang,
|
||||
'title' => "Chapter $number",
|
||||
'volume' => $volume,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapitres sans volume entre deux volumes différents → assignés au volume précédent
|
||||
*
|
||||
* Ch1→Vol1, Ch2→null, Ch3→null, Ch4→Vol2
|
||||
* Après sync : Ch2 et Ch3 doivent avoir Vol1
|
||||
*/
|
||||
public function testVolumeTransitionIsAssignedToPreviousVolume(): void
|
||||
{
|
||||
$manga = $this->makeManga();
|
||||
$this->client->addFeed('manga-123', [
|
||||
$this->chapterEntry('1', 'en', '1'),
|
||||
$this->chapterEntry('2', 'en', null),
|
||||
$this->chapterEntry('3', 'en', null),
|
||||
$this->chapterEntry('4', 'en', '2'),
|
||||
]);
|
||||
|
||||
$this->service->synchronizeChapters($manga);
|
||||
|
||||
$chapters = $this->indexedByNumber($manga->pullNewChapters());
|
||||
|
||||
$this->assertSame(1, $chapters[1.0]->getVolume(), 'Ch1 doit rester Vol1');
|
||||
$this->assertSame(1, $chapters[2.0]->getVolume(), 'Ch2 (transition) doit être assigné à Vol1');
|
||||
$this->assertSame(1, $chapters[3.0]->getVolume(), 'Ch3 (transition) doit être assigné à Vol1');
|
||||
$this->assertSame(2, $chapters[4.0]->getVolume(), 'Ch4 doit rester Vol2');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapitres en début de série sans volume → assignés au premier volume trouvé
|
||||
*
|
||||
* Ch1→null, Ch2→null, Ch3→Vol1
|
||||
* Après sync : Ch1 et Ch2 doivent avoir Vol1
|
||||
*/
|
||||
public function testChaptersWithoutVolumeAtStartGetNextVolume(): void
|
||||
{
|
||||
$manga = $this->makeManga();
|
||||
$this->client->addFeed('manga-123', [
|
||||
$this->chapterEntry('1', 'en', null),
|
||||
$this->chapterEntry('2', 'en', null),
|
||||
$this->chapterEntry('3', 'en', '1'),
|
||||
]);
|
||||
|
||||
$this->service->synchronizeChapters($manga);
|
||||
|
||||
$chapters = $this->indexedByNumber($manga->pullNewChapters());
|
||||
|
||||
$this->assertSame(1, $chapters[1.0]->getVolume(), 'Ch1 (début de série) doit prendre Vol1');
|
||||
$this->assertSame(1, $chapters[2.0]->getVolume(), 'Ch2 (début de série) doit prendre Vol1');
|
||||
$this->assertSame(1, $chapters[3.0]->getVolume(), 'Ch3 doit rester Vol1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapitres avec volumes explicites ne sont pas modifiés
|
||||
*
|
||||
* Ch1→Vol1, Ch2→Vol1, Ch3→Vol2 → inchangé
|
||||
*/
|
||||
public function testChaptersWithExplicitVolumesArePreserved(): void
|
||||
{
|
||||
$manga = $this->makeManga();
|
||||
$this->client->addFeed('manga-123', [
|
||||
$this->chapterEntry('1', 'en', '1'),
|
||||
$this->chapterEntry('2', 'en', '1'),
|
||||
$this->chapterEntry('3', 'en', '2'),
|
||||
]);
|
||||
|
||||
$this->service->synchronizeChapters($manga);
|
||||
|
||||
$chapters = $this->indexedByNumber($manga->pullNewChapters());
|
||||
|
||||
$this->assertSame(1, $chapters[1.0]->getVolume());
|
||||
$this->assertSame(1, $chapters[2.0]->getVolume());
|
||||
$this->assertSame(2, $chapters[3.0]->getVolume());
|
||||
}
|
||||
|
||||
/**
|
||||
* La version française est prioritaire sur l'anglaise
|
||||
*
|
||||
* Même chapitre disponible EN (volume 1) et FR (volume 2) → FR gagne
|
||||
*/
|
||||
public function testFrenchChaptersTakePriorityOverEnglish(): void
|
||||
{
|
||||
$manga = $this->makeManga();
|
||||
$this->client->addFeed('manga-123', [
|
||||
$this->chapterEntry('1', 'en', '1'),
|
||||
$this->chapterEntry('1', 'fr', '2'),
|
||||
]);
|
||||
|
||||
$this->service->synchronizeChapters($manga);
|
||||
|
||||
$chapters = $this->indexedByNumber($manga->pullNewChapters());
|
||||
|
||||
$this->assertCount(1, $chapters, 'Un seul chapitre 1 doit exister');
|
||||
$this->assertSame(2, $chapters[1.0]->getVolume(), 'La version FR (Vol2) doit prendre la priorité');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seuls les nouveaux chapitres sont sauvegardés (pas les doublons)
|
||||
*
|
||||
* Ch1 déjà en DB + Ch2 nouveau → seul Ch2 est retourné
|
||||
*/
|
||||
public function testOnlyNewChaptersAreSaved(): void
|
||||
{
|
||||
$manga = $this->makeManga();
|
||||
|
||||
// Pré-peupler la DB avec Ch1
|
||||
$existingChapter = new Chapter(
|
||||
new ChapterId('existing-uuid'),
|
||||
new MangaId('manga-id'),
|
||||
1.0,
|
||||
'Chapter 1',
|
||||
1,
|
||||
true
|
||||
);
|
||||
$manga->addChapter($existingChapter);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// Feed contient Ch1 (déjà en DB) et Ch2 (nouveau)
|
||||
$this->client->addFeed('manga-123', [
|
||||
$this->chapterEntry('1', 'en', '1'),
|
||||
$this->chapterEntry('2', 'en', '1'),
|
||||
]);
|
||||
|
||||
$newChapterIds = $this->service->synchronizeChapters($manga);
|
||||
|
||||
$this->assertCount(1, $newChapterIds, 'Seul Ch2 doit être retourné comme nouveau');
|
||||
|
||||
$newChapters = $manga->pullNewChapters();
|
||||
$this->assertCount(1, $newChapters);
|
||||
$this->assertSame(2.0, $newChapters[0]->getNumber(), 'Le nouveau chapitre doit être Ch2');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Chapter[] $chapters
|
||||
* @return array<float, Chapter>
|
||||
*/
|
||||
private function indexedByNumber(array $chapters): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($chapters as $chapter) {
|
||||
$result[$chapter->getNumber()] = $chapter;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,9 @@ namespace App\Tests\Domain\Reader\Adapter;
|
||||
|
||||
use App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Reader\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Reader\Domain\Exception\PageNotFoundException;
|
||||
use App\Domain\Reader\Domain\Model\ChapterContext;
|
||||
use App\Domain\Reader\Domain\Model\Page;
|
||||
use App\Domain\Reader\Domain\Model\PageContent;
|
||||
use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
final class InMemoryChapterRepository implements ChapterRepositoryInterface
|
||||
{
|
||||
@@ -94,28 +91,4 @@ final class InMemoryChapterRepository implements ChapterRepositoryInterface
|
||||
return $nextChapter ? new ChapterId($nextChapter) : null;
|
||||
}
|
||||
|
||||
public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
if (!isset($this->chapters[$chapterId->getValue()])) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
$pages = $this->chapters[$chapterId->getValue()]['pages'];
|
||||
$index = $pageNumber->getValue() - 1;
|
||||
|
||||
if (!isset($pages[$index])) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$page = $pages[$index];
|
||||
|
||||
return new PageContent(
|
||||
$page->getId(),
|
||||
$page->getPageNumber(),
|
||||
base64_encode('fake-image-content'),
|
||||
'image/jpeg',
|
||||
800,
|
||||
600
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Domain\Reader\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Reader\Application\Query\GetChapterPage;
|
||||
use App\Domain\Reader\Application\QueryHandler\GetChapterPageHandler;
|
||||
use App\Domain\Reader\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Reader\Domain\Exception\PageNotFoundException;
|
||||
use App\Domain\Reader\Domain\Model\ChapterContext;
|
||||
use App\Domain\Reader\Domain\Model\Page;
|
||||
use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
use App\Tests\Domain\Reader\Adapter\InMemoryChapterRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetChapterPageHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryChapterRepository $repository;
|
||||
private GetChapterPageHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryChapterRepository();
|
||||
$this->handler = new GetChapterPageHandler($this->repository);
|
||||
|
||||
// Préparation des données de test
|
||||
$chapterId = new ChapterId('chapter-1');
|
||||
$context = new ChapterContext(
|
||||
$chapterId,
|
||||
null,
|
||||
null,
|
||||
'Test Manga',
|
||||
1.0,
|
||||
'Chapter 1',
|
||||
'path/to/cbz',
|
||||
1,
|
||||
10,
|
||||
true,
|
||||
new \DateTimeImmutable()
|
||||
);
|
||||
|
||||
$pages = [];
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
$pages[] = new Page(
|
||||
sprintf('page-%d', $i),
|
||||
new PageNumber($i),
|
||||
sprintf('/api/chapters/chapter-1/pages/%d', $i),
|
||||
800,
|
||||
600
|
||||
);
|
||||
}
|
||||
|
||||
$this->repository->addChapter($chapterId, $context, $pages);
|
||||
}
|
||||
|
||||
public function testItThrowsExceptionWhenChapterDoesNotExist(): void
|
||||
{
|
||||
$this->expectException(ChapterNotFoundException::class);
|
||||
$this->handler->handle(new GetChapterPage('invalid-id', 1));
|
||||
}
|
||||
|
||||
public function testItThrowsExceptionWhenPageNumberExceedsTotalPages(): void
|
||||
{
|
||||
$this->expectException(PageNotFoundException::class);
|
||||
$this->handler->handle(new GetChapterPage('chapter-1', 11));
|
||||
}
|
||||
|
||||
public function testItReturnsPageContentSuccessfully(): void
|
||||
{
|
||||
$response = $this->handler->handle(new GetChapterPage('chapter-1', 5));
|
||||
|
||||
$this->assertEquals('page-5', $response->getId());
|
||||
$this->assertEquals(5, $response->getPageNumber());
|
||||
$this->assertNotEmpty($response->getBase64Content());
|
||||
$this->assertEquals('image/jpeg', $response->getMimeType());
|
||||
$this->assertEquals(['width' => 800, 'height' => 600], $response->getDimensions());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user