394 lines
20 KiB
TypeScript
394 lines
20 KiB
TypeScript
import React, { useState, useRef, useEffect } from "react";
|
||
import { Repeat } from "lucide-react";
|
||
import { Task } from "./WeeklyView";
|
||
|
||
interface GridTaskBlockProps {
|
||
task: Task;
|
||
date: Date;
|
||
activeDate: Date;
|
||
cellDuration: number;
|
||
darkMode: boolean;
|
||
isProtected: boolean;
|
||
editingTaskId: string | null;
|
||
setEditingTaskId: (id: string | null) => void;
|
||
updateTask: (id: string, title: string) => void;
|
||
updateTaskNotes: (id: string, notes: string) => void;
|
||
updateTaskDuration: (id: string, duration: number) => void;
|
||
toggleTask: (id: string) => void;
|
||
deleteTask: (id: string) => void;
|
||
toggleTaskRolling: (id: string) => void;
|
||
setSelectedTaskForNotes: (task: Task) => void;
|
||
setSelectedTaskForRecurrence: (task: Task) => void;
|
||
handleDragStart: (e: React.DragEvent, task: Task) => void;
|
||
handleDragEnd: (e: React.DragEvent) => void;
|
||
getSlotHeight: (duration: number) => number;
|
||
draggedTask: Task | null;
|
||
addSubTask: (parentId: string, title: string) => void;
|
||
toggleSubTask: (id: string) => void;
|
||
updateSubTask: (id: string, title: string) => void;
|
||
deleteSubTask: (id: string) => void;
|
||
onSetEditingTaskId?: (id: string | null) => void;
|
||
workingHoursStart: number;
|
||
showTaskCheckboxes?: boolean;
|
||
}
|
||
|
||
export function GridTaskBlock({
|
||
task,
|
||
date,
|
||
activeDate,
|
||
cellDuration,
|
||
darkMode,
|
||
isProtected,
|
||
editingTaskId,
|
||
setEditingTaskId,
|
||
updateTask,
|
||
updateTaskNotes,
|
||
updateTaskDuration,
|
||
toggleTask,
|
||
deleteTask,
|
||
toggleTaskRolling,
|
||
setSelectedTaskForNotes,
|
||
setSelectedTaskForRecurrence,
|
||
handleDragStart,
|
||
handleDragEnd,
|
||
getSlotHeight,
|
||
draggedTask,
|
||
addSubTask,
|
||
toggleSubTask,
|
||
updateSubTask,
|
||
deleteSubTask,
|
||
onSetEditingTaskId,
|
||
workingHoursStart,
|
||
showTaskCheckboxes
|
||
}: GridTaskBlockProps) {
|
||
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
||
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
|
||
const [isSubTasksOpen, setIsSubTasksOpen] = useState(false);
|
||
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
|
||
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
|
||
const notesRef = useRef<HTMLTextAreaElement>(null);
|
||
const subTaskInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Resize State
|
||
const [isResizing, setIsResizing] = useState(false);
|
||
const [resizeHeight, setResizeHeight] = useState<number | null>(null);
|
||
const resizeStartY = useRef<number>(0);
|
||
const resizeStartHeight = useRef<number>(0);
|
||
|
||
// Focus notes when opened
|
||
useEffect(() => {
|
||
if (isNotesOpen && notesRef.current) {
|
||
notesRef.current.focus();
|
||
}
|
||
}, [isNotesOpen]);
|
||
|
||
const handleNotesBlur = () => {
|
||
if (notesValue !== task.markdownContent) {
|
||
updateTaskNotes(task.id, notesValue);
|
||
}
|
||
};
|
||
|
||
const insertMarkdown = (prefix: string, suffix: string = "") => {
|
||
if (!notesRef.current) return;
|
||
const start = notesRef.current.selectionStart;
|
||
const end = notesRef.current.selectionEnd;
|
||
const text = notesValue;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}${prefix}${selection}${suffix}${after}`;
|
||
setNotesValue(newText);
|
||
setTimeout(() => {
|
||
if (notesRef.current) {
|
||
notesRef.current.focus();
|
||
const newCursorPos = start + prefix.length + selection.length + suffix.length;
|
||
notesRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||
}
|
||
}, 0);
|
||
};
|
||
|
||
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
|
||
|
||
useEffect(() => {
|
||
if (!task.startTime) return;
|
||
|
||
const handleResizeMove = (e: MouseEvent) => {
|
||
if (!isResizing) return;
|
||
const deltaY = e.clientY - resizeStartY.current;
|
||
let newHeight = resizeStartHeight.current + deltaY;
|
||
const minHeight = 15 * pixelsPerMinute;
|
||
if (newHeight < minHeight) newHeight = minHeight;
|
||
setResizeHeight(newHeight);
|
||
};
|
||
|
||
const handleResizeEnd = () => {
|
||
if (!isResizing) return;
|
||
setIsResizing(false);
|
||
document.body.style.cursor = "";
|
||
if (resizeHeight !== null) {
|
||
const newDurationMins = Math.round(resizeHeight / pixelsPerMinute / 15) * 15;
|
||
updateTaskDuration(task.id, newDurationMins);
|
||
}
|
||
setResizeHeight(null);
|
||
};
|
||
|
||
if (isResizing) {
|
||
window.addEventListener("mousemove", handleResizeMove);
|
||
window.addEventListener("mouseup", handleResizeEnd);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("mousemove", handleResizeMove);
|
||
window.removeEventListener("mouseup", handleResizeEnd);
|
||
};
|
||
}, [isResizing, resizeHeight, pixelsPerMinute, task.id, updateTaskDuration, task.startTime]);
|
||
|
||
// Conditional rendering should only happen after hooks
|
||
if (!task.startTime) return null;
|
||
|
||
const [startHour, startMinute] = task.startTime.split(":").map(Number);
|
||
const startMinutes = (startHour - workingHoursStart) * 60 + startMinute;
|
||
const topOffset = startMinutes * pixelsPerMinute;
|
||
const duration = task.duration || 15;
|
||
const baseHeight = duration * pixelsPerMinute;
|
||
const currentHeight = isResizing && resizeHeight !== null ? resizeHeight : baseHeight;
|
||
|
||
const onResizeStart = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
setIsResizing(true);
|
||
resizeStartY.current = e.clientY;
|
||
resizeStartHeight.current = baseHeight;
|
||
document.body.style.cursor = "ns-resize";
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={`time-slot-task ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""}`}
|
||
style={{
|
||
position: "absolute",
|
||
top: `${topOffset}px`,
|
||
left: 0,
|
||
right: 0,
|
||
minHeight: `${Math.max(currentHeight, 20)}px`,
|
||
height: isSubTasksOpen ? "auto" : `${currentHeight}px`,
|
||
zIndex: isResizing || isNotesOpen || isSubTasksOpen ? 10 : 5,
|
||
background: (isNotesOpen || isSubTasksOpen || isResizing) ? (darkMode ? "#2a2a2a" : "#ffffff") : "transparent",
|
||
border: (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none",
|
||
borderRadius: (isNotesOpen || isSubTasksOpen || isResizing) ? "4px" : "0",
|
||
padding: "2px 4px",
|
||
boxShadow: (isNotesOpen || isSubTasksOpen || isResizing) ? "0 1px 3px rgba(0,0,0,0.05)" : "none",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
overflow: "visible !important",
|
||
}}
|
||
draggable={!editingTaskId && !isResizing}
|
||
onDragStart={(e) => handleDragStart(e, task)}
|
||
onDragEnd={handleDragEnd}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (editingTaskId !== task.id) toggleTask(task.id);
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "flex-start", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}>
|
||
{editingTaskId === task.id ? (
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement;
|
||
updateTask(task.id, input.value || \"\");
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{ width: "100%", paddingRight: "20px" }}
|
||
>
|
||
<input
|
||
name="title"
|
||
autoFocus
|
||
defaultValue={task.title}
|
||
onBlur={(e) => updateTask(task.id, e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") setEditingTaskId(null);
|
||
if (e.key === "Enter") e.currentTarget.blur();
|
||
}}
|
||
className="weekly-task-text"
|
||
style={{ width: "100%", background: "transparent", border: "none", borderBottom: "1px solid var(--weekly-border)", outline: "none" }}
|
||
/>
|
||
</form>
|
||
) : (
|
||
<span
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: "3px",
|
||
overflow: "visible",
|
||
whiteSpace: "normal",
|
||
wordBreak: "break-word",
|
||
flex: 1,
|
||
}}
|
||
onDoubleClick={(e) => {
|
||
e.stopPropagation();
|
||
setEditingTaskId(task.id);
|
||
}}
|
||
>
|
||
{showTaskCheckboxes && (
|
||
<input
|
||
type="checkbox"
|
||
checked={task.completed}
|
||
onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="task-checkbox flex-shrink-0"
|
||
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", position: "relative", top: "3px", left: "-2px", accentColor: "#FFF" }}
|
||
/>
|
||
)}
|
||
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1 } : { flex: 1 }}>{task.title}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
className="task-actions"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
|
||
onClick={(e) => { e.stopPropagation(); toggleTask(task.id); }}
|
||
title={task.completed ? "Mark incomplete" : "Mark complete"}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="5" y1="12" x2="19" y2="12" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
className="task-action-btn"
|
||
onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}
|
||
title="Edit"
|
||
>
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||
</svg>
|
||
</button>
|
||
<button className={`task-action-btn ${isSubTaskInputOpen ? \"active\" : \"\"}`} onClick={(e) => { e.stopPropagation(); setIsSubTaskInputOpen(!isSubTaskInputOpen); if (!isSubTaskInputOpen) { setIsSubTasksOpen(true); setTimeout(() => subTaskInputRef.current?.focus(), 50); } }} title="Add sub-task">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="12" y1="5" x2="12" y2="19" />
|
||
<line x1="5" y1="12" x2="19" y2="12" />
|
||
</svg>
|
||
</button>
|
||
<button className={`task-action-btn ${isNotesOpen ? \"active\" : \"\"}`} onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }} title="Notes">
|
||
< svg viewBox = "0 0 24 24" width = "12" height = "12" stroke = "currentColor" strokeWidth = "2.5" fill = "none" strokeLinecap = "round" strokeLinejoin = "round" >
|
||
<line x1="3" y1="12" x2="21" y2="12" />
|
||
<line x1="3" y1="6" x2="21" y2="6" />
|
||
<line x1="3" y1="18" x2="21" y2="18" />
|
||
</svg >
|
||
</button >
|
||
{!task.completed && (
|
||
<button
|
||
className={`task-action-btn ${task.isRolling ?\"active\" : \"\"}`}
|
||
onClick = {(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }
|
||
}
|
||
title = {
|
||
task.isRolling ?\"Disable rolling\" : \"Enable rolling\"}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="23 4 23 10 17 10" />
|
||
<path d=\"M20.49 15a9 9 0 1 1-2.12-9.36L23 10\" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
{
|
||
!task.completed && (
|
||
<button
|
||
className={`task-action-btn ${task.isRecurring ?\"active\" : \"\"}`}
|
||
onClick = {(e) => { e.stopPropagation(); setSelectedTaskForRecurrence(task); }
|
||
}
|
||
title = {
|
||
task.isRecurring ?\"Edit recurrence\" : \"Make recurring\"}
|
||
>
|
||
<Repeat size={12} />
|
||
</button>
|
||
)}
|
||
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="3 6 5 6 21 6"></polyline>
|
||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||
</svg>
|
||
</button>
|
||
</div >
|
||
|
||
<div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}>
|
||
{isNotesOpen && (
|
||
<div
|
||
className={`weekly-notes-popup ${topOffset > 180 ? \"on-top\" : \"\"}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
top: topOffset > 180 ? \"auto\" : \"100%\",
|
||
bottom: topOffset > 180 ? \"100%\" : \"auto\",
|
||
marginTop: topOffset > 180 ? \"0\" : \"10px\",
|
||
marginBottom: topOffset > 180 ? \"10px\" : \"0\",
|
||
left: \"-10px\",
|
||
right: \"-10px\",
|
||
width: \"auto\",
|
||
}}
|
||
>
|
||
<div className="notes-toolbar">
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown(\"**\", \"**\")} title=\"Bold\">B</button>
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown(\"*\", \"*\")} title=\"Italic\">i</button>
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown(\"- \")} title=\"List\">☑</button>
|
||
<span style={{ marginLeft: \"auto\", fontSize: \"0.75rem\", color: \"#999\" }}>Markdown</span>
|
||
<button
|
||
className="notes-toolbar-btn"
|
||
onClick={() => setIsNotesOpen(false)}
|
||
title=\"Close\"
|
||
style={{ marginLeft: \"8px\", fontSize: \"1rem\", lineHeight: 1 }}
|
||
>×</button>
|
||
</div>
|
||
<textarea
|
||
ref={notesRef}
|
||
className="weekly-notes-editor-inline"
|
||
value={notesValue}
|
||
onChange={(e) => setNotesValue(e.target.value)}
|
||
onBlur={handleNotesBlur}
|
||
placeholder="Add notes..."
|
||
style={{ minHeight: \"120px\", padding: \"4px\" }}
|
||
/>
|
||
</div>
|
||
)}
|
||
{isSubTasksOpen && task.subTasks && task.subTasks.length > 0 && (
|
||
<ul className="subtask-list mt-1" onClick={(e) => e.stopPropagation()}>
|
||
{task.subTasks.map((subTask: Task) => (
|
||
<li key={subTask.id} className={`subtask-item ${subTask.completed ? \"completed\" : \"\"}`}>
|
||
<button className="subtask-checkbox" onClick={() => toggleSubTask(subTask.id)}>
|
||
{subTask.completed ? (
|
||
<svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><polyline points=\"20 6 9 17 4 12\" /></svg>
|
||
) : (
|
||
<svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\" /></svg>
|
||
)}
|
||
</button>
|
||
{onSetEditingTaskId && editingTaskId === subTask.id ? (
|
||
<form onSubmit={(e) => { e.preventDefault(); const input = e.currentTarget.querySelector(\"input\"); if (input) { updateSubTask(subTask.id, input.value); onSetEditingTaskId(null); } }} style={{ flex: 1 }}>
|
||
<input type=\"text\" defaultValue={subTask.title} autoFocus className=\"subtask-edit-input\" onBlur={(e) => { updateSubTask(subTask.id, e.target.value); onSetEditingTaskId(null); }} onKeyDown={(e) => { if (e.key === \"Escape\") onSetEditingTaskId(null); }} />
|
||
</form>
|
||
) : (
|
||
<span className={`subtask-title ${subTask.completed ?\"completed\" : \"\"}`} onClick={() => onSetEditingTaskId && onSetEditingTaskId(subTask.id)}>{subTask.title}</span>
|
||
)}
|
||
<button className=\"subtask-delete-btn\" onClick={() => deleteSubTask(subTask.id)} title=\"Remove\"><svg viewBox=\"0 0 24 24\" width=\"10\" height=\"10\" stroke=\"currentColor\" strokeWidth=\"2.5\" fill=\"none\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" /><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" /></svg></button>
|
||
</li >
|
||
))}
|
||
</ul >
|
||
)}
|
||
{
|
||
isSubTaskInputOpen && (
|
||
<div className="subtask-add-row mt-1" onClick={(e) => e.stopPropagation()}>
|
||
<form onSubmit={(e) => { e.preventDefault(); if (newSubTaskTitle.trim()) { addSubTask(task.id, newSubTaskTitle.trim()); setNewSubTaskTitle(\"\"); } }} style={{ display: \"flex\", alignItems: \"center\", gap: \"0.25rem\", flex: 1 }}>
|
||
<svg viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" stroke=\"var(--weekly-text-muted, #999)\" strokeWidth=\"2\" fill=\"none\" strokeLinecap=\"round\" strokeLinejoin=\"round\" style={{ flexShrink: 0 }}><circle cx=\"12\" cy=\"12\" r=\"10\" /></svg>
|
||
<input ref={subTaskInputRef} type=\"text\" value={newSubTaskTitle} onChange={(e) => setNewSubTaskTitle(e.target.value)} onBlur={() => { if (!newSubTaskTitle.trim()) setIsSubTaskInputOpen(false); }} onKeyDown={(e) => { if (e.key === \"Escape\") { setNewSubTaskTitle(\"\"); setIsSubTaskInputOpen(false); } }} placeholder=\"Add sub-task...\" className=\"subtask-add-input\" autoFocus />
|
||
</form >
|
||
</div >
|
||
)
|
||
}
|
||
</div >
|
||
<div className="resize-handle" onMouseDown={onResizeStart} />
|
||
</div >
|
||
);
|
||
}
|