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>
7.6 KiB
7.6 KiB
name, description, allowed-tools
| name | description | allowed-tools |
|---|---|---|
| vue-frontend | 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/. | 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
// 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.
// 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
// 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é
// 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
<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)
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 minutesgcTime: 10 minutesretry: 1refetchOnWindowFocus: true
Upload de fichiers (FormData)
Ne pas définir Content-Type manuellement — le navigateur le gère automatiquement avec le boundary correct.
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
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)