diff --git a/package.json b/package.json
index 5242d3b..0611550 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts
index 61ea853..5f838e3 100644
--- a/src/app/api/tasks/route.ts
+++ b/src/app/api/tasks/route.ts
@@ -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) }),
},
});
diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx
index e096fa3..cc1b025 100644
--- a/src/components/WeeklyView.tsx
+++ b/src/components/WeeklyView.tsx
@@ -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() {
+ {/* URL */}
+
+
+
+
{
+ 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 && (
+
+
+
+ )}
+
+
+
{/* Notes */}
-
@@ -9331,7 +9358,6 @@ function TaskItem({
const [showProjectPicker, setShowProjectPicker] = useState(false);
const projectPickerRef = useRef(null);
const inputRef = useRef(null);
- const notesRef = useRef(null);
const subTaskInputRef = useRef(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 (
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(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) {
-
-
-
-
-
-
+ {/* URL field */}
+
+
+
+
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 && (
+
+ )}
+ {urlValue && (
+
+
+
+ )}
+
-