From 7bc43d3c98ff7920adf1c0807501bb7f752d7600 Mon Sep 17 00:00:00 2001 From: mARTin Date: Fri, 3 Apr 2026 15:04:44 +0200 Subject: [PATCH] feat: add hyperlink support to tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- .../20260403_add_task_url/migration.sql | 2 + prisma/schema.prisma | 1 + src/app/api/tasks/route.ts | 6 +- src/components/GridTaskBlock.tsx | 159 +++++++++++++++--- src/components/WeeklyView.tsx | 26 +++ 6 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 prisma/migrations/20260403_add_task_url/migration.sql diff --git a/package.json b/package.json index 979bf60..319baa8 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/prisma/migrations/20260403_add_task_url/migration.sql b/prisma/migrations/20260403_add_task_url/migration.sql new file mode 100644 index 0000000..c5f0623 --- /dev/null +++ b/prisma/migrations/20260403_add_task_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "url" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c54d830..6471cf4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 640a866..61ea853 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -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 }), }, }); diff --git a/src/components/GridTaskBlock.tsx b/src/components/GridTaskBlock.tsx index 3143e65..27bd4fd 100644 --- a/src/components/GridTaskBlock.tsx +++ b/src/components/GridTaskBlock.tsx @@ -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(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 && ( + 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", + }} + > + + + )} {/* Note indicator */} {task.markdownContent && task.markdownContent.trim().length > 0 && (
)} +
+ {isUrlOpen && ( +
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", + }} + > +
+ + 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 && ( + + )} + {urlValue && ( + e.stopPropagation()} + title="Open link" + style={{ color: "#2563eb", flexShrink: 0, display: "inline-flex" }} + > + + + + + + + )} +
+
+ )} {isNotesOpen && (
180 ? "on-top" : ""}`} @@ -584,29 +707,27 @@ export function GridTaskBlock({ left: "-10px", right: "-10px", width: "auto", + padding: "0", }} > -
- - - - Markdown +
-