--- 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/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 ``` ## 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`)