feat: complete tag management and UI integration

- Marked multiple tasks as completed in TODO.md related to tag management features.
- Replaced manual tag input with `TagInput` component in `CreateTaskForm`, `EditTaskForm`, and `QuickAddTask` for better UX.
- Updated `TaskCard` to display tags using `TagDisplay` with color support.
- Enhanced `TasksService` to manage task-tag relationships with CRUD operations.
- Integrated tag management into the global context for better accessibility across components.
This commit is contained in:
Julien Froidefond
2025-09-14 16:44:22 +02:00
parent edbd82e8ac
commit c5a7d16425
27 changed files with 2055 additions and 224 deletions

View File

@@ -1,4 +1,5 @@
import { Card, CardContent } from '@/components/ui/Card';
import Link from 'next/link';
interface HeaderProps {
title: string;
@@ -19,20 +20,38 @@ export function Header({ title, subtitle, stats, syncing = false }: HeaderProps)
<div className="container mx-auto px-6 py-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
{/* Titre tech avec glow */}
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full shadow-lg ${
syncing
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
}`}></div>
<div>
<h1 className="text-2xl font-mono font-bold text-slate-100 tracking-wider">
{title}
</h1>
<p className="text-slate-400 mt-1 font-mono text-sm">
{subtitle} {syncing && '• Synchronisation...'}
</p>
<div className="flex items-center gap-6">
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full shadow-lg ${
syncing
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
}`}></div>
<div>
<h1 className="text-2xl font-mono font-bold text-slate-100 tracking-wider">
{title}
</h1>
<p className="text-slate-400 mt-1 font-mono text-sm">
{subtitle} {syncing && '• Synchronisation...'}
</p>
</div>
</div>
{/* Navigation */}
<nav className="hidden sm:flex items-center gap-4">
<Link
href="/"
className="text-slate-400 hover:text-cyan-400 transition-colors font-mono text-sm uppercase tracking-wider"
>
Kanban
</Link>
<Link
href="/tags"
className="text-slate-400 hover:text-purple-400 transition-colors font-mono text-sm uppercase tracking-wider"
>
Tags
</Link>
</nav>
</div>
{/* Stats tech dashboard */}

View File

