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",
|
||||
"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",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -6,6 +6,20 @@ import { notifyUser } from '@/lib/sse';
|
||||
|
||||
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
|
||||
const generateVirtualId = (originalId: string, dateStr: string) => {
|
||||
return `virtual-${originalId}-${dateStr}`;
|
||||
@ -345,7 +359,7 @@ export async function POST(request: NextRequest) {
|
||||
parentTaskId: parentTaskId || null,
|
||||
...(projectId !== undefined && { projectId: projectId || null }),
|
||||
...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }),
|
||||
...(url !== undefined && { url: url || null }),
|
||||
...(url !== undefined && { url: sanitizeUrl(url) }),
|
||||
...(externalId && { externalId, externalProvider, externalListId }),
|
||||
},
|
||||
});
|
||||
@ -482,7 +496,7 @@ export async function PATCH(request: NextRequest) {
|
||||
...(projectId !== undefined && { projectId: projectId || null }),
|
||||
...(kanbanStage !== undefined && { kanbanStage: kanbanStage || 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)}
|
||||
onUpdate={(newTitle) => updateTask(task.id, newTitle)}
|
||||
onDelete={() => deleteTask(task.id)}
|
||||
onNotes={() => setSelectedTaskForNotes(task)}
|
||||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||||
onUrl={(url) => updateTaskUrl(task.id, url)}
|
||||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||||
onRecurrence={() =>
|
||||
setSelectedTaskForRecurrence(task)
|
||||
@ -8065,7 +8066,7 @@ export default function WeeklyView() {
|
||||
onEdit={() => setEditingTaskId(task.id)}
|
||||
onUpdate={(title) => updateTask(task.id, title)}
|
||||
onDelete={() => deleteTask(task.id)}
|
||||
onNotes={() => setSelectedTaskForNotes(task)}
|
||||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||||
onUrl={(url) => updateTaskUrl(task.id, url)}
|
||||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||||
@ -8138,7 +8139,8 @@ export default function WeeklyView() {
|
||||
onEdit={() => setEditingTaskId(task.id)}
|
||||
onUpdate={(title) => updateTask(task.id, title)}
|
||||
onDelete={() => deleteTask(task.id)}
|
||||
onNotes={() => setSelectedTaskForNotes(task)}
|
||||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||||
onUrl={(url) => updateTaskUrl(task.id, url)}
|
||||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||||
@ -8776,6 +8778,7 @@ export default function WeeklyView() {
|
||||
task={selectedTaskForNotes}
|
||||
onClose={() => setSelectedTaskForNotes(null)}
|
||||
updateTaskNotes={updateTaskNotes}
|
||||
updateTaskUrl={updateTaskUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -8894,21 +8897,45 @@ export default function WeeklyView() {
|
||||
</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 */}
|
||||
<div className="kanban-detail-section">
|
||||
<label><FileText size={13} /> {t.notes || "Notes"}</label>
|
||||
<textarea
|
||||
defaultValue={liveTask.markdownContent || ""}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val !== (liveTask.markdownContent || "")) {
|
||||
updateTaskNotes(liveTask.id, val);
|
||||
setKanbanDetailTask({ ...liveTask, markdownContent: val });
|
||||
}
|
||||
}}
|
||||
<RichTextEditor
|
||||
value={liveTask.markdownContent || ""}
|
||||
onChange={(html) => updateTaskNotes(liveTask.id, html)}
|
||||
placeholder={profile.language === "de" ? "Notizen hinzufügen..." : "Add notes..."}
|
||||
className="kanban-detail-notes"
|
||||
rows={4}
|
||||
minHeight="120px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -9331,7 +9358,6 @@ function TaskItem({
|
||||
const [showProjectPicker, setShowProjectPicker] = useState(false);
|
||||
const projectPickerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const notesRef = useRef<HTMLTextAreaElement>(null);
|
||||
const subTaskInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Touch: tap-to-reveal actions
|
||||
@ -9383,13 +9409,6 @@ function TaskItem({
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
// Focus notes when opened
|
||||
useEffect(() => {
|
||||
if (isNotesOpen && notesRef.current) {
|
||||
notesRef.current.focus();
|
||||
}
|
||||
}, [isNotesOpen]);
|
||||
|
||||
// Focus URL input when opened; sync when task.url changes
|
||||
useEffect(() => { if (isUrlOpen) setTimeout(() => urlInputRef.current?.focus(), 50); }, [isUrlOpen]);
|
||||
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 (
|
||||
<li
|
||||
ref={taskItemRef}
|
||||
@ -10401,14 +10390,17 @@ interface NotesSidebarProps {
|
||||
task: Task;
|
||||
onClose: () => 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 textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(500);
|
||||
const [urlValue, setUrlValue] = useState(task.url || "");
|
||||
const isResizing = useRef(false);
|
||||
|
||||
useEffect(() => { setUrlValue(task.url || ""); }, [task.url]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing.current) return;
|
||||
@ -10440,33 +10432,12 @@ function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
|
||||
setTimeout(onClose, 300);
|
||||
};
|
||||
|
||||
const handleToolbarClick = (before: string, after: string, selectOffsetStart?: number, selectOffsetEnd?: number) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
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
|
||||
);
|
||||
const handleUrlBlur = () => {
|
||||
const normalised = urlValue.trim()
|
||||
? urlValue.trim().startsWith("http") ? urlValue.trim() : `https://${urlValue.trim()}`
|
||||
: "";
|
||||
if (normalised !== (task.url || "")) {
|
||||
updateTaskUrl(task.id, normalised);
|
||||
}
|
||||
};
|
||||
|
||||
@ -10504,22 +10475,59 @@ function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
|
||||
</button>
|
||||
</header>
|
||||
<div className="weekly-notes-sidebar-content">
|
||||
<div className="notes-toolbar">
|
||||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("**", "**")} title="Bold">B</button>
|
||||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("*", "*")} title="Italic">i</button>
|
||||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("[", "](url)")} title="Link">🔗</button>
|
||||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("- ", "")} title="List">☑</button>
|
||||
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("")} title="Image">🖼️</button>
|
||||
{/* URL field */}
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ display: "block", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--weekly-text-muted, #999)", marginBottom: "4px" }}>
|
||||
Link
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="weekly-notes-editor"
|
||||
defaultValue={task.markdownContent || ""}
|
||||
autoFocus
|
||||
placeholder="Add details, notes, or links..."
|
||||
onBlur={(e) => updateTaskNotes(task.id, e.target.value)}
|
||||
/>
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--weekly-text-muted, #999)", marginBottom: "4px" }}>
|
||||
Notes
|
||||
</label>
|
||||
<RichTextEditor
|
||||
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' }}>
|
||||
<button
|
||||
|
||||
Loading…
Reference in New Issue
Block a user