My-Weekly-ToDo-List/src/components/GridTaskBlock.tsx
mARTin-B78 cb17c91ca6 fix: replace Circle with project icon in task hover menu
The project assign button and dropdown list now show the project's
actual icon (or faFolder as fallback) instead of a plain circle,
matching the icon rendering used in the task row stacked column.

v1.111.1
2026-05-11 15:08:16 +02:00

964 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect, Suspense } from "react";
import dynamic from "next/dynamic";
const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false });
import { Repeat, X, ChevronDown, Link } from "lucide-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons";
import { faServer, faFolder, IconDefinition } from "@fortawesome/free-solid-svg-icons";
import MdiIcon from "@mdi/react";
import { allIcons } from "./iconRegistry";
import { Task, KanbanStage, getPriorityBadge } 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;
updateTaskUrl: (id: string, url: 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;
showProjectIcons?: boolean;
showPriorityIcons?: boolean;
priorityStyle?: string;
projects?: any[];
onProjectAssign?: (taskId: string, projectId: string | null) => void;
kanbanStages?: KanbanStage[];
weatherEnabled?: boolean;
onMoveTask?: (taskId: string, targetDate: Date, targetTime: string) => void;
}
export function GridTaskBlock({
task,
date,
activeDate,
cellDuration,
darkMode,
isProtected,
editingTaskId,
setEditingTaskId,
updateTask,
updateTaskNotes,
updateTaskUrl,
updateTaskDuration,
toggleTask,
deleteTask,
toggleTaskRolling,
setSelectedTaskForNotes,
setSelectedTaskForRecurrence,
handleDragStart,
handleDragEnd,
getSlotHeight,
draggedTask,
addSubTask,
toggleSubTask,
updateSubTask,
deleteSubTask,
onSetEditingTaskId,
workingHoursStart,
showTaskCheckboxes,
showProjectIcons,
showPriorityIcons = true,
priorityStyle = "eisenhower",
projects,
onProjectAssign,
kanbanStages = [],
weatherEnabled = false,
onMoveTask,
}: GridTaskBlockProps) {
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
const [isUrlOpen, setIsUrlOpen] = useState(false);
const [urlValue, setUrlValue] = useState(task.url || "");
const urlInputRef = useRef<HTMLInputElement>(null);
const [isSubTasksOpen, setIsSubTasksOpen] = useState(false);
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
const [showProjectPicker, setShowProjectPicker] = useState(false);
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [moveDate, setMoveDate] = useState('');
const [moveTime, setMoveTime] = useState('');
const notesRef = useRef<HTMLTextAreaElement>(null);
const subTaskInputRef = useRef<HTMLInputElement>(null);
const projectPickerRef = useRef<HTMLDivElement>(null);
const moveDialogRef = 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]);
// Close project picker on outside click
useEffect(() => {
if (!showProjectPicker) return;
const handler = (e: Event) => {
if (projectPickerRef.current && !projectPickerRef.current.contains(e.target as Node)) {
setShowProjectPicker(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [showProjectPicker]);
// 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]);
// Focus URL input when opened
useEffect(() => {
if (isUrlOpen) setTimeout(() => urlInputRef.current?.focus(), 50);
}, [isUrlOpen]);
// Sync urlValue if task.url changes externally
useEffect(() => { setUrlValue(task.url || ""); }, [task.url]);
const handleNotesBlur = () => {
if (notesValue !== task.markdownContent) {
updateTaskNotes(task.id, notesValue);
}
};
const handleUrlSave = (val: string) => {
setIsUrlOpen(false);
if (val.trim() !== (task.url || "")) {
updateTaskUrl(task.id, val.trim());
}
};
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 * 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";
};
const onResizeKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
e.stopPropagation();
const step = 15;
const newDuration = e.key === 'ArrowUp'
? Math.min(duration + step, 480)
: Math.max(duration - step, 15);
if (newDuration !== duration) {
updateTaskDuration(task.id, newDuration);
}
};
return (
<div
ref={blockRef}
className={`time-slot-task ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""} ${touchActive ? "touch-active" : ""} ${showProjectPicker ? "picker-open" : ""}`}
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}
onMouseLeave={(e) => {
if (showProjectPicker) {
// Don't close if mouse moved into the project picker dropdown
const related = e.relatedTarget as HTMLElement | null;
if (related && projectPickerRef.current?.contains(related)) return;
setShowProjectPicker(false);
}
}}
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"
aria-label={`Edit task title: ${task.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);
}}
>
{(() => {
const pb = showPriorityIcons ? getPriorityBadge(task, priorityStyle, 11) : null;
const hasProject = !!(showProjectIcons && task.project);
if (!pb && !hasProject) return null;
return (
<span style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-start", flexShrink: 0, gap: "1px", paddingTop: "2px", opacity: task.completed && showTaskCheckboxes ? 0.5 : 1 }}>
{pb && <span title={pb.label} style={{ display: "inline-flex", alignItems: "center" }}>{pb.node}</span>}
{hasProject && (() => {
const rawIcon = task.project!.icon || "";
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
: rawIcon;
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
if (found) {
return found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "11px", color: task.project!.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.5} color={task.project!.color || "#888"} style={{ display: "inline-block" }} />;
}
return task.project!.icon
? <span style={{ fontSize: "11px" }}>{task.project!.icon}</span>
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "11px", color: task.project!.color || "#888" }} />;
})()}
</span>
);
})()}
{showTaskCheckboxes && (
<input
type="checkbox"
checked={task.completed}
aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`}
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 allDone = completed === total;
const expanded = isSubTasksOpen || isSubTaskInputOpen;
const pct = total > 0 ? (completed / total) * 100 : 0;
return (
<button
onClick={(e) => {
e.stopPropagation();
setIsSubTasksOpen(!isSubTasksOpen);
}}
className="subtask-indicator-badge"
aria-label={expanded ? "Collapse subtasks" : `${completed} of ${total} subtasks done`}
aria-expanded={expanded}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px 2px 4px",
borderRadius: "12px",
background: expanded ? "rgba(99,102,241,0.08)" : "transparent",
color: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-text-light, #9ca3af)",
border: "none",
cursor: "pointer",
fontSize: "0.7rem",
fontWeight: 600,
lineHeight: 1,
flexShrink: 0,
whiteSpace: "nowrap",
transition: "all 0.15s",
}}
>
<ChevronDown size={12} aria-hidden="true" style={{ transform: expanded ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 0.2s", flexShrink: 0 }} />
<span style={{
display: "inline-block",
width: "36px",
height: "5px",
borderRadius: "3px",
background: "var(--weekly-border, #e5e7eb)",
position: "relative",
overflow: "hidden",
flexShrink: 0,
}}>
<span style={{
position: "absolute",
left: 0,
top: 0,
height: "100%",
width: `${pct}%`,
borderRadius: "3px",
background: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-accent, #6366f1)",
transition: "width 0.3s ease",
}} />
</span>
<span style={{ opacity: 0.8 }}>{completed}/{total}</span>
</button>
);
})()}
{/* URL indicator */}
{task.url && (
<a
href={task.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
aria-label={`Open link: ${task.url}`}
style={{
display: "inline-flex",
alignItems: "center",
padding: "1px 3px",
borderRadius: "3px",
background: "rgba(37,99,235,0.10)",
color: "#2563eb",
cursor: "pointer",
marginLeft: "3px",
textDecoration: "none",
}}
>
<Link size={10} aria-hidden="true" />
</a>
)}
{/* Note indicator */}
{task.markdownContent && task.markdownContent.trim().length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsNotesOpen(!isNotesOpen);
}}
aria-label={isNotesOpen ? "Collapse note" : "Expand note"}
aria-expanded={isNotesOpen}
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",
border: "none",
}}
>
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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>
</button>
)}
{/* Rolling indicator */}
{task.isRolling && !task.completed && (
<span
title="Auto-rolling task"
style={{
display: "inline-flex",
alignItems: "center",
padding: "1px 3px",
color: "var(--weekly-teal, #009a9a)",
marginLeft: "3px",
flexShrink: 0,
}}
>
<svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</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"
aria-label={`Synced with ${info.label}`}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "3px" }}
>
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
</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); }}
aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button
className="task-action-btn"
onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}
aria-label={`Edit "${task.title}"`}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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); } }}
aria-label="Add sub-task"
aria-expanded={isSubTaskInputOpen}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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); }}
aria-label={isNotesOpen ? "Close notes" : "Open notes"}
aria-expanded={isNotesOpen}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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); }}
aria-label={task.isRolling ? "Disable rolling (task auto-rolls to next day)" : "Enable rolling (task auto-rolls to next day)"}
aria-pressed={task.isRolling}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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); }}
aria-label={task.isRecurring ? "Edit recurrence" : "Make task recurring"}
>
<Repeat size={12} aria-hidden="true" />
</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);
}}
aria-label={task.project ? `Project: ${task.project.name} — change project` : "Assign to project"}
aria-expanded={showProjectPicker}
aria-haspopup="listbox"
>
{task.project ? (() => {
const rawIcon = task.project.icon || "";
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
: rawIcon;
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
if (found) {
return found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} aria-hidden="true" style={{ fontSize: "11px", color: task.project.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.5} color={task.project.color || "#888"} style={{ display: "inline-block" }} />;
}
return task.project.icon
? <span style={{ fontSize: "11px", color: task.project.color || "#888" }}>{task.project.icon}</span>
: <FontAwesomeIcon icon={faFolder} aria-hidden="true" style={{ fontSize: "11px", color: task.project.color || "#888" }} />;
})() : <FontAwesomeIcon icon={faFolder} aria-hidden="true" style={{ fontSize: "11px" }} />}
</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);
}}
>
{(() => {
const rawIcon = p.icon || "";
const normalised = rawIcon.startsWith("fa") && rawIcon.length > 2 && rawIcon[2] === rawIcon[2].toUpperCase()
? rawIcon.slice(2, 3).toLowerCase() + rawIcon.slice(3)
: rawIcon;
const found = allIcons.find(i => i.name === normalised || i.name === rawIcon);
if (found) {
return found.type === "fa"
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "10px", color: p.color || "#888" }} />
: <MdiIcon path={found.icon as string} size={0.45} color={p.color || "#888"} style={{ display: "inline-block" }} />;
}
return p.icon
? <span style={{ fontSize: "10px", color: p.color || "#888" }}>{p.icon}</span>
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "10px", color: p.color || "#888" }} />;
})()}
{p.name}
</button>
))}
</div>
)}
</div>
)}
<button
className={`task-action-btn ${task.url || isUrlOpen ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setIsUrlOpen(!isUrlOpen); }}
aria-label={task.url ? `Edit link: ${task.url}` : "Add link"}
aria-expanded={isUrlOpen}
style={task.url ? { color: "#2563eb" } : {}}
>
<Link size={12} aria-hidden="true" />
</button>
<button
className="task-action-btn delete"
onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }}
aria-label={`Delete "${task.title}"`}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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>
{onMoveTask && (
<button
className={`task-action-btn ${showMoveDialog ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
if (!showMoveDialog) {
// Pre-fill with current date/time
const d = task.scheduledDate ? new Date(task.scheduledDate) : date;
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
setMoveDate(`${yyyy}-${mm}-${dd}`);
setMoveTime(task.startTime || '09:00');
}
setShowMoveDialog(!showMoveDialog);
}}
aria-label="Move task to different date/time"
aria-expanded={showMoveDialog}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="5 9 2 12 5 15" />
<polyline points="9 5 12 2 15 5" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="12" y1="2" x2="12" y2="22" />
</svg>
</button>
)}
</div>
<div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}>
{isUrlOpen && (
<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",
padding: "8px 10px",
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<Link size={13} aria-hidden="true" style={{ color: "#2563eb", flexShrink: 0 }} />
<input
ref={urlInputRef}
type="url"
aria-label="Task link URL"
value={urlValue}
onChange={(e) => setUrlValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); handleUrlSave(urlValue); }
if (e.key === "Escape") { setIsUrlOpen(false); setUrlValue(task.url || ""); }
}}
onBlur={() => handleUrlSave(urlValue)}
placeholder="https://..."
style={{
flex: 1,
fontSize: "0.78rem",
border: "1px solid var(--weekly-border, #ddd)",
borderRadius: "4px",
padding: "3px 6px",
background: "var(--weekly-bg, white)",
color: "var(--weekly-text, #333)",
outline: "none",
}}
/>
{urlValue && (
<button
onMouseDown={(e) => { e.preventDefault(); setUrlValue(""); updateTaskUrl(task.id, ""); setIsUrlOpen(false); }}
aria-label="Remove link"
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: "#999" }}
>
<X size={12} aria-hidden="true" />
</button>
)}
{urlValue && (
<a
href={urlValue.startsWith("http") ? urlValue : `https://${urlValue}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
aria-label="Open link in new tab"
style={{ color: "#2563eb", flexShrink: 0, display: "inline-flex" }}
>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
)}
</div>
</div>
)}
{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",
padding: "0",
}}
>
<div style={{ display: "flex", justifyContent: "flex-end", padding: "4px 6px 0", gap: "4px" }}>
<button
onMouseDown={(e) => { e.preventDefault(); setIsNotesOpen(false); }}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: "1rem", lineHeight: 1, color: "#999", padding: "2px 4px" }}
aria-label="Close notes"
>×</button>
</div>
<div style={{ padding: "0 6px 8px" }}>
<RichTextEditor
value={notesValue}
onChange={(html) => {
setNotesValue(html);
updateTaskNotes(task.id, html);
}}
placeholder="Add notes..."
minHeight="100px"
/>
</div>
</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)}
aria-label={subTask.completed ? `Mark subtask "${subTask.title}" incomplete` : `Mark subtask "${subTask.title}" complete`}
>
{subTask.completed ? (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><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" aria-hidden="true"><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)} aria-label={`Remove subtask "${subTask.title}"`}><svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><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 }} aria-hidden="true"><circle cx="12" cy="12" r="10" /></svg>
<input ref={subTaskInputRef} type="text" aria-label="New sub-task title" 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>
{showMoveDialog && onMoveTask && (
<div
ref={moveDialogRef}
role="dialog"
aria-modal="true"
aria-label={`Move task "${task.title}" to a new date and time`}
className="weekly-notes-popup"
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",
padding: "10px 12px",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<p style={{ margin: 0, fontSize: "0.78rem", fontWeight: 600, color: "var(--weekly-text, #333)" }}>
Move: <em>{task.title}</em>
</p>
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
<label htmlFor={`move-date-${task.id}`} style={{ fontSize: "0.75rem", whiteSpace: "nowrap" }}>Date</label>
<input
id={`move-date-${task.id}`}
type="date"
value={moveDate}
onChange={(e) => setMoveDate(e.target.value)}
autoFocus
style={{ flex: 1, fontSize: "0.78rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "3px 6px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }}
/>
</div>
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
<label htmlFor={`move-time-${task.id}`} style={{ fontSize: "0.75rem", whiteSpace: "nowrap" }}>Time</label>
<input
id={`move-time-${task.id}`}
type="time"
value={moveTime}
onChange={(e) => setMoveTime(e.target.value)}
step="900"
style={{ flex: 1, fontSize: "0.78rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "3px 6px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }}
/>
</div>
<div style={{ display: "flex", gap: "6px", justifyContent: "flex-end" }}>
<button
onClick={() => setShowMoveDialog(false)}
style={{ fontSize: "0.75rem", padding: "3px 8px", borderRadius: "4px", border: "1px solid var(--weekly-border, #ddd)", background: "transparent", cursor: "pointer", color: "var(--weekly-text, #333)" }}
>
Cancel
</button>
<button
onClick={() => {
if (moveDate && moveTime) {
const [y, m, d] = moveDate.split('-').map(Number);
const targetDate = new Date(y, m - 1, d);
// Round time to nearest 15 min
const [h, min] = moveTime.split(':').map(Number);
const rounded = Math.round(min / 15) * 15;
const roundedMin = rounded === 60 ? 0 : rounded;
const roundedH = rounded === 60 ? h + 1 : h;
const roundedTime = `${String(roundedH).padStart(2, '0')}:${String(roundedMin).padStart(2, '0')}`;
onMoveTask(task.id, targetDate, roundedTime);
setShowMoveDialog(false);
}
}}
style={{ fontSize: "0.75rem", padding: "3px 8px", borderRadius: "4px", border: "none", background: "var(--weekly-accent, #6366f1)", color: "white", cursor: "pointer" }}
>
Move
</button>
</div>
</div>
)}
<div
className="resize-handle"
onMouseDown={onResizeStart}
onKeyDown={onResizeKeyDown}
tabIndex={0}
role="slider"
aria-label={`Resize task duration, currently ${duration} minutes`}
aria-valuemin={15}
aria-valuemax={480}
aria-valuenow={duration}
aria-valuetext={`${duration} minutes`}
/>
</div>
);
}