- Updated TaskCard to conditionally render edit and delete buttons only when not in editing mode, improving user experience by preventing accidental actions while editing.
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
||
import { Task } from '@/lib/types';
|
||
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';
|
||
import { getPriorityConfig, getPriorityColorHex } from '@/lib/status-config';
|
||
|
||
interface TaskCardProps {
|
||
task: Task;
|
||
onDelete?: (taskId: string) => Promise<void>;
|
||
onEdit?: (task: Task) => void;
|
||
onUpdateTitle?: (taskId: string, newTitle: string) => Promise<void>;
|
||
compactView?: boolean;
|
||
}
|
||
|
||
export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView = false }: TaskCardProps) {
|
||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||
const [editTitle, setEditTitle] = useState(task.title);
|
||
const [showTooltip, setShowTooltip] = useState(false);
|
||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
const { tags: availableTags } = useTasksContext();
|
||
|
||
// Configuration du draggable
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
isDragging,
|
||
} = useDraggable({
|
||
id: task.id,
|
||
});
|
||
|
||
// Mettre à jour le titre local quand la tâche change
|
||
useEffect(() => {
|
||
setEditTitle(task.title);
|
||
}, [task.title]);
|
||
|
||
// Nettoyer le timeout au démontage
|
||
useEffect(() => {
|
||
return () => {
|
||
if (timeoutRef.current) {
|
||
clearTimeout(timeoutRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const handleDelete = async (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (onDelete) {
|
||
await onDelete(task.id);
|
||
}
|
||
};
|
||
|
||
const handleEdit = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (onEdit) {
|
||
onEdit(task);
|
||
}
|
||
};
|
||
|
||
const handleTitleClick = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (onUpdateTitle && !isDragging) {
|
||
setIsEditingTitle(true);
|
||
}
|
||
};
|
||
|
||
const handleTitleSave = async () => {
|
||
const trimmedTitle = editTitle.trim();
|
||
if (trimmedTitle && trimmedTitle !== task.title && onUpdateTitle) {
|
||
await onUpdateTitle(task.id, trimmedTitle);
|
||
}
|
||
setIsEditingTitle(false);
|
||
};
|
||
|
||
const handleTitleCancel = () => {
|
||
setEditTitle(task.title);
|
||
setIsEditingTitle(false);
|
||
};
|
||
|
||
const handleTitleKeyPress = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
handleTitleSave();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
handleTitleCancel();
|
||
}
|
||
};
|
||
|
||
const handleMouseEnter = () => {
|
||
if (!isEditingTitle) {
|
||
timeoutRef.current = setTimeout(() => {
|
||
setShowTooltip(true);
|
||
}, 100);
|
||
}
|
||
};
|
||
|
||
const handleMouseLeave = () => {
|
||
if (timeoutRef.current) {
|
||
clearTimeout(timeoutRef.current);
|
||
timeoutRef.current = null;
|
||
}
|
||
setShowTooltip(false);
|
||
};
|
||
|
||
// Style de transformation pour le drag
|
||
const style = transform ? {
|
||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||
} : undefined;
|
||
|
||
// Extraire les emojis du titre pour les afficher comme tags visuels
|
||
const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
|
||
const titleEmojis = task.title.match(emojiRegex) || [];
|
||
const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim();
|
||
|
||
// Composant titre avec tooltip
|
||
const TitleWithTooltip = () => (
|
||
<div className="relative flex-1">
|
||
<h4
|
||
className="font-mono text-sm font-medium text-[var(--foreground)] leading-tight line-clamp-2 cursor-pointer hover:text-[var(--primary)] transition-colors"
|
||
onClick={handleTitleClick}
|
||
onMouseEnter={handleMouseEnter}
|
||
onMouseLeave={handleMouseLeave}
|
||
title={onUpdateTitle ? "Cliquer pour éditer" : undefined}
|
||
>
|
||
{titleWithoutEmojis}
|
||
</h4>
|
||
|
||
{/* Tooltip */}
|
||
{showTooltip && (
|
||
<div className="absolute z-50 bottom-full left-0 mb-2 px-2 py-1 bg-[var(--background)] border border-[var(--border)] rounded-md shadow-lg max-w-xs whitespace-normal break-words text-xs font-mono text-[var(--foreground)]">
|
||
{titleWithoutEmojis}
|
||
<div className="absolute top-full left-2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-[var(--border)]"></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
|
||
let displayEmojis: string[] = titleEmojis;
|
||
if (displayEmojis.length === 0 && task.tags && task.tags.length > 0) {
|
||
const firstTag = availableTags.find(tag => tag.name === task.tags[0]);
|
||
if (firstTag) {
|
||
const tagEmojis = firstTag.name.match(emojiRegex);
|
||
if (tagEmojis && tagEmojis.length > 0) {
|
||
displayEmojis = [tagEmojis[0]]; // Prendre seulement le premier emoji du tag
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// Vue compacte : seulement le titre
|
||
if (compactView) {
|
||
return (
|
||
<Card
|
||
ref={setNodeRef}
|
||
style={style}
|
||
className={`p-2 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||
} ${
|
||
task.status === 'done' ? 'opacity-60' : ''
|
||
}`}
|
||
{...attributes}
|
||
{...(isEditingTitle ? {} : listeners)}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
{displayEmojis.length > 0 && (
|
||
<div className="flex gap-1 flex-shrink-0">
|
||
{displayEmojis.slice(0, 1).map((emoji, index) => (
|
||
<span
|
||
key={index}
|
||
className="text-base opacity-90 font-emoji"
|
||
style={{
|
||
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
||
fontVariantEmoji: 'normal'
|
||
}}
|
||
>
|
||
{emoji}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{isEditingTitle ? (
|
||
<input
|
||
type="text"
|
||
value={editTitle}
|
||
onChange={(e) => setEditTitle(e.target.value)}
|
||
onKeyDown={handleTitleKeyPress}
|
||
onBlur={handleTitleSave}
|
||
autoFocus
|
||
className="flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium leading-tight"
|
||
/>
|
||
) : (
|
||
<TitleWithTooltip />
|
||
)}
|
||
|
||
<div className="flex items-center gap-1 flex-shrink-0">
|
||
{/* Boutons d'action compacts - masqués en mode édition */}
|
||
{!isEditingTitle && onEdit && (
|
||
<button
|
||
onClick={handleEdit}
|
||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs"
|
||
title="Modifier la tâche"
|
||
>
|
||
✎
|
||
</button>
|
||
)}
|
||
|
||
{!isEditingTitle && onDelete && (
|
||
<button
|
||
onClick={handleDelete}
|
||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs"
|
||
title="Supprimer la tâche"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
|
||
{/* Indicateur de priorité compact */}
|
||
<div
|
||
className="w-1.5 h-1.5 rounded-full"
|
||
style={{ backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color) }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// Vue détaillée : version complète
|
||
return (
|
||
<Card
|
||
ref={setNodeRef}
|
||
style={style}
|
||
className={`p-3 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||
} ${
|
||
task.status === 'done' ? 'opacity-60' : ''
|
||
}`}
|
||
{...attributes}
|
||
{...(isEditingTitle ? {} : listeners)}
|
||
>
|
||
{/* Header tech avec titre et status */}
|
||
<div className="flex items-start gap-2 mb-2">
|
||
{displayEmojis.length > 0 && (
|
||
<div className="flex gap-1 flex-shrink-0">
|
||
{displayEmojis.slice(0, 2).map((emoji, index) => (
|
||
<span
|
||
key={index}
|
||
className="text-sm opacity-80 font-emoji"
|
||
style={{
|
||
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
||
fontVariantEmoji: 'normal'
|
||
}}
|
||
>
|
||
{emoji}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{isEditingTitle ? (
|
||
<input
|
||
type="text"
|
||
value={editTitle}
|
||
onChange={(e) => setEditTitle(e.target.value)}
|
||
onKeyDown={handleTitleKeyPress}
|
||
onBlur={handleTitleSave}
|
||
autoFocus
|
||
className="flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium leading-tight"
|
||
/>
|
||
) : (
|
||
<TitleWithTooltip />
|
||
)}
|
||
|
||
<div className="flex items-center gap-1 flex-shrink-0">
|
||
{/* Bouton d'édition discret - masqué en mode édition */}
|
||
{!isEditingTitle && onEdit && (
|
||
<button
|
||
onClick={handleEdit}
|
||
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs"
|
||
title="Modifier la tâche"
|
||
>
|
||
✎
|
||
</button>
|
||
)}
|
||
|
||
{/* Bouton de suppression discret - masqué en mode édition */}
|
||
{!isEditingTitle && onDelete && (
|
||
<button
|
||
onClick={handleDelete}
|
||
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs"
|
||
title="Supprimer la tâche"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
|
||
{/* Indicateur de priorité tech */}
|
||
<div
|
||
className="w-2 h-2 rounded-full animate-pulse shadow-sm"
|
||
style={{
|
||
backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color),
|
||
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Description tech */}
|
||
{task.description && (
|
||
<p className="text-xs text-[var(--muted-foreground)] mb-3 line-clamp-1 font-mono">
|
||
{task.description}
|
||
</p>
|
||
)}
|
||
|
||
{/* Tags avec couleurs */}
|
||
{task.tags && task.tags.length > 0 && (
|
||
<div className={
|
||
(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt)
|
||
? "mb-3"
|
||
: "mb-0"
|
||
}>
|
||
<TagDisplay
|
||
tags={task.tags}
|
||
availableTags={availableTags}
|
||
size="sm"
|
||
maxTags={3}
|
||
showColors={true}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer tech avec séparateur néon - seulement si des données à afficher */}
|
||
{(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt) && (
|
||
<div className="pt-2 border-t border-[var(--border)]/50">
|
||
<div className="flex items-center justify-between text-xs">
|
||
{task.dueDate ? (
|
||
<span className="flex items-center gap-1 text-[var(--muted-foreground)] font-mono">
|
||
<span className="text-[var(--primary)]">⏰</span>
|
||
{formatDistanceToNow(new Date(task.dueDate), {
|
||
addSuffix: true,
|
||
locale: fr
|
||
})}
|
||
</span>
|
||
) : (
|
||
<div></div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2">
|
||
{task.source !== 'manual' && task.source && (
|
||
<Badge variant="outline" size="sm">
|
||
{task.source}
|
||
</Badge>
|
||
)}
|
||
|
||
{task.completedAt && (
|
||
<span className="text-emerald-400 font-mono font-bold">✓ DONE</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|