feat: page MangaDetails en vue.js

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-03-24 18:01:24 +01:00
parent bee8572dc5
commit 41dc3c51aa
10 changed files with 477 additions and 135 deletions

View File

@@ -1,13 +1,5 @@
<template> <template>
<div class="min-h-screen bg-gray-100"> <router-view></router-view>
<main>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</template> </template>
<script> <script>

View File

@@ -1,72 +1,89 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository'; import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
const mangaRepository = new ApiMangaRepository(); const mangaRepository = new ApiMangaRepository();
export const useMangaStore = defineStore('manga', () => { export const useMangaStore = defineStore('manga', {
const collection = ref(null); state: () => ({
const detailedMangas = ref({}); // État pour la collection
const loading = ref(false); collection: null,
const error = ref(null); // État pour les détails
const isBackgroundLoading = ref(false); currentManga: null,
chapters: [],
loading: false,
error: null,
isBackgroundLoading: false
}),
const loadCollection = async () => { actions: {
if (loading.value) return; // Actions pour la collection
async loadCollection() {
loading.value = true; if (this.loading) return;
error.value = null;
console.log('Starting loadCollection...');
try { this.loading = true;
collection.value = await mangaRepository.getCollection(); this.error = null;
} catch (err) {
error.value = err.message; try {
console.error('Failed to load collection:', err); console.log('Fetching collection from repository...');
} finally { this.collection = await mangaRepository.getCollection();
loading.value = false; console.log('Collection loaded:', this.collection);
} catch (err) {
this.error = err.message;
console.error('Failed to load collection:', err);
} finally {
this.loading = false;
console.log('loadCollection finished. Loading:', this.loading);
}
},
async refreshCollectionInBackground() {
if (this.isBackgroundLoading) return;
this.isBackgroundLoading = true;
try {
const updatedCollection = await mangaRepository.getCollection();
this.collection = updatedCollection;
} catch (err) {
console.error('Failed to refresh collection:', err);
} finally {
this.isBackgroundLoading = false;
}
},
// Actions pour les détails du manga
async fetchMangaDetails(mangaId) {
this.loading = true;
this.error = null;
try {
this.currentManga = await mangaRepository.getMangaById(mangaId);
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
async fetchMangaChapters(mangaId) {
this.loading = true;
this.error = null;
try {
const response = await mangaRepository.getChapters(mangaId);
console.log('API Response:', response); // Pour déboguer
this.chapters = Array.isArray(response) ? response :
(response.items ? response.items : []);
} catch (error) {
this.error = error.message;
console.error('Failed to fetch chapters:', error);
} finally {
this.loading = false;
}
},
clearCurrentManga() {
this.currentManga = null;
this.chapters = [];
} }
}; }
const refreshCollectionInBackground = async () => {
if (isBackgroundLoading.value) return;
isBackgroundLoading.value = true;
try {
const updatedCollection = await mangaRepository.getCollection();
collection.value = updatedCollection;
} catch (err) {
console.error('Failed to refresh collection:', err);
} finally {
isBackgroundLoading.value = false;
}
};
const loadMangaDetail = async (slug) => {
if (detailedMangas.value[slug]) return;
try {
const manga = await mangaRepository.getMangaBySlug(slug);
detailedMangas.value[slug] = manga;
} catch (err) {
console.error(`Failed to load manga details for ${slug}:`, err);
throw err;
}
};
const getMangaFromCollection = (slug) => {
return collection.value?.items.find(manga => manga.slug === slug);
};
return {
collection,
detailedMangas,
loading,
error,
isBackgroundLoading,
loadCollection,
refreshCollectionInBackground,
loadMangaDetail,
getMangaFromCollection
};
}); });

View File

@@ -0,0 +1,17 @@
export class MangaApi {
static async fetchById(mangaId) {
const response = await fetch(`/api/mangas/${mangaId}`);
if (!response.ok) {
throw new Error('Failed to fetch manga details');
}
return response.json();
}
static async fetchChapters(mangaId) {
const response = await fetch(`/api/mangas/${mangaId}/chapters`);
if (!response.ok) {
throw new Error('Failed to fetch manga chapters');
}
return response.json();
}
}

View File

@@ -22,6 +22,32 @@ export class ApiMangaRepository {
} }
} }
async getMangaById(id) {
try {
const response = await fetch(`/api/mangas/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch manga details');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async getChapters(mangaId) {
try {
const response = await fetch(`/api/mangas/${mangaId}/chapters`);
if (!response.ok) {
throw new Error('Failed to fetch manga chapters');
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async getMangaBySlug(slug) { async getMangaBySlug(slug) {
try { try {
const response = await fetch(`/api/mangas/${slug}`); const response = await fetch(`/api/mangas/${slug}`);

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
class="bg-white rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105" class="bg-white rounded-lg shadow-md overflow-hidden cursor-pointer transition-transform hover:scale-105"
@click="handleClick" @click="navigateToDetails"
> >
<div class="relative pb-[150%]"> <div class="relative pb-[150%]">
<img <img
@@ -34,8 +34,11 @@ const props = defineProps({
} }
}); });
const handleClick = () => { const navigateToDetails = () => {
router.push(`/manga/${props.manga.slug}`); router.push({
name: 'manga-details',
params: { id: props.manga.id }
});
}; };
const formatDate = (dateString) => { const formatDate = (dateString) => {

View File

@@ -1,5 +1,5 @@
<template> <template>
<Layout @add-manga-click="handleAddMangaClick"> <div>
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" /> <Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
<div class="container mx-auto px-4"> <div class="container mx-auto px-4">
<MangaGrid :mangas="collection?.items || []" /> <MangaGrid :mangas="collection?.items || []" />
@@ -7,7 +7,7 @@
Mise à jour en cours... Mise à jour en cours...
</div> </div>
</div> </div>
</Layout> </div>
</template> </template>
<script setup> <script setup>
@@ -15,7 +15,6 @@ import { onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useMangaStore } from '../../application/store/mangaStore'; import { useMangaStore } from '../../application/store/mangaStore';
import Layout from '../../../../shared/components/layout/Layout.vue';
import MangaGrid from '../components/MangaGrid.vue'; import MangaGrid from '../components/MangaGrid.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue'; import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { import {
@@ -37,10 +36,17 @@ const {
isBackgroundLoading isBackgroundLoading
} = storeToRefs(mangaStore); } = storeToRefs(mangaStore);
const { loadCollection, refreshCollectionInBackground } = mangaStore;
onMounted(() => { onMounted(() => {
loadCollection(); console.log('HomePage mounted');
console.log('Store state before loadCollection:', {
collection: collection.value,
loading: loading.value,
error: error.value
});
mangaStore.loadCollection();
console.log('loadCollection called');
}); });
const handleAddMangaClick = (query = '') => { const handleAddMangaClick = (query = '') => {
@@ -52,7 +58,7 @@ const toolbarConfig = {
{ {
icon: ArrowPathIcon, icon: ArrowPathIcon,
label: 'Refresh', label: 'Refresh',
onClick: refreshCollectionInBackground, onClick: () => mangaStore.refreshCollectionInBackground(),
active: isBackgroundLoading active: isBackgroundLoading
}, },
{ icon: MagnifyingGlassIcon, label: 'Search', onClick: () => {} } { icon: MagnifyingGlassIcon, label: 'Search', onClick: () => {} }

View File

@@ -0,0 +1,279 @@
<template>
<!-- États de chargement et d'erreur -->
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
<div v-else-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{{ error }}
</div>
<div v-else-if="currentManga" class="relative">
<!-- Bannière avec image de fond -->
<div class="shadow-lg text-white">
<div class="relative h-96 bg-cover bg-center" :style="{ backgroundImage: `url('${currentManga.imageUrl}')` }">
<div class="absolute inset-0 bg-black opacity-50"></div>
<div class="absolute inset-0 flex flex-row justify-center p-4">
<!-- Image de couverture -->
<div class="hidden mr-12 xl:block 2xl:block">
<img :src="currentManga.thumbnailUrl" :alt="currentManga.title" class="max-w-72 max-h-72 ml-4">
</div>
<!-- Informations du manga -->
<div class="flex flex-col">
<div class="flex items-center mb-4">
<BookmarkIcon class="h-8 w-8 text-white" />
<h1 class="text-3xl font-bold ml-4">{{ currentManga.title }}</h1>
</div>
<div class="flex items-center mb-4">
<span class="mr-4">{{ currentManga.year }}</span>
<span>Chapitres: {{ currentManga.totalChapters }}</span>
</div>
<div class="flex items-center mb-4">
<FolderIcon class="h-6 w-6 text-gray-400 mr-2" />
<span class="truncate">/media/mangas/{{ currentManga.title }} ({{ currentManga.year }})</span>
<span class="ml-auto bg-green-600 py-1 px-2 rounded">{{ currentManga.status || 'Terminé' }}</span>
</div>
<div class="flex items-center mb-4">
<template v-if="currentManga.tags?.length">
<template v-for="(tag, index) in currentManga.tags.slice(0, 5)" :key="index">
<span class="bg-gray-700 py-1 px-2 rounded-sm mr-2">{{ tag }}</span>
</template>
<span v-if="currentManga.tags.length > 5" class="bg-gray-700 py-1 px-2 rounded-sm mr-2">...</span>
</template>
</div>
<div class="mb-4">
<div class="flex items-center mb-2">
<HeartIcon class="h-6 w-6 text-red-500 mr-2" />
<span>{{ currentManga.rating }}</span>
</div>
<p>{{ currentManga.description }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Liste des volumes et chapitres -->
<div class="p-4">
<div v-for="volume in volumes" :key="volume.number" class="mb-4">
<div class="bg-white rounded-sm shadow">
<!-- En-tête du volume -->
<div class="relative flex items-center justify-between bg-white p-4 rounded-t-sm">
<!-- Partie gauche -->
<div class="flex items-center space-x-4">
<BookmarkIcon class="h-8 w-8 text-gray-500" />
<h2 class="text-xl font-semibold w-28">Volume {{ String(volume.number).padStart(2, '0') }}</h2>
<div class="flex items-center w-16">
<span :class="[
'px-2 py-1 text-sm rounded w-full text-center text-white',
{
'bg-red-500': volume.progress === '0/0',
'bg-yellow-500': volume.progress !== volume.chapters.length + '/0',
'bg-green-500': volume.progress === volume.chapters.length + '/0'
}
]">
{{ volume.progress }}
</span>
</div>
</div>
<!-- Bouton toggle -->
<div class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
<component
:is="isVolumeOpen(volume.number) ? ChevronUpIcon : ChevronDownIcon"
class="h-6 w-6 bg-gray-400 rounded-full p-1 text-white hover:bg-green-500 cursor-pointer"
@click="toggleVolume(volume)"
/>
</div>
<!-- Actions du volume -->
<div class="flex space-x-2 text-xl text-bold">
<button class="w-8 text-center" @click="searchVolume(volume)">
<MagnifyingGlassIcon class="h-6 w-6 text-gray-500 hover:text-green-500" />
</button>
<button class="w-8 text-center" @click="downloadVolume(volume)">
<ArrowDownTrayIcon class="h-6 w-6 text-gray-500 hover:text-green-500" />
</button>
</div>
</div>
<!-- Liste des chapitres -->
<div v-show="isVolumeOpen(volume.number)" class="p-4 border-t">
<table class="min-w-full table-auto">
<thead>
<tr>
<th class="px-4 py-2 text-left">#</th>
<th class="px-4 py-2 text-left">Titre</th>
<th class="px-4 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="chapter in volume.chapters"
:key="chapter.id"
class="border-t hover:bg-green-100">
<td class="px-4 py-2" :class="{ 'text-green-500': chapter.isDownloaded }">
{{ String(chapter.number).padStart(2, '0') }}
</td>
<td class="px-4 py-2 w-full text-left">
<router-link
v-if="chapter.isDownloaded"
:to="{ name: 'reader', params: { mangaSlug: currentManga.slug, chapterNumber: chapter.number, pageNumber: 1 }}"
class="hover:text-green-500">
{{ chapter.title || 'Sans titre' }}
</router-link>
<span v-else>{{ chapter.title || 'Sans titre' }}</span>
</td>
<td class="px-4 py-2 flex justify-end gap-2">
<button v-if="!chapter.isDownloaded"
@click="searchChapter(chapter)"
class="text-gray-500 hover:text-green-500">
<MagnifyingGlassIcon class="h-5 w-5" />
</button>
<button v-else
@click="deleteChapter(chapter)"
class="text-gray-500 hover:text-green-500">
<XMarkIcon class="h-5 w-5" />
</button>
<button @click="downloadChapter(chapter)"
class="text-gray-500 hover:text-green-500">
<ArrowDownTrayIcon class="h-5 w-5" />
</button>
<button @click="hideChapter(chapter)"
class="text-gray-500 hover:text-green-500">
<TrashIcon class="h-5 w-5" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMangaStore } from '../../application/store/mangaStore';
import { storeToRefs } from 'pinia';
import {
BookmarkIcon,
FolderIcon,
HeartIcon,
ChevronUpIcon,
ChevronDownIcon,
MagnifyingGlassIcon,
ArrowDownTrayIcon,
XMarkIcon,
TrashIcon
} from '@heroicons/vue/24/outline';
const route = useRoute();
const router = useRouter();
const store = useMangaStore();
const { currentManga, chapters, loading, error } = storeToRefs(store);
// Map réactif pour stocker l'état d'ouverture des volumes
const openVolumes = ref(new Map());
// Organise les chapitres par volumes
const volumes = computed(() => {
if (!chapters.value) return [];
const chaptersArray = Array.isArray(chapters.value) ? chapters.value :
(chapters.value.items ? chapters.value.items : []);
const volumeMap = new Map();
chaptersArray.forEach(chapter => {
const volumeNumber = chapter.volume || 'Unknown';
if (!volumeMap.has(volumeNumber)) {
// Initialiser l'état d'ouverture si ce n'est pas déjà fait
if (!openVolumes.value.has(volumeNumber)) {
openVolumes.value.set(volumeNumber, volumeNumber === chaptersArray[0]?.volume);
}
volumeMap.set(volumeNumber, {
number: volumeNumber,
progress: '0/0',
chapters: []
});
}
volumeMap.get(volumeNumber).chapters.push({
...chapter,
isDownloaded: Boolean(chapter.cbzPath)
});
});
// Calcul du progrès pour chaque volume
for (const volume of volumeMap.values()) {
const downloaded = volume.chapters.filter(c => c.isDownloaded).length;
const total = volume.chapters.length;
volume.progress = `${downloaded}/${total}`;
}
return Array.from(volumeMap.values()).sort((a, b) => b.number - a.number);
});
const isVolumeOpen = (volumeNumber) => {
return openVolumes.value.get(volumeNumber) || false;
};
const toggleVolume = (volume) => {
openVolumes.value.set(volume.number, !openVolumes.value.get(volume.number));
};
const loadData = async () => {
const mangaId = route.params.id;
await Promise.all([
store.fetchMangaDetails(mangaId),
store.fetchMangaChapters(mangaId)
]);
};
// Actions sur les chapitres et volumes
const searchChapter = async (chapter) => {
// TODO: Implémenter la recherche de chapitre
console.log('Recherche du chapitre:', chapter.id);
};
const downloadChapter = async (chapter) => {
// TODO: Implémenter le téléchargement du chapitre
console.log('Téléchargement du chapitre:', chapter.id);
};
const deleteChapter = async (chapter) => {
// TODO: Implémenter la suppression du chapitre
console.log('Suppression du chapitre:', chapter.id);
};
const hideChapter = async (chapter) => {
// TODO: Implémenter le masquage du chapitre
console.log('Masquage du chapitre:', chapter.id);
};
const searchVolume = async (volume) => {
// TODO: Implémenter la recherche du volume
console.log('Recherche du volume:', volume.number);
};
const downloadVolume = async (volume) => {
// TODO: Implémenter le téléchargement du volume
console.log('Téléchargement du volume:', volume.number);
};
onMounted(() => {
loadData();
});
onUnmounted(() => {
store.clearCurrentManga();
});
</script>

View File

@@ -1,16 +1,9 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import routes from './router' import { router } from './router'
import '../../styles/app.scss' import '../../styles/app.scss'
// Création du router
const router = createRouter({
history: createWebHistory('/vue/'),
routes
})
// Création du store // Création du store
const pinia = createPinia() const pinia = createPinia()

View File

@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import Layout from '../shared/components/layout/Layout.vue';
import HomePage from '../domain/manga/presentation/pages/HomePage.vue'; import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
// Placeholder component for new routes // Placeholder component for new routes
@@ -18,110 +19,118 @@ const PlaceholderComponent = {
}; };
const routes = [ const routes = [
{ {
path: '/', path: '/',
component: Layout,
children: [
{
path: '',
name: 'home', name: 'home',
component: HomePage component: HomePage
}, },
{ {
path: '/manga/:slug', path: '/manga/:id',
name: 'manga-detail', name: 'manga-details',
component: PlaceholderComponent, component: () => import('../domain/manga/presentation/pages/MangaDetails.vue')
props: { title: 'Détails du manga' } },
}, {
{
path: '/add', path: '/add',
name: 'add-manga', name: 'add-manga',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Ajouter un manga' } props: { title: 'Ajouter un manga' }
}, },
{ {
path: '/reader/:chapterId', path: '/reader/:chapterId',
name: 'reader', name: 'reader',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Lecteur' } props: { title: 'Lecteur' }
}, },
// Pages placeholder avec chargement différé // Pages placeholder avec chargement différé
{ {
path: '/import', path: '/import',
name: 'import', name: 'import',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Import de bibliothèque' } props: { title: 'Import de bibliothèque' }
}, },
{ {
path: '/discover', path: '/discover',
name: 'discover', name: 'discover',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Découvrir' } props: { title: 'Découvrir' }
}, },
{ {
path: '/convert', path: '/convert',
name: 'convert', name: 'convert',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Convertir CBR en CBZ' } props: { title: 'Convertir CBR en CBZ' }
}, },
{ {
path: '/calendar', path: '/calendar',
name: 'calendar', name: 'calendar',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Calendrier' } props: { title: 'Calendrier' }
}, },
{ {
path: '/activity', path: '/activity',
name: 'activity', name: 'activity',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Activité' } props: { title: 'Activité' }
}, },
// Paramètres // Paramètres
{ {
path: '/settings/general', path: '/settings/general',
name: 'settings-general', name: 'settings-general',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Paramètres généraux' } props: { title: 'Paramètres généraux' }
}, },
{ {
path: '/settings/folders', path: '/settings/folders',
name: 'settings-folders', name: 'settings-folders',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Gestion des dossiers' } props: { title: 'Gestion des dossiers' }
}, },
{ {
path: '/settings/scrappers', path: '/settings/scrappers',
name: 'settings-scrappers', name: 'settings-scrappers',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Configuration des scrappers' } props: { title: 'Configuration des scrappers' }
}, },
{ {
path: '/settings/ui', path: '/settings/ui',
name: 'settings-ui', name: 'settings-ui',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: "Paramètres de l'interface" } props: { title: "Paramètres de l'interface" }
}, },
// Système // Système
{ {
path: '/system/status', path: '/system/status',
name: 'system-status', name: 'system-status',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Status du système' } props: { title: 'Status du système' }
}, },
{ {
path: '/system/backup', path: '/system/backup',
name: 'system-backup', name: 'system-backup',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Sauvegarde' } props: { title: 'Sauvegarde' }
}, },
{ {
path: '/system/logs', path: '/system/logs',
name: 'system-logs', name: 'system-logs',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Journaux système' } props: { title: 'Journaux système' }
}, },
{ {
path: '/system/updates', path: '/system/updates',
name: 'system-updates', name: 'system-updates',
component: PlaceholderComponent, component: PlaceholderComponent,
props: { title: 'Mises à jour' } props: { title: 'Mises à jour' }
} }
]
}
]; ];
export default routes; export const router = createRouter({
history: createWebHistory('/vue/'),
routes
});

View File

@@ -12,7 +12,7 @@
/> />
<main class="pt-16 md:ml-60"> <main class="pt-16 md:ml-60">
<slot></slot> <router-view></router-view>
</main> </main>
</div> </div>
</template> </template>