Files
Mangarr/.claude/skills/vue-frontend/SKILL.md
ext.jeremy.guillot@maxicoffee.domains 322c396165 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>
2026-03-09 22:05:45 +01:00

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 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.

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)