Wave-1 user feedback (points 7–10): - Settings: new Projects tab with full CRUD (name, color, icon picker) - Priority Icons render in simple/calendar/list views with style-specific visuals (Eisenhower icons, ABCDE letters, Ivy Lee 1–6, Pareto star). Toggle + style selector in Settings; Ivy Lee ranks now persist to task.priority so cross-view badges stay consistent. - Someday Lists gain optional icon (left of title) and color (left border tint), edited via a pencil-popup with IconPicker + color picker. - Tabs gain optional icon and color, stored in user.viewSettings JSON and edited via the same popup pattern. Schema: SomedayList.color/icon, User.showPriorityIcons/priorityStyle. v1.99.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
934 lines
52 KiB
TypeScript
934 lines
52 KiB
TypeScript
import React, { useState, useRef, useEffect, Suspense } from "react";
|
||
import dynamic from "next/dynamic";
|
||
const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false });
|
||
import { Repeat, Circle, 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 } 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);
|
||
}}
|
||
>
|
||
{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" }}>{showProjectIcons && 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);
|
||
const iconStyle = { marginRight: "4px", verticalAlign: "middle" } as const;
|
||
if (found) {
|
||
return found.type === "fa"
|
||
? <FontAwesomeIcon icon={found.icon as IconDefinition} style={{ fontSize: "11px", ...iconStyle, color: task.project!.color || "#888" }} />
|
||
: <MdiIcon path={found.icon as string} size={0.5} color={task.project!.color || "#888"} style={{ ...iconStyle, display: "inline-block" }} />;
|
||
}
|
||
return task.project!.icon
|
||
? <span style={{ fontSize: "11px", ...iconStyle }}>{task.project!.icon}</span>
|
||
: <FontAwesomeIcon icon={faFolder} style={{ fontSize: "11px", ...iconStyle, color: task.project!.color || "#888" }} />;
|
||
})()}{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>
|
||
)}
|
||
{weatherEnabled && (() => {
|
||
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>
|
||
{!weatherEnabled && (() => {
|
||
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
|
||
aria-label={`Synced with ${info.label}`}
|
||
style={{ position: "absolute", top: "3px", right: "4px", display: "inline-flex", alignItems: "center", opacity: 0.45, zIndex: 2 }}
|
||
>
|
||
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
|
||
</span>
|
||
);
|
||
})()}
|
||
|
||
<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"
|
||
>
|
||
<Circle
|
||
size={12}
|
||
aria-hidden="true"
|
||
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 ${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>
|
||
);
|
||
}
|