feat: upgrade notes/url to full sidebar+kanban coverage, fix onNotes save bug, harden URL sanitization

- Fix onNotes prop in all three TaskItem render sites: was calling
  setSelectedTaskForNotes (ignoring HTML), now correctly calls
  updateTaskNotes so inline RichTextEditor saves on every change
- NotesSidebar: replace plain markdown textarea+toolbar with RichTextEditor;
  add URL field with open/clear actions
- Kanban detail modal: add URL field; replace plain textarea with RichTextEditor
- Tasks API: add sanitizeUrl() helper — strips javascript:/data: schemes,
  normalises missing protocol to https:// before storing
- Remove dead code in TaskItem: notesRef, handleNotesBlur, insertMarkdown
  (all replaced by RichTextEditor)

v1.83.0
This commit is contained in:
mARTin 2026-04-05 11:59:44 +02:00
parent 388618e62c
commit a6a1a68c73
3 changed files with 120 additions and 98 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.82.1", "version": "1.83.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -6,6 +6,20 @@ import { notifyUser } from '@/lib/sse';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// Validate and sanitize a URL — only allow http/https, reject javascript: and data: schemes
function sanitizeUrl(raw: string | null | undefined): string | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed.startsWith('http') ? trimmed : `https://${trimmed}`);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
return parsed.href;
} catch {
return null;
}
}
// Helper to generate a deterministic virtual ID // Helper to generate a deterministic virtual ID
const generateVirtualId = (originalId: string, dateStr: string) => { const generateVirtualId = (originalId: string, dateStr: string) => {
return `virtual-${originalId}-${dateStr}`; return `virtual-${originalId}-${dateStr}`;
@ -345,7 +359,7 @@ export async function POST(request: NextRequest) {
parentTaskId: parentTaskId || null, parentTaskId: parentTaskId || null,
...(projectId !== undefined && { projectId: projectId || null }), ...(projectId !== undefined && { projectId: projectId || null }),
...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }), ...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }),
...(url !== undefined && { url: url || null }), ...(url !== undefined && { url: sanitizeUrl(url) }),
...(externalId && { externalId, externalProvider, externalListId }), ...(externalId && { externalId, externalProvider, externalListId }),
}, },
}); });
@ -482,7 +496,7 @@ export async function PATCH(request: NextRequest) {
...(projectId !== undefined && { projectId: projectId || null }), ...(projectId !== undefined && { projectId: projectId || null }),
...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }), ...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }),
...(externalProvider !== undefined && { externalProvider: externalProvider || null }), ...(externalProvider !== undefined && { externalProvider: externalProvider || null }),
...(url !== undefined && { url: url || null }), ...(url !== undefined && { url: sanitizeUrl(url) }),
}, },
}); });

View File

