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:
parent
388618e62c
commit
a6a1a68c73
@ -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": {
|
||||||
|
|||||||
@ -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) }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 === "") {
|
|
||||||
// Special case for image to match original logic precisely
|
|
||||||
newText = `${beforeText}${afterText}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTaskNotes(task.id, newText);
|
|
||||||
textarea.value = newText;
|
|
||||||
textarea.focus();
|
|
||||||
|
|
||||||
if (before === "") {
|
|
||||||
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("")} 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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user