- Swap simple/calendar icons in header view switcher - Add calendar view button to header view switcher - Reorder header: view switcher first, then days, hours, slot duration - Add header display setting: calendar week (KW) or month name - Show kanban stage color border on GridTaskBlock (week/simple views) - Show date, weekday, and time on kanban cards - Remove task swipe gestures on mobile (use container swipe for day nav) - Fix mobile landscape 3-day grid overflow - Add headerDisplay to schema, API, and settings UI v1.30.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
564 lines
32 KiB
TypeScript
564 lines
32 KiB
TypeScript
import React, { useState, useRef, useEffect } from "react";
|
||
import { Repeat, Circle, X } from "lucide-react";
|
||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||
import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons";
|
||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||
import { Task, KanbanStage } 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;
|
||
projects?: any[];
|
||
onProjectAssign?: (taskId: string, projectId: string | null) => void;
|
||
kanbanStages?: KanbanStage[];
|
||
}
|
||
|
||
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,
|
||
projects,
|
||
onProjectAssign,
|
||
kanbanStages = []
|
||
}: 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 [showProjectPicker, setShowProjectPicker] = useState(false);
|
||
const notesRef = useRef<HTMLTextAreaElement>(null);
|
||
const subTaskInputRef = useRef<HTMLInputElement>(null);
|
||
const projectPickerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Touch: tap-to-reveal actions
|
||
const [touchActive, setTouchActive] = useState(false);
|
||
const blockRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
if (!touchActive) return;
|
||
const handler = (e: Event) => {
|
||
if (blockRef.current && !blockRef.current.contains(e.target as Node)) setTouchActive(false);
|
||
};
|
||
document.addEventListener("touchstart", handler);
|
||
document.addEventListener("mousedown", handler);
|
||
return () => { document.removeEventListener("touchstart", handler); document.removeEventListener("mousedown", handler); };
|
||
}, [touchActive]);
|
||
|
||
// 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
|
||
ref={blockRef}
|
||
className={`time-slot-task ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""} ${touchActive ? "touch-active" : ""}`}
|
||
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",
|
||
borderLeft: (() => {
|
||
const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null;
|
||
if (stageColor) return `4px solid ${stageColor}`;
|
||
if (task.project?.color) return `4px solid ${task.project.color}`;
|
||
return (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none";
|
||
})(),
|
||
borderRadius: (isNotesOpen || isSubTasksOpen || isResizing) ? "4px" : "0",
|
||
padding: (task.kanbanStage && kanbanStages.some(s => s.id === task.kanbanStage)) || task.project?.color ? "2px 4px 2px 8px" : "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();
|
||
// Touch: toggle action toolbar on tap instead of toggling completion
|
||
if (window.matchMedia("(pointer: coarse)").matches && editingTaskId !== task.id) {
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest(".task-actions") || target.closest("button")) return;
|
||
setTouchActive(!touchActive);
|
||
return;
|
||
}
|
||
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 HTMLTextAreaElement;
|
||
updateTask(task.id, input.value || "");
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{ width: "100%", paddingRight: "20px" }}
|
||
>
|
||
<textarea
|
||
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.shiftKey) { e.preventDefault(); e.currentTarget.blur(); }
|
||
}}
|
||
rows={(task.title?.split("\n").length) || 1}
|
||
className="weekly-task-text"
|
||
style={{ width: "100%", background: "transparent", border: "none", borderBottom: "1px solid var(--weekly-border)", outline: "none", resize: "none", overflow: "hidden", fontFamily: "inherit", lineHeight: "inherit" }}
|
||
/>
|
||
</form>
|
||
) : (
|
||
<span
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: "3px",
|
||
overflow: "visible",
|
||
whiteSpace: "pre-wrap",
|
||
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, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" } : { flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" }}>
|
||
<span style={{ marginRight: "4px" }}>{task.title}</span>
|
||
{/* Subtask indicator */}
|
||
{task.subTasks && task.subTasks.length > 0 && (() => {
|
||
const completed = task.subTasks.filter(s => s.completed).length;
|
||
const total = task.subTasks.length;
|
||
const expanded = isSubTasksOpen || isSubTaskInputOpen;
|
||
return (
|
||
<div
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setIsSubTasksOpen(!isSubTasksOpen);
|
||
}}
|
||
className="subtask-indicator-badge"
|
||
title={expanded ? "Collapse subtasks" : `${completed}/${total} subtasks done`}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: "2px",
|
||
padding: "1px 5px",
|
||
borderRadius: "8px",
|
||
background: expanded ? "rgba(99, 102, 241, 0.15)" : (completed === total) ? "rgba(34, 197, 94, 0.15)" : "rgba(0,0,0,0.06)",
|
||
color: expanded ? "#6366f1" : (completed === total) ? "#22c55e" : (darkMode ? "#aaa" : "#666"),
|
||
border: "none",
|
||
cursor: "pointer",
|
||
fontSize: "0.65rem",
|
||
fontWeight: 600,
|
||
lineHeight: 1,
|
||
flexShrink: 0,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
|
||
<polyline points="9 18 15 12 9 6" />
|
||
</svg>
|
||
{completed}/{total}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Note indicator */}
|
||
{task.markdownContent && task.markdownContent.trim().length > 0 && (
|
||
<div
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setIsNotesOpen(!isNotesOpen);
|
||
}}
|
||
title={isNotesOpen ? "Collapse note" : "Expand note"}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
padding: "1px 3px",
|
||
borderRadius: "3px",
|
||
background: isNotesOpen ? "rgba(245, 158, 11, 0.12)" : "rgba(0,0,0,0.04)",
|
||
color: isNotesOpen ? "#f59e0b" : (darkMode ? "#888" : "#999"),
|
||
cursor: "pointer",
|
||
marginLeft: "3px"
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||
<polyline points="14 2 14 8 20 8" />
|
||
</svg>
|
||
</div>
|
||
)}
|
||
</span>
|
||
</span>
|
||
)}
|
||
{(() => {
|
||
const provider = task.externalProvider
|
||
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
|
||
if (!provider) return null;
|
||
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
|
||
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
|
||
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
||
apple: { icon: faApple, color: "#555", label: "Apple" },
|
||
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
||
};
|
||
const info = iconMap[provider];
|
||
if (!info) return null;
|
||
return (
|
||
<span
|
||
className="flex-shrink-0"
|
||
title={`Synced with ${info.label}`}
|
||
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "auto", paddingLeft: "4px" }}
|
||
>
|
||
<FontAwesomeIcon icon={info.icon} style={{ width: 10, height: 10, color: info.color }} />
|
||
</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>
|
||
)}
|
||
|
||
{/* Project Assignment */}
|
||
{projects && projects.length > 0 && onProjectAssign && (
|
||
<div className="relative" ref={projectPickerRef}>
|
||
<button
|
||
className={`task-action-btn ${task.project ? "active" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setShowProjectPicker(!showProjectPicker);
|
||
}}
|
||
title={task.project ? task.project.name : "Assign project"}
|
||
>
|
||
<Circle
|
||
size={12}
|
||
fill={task.project?.color || "none"}
|
||
stroke={task.project?.color || "currentColor"}
|
||
strokeWidth={2}
|
||
/>
|
||
</button>
|
||
{showProjectPicker && (
|
||
<div className="absolute z-[100] top-full left-1/2 -translate-x-1/2 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}>
|
||
{task.projectId && (
|
||
<button
|
||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onProjectAssign(task.id, null);
|
||
setShowProjectPicker(false);
|
||
}}
|
||
>
|
||
<X size={10} /> Remove
|
||
</button>
|
||
)}
|
||
{projects.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 ${task.projectId === p.id ? "font-bold" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onProjectAssign(task.id, task.projectId === p.id ? null : p.id);
|
||
setShowProjectPicker(false);
|
||
}}
|
||
>
|
||
<Circle size={10} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
|
||
{p.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<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>
|
||
);
|
||
}
|