@@ -0,0 +1,157 @@
'use client';
import { Tag } from '@/lib/types';
import { Badge } from './Badge';
interface TagDisplayProps {
tags: string[];
availableTags?: Tag[]; // Tags avec couleurs depuis la DB
size?: 'sm' | 'md' | 'lg';
maxTags?: number;
showColors?: boolean;
onClick?: (tagName: string) => void;
className?: string;
}
export function TagDisplay({
tags,
availableTags = [],
size = 'sm',
maxTags,
showColors = true,
onClick,
className = ""
}: TagDisplayProps) {
if (!tags || tags.length === 0) {
return null;
}
const displayTags = maxTags ? tags.slice(0, maxTags) : tags;
const remainingCount = maxTags && tags.length > maxTags ? tags.length - maxTags : 0;
const getTagColor = (tagName: string): string => {
if (!showColors) return '#6b7280'; // gray-500
const tag = availableTags.find(t => t.name === tagName);
return tag?.color || '#6b7280';
};
const sizeClasses = {
sm: 'text-xs px-2 py-0.5',
md: 'text-sm px-2 py-1',
lg: 'text-base px-3 py-1.5'
};
return (
<div className={`flex flex-wrap gap-1 ${className}`}>
{displayTags.map((tagName, index) => {
const color = getTagColor(tagName);
return (
<div
key={index}
onClick={() => onClick?.(tagName)}
className={`inline-flex items-center gap-1.5 rounded-full border transition-colors ${sizeClasses[size]} ${
onClick ? 'cursor-pointer hover:opacity-80' : ''
}`}
style={{
backgroundColor: showColors ? `${color}20` : undefined,
borderColor: showColors ? `${color}60` : undefined,
color: showColors ? color : undefined
}}
>
{showColors && (
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
)}
<span className="font-medium">{tagName}</span>
</div>
);
})}
{remainingCount > 0 && (
<Badge variant="default" size="sm">
+{remainingCount}
</Badge>
)}
</div>
);
}
interface TagListProps {
tags: Tag[];
onTagClick?: (tag: Tag) => void;
onTagEdit?: (tag: Tag) => void;
onTagDelete?: (tag: Tag) => void;
className?: string;
}
/**
* Composant pour afficher une liste complète de tags avec actions
*/
export function TagList({
tags,
onTagClick,
onTagEdit,
onTagDelete,
className = ""
}: TagListProps) {
if (!tags || tags.length === 0) {
return (
<div className="text-center py-8 text-slate-400">
<div className="text-4xl mb-2">🏷</div>
<p className="text-sm">Aucun tag trouvé</p>
</div>
);
}
return (
<div className={`space-y-2 ${className}`}>
{tags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between p-3 bg-slate-800 rounded-lg border border-slate-700 hover:border-slate-600 transition-colors group"
>
<div
className="flex items-center gap-3 flex-1 cursor-pointer"
onClick={() => onTagClick?.(tag)}
>
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: tag.color }}
/>
<span className="text-slate-200 font-medium">{tag.name}</span>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{onTagEdit && (
<button
onClick={() => onTagEdit(tag)}
className="p-1 text-slate-400 hover:text-cyan-400 transition-colors"
title="Éditer le tag"
>
<svg className="w-4 h-4" 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>
)}
{onTagDelete && (
<button
onClick={() => onTagDelete(tag)}
className="p-1 text-slate-400 hover:text-red-400 transition-colors"
title="Supprimer le tag"
>
<svg className="w-4 h-4" 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>
</div>
))}
</div>
);
}

184
components/ui/TagInput.tsx Normal file
View File