@ -7365,7 +7365,8 @@ export default function WeeklyView() {
onEdit={() => setEditingTaskId(task.id)} onEdit={() => setEditingTaskId(task.id)}
onUpdate={(newTitle) => updateTask(task.id, newTitle)} onUpdate={(newTitle) => updateTask(task.id, newTitle)}
onDelete={() => deleteTask(task.id)} onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)} onNotes={(notes) => updateTaskNotes(task.id, notes)}
onUrl={(url) => updateTaskUrl(task.id, url)}
onRollToggle={() => toggleTaskRolling(task.id)} onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => onRecurrence={() =>
setSelectedTaskForRecurrence(task) setSelectedTaskForRecurrence(task)
@ -8065,7 +8066,7 @@ export default function WeeklyView() {
onEdit={() => setEditingTaskId(task.id)} onEdit={() => setEditingTaskId(task.id)}
onUpdate={(title) => updateTask(task.id, title)} onUpdate={(title) => updateTask(task.id, title)}
onDelete={() => deleteTask(task.id)} onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)} onNotes={(notes) => updateTaskNotes(task.id, notes)}
onUrl={(url) => updateTaskUrl(task.id, url)} onUrl={(url) => updateTaskUrl(task.id, url)}
onRollToggle={() => toggleTaskRolling(task.id)} onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)} onRecurrence={() => setSelectedTaskForRecurrence(task)}
@ -8138,7 +8139,8 @@ export default function WeeklyView() {
onEdit={() => setEditingTaskId(task.id)} onEdit={() => setEditingTaskId(task.id)}
onUpdate={(title) => updateTask(task.id, title)} onUpdate={(title) => updateTask(task.id, title)}
onDelete={() => deleteTask(task.id)} onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)} onNotes={(notes) => updateTaskNotes(task.id, notes)}
onUrl={(url) => updateTaskUrl(task.id, url)}
onRollToggle={() => toggleTaskRolling(task.id)} onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)} onRecurrence={() => setSelectedTaskForRecurrence(task)}
onDragStart={(e, t) => handleDragStart(e, t)} onDragStart={(e, t) => handleDragStart(e, t)}
@ -8776,6 +8778,7 @@ export default function WeeklyView() {
task={selectedTaskForNotes} task={selectedTaskForNotes}
onClose={() => setSelectedTaskForNotes(null)} onClose={() => setSelectedTaskForNotes(null)}
updateTaskNotes={updateTaskNotes} updateTaskNotes={updateTaskNotes}
updateTaskUrl={updateTaskUrl}
/> />
) )
} }
@ -8894,21 +8897,45 @@ export default function WeeklyView() {
</div> </div>
</div> </div>
{/* URL */}
<div className="kanban-detail-section">
<label><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: "inline" }}><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> {t.link || "Link"}</label>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<input
type="url"
defaultValue={liveTask.url || ""}
onBlur={(e) => {
const raw = e.target.value.trim();
const normalised = raw ? (raw.startsWith("http") ? raw : `https://${raw}`) : "";
if (normalised !== (liveTask.url || "")) {
updateTaskUrl(liveTask.id, normalised);
setKanbanDetailTask({ ...liveTask, url: normalised || null });
}
}}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
placeholder="https://..."
className="kanban-detail-url"
style={{ flex: 1 }}
/>
{liveTask.url && (
<a href={liveTask.url} target="_blank" rel="noopener noreferrer" title="Open link" style={{ color: "#2563eb", display: "inline-flex", flexShrink: 0 }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<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>
{/* Notes */} {/* Notes */}
<div className="kanban-detail-section"> <div className="kanban-detail-section">
<label><FileText size={13} /> {t.notes || "Notes"}</label> <label><FileText size={13} /> {t.notes || "Notes"}</label>
<textarea <RichTextEditor
defaultValue={liveTask.markdownContent || ""} value={liveTask.markdownContent || ""}
onBlur={(e) => { onChange={(html) => updateTaskNotes(liveTask.id, html)}
const val = e.target.value;
if (val !== (liveTask.markdownContent || "")) {
updateTaskNotes(liveTask.id, val);
setKanbanDetailTask({ ...liveTask, markdownContent: val });
}
}}
placeholder={profile.language === "de" ? "Notizen hinzufügen..." : "Add notes..."} placeholder={profile.language === "de" ? "Notizen hinzufügen..." : "Add notes..."}
className="kanban-detail-notes" minHeight="120px"
rows={4}
/> />
</div> </div>
@ -9331,7 +9358,6 @@ function TaskItem({
const [showProjectPicker, setShowProjectPicker] = useState(false); const [showProjectPicker, setShowProjectPicker] = useState(false);
const projectPickerRef = useRef<HTMLDivElement>(null); const projectPickerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const notesRef = useRef<HTMLTextAreaElement>(null);
const subTaskInputRef = useRef<HTMLInputElement>(null); const subTaskInputRef = useRef<HTMLInputElement>(null);
// Touch: tap-to-reveal actions // Touch: tap-to-reveal actions
@ -9383,13 +9409,6 @@ function TaskItem({
} }
}, [isEditing]); }, [isEditing]);
// Focus notes when opened
useEffect(() => {
if (isNotesOpen && notesRef.current) {
notesRef.current.focus();
}
}, [isNotesOpen]);
// Focus URL input when opened; sync when task.url changes // Focus URL input when opened; sync when task.url changes
useEffect(() => { if (isUrlOpen) setTimeout(() => urlInputRef.current?.focus(), 50); }, [isUrlOpen]); useEffect(() => { if (isUrlOpen) setTimeout(() => urlInputRef.current?.focus(), 50); }, [isUrlOpen]);
useEffect(() => { setUrlValue(task.url || ""); }, [task.url]); useEffect(() => { setUrlValue(task.url || ""); }, [task.url]);
@ -9417,36 +9436,6 @@ function TaskItem({
} }
}; };
const handleNotesBlur = () => {
if (notesValue !== task.markdownContent) {
onNotes(notesValue);
}
};
// Markdown insertion helper
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);
};
return ( return (
<li <li
ref={taskItemRef} ref={taskItemRef}
@ -10401,14 +10390,17 @@ interface NotesSidebarProps {
task: Task; task: Task;
onClose: () => void; onClose: () => void;
updateTaskNotes: (id: string, notes: string) => void; updateTaskNotes: (id: string, notes: string) => void;
updateTaskUrl: (id: string, url: string) => void;
} }
function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) { function NotesSidebar({ task, onClose, updateTaskNotes, updateTaskUrl }: NotesSidebarProps) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [sidebarWidth, setSidebarWidth] = useState(500); const [sidebarWidth, setSidebarWidth] = useState(500);
const [urlValue, setUrlValue] = useState(task.url || "");
const isResizing = useRef(false); const isResizing = useRef(false);
useEffect(() => { setUrlValue(task.url || ""); }, [task.url]);
useEffect(() => { useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!isResizing.current) return; if (!isResizing.current) return;
@ -10440,33 +10432,12 @@ function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
setTimeout(onClose, 300); setTimeout(onClose, 300);
}; };
const handleToolbarClick = (before: string, after: string, selectOffsetStart?: number, selectOffsetEnd?: number) => { const handleUrlBlur = () => {
const textarea = textareaRef.current; const normalised = urlValue.trim()
if (!textarea) return; ? urlValue.trim().startsWith("http") ? urlValue.trim() : `https://${urlValue.trim()}`
const start = textarea.selectionStart; : "";
const end = textarea.selectionEnd; if (normalised !== (task.url || "")) {
const text = textarea.value; updateTaskUrl(task.id, normalised);
const beforeText = text.substring(0, start);
const selection = text.substring(start, end);
const afterText = text.substring(end);
let newText = `${beforeText}${before}${selection}${after}${afterText}`;
if (before === "![" && after === "](url)") {
// Special case for image to match original logic precisely
newText = `${beforeText}![alt text](url)${afterText}`;
}
updateTaskNotes(task.id, newText);
textarea.value = newText;
textarea.focus();
if (before === "![" && after === "](url)") {
textarea.setSelectionRange(start + 2, start + 10);
} else {
textarea.setSelectionRange(
start + before.length,
start + before.length + selection.length
);
} }
}; };
@ -10504,22 +10475,59 @@ function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
</button> </button>
</header> </header>
<div className="weekly-notes-sidebar-content"> <div className="weekly-notes-sidebar-content">
<div className="notes-toolbar"> {/* URL field */}
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("**", "**")} title="Bold">B</button> <div style={{ marginBottom: "12px" }}>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("*", "*")} title="Italic">i</button> <label style={{ display: "block", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--weekly-text-muted, #999)", marginBottom: "4px" }}>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("[", "](url)")} title="Link">🔗</button> Link
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("- ", "")} title="List"></button> </label>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("![", "](url)")} title="Image">🖼</button> <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<input
type="url"
value={urlValue}
onChange={(e) => setUrlValue(e.target.value)}
onBlur={handleUrlBlur}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
placeholder="https://..."
style={{ flex: 1, fontSize: "0.82rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "5px 8px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }}
/>
{urlValue && (
<button
onClick={() => { setUrlValue(""); updateTaskUrl(task.id, ""); }}
title="Remove link"
style={{ background: "none", border: "none", cursor: "pointer", color: "#999", padding: "4px" }}
>
<svg viewBox="0 0 24 24" width="13" height="13" 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>
)}
{urlValue && (
<a
href={urlValue.startsWith("http") ? urlValue : `https://${urlValue}`}
target="_blank"
rel="noopener noreferrer"
title="Open link"
style={{ color: "#2563eb", display: "inline-flex", padding: "4px" }}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<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> </div>
<textarea {/* Notes */}
ref={textareaRef} <div>
className="weekly-notes-editor" <label style={{ display: "block", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--weekly-text-muted, #999)", marginBottom: "4px" }}>
defaultValue={task.markdownContent || ""} Notes
autoFocus </label>
placeholder="Add details, notes, or links..." <RichTextEditor
onBlur={(e) => updateTaskNotes(task.id, e.target.value)} value={task.markdownContent || ""}
/> onChange={(html) => updateTaskNotes(task.id, html)}
placeholder="Add details, notes, or links..."
minHeight="300px"
/>
</div>
<div className="weekly-modal-actions" style={{ marginTop: '24px' }}> <div className="weekly-modal-actions" style={{ marginTop: '24px' }}>
<button <button