FlowWeb / src /features /canvas /components /nodes /SketchNode.jsx
danylokhodus's picture
canban
f104d74
Raw
History Blame Contribute Delete
12.9 kB
import React, { useState, useRef, useEffect } from 'react';
import { Handle, Position, useReactFlow, useUpdateNodeInternals } from '@xyflow/react';
import { CheckCircle2, Circle, X, ArrowRight, Sparkles, AlertCircle, CheckSquare, Settings } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../../../../context/ThemeContext';
import { isDefaultLabel } from '../../utils';
const PASTEL_COLORS = {
yellow: { light: '#fef9c3', dark: '#2e2b14', borderLight: '#eab308', borderDark: '#ca8a04' },
blue: { light: '#dbeafe', dark: '#111827', borderLight: '#3b82f6', borderDark: '#2563eb' },
green: { light: '#dcfce7', dark: '#062f17', borderLight: '#22c55e', borderDark: '#16a34a' },
pink: { light: '#fce7f3', dark: '#37061d', borderLight: '#ec4899', borderDark: '#db2777' },
purple: { light: '#f3e8ff', dark: '#210638', borderLight: '#a855f7', borderDark: '#9333ea' },
orange: { light: '#ffedd5', dark: '#2e1208', borderLight: '#f97316', borderDark: '#ea580c' },
white: { light: '#f5f5f4', dark: '#1c1917', borderLight: '#d6d3d1', borderDark: '#44403c' }
};
const getInitials = (displayName, email) => {
if (displayName && displayName.trim()) {
const parts = displayName.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return parts[0].substring(0, 2).toUpperCase();
}
if (email && email.trim()) {
return email.substring(0, 2).toUpperCase();
}
return 'U';
};
const SketchNode = ({ id, data, selected }) => {
const { setNodes } = useReactFlow();
const updateNodeInternals = useUpdateNodeInternals();
const { t } = useTranslation();
const { isDarkMode } = useTheme();
const [showWarning, setShowWarning] = useState(false);
const warningTimeoutRef = useRef(null);
// Re-calculate handles when node size might change
useEffect(() => {
updateNodeInternals(id);
}, [id, data.subgoals, data.showProgress, data.description, data.status, showWarning, updateNodeInternals]);
useEffect(() => {
return () => {
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
};
}, []);
const toggleDone = (e) => {
e.stopPropagation();
// Check if subgoals constraint is satisfied
if (data.status !== 'Done' && data.subgoals && data.subgoals.done < data.subgoals.total) {
setShowWarning(true);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
warningTimeoutRef.current = setTimeout(() => {
setShowWarning(false);
}, 3000);
return;
}
const newStatus = data.status === 'Done' ? 'Todo' : 'Done';
const newProgress = newStatus === 'Done' ? 100 : 0;
if (data.updateNodeData) {
data.updateNodeData(id, {
status: newStatus,
progress: data.showProgress ? newProgress : (data.progress || 0)
});
} else {
setNodes((nds) =>
nds.map((node) =>
node.id === id ? {
...node,
data: {
...node.data,
status: newStatus,
progress: data.showProgress ? newProgress : (data.progress || 0)
}
} : node
)
);
}
};
const handleProgressChange = (e) => {
e.stopPropagation();
const val = parseInt(e.target.value, 10);
// Check if subgoals constraint is satisfied
if (val === 100 && data.subgoals && data.subgoals.done < data.subgoals.total) {
setShowWarning(true);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
warningTimeoutRef.current = setTimeout(() => {
setShowWarning(false);
}, 3000);
// Cap progress at 99% and status to In Progress
updateProgress(99, 'In Progress');
return;
}
const newStatus = val === 100 ? 'Done' : (val === 0 ? 'Todo' : 'In Progress');
updateProgress(val, newStatus);
};
const updateProgress = (progressVal, newStatus) => {
if (data.updateNodeData) {
data.updateNodeData(id, { progress: progressVal, status: newStatus });
} else {
setNodes((nds) =>
nds.map((node) =>
node.id === id ? { ...node, data: { ...node.data, progress: progressVal, status: newStatus } } : node
)
);
}
};
const openConfigureModal = (e) => {
e.stopPropagation();
window.dispatchEvent(new CustomEvent('configure-sketch-node', { detail: { nodeId: id } }));
};
const getStatusColor = (status) => {
switch (status) {
case 'Done': return 'bg-green-500/10 text-success border-success/30';
case 'Blocked': return 'bg-red-500/10 text-error border-error/30';
case 'In Progress': return 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30';
case 'Review': return 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30';
case 'Todo':
case 'Pending': return 'bg-secondary/15 text-primary/70 border-primary/20';
default: return 'bg-surface-container-lowest text-primary border-primary';
}
};
const progressValue = data.progress !== undefined ? data.progress : 0;
const assignees = data.assignees && Array.isArray(data.assignees)
? data.assignees
: (data.assignee ? [data.assignee] : []);
const tooltipText = assignees.map(a => {
const name = a.displayName || t('canvas.collaborator', 'Collaborator');
return a.email ? `${name} (${a.email})` : name;
}).join(', ');
const maxVisible = 3;
const shouldTruncate = assignees.length > maxVisible;
const visibleAssignees = shouldTruncate ? assignees.slice(0, 2) : assignees;
const remainingCount = assignees.length - visibleAssignees.length;
const selectedColor = data.color && data.color !== 'default' ? PASTEL_COLORS[data.color] : null;
const isDone = data.status === 'Done';
const nodeBg = selectedColor
? (isDarkMode ? selectedColor.dark : selectedColor.light)
: 'var(--color-surface-container-lowest)';
const nodeBorder = selectedColor
? (isDarkMode ? selectedColor.borderDark : selectedColor.borderLight)
: 'var(--color-primary)';
return (
<div
onDoubleClick={openConfigureModal}
className={`px-4 py-3 min-w-[220px] max-w-[320px] rough-border transition-all group relative ${
selected
? 'shadow-[6px_6px_0px_0px_rgba(52,211,153,0.3)] dark:shadow-[6px_6px_0px_0px_rgba(52,211,153,0.3)] -translate-y-1 border-emerald-400 ring-4 ring-emerald-400/20 scale-[1.01]'
: (isDone ? 'shadow-[0_0_12px_rgba(34,197,94,0.3)] dark:shadow-[0_0_15px_rgba(34,197,94,0.25)] ring-2 ring-green-500/40' : '')
} ${isDone ? 'opacity-95' : ''} ${
data.isNextStep ? 'ring-4 ring-success/30 ring-offset-2 ring-offset-background shadow-[0_0_20px_rgba(34,197,94,0.2)]' : ''
} ${showWarning ? 'animate-shake' : ''}`}
style={{
backgroundColor: nodeBg,
borderColor: selectedColor ? nodeBorder : undefined
}}
>
<Handle type="target" position={Position.Top} id="main-target" className="!bg-primary !w-3 !h-3 !border-2 !border-white" />
<Handle type="source" position={Position.Bottom} id="main-source" className="!bg-primary !w-3 !h-3 !border-2 !border-white" />
<Handle type="target" position={Position.Left} id="subgoals-target-left" className="!bg-yellow-500 !w-3 !h-3 !border-2 !border-white" />
<Handle type="source" position={Position.Left} id="subgoals-source-left" className="!bg-yellow-500 !w-3 !h-3 !border-2 !border-white !opacity-0" />
<div className="flex flex-col gap-2">
<div className="flex justify-between items-start gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<button onClick={toggleDone} className="transition-colors hover:scale-110 cursor-pointer shrink-0">
{data.status === 'Done' ?
<CheckCircle2 size={20} className="text-success fill-success/10" /> :
<Circle size={20} className="text-on-surface-variant/30" />
}
</button>
<span className="font-display-lg text-lg text-primary leading-tight break-words flex-1 min-w-0">
{isDefaultLabel(data.label) ? t('canvas.new_goal', 'New Goal') : data.label}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{data.isNextStep && <ArrowRight size={18} className="text-success animate-bounce-x" />}
<button
onClick={openConfigureModal}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-on-surface-variant/70 hover:text-primary hover:bg-primary/5 rounded-full cursor-pointer pointer-events-auto shrink-0"
title={t('canvas.configure', 'Configure')}
>
<Settings size={14} />
</button>
</div>
</div>
{data.description && (
<p className="text-sm font-accent-note text-on-surface-variant line-clamp-2 italic">
{data.description}
</p>
)}
{data.subgoals && data.subgoals.total > 0 && (
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] font-bold text-secondary bg-secondary/5 px-2 py-1 rounded-sm border border-secondary/10 w-fit">
<CheckSquare size={11} className="text-secondary" />
<span>
{t('canvas.subgoals_progress', { done: data.subgoals.done, total: data.subgoals.total })}
</span>
</div>
)}
{data.showProgress && (
<div className="mt-2 flex flex-col gap-1">
<div className="flex justify-between items-center text-[10px] font-bold text-on-surface-variant">
<span>{t('canvas.progress_readiness', 'Readiness')}</span>
<span className="bg-[#a7f3d0]/30 text-[#059669] px-1.5 py-0.5 rounded-full font-mono text-[10px] font-bold">
{progressValue}%
</span>
</div>
<div className="w-full h-3 bg-secondary/15 border border-primary rounded-full overflow-hidden p-0.5">
<div
className="h-full rounded-full transition-all duration-500 ease-out"
style={{
width: `${progressValue}%`,
background: 'linear-gradient(to right, #a7f3d0, #10b981)'
}}
/>
</div>
</div>
)}
{showWarning && (
<div className="mt-1.5 p-1.5 rounded-sm bg-red-500/10 border border-red-500/30 flex items-center gap-1.5 text-[10px] font-bold text-red-600 dark:text-red-400 animate-shake shadow-sm">
<AlertCircle size={12} className="text-red-500 shrink-0" />
<span>{t('canvas.subgoal_warning')}</span>
</div>
)}
<div className="flex items-center justify-between mt-1">
<span className={`text-[8px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-sm border ${getStatusColor(data.status)}`}>
{data.status}
</span>
<div className="flex items-center gap-2">
{data.ai && <Sparkles size={12} className="text-secondary animate-pulse" />}
{assignees.length > 0 && (
<div
title={tooltipText}
className="flex items-center -space-x-1.5 flex-row-reverse cursor-help"
>
{shouldTruncate && (
<div
className="w-6 h-6 rounded-full border border-primary bg-secondary/20 text-primary flex items-center justify-center text-[8px] font-extrabold shadow-[1px_1px_0px_0px_var(--color-primary)] relative transition-transform hover:scale-105 z-0"
>
+{remainingCount}
</div>
)}
{visibleAssignees.slice().reverse().map((assignee, index) => (
<div
key={assignee.sharedWithUserId || assignee.email || index}
className="w-6 h-6 rounded-full border border-primary bg-secondary/10 text-primary flex items-center justify-center text-[9px] font-bold shadow-[1px_1px_0px_0px_var(--color-primary)] relative transition-transform hover:scale-105 hover:z-10"
style={{ zIndex: index + 1 }}
>
{getInitials(assignee.displayName, assignee.email)}
</div>
))}
</div>
)}
</div>
</div>
</div>
<Handle type="target" position={Position.Right} id="subgoals-target-right" className="!bg-yellow-500 !w-3 !h-3 !border-2 !border-white !opacity-0" />
<Handle type="source" position={Position.Right} id="subgoals-source-right" className="!bg-yellow-500 !w-3 !h-3 !border-2 !border-white" />
</div>
);
};
export default SketchNode;