diff --git a/index.html b/index.html index b6b00fb..6a5655e 100644 --- a/index.html +++ b/index.html @@ -27,14 +27,22 @@

TaskForge

- +
+ + +
diff --git a/server.js b/server.js index 695407f..86de0a0 100644 --- a/server.js +++ b/server.js @@ -261,6 +261,7 @@ function sanitizeTasks(tasks) { .map((task) => ({ id: normalizeString(task.id).slice(0, 128), title: normalizeString(task.title).slice(0, 500), + description: normalizeString(task.description).slice(0, 5000), done: Boolean(task.done), createdAt: Number.isFinite(task.createdAt) ? task.createdAt : Date.now(), })) diff --git a/src/app.js b/src/app.js index 46dd724..edff5ee 100644 --- a/src/app.js +++ b/src/app.js @@ -5,10 +5,12 @@ import { startGitHubSignIn, } from "./auth.js"; import { load, loadLocal, save } from "./storage.js"; +import { renderMarkdown } from "./markdown.js"; import { createTask, toggleTask, removeTask } from "./tasks.js"; const form = document.getElementById("task-form"); const input = document.getElementById("task-input"); +const description = document.getElementById("task-description"); const list = document.getElementById("task-list"); const emptyState = document.getElementById("empty-state"); const authStatus = document.getElementById("auth-status"); @@ -65,9 +67,20 @@ function render() { render(); }); + const content = document.createElement("div"); + content.className = "task-content"; + const label = document.createElement("span"); label.className = "task-title"; label.textContent = task.title; + content.append(label); + + if (task.description) { + const description = document.createElement("div"); + description.className = "task-description"; + description.append(renderMarkdown(task.description)); + content.append(description); + } const del = document.createElement("button"); del.type = "button"; @@ -80,7 +93,7 @@ function render() { render(); }); - li.append(checkbox, label, del); + li.append(checkbox, content, del); list.appendChild(li); } } @@ -89,9 +102,10 @@ form.addEventListener("submit", async (e) => { e.preventDefault(); const title = input.value.trim(); if (!title) return; - tasks = [createTask(title), ...tasks]; + tasks = [createTask(title, description.value), ...tasks]; await save(tasks, user); input.value = ""; + description.value = ""; input.focus(); render(); }); diff --git a/src/markdown.js b/src/markdown.js new file mode 100644 index 0000000..a7a8743 --- /dev/null +++ b/src/markdown.js @@ -0,0 +1,58 @@ +const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + +export function renderMarkdown(markdown) { + const root = document.createDocumentFragment(); + const blocks = String(markdown || "").replace(/\r\n?/g, "\n").split(/\n{2,}/); + + for (const block of blocks) { + const text = block.trim(); + if (!text) continue; + const p = document.createElement("p"); + appendInlineMarkdown(p, text.replace(/\n/g, " ")); + root.append(p); + } + + return root; +} + +function appendInlineMarkdown(parent, source) { + const pattern = /(`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*)/g; + let index = 0; + for (const match of source.matchAll(pattern)) { + appendText(parent, source.slice(index, match.index)); + if (match[2]) appendElement(parent, "code", match[2]); + else if (match[3] && match[4]) appendLink(parent, match[3], match[4]); + else if (match[5]) appendElement(parent, "strong", match[5]); + else if (match[6]) appendElement(parent, "em", match[6]); + index = match.index + match[0].length; + } + appendText(parent, source.slice(index)); +} + +function appendText(parent, text) { + if (text) parent.append(document.createTextNode(text)); +} + +function appendElement(parent, tagName, text) { + const element = document.createElement(tagName); + element.textContent = text; + parent.append(element); +} + +function appendLink(parent, text, href) { + try { + const url = new URL(href, window.location.href); + if (!ALLOWED_PROTOCOLS.has(url.protocol)) { + appendText(parent, text); + return; + } + const a = document.createElement("a"); + a.href = url.href; + a.textContent = text; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + parent.append(a); + } catch { + appendText(parent, text); + } +} diff --git a/src/tasks.js b/src/tasks.js index a54af4a..83a521d 100644 --- a/src/tasks.js +++ b/src/tasks.js @@ -1,7 +1,8 @@ -export function createTask(title) { +export function createTask(title, description = "") { return { id: cryptoRandomId(), title: title.trim(), + description: description.trim(), done: false, createdAt: Date.now(), }; diff --git a/styles.css b/styles.css index a6c7afc..0bb01bb 100644 --- a/styles.css +++ b/styles.css @@ -209,3 +209,48 @@ body { max-width: none; } } + +.task-fields { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.5rem; +} + +.task-form textarea { + min-height: 5rem; + resize: vertical; + padding: 0.5rem 0.75rem; + font: inherit; + color: inherit; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.task-form textarea:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; + border-color: var(--accent); +} + +.task-content { + flex: 1; + min-width: 0; +} + +.task-description { + margin-top: 0.25rem; + color: var(--muted); + font-size: 0.92rem; +} + +.task-description p { + margin: 0.25rem 0 0; +} + +.task-description code { + padding: 0.1rem 0.25rem; + background: rgba(175, 184, 193, 0.2); + border-radius: 4px; +} diff --git a/test/server.test.js b/test/server.test.js index c3b9f65..70dfbfd 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -10,12 +10,18 @@ const { SESSION_COOKIE, createServer, sanitizeTasks } = require("../server.js"); test("sanitizeTasks drops malformed rows and trims persisted fields", () => { assert.deepEqual( sanitizeTasks([ - { id: " a ", title: " Write tests ", done: 1, createdAt: 10 }, + { id: " a ", title: " Write tests ", description: " **safe** details ", done: 1, createdAt: 10 }, { id: "", title: "missing id" }, { id: "missing-title", title: "" }, null, ]), - [{ id: "a", title: "Write tests", done: true, createdAt: 10 }], + [{ + id: "a", + title: "Write tests", + description: "**safe** details", + done: true, + createdAt: 10, + }], ); }); @@ -104,7 +110,14 @@ test("authenticated task API persists tasks by GitHub user id", async () => { method: "PUT", cookie, body: { - tasks: [{ id: "task-1", title: "Ship OAuth", done: false }], + tasks: [ + { + id: "task-1", + title: "Ship OAuth", + description: "Review **token** handling", + done: false, + }, + ], }, }); assert.equal(saved.status, 200); @@ -114,6 +127,7 @@ test("authenticated task API persists tasks by GitHub user id", async () => { { id: "task-1", title: "Ship OAuth", + description: "Review **token** handling", done: false, createdAt: loaded.body.tasks[0].createdAt, },