feat: enhance GeneralSettingsPage with tag management

- Added tag management functionality to the `GeneralSettingsPageClient`, including filtering, sorting, and CRUD operations for tags.
- Integrated a modal for creating and editing tags, improving user experience in managing task labels.
- Updated the `Header` component to replace the 'Tags' link with 'Manager'.
- Removed the deprecated `TagsPage` and `TagsPageClient` components to streamline the codebase.
- Adjusted data fetching in `page.tsx` to include initial tags alongside user preferences.
This commit is contained in:
Julien Froidefond
2025-09-19 13:34:15 +02:00
parent d6722e90d1
commit c4d8bacd97
5 changed files with 307 additions and 245 deletions

View File

@@ -1,4 +1,5 @@
import { userPreferencesService } from '@/services/user-preferences';
import { tagsService } from '@/services/tags';
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
// Force dynamic rendering for real-time data
@@ -6,7 +7,10 @@ export const dynamic = 'force-dynamic';
export default async function GeneralSettingsPage() {
// Fetch data server-side
const preferences = await userPreferencesService.getAllPreferences();
const [preferences, tags] = await Promise.all([
userPreferencesService.getAllPreferences(),
tagsService.getTags()
]);
return <GeneralSettingsPageClient initialPreferences={preferences} />;
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
}

View File

@@ -1,224 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import React from 'react';
import { Tag } from '@/lib/types';
import { useTags } from '@/hooks/useTags';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TagForm } from '@/components/forms/TagForm';
import { Header } from '@/components/ui/Header';
interface TagsPageClientProps {
initialTags: Tag[];
}
export function TagsPageClient({ initialTags }: TagsPageClientProps) {
const {
tags,
loading,
error,
refreshTags,
deleteTag
} = useTags(initialTags as (Tag & { usage: number })[]);
const [searchQuery, setSearchQuery] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
// Filtrer et trier les tags
const filteredAndSortedTags = useMemo(() => {
let filtered = tags;
// Filtrer par recherche
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = tags.filter(tag =>
tag.name.toLowerCase().includes(query)
);
}
// 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 handleEditTag = (tag: Tag) => {
setEditingTag(tag);
};
const handleDeleteTag = async (tag: Tag) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
return;
}
setDeletingTagId(tag.id);
try {
// Utiliser la server action directement depuis useTags
await deleteTag(tag.id);
// Refresh la liste des tags
await refreshTags();
} catch (error) {
console.error('Erreur lors de la suppression:', error);
} finally {
setDeletingTagId(null);
}
};
return (
<div className="min-h-screen bg-[var(--background)]">
{/* Header uniforme */}
<Header
title="TowerControl"
subtitle="Tags - Gestion des étiquettes"
syncing={loading}
/>
{/* Header spécifique aux tags */}
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/30">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-lg font-mono font-bold text-[var(--foreground)] tracking-wider">
Tags ({filteredAndSortedTags.length})
</h2>
</div>
<Button
variant="primary"
onClick={() => setIsCreateModalOpen(true)}
className="flex items-center gap-2"
>
<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>
{/* Contenu principal */}
<div className="container mx-auto px-6 py-6">
{/* Barre de recherche */}
<div className="max-w-md mx-auto mb-6">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Rechercher un tag..."
className="w-full"
/>
</div>
{/* Messages d'état */}
{error && (
<div className="max-w-md mx-auto mb-6 bg-[var(--destructive)]/20 border border-[var(--destructive)]/30 rounded-lg p-3 text-center">
<div className="text-[var(--destructive)] text-sm">Erreur : {error}</div>
</div>
)}
{loading && (
<div className="text-center py-8">
<div className="text-[var(--muted-foreground)]">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-[var(--muted-foreground)]">
<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-[var(--card)] rounded-lg border border-[var(--border)] hover:border-[var(--border)] 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-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors rounded-lg hover:bg-[var(--card-hover)]"
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-[var(--muted-foreground)] hover:text-[var(--destructive)] transition-colors rounded-lg hover:bg-[var(--destructive)]/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-[var(--foreground)] font-medium truncate text-sm mb-1">
{tag.name}
</h3>
<p className="text-[var(--muted-foreground)] text-xs mb-2">
{usage} tâche{usage > 1 ? 's' : ''}
</p>
{tag.isPinned && (
<span className="inline-flex items-center text-[var(--accent)] text-xs bg-[var(--accent)]/10 px-2 py-1 rounded-full">
🎯 Objectif
</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{/* Modals */}
<TagForm
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={refreshTags}
/>
<TagForm
isOpen={!!editingTag}
onClose={() => setEditingTag(null)}
onSuccess={refreshTags}
tag={editingTag}
/>
</div>
);
}

View File

@@ -1,14 +0,0 @@
import { tagsService } from '@/services/tags';
import { TagsPageClient } from './TagsPageClient';
// Force dynamic rendering (no static generation)
export const dynamic = 'force-dynamic';
export default async function TagsPage() {
// SSR - Récupération des tags côté serveur
const initialTags = await tagsService.getTags();
return (
<TagsPageClient initialTags={initialTags} />
);
}