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