Spaces:
Running
Running
| 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; | |