style(manga): refondre la page d'ajout de manga sur le design system #21

Merged
colgora merged 1 commits from style/add-manga-ui-redesign into main 2026-03-15 20:56:30 +01:00

View File

@@ -1,108 +1,155 @@
<template> <template>
<div class="overflow-y-auto h-full"> <div class="flex flex-col h-full">
<div class="container mx-auto px-4 py-8"> <Toolbar :config="toolbarConfig" />
<!-- Barre de recherche -->
<div class="mb-8"> <div class="overflow-y-auto flex-1">
<div class="flex gap-4"> <div class="px-6 py-8">
<!-- Recherche -->
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Recherche</h2>
<input <input
type="text" type="text"
v-model="searchQuery" v-model="searchQuery"
@keyup.enter="performSearch" @keyup.enter="performSearch"
placeholder="Rechercher un manga..." placeholder="Rechercher un manga..."
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" /> class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" />
<button </section>
@click="performSearch"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Rechercher
</button>
</div>
</div>
<!-- État de chargement --> <!-- État de chargement -->
<div v-if="loading" class="text-center py-8"> <section v-if="loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div> <div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
<p class="mt-4 text-gray-600 dark:text-gray-400">Recherche en cours...</p> <div class="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
<span class="text-sm">Recherche en cours...</span>
</div> </div>
</section>
<!-- Message d'erreur --> <!-- Message d'erreur -->
<div v-if="error" class="bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded relative mb-6"> <section v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
{{ error }} <p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
</div> </section>
<!-- Résultats de recherche --> <!-- Résultats -->
<div v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700"> <section v-if="searchResults.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Résultats</h2>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ searchResults.length }} manga(s)</span>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700/50">
<div <div
v-for="manga in searchResults" v-for="manga in searchResults"
:key="manga.externalId" :key="manga.externalId"
class="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors border-b border-gray-100 dark:border-gray-700 cursor-pointer" class="flex items-start gap-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors cursor-pointer px-2"
@click="openMangaModal(manga)"> @click="openMangaModal(manga)">
<img <img
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'" :src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
alt="" alt=""
class="h-36 w-24 object-cover flex-shrink-0 self-start" class="h-36 w-24 object-cover flex-shrink-0"
referrerpolicy="no-referrer" /> referrerpolicy="no-referrer" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p> <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ manga.title }}</p>
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p> <p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">{{ manga.description }}</p>
</div> </div>
</div> </div>
</div> </div>
<p v-else-if="!loading && searchQuery" class="text-center text-gray-600 dark:text-gray-400">Aucun résultat trouvé</p> </section>
<!-- Modal de confirmation --> <!-- Aucun résultat -->
<section v-else-if="hasSearched && !loading" class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Aucun résultat trouvé</p>
</section>
</div>
</div>
<!-- Modal de détail -->
<Dialog :open="isModalOpen" @close="closeModal" class="relative z-50"> <Dialog :open="isModalOpen" @close="closeModal" class="relative z-50">
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" aria-hidden="true" /> <div class="fixed inset-0 bg-gray-900/70 dark:bg-gray-900/80 transition-opacity" aria-hidden="true" />
<div class="fixed inset-0 flex items-center justify-center p-4"> <div class="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6"> <DialogPanel v-if="selectedManga" class="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
<DialogTitle class="text-lg mb-4 text-gray-900 dark:text-gray-100"> Ajouter à la bibliothèque </DialogTitle>
<div v-if="selectedManga"> <!-- En-tête avec couverture -->
<div class="flex gap-4"> <div class="flex gap-0 border-b border-gray-200 dark:border-gray-700">
<img <img
:src="selectedManga.imageUrl || '/placeholder-cover.png'" :src="selectedManga.imageUrl || selectedManga.thumbnailUrl || '/placeholder-cover.png'"
:alt="selectedManga.title" :alt="selectedManga.title"
class="h-48 w-32 object-cover" /> class="h-64 w-44 object-cover flex-shrink-0"
<div class="flex-1 min-w-0"> referrerpolicy="no-referrer" />
<h4 class="text-lg text-gray-900 dark:text-gray-100">{{ selectedManga.title }}</h4> <div class="flex-1 min-w-0 p-6 flex flex-col justify-between">
<p class="mt-2 text-gray-700 dark:text-gray-300"> <div>
{{ truncatedDescription }} <DialogTitle class="text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug">
{{ selectedManga.title }}
</DialogTitle>
<div class="mt-3 space-y-1.5">
<p v-if="selectedManga.author" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Auteur</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.author }}</span>
</p>
<p v-if="selectedManga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Publication</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.publicationYear }}</span>
</p>
<p v-if="selectedManga.status" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Statut</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.status }}</span>
</p>
<p v-if="selectedManga.rating" class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">Note</span>
<span class="ml-2 text-gray-700 dark:text-gray-200">{{ selectedManga.rating.toFixed(2) }} / 10</span>
</p> </p>
</div> </div>
</div> </div>
<div v-if="selectedManga.genres?.length" class="flex flex-wrap gap-1.5 mt-4">
<span
v-for="genre in selectedManga.genres"
:key="genre"
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{{ genre }}
</span>
</div>
</div>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <!-- Description -->
<div class="px-6 py-4 overflow-y-auto flex-1">
<h3 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
<p v-if="selectedManga.description" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{{ selectedManga.description }}
</p>
<p v-else class="text-sm text-gray-400 dark:text-gray-500 italic">Aucune description disponible.</p>
</div>
<!-- Actions -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button <button
type="button" type="button"
@click="closeModal" @click="closeModal"
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 dark:bg-gray-800"> class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors px-4 py-2">
Annuler Annuler
</button> </button>
<button <button
type="button" type="button"
@click="addManga" @click="addManga"
:disabled="adding" :disabled="adding"
class="px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center"> class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 font-medium transition-colors inline-flex items-center gap-2">
<span v-if="adding" class="mr-2"> <ArrowPathIcon v-if="adding" class="h-4 w-4 animate-spin" />
<ArrowPathIcon class="h-5 w-5 animate-spin" /> {{ adding ? 'Ajout en cours...' : 'Ajouter à la bibliothèque' }}
</span>
{{ adding ? 'Ajout en cours...' : 'Ajouter' }}
</button> </button>
</div> </div>
</DialogPanel> </DialogPanel>
</div> </div>
</Dialog> </Dialog>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'; import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
import { ArrowPathIcon } from '@heroicons/vue/24/solid'; import { ArrowPathIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore'; import { useMangaStore } from '../../application/store/mangaStore';
const router = useRouter(); const router = useRouter();
@@ -110,20 +157,35 @@ import { useMangaStore } from '../../application/store/mangaStore';
const mangaStore = useMangaStore(); const mangaStore = useMangaStore();
const searchQuery = ref(''); const searchQuery = ref('');
const hasSearched = ref(false);
const isModalOpen = ref(false); const isModalOpen = ref(false);
const selectedManga = ref(null); const selectedManga = ref(null);
// Récupération des états du store
const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore); const { searchResults, loadingSearch: loading, searchError: error, addingManga: adding } = storeToRefs(mangaStore);
const truncatedDescription = computed(() => { const toolbarConfig = computed(() => ({
if (!selectedManga.value?.description) return ''; leftSection: [
return selectedManga.value.description.length > 500 { type: 'label', text: 'Ajouter un manga', class: 'text-sm font-medium' },
? selectedManga.value.description.slice(0, 500) + '...' ],
: selectedManga.value.description; rightSection: [
{
type: 'button',
icon: MagnifyingGlassIcon,
label: 'Rechercher',
onClick: performSearch,
disabled: !searchQuery.value.trim() || loading.value,
},
],
}));
let debounceTimer = null;
watch(searchQuery, newVal => {
clearTimeout(debounceTimer);
if (newVal.trim().length > 3) {
debounceTimer = setTimeout(performSearch, 500);
}
}); });
// Effectuer la recherche au chargement si un paramètre q est présent
onMounted(() => { onMounted(() => {
const queryParam = route.query.q; const queryParam = route.query.q;
if (queryParam) { if (queryParam) {
@@ -132,8 +194,8 @@ import { useMangaStore } from '../../application/store/mangaStore';
} }
}); });
// Nettoyer la recherche et les résultats lors du démontage du composant
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearTimeout(debounceTimer);
searchQuery.value = ''; searchQuery.value = '';
mangaStore.clearSearchResults(); mangaStore.clearSearchResults();
}); });
@@ -142,6 +204,7 @@ import { useMangaStore } from '../../application/store/mangaStore';
if (!searchQuery.value.trim()) return; if (!searchQuery.value.trim()) return;
try { try {
await mangaStore.searchMangaDex(searchQuery.value); await mangaStore.searchMangaDex(searchQuery.value);
hasSearched.value = true;
} catch (e) { } catch (e) {
console.error('Erreur de recherche:', e); console.error('Erreur de recherche:', e);
} }
@@ -159,7 +222,6 @@ import { useMangaStore } from '../../application/store/mangaStore';
const addManga = async () => { const addManga = async () => {
if (!selectedManga.value) return; if (!selectedManga.value) return;
try { try {
await mangaStore.createFromMangaDex(selectedManga.value.externalId); await mangaStore.createFromMangaDex(selectedManga.value.externalId);
router.push('/manga'); router.push('/manga');