feat: add hyperlink support to tasks

Option A — URL field per task:
- New url String? column in Task schema (migration: 20260403_add_task_url)
- Link icon button in GridTaskBlock action bar; click to open inline URL popup
  with input, clear (×) button, and open-in-new-tab shortcut
- Blue link indicator shown inline on the task card when a URL is set
- updateTaskUrl() in WeeklyView persists to DB via PATCH /api/tasks

Option B — Rich text notes with inline hyperlinks:
- GridTaskBlock notes popup upgraded from plain markdown textarea to
  RichTextEditor (Tiptap), which already has bold/italic/underline/
  bullet/blockquote/link toolbar and full link support
- Notes now save on every change via onChange (no blur required)

v1.82.0
This commit is contained in:
mARTin 2026-04-03 15:04:44 +02:00
parent f94f83af73
commit 7bc43d3c98
6 changed files with 174 additions and 22 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.81.20",
"version": "1.82.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": {

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "url" TEXT;

View File

@ -199,6 +199,7 @@ model Task {
somedaySlotIndex Int?
projectId String?
kanbanStage String?
url String?
parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade)
subTasks Task[] @relation("SubTasks")
project Project? @relation(fields: [projectId], references: [id])

View File

@ -233,7 +233,7 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, parentTaskId, projectId, kanbanStage } = body;
const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, parentTaskId, projectId, kanbanStage, url } = body;
let { isRolling } = body;
const { isRecurring } = body;
@ -345,6 +345,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 }),
...(externalId && { externalId, externalProvider, externalListId }),
},
});
@ -389,7 +390,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json();
const { id } = body;
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, restore, parentTaskId, projectId, kanbanStage, externalProvider } = body;
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, restore, parentTaskId, projectId, kanbanStage, externalProvider, url } = body;
if (!id) {
return NextResponse.json(
@ -481,6 +482,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 }),
},
});

View File

@ -1,5 +1,7 @@
import React, { useState, useRef, useEffect } from "react";
import { Repeat, Circle, X, ChevronDown } from "lucide-react";
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";
@ -18,6 +20,7 @@ interface GridTaskBlockProps {
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;
@ -53,6 +56,7 @@ export function GridTaskBlock({
setEditingTaskId,
updateTask,
updateTaskNotes,
updateTaskUrl,
updateTaskDuration,
toggleTask,
deleteTask,
@ -78,6 +82,9 @@ export function GridTaskBlock({
}: 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("");
@ -124,12 +131,27 @@ export function GridTaskBlock({
}
}, [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;
@ -382,6 +404,29 @@ export function GridTaskBlock({
);
})()}
{/* URL indicator */}
{task.url && (
<a
href={task.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title={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} />
</a>
)}
{/* Note indicator */}
{task.markdownContent && task.markdownContent.trim().length > 0 && (
<div
@ -563,6 +608,14 @@ export function GridTaskBlock({
)}
</div>
)}
<button
className={`task-action-btn ${task.url || isUrlOpen ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setIsUrlOpen(!isUrlOpen); }}
title={task.url ? task.url : "Add link"}
style={task.url ? { color: "#2563eb" } : {}}
>
<Link size={12} />
</button>
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
@ -572,6 +625,76 @@ export function GridTaskBlock({
</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} style={{ color: "#2563eb", flexShrink: 0 }} />
<input
ref={urlInputRef}
type="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); }}
title="Remove link"
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: "#999" }}
>
<X size={12} />
</button>
)}
{urlValue && (
<a
href={urlValue.startsWith("http") ? urlValue : `https://${urlValue}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title="Open link"
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">
<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" : ""}`}
@ -584,29 +707,27 @@ export function GridTaskBlock({
left: "-10px",
right: "-10px",
width: "auto",
padding: "0",
}}
>
<div className="notes-toolbar">
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("**", "**")} title="Bold">B</button>
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("*", "*")} title="Italic">i</button>
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("- ")} title="List"></button>
<span style={{ marginLeft: "auto", fontSize: "0.75rem", color: "#999" }}>Markdown</span>
<div style={{ display: "flex", justifyContent: "flex-end", padding: "4px 6px 0", gap: "4px" }}>
<button
className="notes-toolbar-btn"
onClick={() => setIsNotesOpen(false)}
onMouseDown={(e) => { e.preventDefault(); setIsNotesOpen(false); }}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: "1rem", lineHeight: 1, color: "#999", padding: "2px 4px" }}
title="Close"
style={{ marginLeft: "8px", fontSize: "1rem", lineHeight: 1 }}
>×</button>
</div>
<textarea
ref={notesRef}
className="weekly-notes-editor-inline"
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
onBlur={handleNotesBlur}
placeholder="Add notes..."
style={{ minHeight: "120px", padding: "4px" }}
/>
<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 && (

View File

@ -166,6 +166,7 @@ export interface Task {
projectId?: string | null;
project?: { id: string; name: string; icon?: string | null; color?: string | null } | null;
kanbanStage?: string | null;
url?: string | null;
}
interface CalendarEvent {
@ -4320,6 +4321,30 @@ export default function WeeklyView() {
}
};
const updateTaskUrl = async (taskId: string, url: string) => {
const task = findTaskAnywhere(taskId);
const isSomeday = !!task?.somedayListId;
const normalised = url.trim() ? (url.trim().startsWith("http") ? url.trim() : `https://${url.trim()}`) : "";
if (isSomeday) {
setSomedayLists(prev => prev.map(l => ({
...l,
tasks: l.tasks.map(t => t.id === taskId ? { ...t, url: normalised || null, updatedAt: new Date() } : t),
})));
} else {
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, url: normalised || null, updatedAt: new Date() } : t));
}
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, url: normalised || null }),
});
} catch (error) {
console.error("Error updating task url:", error);
}
};
const toggleTaskRolling = async (taskId: string) => {
saveSnapshot();
const task = findTaskAnywhere(taskId);
@ -6921,6 +6946,7 @@ export default function WeeklyView() {
setEditingTaskId={setEditingTaskId}
updateTask={updateTask}
updateTaskNotes={updateTaskNotes}
updateTaskUrl={updateTaskUrl}
updateTaskDuration={updateTaskDuration}
toggleTask={toggleTask}
deleteTask={deleteTask}