@@ -0,0 +1,184 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Tag } from '@/lib/types';
import { useTagsAutocomplete } from '@/hooks/useTags';
import { Badge } from './Badge';
interface TagInputProps {
tags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
maxTags?: number;
className?: string;
}
export function TagInput({
tags,
onChange,
placeholder = "Ajouter des tags...",
maxTags = 10,
className = ""
}: TagInputProps) {
const [inputValue, setInputValue] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null);
const { suggestions, loading, searchTags, clearSuggestions } = useTagsAutocomplete();
// Rechercher des suggestions quand l'input change
useEffect(() => {
if (inputValue.trim()) {
searchTags(inputValue);
setShowSuggestions(true);
setSelectedIndex(-1);
} else {
clearSuggestions();
setShowSuggestions(false);
}
}, [inputValue, searchTags, clearSuggestions]);
const addTag = (tagName: string) => {
const trimmedTag = tagName.trim();
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
onChange([...tags, trimmedTag]);
}
setInputValue('');
setShowSuggestions(false);
setSelectedIndex(-1);
};
const removeTag = (tagToRemove: string) => {
onChange(tags.filter(tag => tag !== tagToRemove));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
addTag(suggestions[selectedIndex].name);
} else if (inputValue.trim()) {
addTag(inputValue);
}
} else if (e.key === 'Escape') {
setShowSuggestions(false);
setSelectedIndex(-1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev =>
prev < suggestions.length - 1 ? prev + 1 : prev
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
// Supprimer le dernier tag si l'input est vide
removeTag(tags[tags.length - 1]);
}
};
const handleSuggestionClick = (tag: Tag) => {
addTag(tag.name);
};
const handleBlur = (e: React.FocusEvent) => {
// Délai pour permettre le clic sur une suggestion
setTimeout(() => {
if (!suggestionsRef.current?.contains(e.relatedTarget as Node)) {
setShowSuggestions(false);
setSelectedIndex(-1);
}
}, 150);
};
return (
<div className={`relative ${className}`}>
{/* Container des tags et input */}
<div className="min-h-[42px] p-2 border border-slate-600 rounded-lg bg-slate-800 focus-within:border-cyan-400 focus-within:ring-1 focus-within:ring-cyan-400/20 transition-colors">
<div className="flex flex-wrap gap-1 items-center">
{/* Tags existants */}
{tags.map((tag, index) => (
<Badge
key={index}
variant="default"
className="flex items-center gap-1 px-2 py-1 text-xs"
>
<span>{tag}</span>
<button
type="button"
onClick={() => removeTag(tag)}
className="text-slate-400 hover:text-slate-200 ml-1"
aria-label={`Supprimer le tag ${tag}`}
>
×
</button>
</Badge>
))}
{/* Input pour nouveau tag */}
{tags.length < maxTags && (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={() => inputValue && setShowSuggestions(true)}
placeholder={tags.length === 0 ? placeholder : ""}
className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-slate-100 placeholder-slate-400 text-sm"
/>
)}
</div>
</div>
{/* Suggestions dropdown */}
{showSuggestions && (suggestions.length > 0 || loading) && (
<div
ref={suggestionsRef}
className="absolute top-full left-0 right-0 mt-1 bg-slate-800 border border-slate-600 rounded-lg shadow-lg z-50 max-h-48 overflow-y-auto"
>
{loading ? (
<div className="p-3 text-center text-slate-400 text-sm">
Recherche...
</div>
) : (
suggestions.map((tag, index) => (
<button
key={tag.id}
type="button"
onClick={() => handleSuggestionClick(tag)}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
index === selectedIndex
? 'bg-slate-700 text-cyan-300'
: 'text-slate-200 hover:bg-slate-700'
} ${tags.includes(tag.name) ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={tags.includes(tag.name)}
>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tag.color }}
/>
<span>{tag.name}</span>
{tags.includes(tag.name) && (
<span className="text-xs text-slate-400 ml-auto">Déjà ajouté</span>
)}
</div>
</button>
))
)}
</div>
)}
{/* Indicateur de limite */}
{tags.length >= maxTags && (
<div className="text-xs text-slate-400 mt-1">
Limite de {maxTags} tags atteinte
</div>
)}
</div>
);
}

91
components/ui/TagList.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { Tag } from '@/lib/types';
import { Button } from '@/components/ui/Button';
interface TagListProps {
tags: (Tag & { usage?: number })[];
onTagEdit?: (tag: Tag) => void;
onTagDelete?: (tag: Tag) => void;
showActions?: boolean;
showUsage?: boolean;
}
export function TagList({
tags,
onTagEdit,
onTagDelete,
showActions = true
}: TagListProps) {
if (tags.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">🏷</div>
<p className="text-lg mb-2">Aucun tag trouvé</p>
<p className="text-sm">Créez votre premier tag pour commencer</p>
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tags.map((tag) => (
<div
key={tag.id}
className="group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3"
>
{/* Contenu principal */}
<div className="flex items-center gap-3">
<div
className="w-5 h-5 rounded-full shadow-sm"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="text-slate-200 font-medium truncate">
{tag.name}
</h3>
{tag.usage !== undefined && (
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
{tag.usage}
</span>
)}
</div>
</div>
</div>
{/* Actions (apparaissent au hover) */}
{showActions && (onTagEdit || onTagDelete) && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onTagEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => onTagEdit(tag)}
className="h-7 px-2 text-xs bg-slate-700/80 backdrop-blur-sm border border-slate-600 hover:border-slate-500 hover:bg-slate-600"
>
</Button>
)}
{onTagDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onTagDelete(tag)}
className="h-7 px-2 text-xs bg-slate-700/80 backdrop-blur-sm border border-slate-600 hover:border-red-500 hover:text-red-400 hover:bg-red-900/20"
>
🗑
</Button>
)}
</div>
)}
{/* Indicateur de couleur en bas */}
<div
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
style={{ backgroundColor: tag.color }}
/>
</div>
))}
</div>
);
}