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:
parent
f94f83af73
commit
7bc43d3c98
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"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": {
|
||||||
|
|||||||
2
prisma/migrations/20260403_add_task_url/migration.sql
Normal file
2
prisma/migrations/20260403_add_task_url/migration.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "url" TEXT;
|
||||||
@ -199,6 +199,7 @@ model Task {
|
|||||||
somedaySlotIndex Int?
|
somedaySlotIndex Int?
|
||||||
projectId String?
|
projectId String?
|
||||||
kanbanStage String?
|
kanbanStage String?
|
||||||
|
url String?
|
||||||
parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade)
|
parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade)
|
||||||
subTasks Task[] @relation("SubTasks")
|
subTasks Task[] @relation("SubTasks")
|
||||||
project Project? @relation(fields: [projectId], references: [id])
|
project Project? @relation(fields: [projectId], references: [id])
|
||||||
|
|||||||
@ -233,7 +233,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json();
|
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;
|
let { isRolling } = body;
|
||||||
const { isRecurring } = body;
|
const { isRecurring } = body;
|
||||||
|
|
||||||
@ -345,6 +345,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 }),
|
||||||
...(externalId && { externalId, externalProvider, externalListId }),
|
...(externalId && { externalId, externalProvider, externalListId }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -389,7 +390,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
const { id } = body;
|
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) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -481,6 +482,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 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect, Suspense } from "react";
|
||||||
import { Repeat, Circle, X, ChevronDown } from "lucide-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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons";
|
import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons";
|
||||||
import { faServer, faFolder, IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
import { faServer, faFolder, IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||||
@ -18,6 +20,7 @@ interface GridTaskBlockProps {
|
|||||||
setEditingTaskId: (id: string | null) => void;
|
setEditingTaskId: (id: string | null) => void;
|
||||||
updateTask: (id: string, title: string) => void;
|
updateTask: (id: string, title: string) => void;
|
||||||
updateTaskNotes: (id: string, notes: string) => void;
|
updateTaskNotes: (id: string, notes: string) => void;
|
||||||
|
updateTaskUrl: (id: string, url: string) => void;
|
||||||
updateTaskDuration: (id: string, duration: number) => void;
|
updateTaskDuration: (id: string, duration: number) => void;
|
||||||
toggleTask: (id: string) => void;
|
toggleTask: (id: string) => void;
|
||||||
deleteTask: (id: string) => void;
|
deleteTask: (id: string) => void;
|
||||||
@ -53,6 +56,7 @@ export function GridTaskBlock({
|
|||||||
setEditingTaskId,
|
setEditingTaskId,
|
||||||
updateTask,
|
updateTask,
|
||||||
updateTaskNotes,
|
updateTaskNotes,
|
||||||
|
updateTaskUrl,
|
||||||
updateTaskDuration,
|
updateTaskDuration,
|
||||||
toggleTask,
|
toggleTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
@ -78,6 +82,9 @@ export function GridTaskBlock({
|
|||||||
}: GridTaskBlockProps) {
|
}: GridTaskBlockProps) {
|
||||||
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
||||||
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
|
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 [isSubTasksOpen, setIsSubTasksOpen] = useState(false);
|
||||||
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
|
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
|
||||||
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
|
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
|
||||||
@ -124,12 +131,27 @@ export function GridTaskBlock({
|
|||||||
}
|
}
|
||||||
}, [isNotesOpen]);
|
}, [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 = () => {
|
const handleNotesBlur = () => {
|
||||||
if (notesValue !== task.markdownContent) {
|
if (notesValue !== task.markdownContent) {
|
||||||
updateTaskNotes(task.id, notesValue);
|
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 = "") => {
|
const insertMarkdown = (prefix: string, suffix: string = "") => {
|
||||||
if (!notesRef.current) return;
|
if (!notesRef.current) return;
|
||||||
const start = notesRef.current.selectionStart;
|
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 */}
|
{/* Note indicator */}
|
||||||
{task.markdownContent && task.markdownContent.trim().length > 0 && (
|
{task.markdownContent && task.markdownContent.trim().length > 0 && (
|
||||||
<div
|
<div
|
||||||
@ -563,6 +608,14 @@ export function GridTaskBlock({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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">
|
<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>
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
@ -572,6 +625,76 @@ export function GridTaskBlock({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}>
|
<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 && (
|
{isNotesOpen && (
|
||||||
<div
|
<div
|
||||||
className={`weekly-notes-popup ${topOffset > 180 ? "on-top" : ""}`}
|
className={`weekly-notes-popup ${topOffset > 180 ? "on-top" : ""}`}
|
||||||
@ -584,29 +707,27 @@ export function GridTaskBlock({
|
|||||||
left: "-10px",
|
left: "-10px",
|
||||||
right: "-10px",
|
right: "-10px",
|
||||||
width: "auto",
|
width: "auto",
|
||||||
|
padding: "0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="notes-toolbar">
|
<div style={{ display: "flex", justifyContent: "flex-end", padding: "4px 6px 0", gap: "4px" }}>
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
className="notes-toolbar-btn"
|
onMouseDown={(e) => { e.preventDefault(); setIsNotesOpen(false); }}
|
||||||
onClick={() => setIsNotesOpen(false)}
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: "1rem", lineHeight: 1, color: "#999", padding: "2px 4px" }}
|
||||||
title="Close"
|
title="Close"
|
||||||
style={{ marginLeft: "8px", fontSize: "1rem", lineHeight: 1 }}
|
|
||||||
>×</button>
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<div style={{ padding: "0 6px 8px" }}>
|
||||||
ref={notesRef}
|
<RichTextEditor
|
||||||
className="weekly-notes-editor-inline"
|
value={notesValue}
|
||||||
value={notesValue}
|
onChange={(html) => {
|
||||||
onChange={(e) => setNotesValue(e.target.value)}
|
setNotesValue(html);
|
||||||
onBlur={handleNotesBlur}
|
updateTaskNotes(task.id, html);
|
||||||
placeholder="Add notes..."
|
}}
|
||||||
style={{ minHeight: "120px", padding: "4px" }}
|
placeholder="Add notes..."
|
||||||
/>
|
minHeight="100px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSubTasksOpen && task.subTasks && task.subTasks.length > 0 && (
|
{isSubTasksOpen && task.subTasks && task.subTasks.length > 0 && (
|
||||||
|
|||||||
@ -166,6 +166,7 @@ export interface Task {
|
|||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
project?: { id: string; name: string; icon?: string | null; color?: string | null } | null;
|
project?: { id: string; name: string; icon?: string | null; color?: string | null } | null;
|
||||||
kanbanStage?: string | null;
|
kanbanStage?: string | null;
|
||||||
|
url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalendarEvent {
|
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) => {
|
const toggleTaskRolling = async (taskId: string) => {
|
||||||
saveSnapshot();
|
saveSnapshot();
|
||||||
const task = findTaskAnywhere(taskId);
|
const task = findTaskAnywhere(taskId);
|
||||||
@ -6921,6 +6946,7 @@ export default function WeeklyView() {
|
|||||||
setEditingTaskId={setEditingTaskId}
|
setEditingTaskId={setEditingTaskId}
|
||||||
updateTask={updateTask}
|
updateTask={updateTask}
|
||||||
updateTaskNotes={updateTaskNotes}
|
updateTaskNotes={updateTaskNotes}
|
||||||
|
updateTaskUrl={updateTaskUrl}
|
||||||
updateTaskDuration={updateTaskDuration}
|
updateTaskDuration={updateTaskDuration}
|
||||||
toggleTask={toggleTask}
|
toggleTask={toggleTask}
|
||||||
deleteTask={deleteTask}
|
deleteTask={deleteTask}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user