refactor(reader): serve pages as static files instead of base64
Replace the per-page API call (base64 payload) with static image URLs
served directly by Caddy from public/images/pages/{chapterId}/.
- LocalImageStorage now stores to public/images/ (was MANGA_DATA_PATH)
- LegacyChapterRepository returns /images/pages/{id}/{file} URLs,
uses getimagesize() instead of loading file content into memory
- Delete GetChapterPage query/handler/response, ChapterPageResource,
ChapterPageProvider, PageContent model
- Remove getPageContent() from ChapterRepositoryInterface
- Frontend: loadChapter() fetches chapter + all pages in parallel,
ReaderPage uses URL instead of base64 data URI, InfiniteReader drops
lazy-load observer side effect, readerStore drops loadedPages/preload
- GetChapterPagesTest: extract fixture images from CBZ at runtime,
ignore tests/Fixtures/pages/ in .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6875ad4222
commit
322c396165
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`)
|
||||
Reference in New Issue
Block a user