refactor: update tag data structure and improve tag handling

- Changed `TagsResponse` and `UseTagsState` to include `usage` count in the tag data structure for better tracking.
- Simplified tag initialization in `useTags` to directly use `initialData`.
- Enhanced `TagsPageClient` to filter and sort tags based on usage, improving user experience in tag management.
- Removed unused variables and streamlined the search functionality for better performance.
This commit is contained in:
Julien Froidefond
2025-09-15 09:29:38 +02:00
parent fc20154ef6
commit 631da57ea2
3 changed files with 142 additions and 156 deletions

View File

@@ -21,7 +21,7 @@ export interface TagFilters {
// Types pour les réponses
export interface TagsResponse {
data: Tag[];
data: Array<Tag & { usage: number }>;
message: string;
}

View File

@@ -5,7 +5,7 @@ import { tagsClient, TagFilters, CreateTagData, UpdateTagData, TagsClient } from
import { Tag } from '@/lib/types';
interface UseTagsState {
tags: Tag[];
tags: Array<Tag & { usage: number }>;
popularTags: Array<Tag & { usage: number }>;
loading: boolean;
error: string | null;
@@ -29,7 +29,7 @@ export function useTags(
initialFilters?: TagFilters
): UseTagsState & UseTagsActions {
const [state, setState] = useState<UseTagsState>({
tags: initialData?.map(tag => ({ id: tag.id, name: tag.name, color: tag.color, isPinned: tag.isPinned })) || [],
tags: initialData || [],
popularTags: initialData || [],
loading: !initialData,
error: null

View File

@@ -1,13 +1,12 @@
'use client';
import { useState } from 'react';
import { useState, useMemo } from 'react';
import React from 'react';
import { Tag } from '@/lib/types';
import { useTags } from '@/hooks/useTags';
import { CreateTagData, UpdateTagData } from '@/clients/tags-client';
import { CreateTagData } from '@/clients/tags-client';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TagList } from '@/components/ui/TagList';
import { TagForm } from '@/components/forms/TagForm';
import Link from 'next/link';
@@ -18,44 +17,38 @@ interface TagsPageClientProps {
export function TagsPageClient({ initialTags }: TagsPageClientProps) {
const {
tags,
popularTags,
loading,
error,
createTag,
updateTag,
deleteTag,
getPopularTags,
searchTags
} = useTags();
deleteTag
} = useTags(initialTags as (Tag & { usage: number })[]);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Tag[]>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [showPopular, setShowPopular] = useState(false);
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
const [localTags, setLocalTags] = useState<Tag[]>(initialTags);
// Utiliser les tags du hook s'ils sont chargés, sinon les tags locaux
const displayTags = tags.length > 0 ? tags : localTags;
const filteredTags = searchQuery ? searchResults : displayTags;
// Filtrer et trier les tags
const filteredAndSortedTags = useMemo(() => {
let filtered = tags;
// Synchroniser les tags locaux avec les tags du hook une seule fois
React.useEffect(() => {
if (tags.length > 0 && localTags === initialTags) {
setLocalTags(tags);
// Filtrer par recherche
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = tags.filter(tag =>
tag.name.toLowerCase().includes(query)
);
}
}, [tags, localTags, initialTags]);
const handleSearch = async (query: string) => {
setSearchQuery(query);
if (query.trim()) {
const results = await searchTags(query);
setSearchResults(results);
} else {
setSearchResults([]);
}
};
// Trier par usage puis par nom
return filtered.sort((a, b) => {
const usageA = (a as Tag & { usage?: number }).usage || 0;
const usageB = (b as Tag & { usage?: number }).usage || 0;
if (usageB !== usageA) return usageB - usageA;
return a.name.localeCompare(b.name);
});
}, [tags, searchQuery]);
const handleCreateTag = async (data: CreateTagData) => {
await createTag(data);
@@ -66,7 +59,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
setEditingTag(tag);
};
const handleUpdateTag = async (data: { name: string; color: string }) => {
const handleUpdateTag = async (data: { name: string; color: string; isPinned?: boolean }) => {
if (editingTag) {
await updateTag({
tagId: editingTag.id,
@@ -77,38 +70,22 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
};
const handleDeleteTag = async (tag: Tag) => {
// Suppression optimiste : retirer immédiatement de l'affichage
setDeletingTagId(tag.id);
// Mettre à jour les tags locaux pour suppression immédiate
const updatedTags = displayTags.filter(t => t.id !== tag.id);
setLocalTags(updatedTags);
try {
await deleteTag(tag.id);
// Succès : les tags seront mis à jour par le hook
} catch (error) {
// En cas d'erreur, restaurer le tag dans l'affichage
setLocalTags(prev => [...prev, tag].sort((a, b) => a.name.localeCompare(b.name)));
console.error('Erreur lors de la suppression:', error);
} finally {
setDeletingTagId(null);
}
};
const handleShowPopular = async () => {
if (!showPopular) {
await getPopularTags(10);
}
setShowPopular(!showPopular);
};
return (
<div className="min-h-screen bg-slate-950">
{/* Header */}
{/* Header simplifié */}
<div className="bg-slate-900/80 backdrop-blur-sm border-b border-slate-700/50">
<div className="container mx-auto px-6 py-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Bouton retour */}
<Link
@@ -127,118 +104,127 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
</Link>
<div className="w-3 h-3 bg-purple-400 rounded-full animate-pulse shadow-purple-400/50 shadow-lg"></div>
<div>
<h1 className="text-2xl font-mono font-bold text-slate-100 tracking-wider">
Gestion des Tags
<h1 className="text-xl font-mono font-bold text-slate-100 tracking-wider">
Tags ({filteredAndSortedTags.length})
</h1>
<p className="text-slate-400 mt-1 font-mono text-sm">
Organisez et gérez vos étiquettes
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
onClick={handleShowPopular}
disabled={false}
>
{showPopular ? 'Tous les tags' : 'Tags populaires'}
</Button>
<Button
variant="primary"
onClick={() => setIsCreateModalOpen(true)}
disabled={false}
className="flex items-center gap-2"
>
+ Nouveau tag
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nouveau tag
</Button>
</div>
</div>
</div>
</div>
{/* Contenu principal */}
<div className="container mx-auto px-6 py-8">
<div className="max-w-4xl mx-auto space-y-8">
<div className="container mx-auto px-6 py-6">
{/* Barre de recherche */}
<div className="space-y-2">
<label className="block text-sm font-mono font-medium text-slate-300 uppercase tracking-wider">
Rechercher des tags
</label>
<div className="max-w-md mx-auto mb-6">
<Input
type="text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Tapez pour rechercher..."
className="max-w-md"
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Rechercher un tag..."
className="w-full"
/>
</div>
{/* Statistiques */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-2xl font-mono font-bold text-slate-100">
{displayTags.length}
</div>
<div className="text-sm text-slate-400 font-mono uppercase tracking-wide">
Tags total
</div>
</div>
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-2xl font-mono font-bold text-slate-100">
{popularTags.length > 0 ? popularTags[0]?.usage || 0 : 0}
</div>
<div className="text-sm text-slate-400 font-mono uppercase tracking-wide">
Plus utilisé
</div>
</div>
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="text-2xl font-mono font-bold text-slate-100">
{searchQuery ? searchResults.length : displayTags.length}
</div>
<div className="text-sm text-slate-400 font-mono uppercase tracking-wide">
{searchQuery ? 'Résultats' : 'Affichés'}
</div>
</div>
</div>
{/* Messages d'état */}
{error && (
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-4">
<div className="text-red-400 text-sm">
Erreur : {error}
</div>
<div className="max-w-md mx-auto mb-6 bg-red-900/20 border border-red-500/30 rounded-lg p-3 text-center">
<div className="text-red-400 text-sm">Erreur : {error}</div>
</div>
)}
{loading && displayTags.length === 0 && (
{loading && (
<div className="text-center py-8">
<div className="text-slate-400">Chargement...</div>
</div>
)}
{/* Liste des tags */}
<div className="space-y-4">
<h2 className="text-lg font-mono font-bold text-slate-200 uppercase tracking-wider">
{showPopular
? 'Tags populaires'
: searchQuery
? `Résultats pour "${searchQuery}"`
: 'Tous les tags'
}
</h2>
<TagList
tags={showPopular ? popularTags : filteredTags}
onTagEdit={handleEditTag}
onTagDelete={handleDeleteTag}
showUsage={true}
deletingTagId={deletingTagId}
{/* Tags en grille compacte */}
{!loading && (
<div className="max-w-6xl mx-auto">
{filteredAndSortedTags.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">🏷</div>
<p className="text-lg mb-2">
{searchQuery ? 'Aucun tag trouvé' : 'Aucun tag'}
</p>
<p className="text-sm">
{searchQuery ? 'Essayez un autre terme' : 'Créez votre premier tag'}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredAndSortedTags.map((tag) => {
const isDeleting = deletingTagId === tag.id;
const usage = (tag as Tag & { usage?: number }).usage || 0;
return (
<div
key={tag.id}
className={`relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 p-4 group ${
isDeleting ? 'opacity-50 pointer-events-none' : ''
}`}
>
{/* Actions en overlay */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={() => handleEditTag(tag)}
className="p-1.5 text-slate-400 hover:text-slate-200 transition-colors rounded-lg hover:bg-slate-700/80"
title="Modifier"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDeleteTag(tag)}
className="p-1.5 text-slate-400 hover:text-red-400 transition-colors rounded-lg hover:bg-red-900/20"
title="Supprimer"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
{/* Contenu principal */}
<div className="flex items-start gap-3 pr-12">
<div
className="w-5 h-5 rounded-full flex-shrink-0 mt-0.5"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<h3 className="text-slate-100 font-medium truncate text-sm mb-1">
{tag.name}
</h3>
<p className="text-slate-400 text-xs mb-2">
{usage} tâche{usage > 1 ? 's' : ''}
</p>
{tag.isPinned && (
<span className="inline-flex items-center text-purple-400 text-xs bg-purple-400/10 px-2 py-1 rounded-full">
🎯 Objectif
</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{/* Modals */}
<TagForm