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:
@@ -21,7 +21,7 @@ export interface TagFilters {
|
|||||||
|
|
||||||
// Types pour les réponses
|
// Types pour les réponses
|
||||||
export interface TagsResponse {
|
export interface TagsResponse {
|
||||||
data: Tag[];
|
data: Array<Tag & { usage: number }>;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { tagsClient, TagFilters, CreateTagData, UpdateTagData, TagsClient } from
|
|||||||
import { Tag } from '@/lib/types';
|
import { Tag } from '@/lib/types';
|
||||||
|
|
||||||
interface UseTagsState {
|
interface UseTagsState {
|
||||||
tags: Tag[];
|
tags: Array<Tag & { usage: number }>;
|
||||||
popularTags: Array<Tag & { usage: number }>;
|
popularTags: Array<Tag & { usage: number }>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -29,7 +29,7 @@ export function useTags(
|
|||||||
initialFilters?: TagFilters
|
initialFilters?: TagFilters
|
||||||
): UseTagsState & UseTagsActions {
|
): UseTagsState & UseTagsActions {
|
||||||
const [state, setState] = useState<UseTagsState>({
|
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 || [],
|
popularTags: initialData || [],
|
||||||
loading: !initialData,
|
loading: !initialData,
|
||||||
error: null
|
error: null
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tag } from '@/lib/types';
|
import { Tag } from '@/lib/types';
|
||||||
import { useTags } from '@/hooks/useTags';
|
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 { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { TagList } from '@/components/ui/TagList';
|
|
||||||
import { TagForm } from '@/components/forms/TagForm';
|
import { TagForm } from '@/components/forms/TagForm';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
@@ -18,44 +17,38 @@ interface TagsPageClientProps {
|
|||||||
export function TagsPageClient({ initialTags }: TagsPageClientProps) {
|
export function TagsPageClient({ initialTags }: TagsPageClientProps) {
|
||||||
const {
|
const {
|
||||||
tags,
|
tags,
|
||||||
popularTags,
|
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
createTag,
|
createTag,
|
||||||
updateTag,
|
updateTag,
|
||||||
deleteTag,
|
deleteTag
|
||||||
getPopularTags,
|
} = useTags(initialTags as (Tag & { usage: number })[]);
|
||||||
searchTags
|
|
||||||
} = useTags();
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<Tag[]>([]);
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||||
const [showPopular, setShowPopular] = useState(false);
|
|
||||||
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
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
|
// Filtrer et trier les tags
|
||||||
const displayTags = tags.length > 0 ? tags : localTags;
|
const filteredAndSortedTags = useMemo(() => {
|
||||||
const filteredTags = searchQuery ? searchResults : displayTags;
|
let filtered = tags;
|
||||||
|
|
||||||
// Synchroniser les tags locaux avec les tags du hook une seule fois
|
// Filtrer par recherche
|
||||||
React.useEffect(() => {
|
if (searchQuery.trim()) {
|
||||||
if (tags.length > 0 && localTags === initialTags) {
|
const query = searchQuery.toLowerCase();
|
||||||
setLocalTags(tags);
|
filtered = tags.filter(tag =>
|
||||||
|
tag.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [tags, localTags, initialTags]);
|
|
||||||
|
// Trier par usage puis par nom
|
||||||
const handleSearch = async (query: string) => {
|
return filtered.sort((a, b) => {
|
||||||
setSearchQuery(query);
|
const usageA = (a as Tag & { usage?: number }).usage || 0;
|
||||||
if (query.trim()) {
|
const usageB = (b as Tag & { usage?: number }).usage || 0;
|
||||||
const results = await searchTags(query);
|
if (usageB !== usageA) return usageB - usageA;
|
||||||
setSearchResults(results);
|
return a.name.localeCompare(b.name);
|
||||||
} else {
|
});
|
||||||
setSearchResults([]);
|
}, [tags, searchQuery]);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateTag = async (data: CreateTagData) => {
|
const handleCreateTag = async (data: CreateTagData) => {
|
||||||
await createTag(data);
|
await createTag(data);
|
||||||
@@ -66,7 +59,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
|
|||||||
setEditingTag(tag);
|
setEditingTag(tag);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateTag = async (data: { name: string; color: string }) => {
|
const handleUpdateTag = async (data: { name: string; color: string; isPinned?: boolean }) => {
|
||||||
if (editingTag) {
|
if (editingTag) {
|
||||||
await updateTag({
|
await updateTag({
|
||||||
tagId: editingTag.id,
|
tagId: editingTag.id,
|
||||||
@@ -77,38 +70,22 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTag = async (tag: Tag) => {
|
const handleDeleteTag = async (tag: Tag) => {
|
||||||
// Suppression optimiste : retirer immédiatement de l'affichage
|
|
||||||
setDeletingTagId(tag.id);
|
setDeletingTagId(tag.id);
|
||||||
|
|
||||||
// Mettre à jour les tags locaux pour suppression immédiate
|
|
||||||
const updatedTags = displayTags.filter(t => t.id !== tag.id);
|
|
||||||
setLocalTags(updatedTags);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteTag(tag.id);
|
await deleteTag(tag.id);
|
||||||
// Succès : les tags seront mis à jour par le hook
|
|
||||||
} catch (error) {
|
} 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);
|
console.error('Erreur lors de la suppression:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingTagId(null);
|
setDeletingTagId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShowPopular = async () => {
|
|
||||||
if (!showPopular) {
|
|
||||||
await getPopularTags(10);
|
|
||||||
}
|
|
||||||
setShowPopular(!showPopular);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950">
|
<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="bg-slate-900/80 backdrop-blur-sm border-b border-slate-700/50">
|
||||||
<div className="container mx-auto px-6 py-6">
|
<div className="container mx-auto px-6 py-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Bouton retour */}
|
{/* Bouton retour */}
|
||||||
<Link
|
<Link
|
||||||
@@ -127,117 +104,126 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="w-3 h-3 bg-purple-400 rounded-full animate-pulse shadow-purple-400/50 shadow-lg"></div>
|
<div className="w-3 h-3 bg-purple-400 rounded-full animate-pulse shadow-purple-400/50 shadow-lg"></div>
|
||||||
<div>
|
<h1 className="text-xl font-mono font-bold text-slate-100 tracking-wider">
|
||||||
<h1 className="text-2xl font-mono font-bold text-slate-100 tracking-wider">
|
Tags ({filteredAndSortedTags.length})
|
||||||
Gestion des Tags
|
</h1>
|
||||||
</h1>
|
|
||||||
<p className="text-slate-400 mt-1 font-mono text-sm">
|
|
||||||
Organisez et gérez vos étiquettes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<Button
|
||||||
<Button
|
variant="primary"
|
||||||
variant="secondary"
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
onClick={handleShowPopular}
|
className="flex items-center gap-2"
|
||||||
disabled={false}
|
>
|
||||||
>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{showPopular ? 'Tous les tags' : 'Tags populaires'}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
</Button>
|
</svg>
|
||||||
<Button
|
Nouveau tag
|
||||||
variant="primary"
|
</Button>
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
|
||||||
disabled={false}
|
|
||||||
>
|
|
||||||
+ Nouveau tag
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
<div className="container mx-auto px-6 py-8">
|
<div className="container mx-auto px-6 py-6">
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
{/* Barre de recherche */}
|
||||||
{/* Barre de recherche */}
|
<div className="max-w-md mx-auto mb-6">
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<label className="block text-sm font-mono font-medium text-slate-300 uppercase tracking-wider">
|
type="text"
|
||||||
Rechercher des tags
|
value={searchQuery}
|
||||||
</label>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
<Input
|
placeholder="Rechercher un tag..."
|
||||||
type="text"
|
className="w-full"
|
||||||
value={searchQuery}
|
/>
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
|
||||||
placeholder="Tapez pour rechercher..."
|
|
||||||
className="max-w-md"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && displayTags.length === 0 && (
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Messages d'état */}
|
||||||
|
{error && (
|
||||||
|
<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 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-slate-400">Chargement...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
|
|||||||
Reference in New Issue
Block a user