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:
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
|
||||
@@ -25,7 +25,6 @@ export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: C
|
||||
dueDate: undefined
|
||||
});
|
||||
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
@@ -69,35 +68,10 @@ export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: C
|
||||
tags: [],
|
||||
dueDate: undefined
|
||||
});
|
||||
setTagInput('');
|
||||
setErrors({});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const tag = tagInput.trim();
|
||||
if (tag && !formData.tags?.includes(tag)) {
|
||||
setFormData((prev: CreateTaskData) => ({
|
||||
...prev,
|
||||
tags: [...(prev.tags || []), tag]
|
||||
}));
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setFormData((prev: CreateTaskData) => ({
|
||||
...prev,
|
||||
tags: prev.tags?.filter((tag: string) => tag !== tagToRemove) || []
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Nouvelle tâche" size="lg">
|
||||
@@ -188,39 +162,12 @@ export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: C
|
||||
Tags
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={handleTagKeyPress}
|
||||
placeholder="Ajouter un tag..."
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={addTag}
|
||||
disabled={!tagInput.trim() || loading}
|
||||
>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.tags.map((tag: string, index: number) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="primary"
|
||||
className="cursor-pointer hover:bg-red-950/50 hover:text-red-300 hover:border-red-500/30 transition-colors"
|
||||
onClick={() => removeTag(tag)}
|
||||
>
|
||||
{tag} ✕
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<TagInput
|
||||
tags={formData.tags || []}
|
||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||
placeholder="Ajouter des tags..."
|
||||
maxTags={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { UpdateTaskData } from '@/clients/tasks-client';
|
||||
|
||||
@@ -26,7 +26,6 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
dueDate: undefined
|
||||
});
|
||||
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Pré-remplir le formulaire quand la tâche change
|
||||
@@ -79,35 +78,10 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTagInput('');
|
||||
setErrors({});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const tag = tagInput.trim();
|
||||
if (tag && !formData.tags?.includes(tag)) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...(prev.tags || []), tag]
|
||||
}));
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags?.filter((tag: string) => tag !== tagToRemove) || []
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
};
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
@@ -200,39 +174,12 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
Tags
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={handleTagKeyPress}
|
||||
placeholder="Ajouter un tag..."
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={addTag}
|
||||
disabled={!tagInput.trim() || loading}
|
||||
>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.tags.map((tag: string, index: number) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="primary"
|
||||
className="cursor-pointer hover:bg-red-950/50 hover:text-red-300 hover:border-red-500/30 transition-colors"
|
||||
onClick={() => removeTag(tag)}
|
||||
>
|
||||
{tag} ✕
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<TagInput
|
||||
tags={formData.tags || []}
|
||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||
placeholder="Ajouter des tags..."
|
||||
maxTags={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
201
components/forms/TagForm.tsx
Normal file
201
components/forms/TagForm.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Tag } from '@/lib/types';
|
||||
import { TagsClient } from '@/clients/tags-client';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface TagFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: { name: string; color: string }) => Promise<void>;
|
||||
tag?: Tag | null; // Si fourni, mode édition
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#3B82F6', // Blue
|
||||
'#EF4444', // Red
|
||||
'#10B981', // Green
|
||||
'#F59E0B', // Yellow
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#06B6D4', // Cyan
|
||||
'#84CC16', // Lime
|
||||
'#F97316', // Orange
|
||||
'#6366F1', // Indigo
|
||||
'#14B8A6', // Teal
|
||||
'#F43F5E', // Rose
|
||||
];
|
||||
|
||||
export function TagForm({ isOpen, onClose, onSubmit, tag, loading = false }: TagFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
color: '#3B82F6'
|
||||
});
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
// Pré-remplir le formulaire en mode édition
|
||||
useEffect(() => {
|
||||
if (tag) {
|
||||
setFormData({
|
||||
name: tag.name,
|
||||
color: tag.color
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
color: TagsClient.generateRandomColor()
|
||||
});
|
||||
}
|
||||
setErrors([]);
|
||||
}, [tag, isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
const validationErrors = TagsClient.validateTagData(formData);
|
||||
if (validationErrors.length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setErrors([error instanceof Error ? error.message : 'Erreur inconnue']);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorSelect = (color: string) => {
|
||||
setFormData(prev => ({ ...prev, color }));
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({ ...prev, color: e.target.value }));
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={tag ? 'Éditer le tag' : 'Nouveau tag'}>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Nom du tag */}
|
||||
<div>
|
||||
<label htmlFor="tag-name" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Nom du tag
|
||||
</label>
|
||||
<Input
|
||||
id="tag-name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData(prev => ({ ...prev, name: e.target.value }));
|
||||
setErrors([]);
|
||||
}}
|
||||
placeholder="Nom du tag..."
|
||||
maxLength={50}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sélecteur de couleur */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-3">
|
||||
Couleur du tag
|
||||
</label>
|
||||
|
||||
{/* Aperçu de la couleur sélectionnée */}
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-800 rounded-lg border border-slate-600">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border-2 border-slate-500"
|
||||
style={{ backgroundColor: formData.color }}
|
||||
/>
|
||||
<span className="text-slate-200 font-medium">{formData.name || 'Aperçu du tag'}</span>
|
||||
</div>
|
||||
|
||||
{/* Couleurs prédéfinies */}
|
||||
<div className="grid grid-cols-6 gap-2 mb-4">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => handleColorSelect(color)}
|
||||
className={`w-10 h-10 rounded-lg border-2 transition-all hover:scale-110 ${
|
||||
formData.color === color
|
||||
? 'border-white shadow-lg'
|
||||
: 'border-slate-600 hover:border-slate-400'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Couleur personnalisée */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="custom-color" className="text-sm text-slate-400">
|
||||
Couleur personnalisée :
|
||||
</label>
|
||||
<input
|
||||
id="custom-color"
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={handleCustomColorChange}
|
||||
disabled={loading}
|
||||
className="w-12 h-8 rounded border border-slate-600 bg-slate-800 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
if (TagsClient.isValidColor(e.target.value)) {
|
||||
handleCustomColorChange(e as any);
|
||||
}
|
||||
}}
|
||||
placeholder="#RRGGBB"
|
||||
maxLength={7}
|
||||
disabled={loading}
|
||||
className="w-24 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Erreurs */}
|
||||
{errors.length > 0 && (
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||
<div className="text-red-400 text-sm space-y-1">
|
||||
{errors.map((error, index) => (
|
||||
<div key={index}>• {error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-700">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || !formData.name.trim()}
|
||||
>
|
||||
{loading ? 'Enregistrement...' : (tag ? 'Mettre à jour' : 'Créer')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +118,7 @@ export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, onEditTask, onU
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id="kanban-board"
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { TaskStatus, TaskPriority } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
|
||||
@@ -21,7 +21,6 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
|
||||
tags: [],
|
||||
dueDate: undefined
|
||||
});
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeField, setActiveField] = useState<'title' | 'description' | 'tags' | 'date' | null>('title');
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
@@ -53,7 +52,6 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
|
||||
tags: [],
|
||||
dueDate: undefined
|
||||
});
|
||||
setTagInput('');
|
||||
setActiveField('title');
|
||||
setIsSubmitting(false);
|
||||
titleRef.current?.focus();
|
||||
@@ -73,9 +71,8 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
|
||||
if (field === 'title' && formData.title.trim()) {
|
||||
console.log('Calling handleSubmit from title');
|
||||
handleSubmit();
|
||||
} else if (field === 'tags' && tagInput.trim()) {
|
||||
console.log('Adding tag');
|
||||
addTag();
|
||||
} else if (field === 'tags') {
|
||||
// TagInput gère ses propres événements Enter
|
||||
} else if (formData.title.trim()) {
|
||||
// Permettre création depuis n'importe quel champ si titre rempli
|
||||
console.log('Calling handleSubmit from other field');
|
||||
@@ -95,21 +92,10 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
|
||||
// Laisser passer tous les autres événements (y compris les raccourcis système)
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const tag = tagInput.trim();
|
||||
if (tag && !formData.tags?.includes(tag)) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...(prev.tags || []), tag]
|
||||
}));
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
const handleTagsChange = (tags: string[]) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags?.filter(tag => tag !== tagToRemove) || []
|
||||
tags
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -171,28 +157,12 @@ export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps)
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-2">
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{formData.tags && formData.tags.map((tag: string, index: number) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="cursor-pointer hover:bg-red-950/50 hover:text-red-300 hover:border-red-500/30 transition-colors"
|
||||
onClick={() => removeTag(tag)}
|
||||
>
|
||||
{tag} ✕
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'tags')}
|
||||
onFocus={() => setActiveField('tags')}
|
||||
<TagInput
|
||||
tags={formData.tags || []}
|
||||
onChange={handleTagsChange}
|
||||
placeholder="Tags..."
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-transparent border-none outline-none text-xs text-slate-400 font-mono placeholder-slate-500"
|
||||
maxTags={5}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { formatDistanceToNow } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
|
||||
interface TaskCardProps {
|
||||
@@ -16,6 +18,7 @@ interface TaskCardProps {
|
||||
export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProps) {
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(task.title);
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
|
||||
// Configuration du draggable
|
||||
const {
|
||||
@@ -170,24 +173,16 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProp
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tags avec composant Badge */}
|
||||
{/* Tags avec couleurs */}
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{task.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="hover:bg-cyan-950/80 transition-colors"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{task.tags.length > 3 && (
|
||||
<Badge variant="outline" size="sm">
|
||||
+{task.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<TagDisplay
|
||||
tags={task.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={3}
|
||||
showColors={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
157
components/ui/TagDisplay.tsx
Normal file
157
components/ui/TagDisplay.tsx
Normal 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
184
components/ui/TagInput.tsx
Normal 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
91
components/ui/TagList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user