diff --git a/.claude/skills/vue-frontend/SKILL.md b/.claude/skills/vue-frontend/SKILL.md new file mode 100644 index 0000000..0703ba7 --- /dev/null +++ b/.claude/skills/vue-frontend/SKILL.md @@ -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/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`) diff --git a/.gitignore b/.gitignore index 9fecd97..1a876c9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ yarn-error.log /public/images/ src/Controller/TestController.php .phpunit.cache/test-results +/tests/Fixtures/pages/ diff --git a/assets/vue/app/domain/reader/application/store/readerStore.js b/assets/vue/app/domain/reader/application/store/readerStore.js index 75a02a3..8338c68 100644 --- a/assets/vue/app/domain/reader/application/store/readerStore.js +++ b/assets/vue/app/domain/reader/application/store/readerStore.js @@ -13,7 +13,6 @@ export const useReaderStore = defineStore('reader', { error: null, pages: [], totalPages: 0, - loadedPages: new Set(), // Garder une trace des pages déjà chargées // Paramètres pour les doubles pages doublePageSettings: { @@ -32,7 +31,6 @@ export const useReaderStore = defineStore('reader', { // Getters pour les doubles pages effectiveDoublePageMode: (state) => { - // Si la détection automatique est désactivée, retourner 'normal' if (!state.doublePageSettings.autoDetect) { return 'normal'; } @@ -55,28 +53,20 @@ export const useReaderStore = defineStore('reader', { try { const repository = new ApiChapterRepository(); - // Charger les informations du chapitre - const chapterData = await repository.getChapter(chapterId); + const [chapterData, pagesData] = await Promise.all([ + repository.getChapter(chapterId), + repository.getChapterPages(chapterId, 1, 9999), + ]); + this.currentChapter = Chapter.create(chapterData); - - // Charger la liste des pages - const pagesData = await repository.getChapterPages(chapterId); - - // Initialiser le tableau avec des placeholders - this.pages = new Array(pagesData.totalItems).fill(null); + this.pages = pagesData.pages.map(p => ({ + id: p.id, + pageNumber: p.pageNumber, + url: p.url, + dimensions: p.dimensions, + })); this.totalPages = pagesData.totalItems; - this.loadedPages.clear(); - - // Charger la première page - if (this.totalPages > 0) { - this.currentPage = 0; - await this.loadPageData(0); - - // En mode infini, précharger les premières pages - if (this.readingMode === 'infinite') { - await this.preloadNextPages(0); - } - } + this.currentPage = 0; } catch (error) { this.error = error.message; } finally { @@ -84,100 +74,28 @@ export const useReaderStore = defineStore('reader', { } }, - async loadPageData(pageIndex) { - if (!this.currentChapter || pageIndex < 0 || pageIndex >= this.totalPages) { - return; - } - - // Si la page est déjà chargée, ne rien faire - if (this.loadedPages.has(pageIndex)) { - return; - } - - const pageNumber = pageIndex + 1; // Convertir en 1-based pour l'API - - // Marquer la page comme en cours de chargement - const newPages = [...this.pages]; - newPages[pageIndex] = { loading: true }; - this.pages = newPages; - - try { - const repository = new ApiChapterRepository(); - const pageData = await repository.getChapterPage(this.currentChapter.id, pageNumber); - - // Vérifier que les données sont valides - if (!pageData || !pageData.base64Content) { - throw new Error("Données de page invalides reçues de l'API"); - } - - // Mettre à jour la page - const updatedPages = [...this.pages]; - updatedPages[pageIndex] = { - id: pageData.id, - pageNumber: pageData.pageNumber, - base64Content: pageData.base64Content, - mimeType: pageData.mimeType, - dimensions: pageData.dimensions - }; - this.pages = updatedPages; - this.loadedPages.add(pageIndex); - } catch (error) { - console.error(`Erreur lors du chargement de la page ${pageNumber}:`, error); - // Marquer la page comme en erreur - const errorPages = [...this.pages]; - errorPages[pageIndex] = { error: error.message }; - this.pages = errorPages; - } - }, - - async preloadNextPages(startIndex, count = 3) { - const promises = []; - for (let i = 1; i <= count; i++) { - const pageIndex = startIndex + i; - if (pageIndex < this.totalPages) { - promises.push(this.loadPageData(pageIndex)); - } - } - await Promise.all(promises); - }, - - async handlePageVisible(pageIndex) { + handlePageVisible(pageIndex) { if (pageIndex !== this.currentPage) { this.currentPage = pageIndex; - // Précharger les pages suivantes - if (this.readingMode === 'infinite') { - await this.preloadNextPages(pageIndex); - } } }, - async nextPage() { + nextPage() { if (!this.isLastPage) { this.currentPage++; - await this.loadPageData(this.currentPage); } }, - async previousPage() { + previousPage() { if (!this.isFirstPage) { this.currentPage--; - await this.loadPageData(this.currentPage); } }, async setReadingMode(mode) { if (mode === this.readingMode) return; - this.readingMode = mode; this.savePreferences(); - - // S'assurer que la page courante est chargée - await this.loadPageData(this.currentPage); - - // Si on passe en mode infini, précharger les pages suivantes - if (mode === 'infinite') { - await this.preloadNextPages(this.currentPage); - } }, setReadingDirection(direction) { @@ -190,7 +108,6 @@ export const useReaderStore = defineStore('reader', { this.savePreferences(); }, - // Nouvelles actions pour les doubles pages setDoublePageMode(mode) { if (['rotate', 'scroll', 'normal'].includes(mode)) { this.doublePageSettings.mobileMode = mode; @@ -225,16 +142,10 @@ export const useReaderStore = defineStore('reader', { async goToPreviousChapter() { if (this.currentChapter?.navigation?.previousChapter) { await this.loadChapter(this.currentChapter.navigation.previousChapter); - // Aller à la dernière page du chapitre précédent this.currentPage = Math.max(0, this.totalPages - 1); - // S'assurer que la page est chargée - if (this.totalPages > 0) { - await this.loadPageData(this.currentPage); - } } }, - // Gestion de la persistance des préférences savePreferences() { try { const preferences = { @@ -255,7 +166,6 @@ export const useReaderStore = defineStore('reader', { if (stored) { const preferences = JSON.parse(stored); - // Appliquer les préférences sauvegardées if (preferences.readingMode) this.readingMode = preferences.readingMode; if (preferences.readingDirection) this.readingDirection = preferences.readingDirection; if (typeof preferences.zoom === 'number') this.zoom = preferences.zoom; @@ -277,7 +187,6 @@ export const useReaderStore = defineStore('reader', { } }, - // Réinitialiser les préférences resetPreferences() { this.readingMode = 'single'; this.readingDirection = 'ltr'; diff --git a/assets/vue/app/domain/reader/infrastructure/repository/ApiChapterRepository.js b/assets/vue/app/domain/reader/infrastructure/repository/ApiChapterRepository.js index 09665ff..62cff0d 100644 --- a/assets/vue/app/domain/reader/infrastructure/repository/ApiChapterRepository.js +++ b/assets/vue/app/domain/reader/infrastructure/repository/ApiChapterRepository.js @@ -9,7 +9,7 @@ export class ApiChapterRepository extends ChapterRepositoryInterface { return response.json(); } - async getChapterPages(chapterId, page = 1, itemsPerPage = 20) { + async getChapterPages(chapterId, page = 1, itemsPerPage = 9999) { const response = await fetch( `/api/reader/chapter/${chapterId}/pages?page=${page}&itemsPerPage=${itemsPerPage}` ); @@ -18,12 +18,4 @@ export class ApiChapterRepository extends ChapterRepositoryInterface { } return response.json(); } - - async getChapterPage(chapterId, pageNumber) { - const response = await fetch(`/api/reader/chapter/${chapterId}/page/${pageNumber}`); - if (!response.ok) { - throw new Error('Failed to fetch chapter page'); - } - return response.json(); - } } diff --git a/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue b/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue index 9d8fbc3..05fd672 100644 --- a/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue +++ b/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue @@ -6,13 +6,10 @@
-
+
-
- {{ page.error }} -
- +
diff --git a/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue b/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue index 0b104dd..33ac8a0 100644 --- a/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue +++ b/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue @@ -1,7 +1,7 @@