diff --git a/README.md b/README.md index 04e670c..c6eab48 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A simple, community-maintained open-source task tracker. Add tasks, check them o ## Features +- Optional task descriptions with sanitized basic Markdown rendering - Single-page, zero-build static web app - Tasks persist in `localStorage` — no account, no server, no tracking - Keyboard-friendly (more shortcuts coming, see #5) diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 0000000..2c3fffb --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,15 @@ +module.exports = [ + { + files: ["src/**/*.js"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + crypto: "readonly", + document: "readonly", + URL: "readonly", + window: "readonly", + }, + }, + }, +]; diff --git a/index.html b/index.html index 7309515..7c1081e 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -14,14 +14,22 @@

TaskForge

- +
+ + +
diff --git a/src/app.js b/src/app.js index fe0e9c1..5201ac1 100644 --- a/src/app.js +++ b/src/app.js @@ -1,8 +1,10 @@ import { load, 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 descriptionInput = document.getElementById("task-description"); const list = document.getElementById("task-list"); const emptyState = document.getElementById("empty-state"); @@ -29,9 +31,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"; @@ -44,7 +57,7 @@ function render() { render(); }); - li.append(checkbox, label, del); + li.append(checkbox, content, del); list.appendChild(li); } } @@ -52,10 +65,12 @@ function render() { form.addEventListener("submit", (e) => { e.preventDefault(); const title = input.value.trim(); + const description = descriptionInput.value.trim(); if (!title) return; - tasks = [createTask(title), ...tasks]; + tasks = [createTask(title, description), ...tasks]; save(tasks); input.value = ""; + descriptionInput.value = ""; input.focus(); render(); }); diff --git a/src/markdown.js b/src/markdown.js new file mode 100644 index 0000000..12d69b2 --- /dev/null +++ b/src/markdown.js @@ -0,0 +1,85 @@ +const INLINE_PATTERN = + /(\[([^\]\n]+)\]\(([^)\s]+)\)|`([^`\n]+)`|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*)/g; + +export function renderMarkdown(markdown) { + const fragment = document.createDocumentFragment(); + const blocks = markdown + .trim() + .split(/\n{2,}/) + .map((block) => block.trim()) + .filter(Boolean); + + for (const block of blocks) { + const paragraph = document.createElement("p"); + renderInlineMarkdown(block, paragraph); + fragment.append(paragraph); + } + + return fragment; +} + +function renderInlineMarkdown(text, parent) { + let lastIndex = 0; + + for (const match of text.matchAll(INLINE_PATTERN)) { + appendTextWithLineBreaks(parent, text.slice(lastIndex, match.index)); + parent.append(createInlineNode(match)); + lastIndex = match.index + match[0].length; + } + + appendTextWithLineBreaks(parent, text.slice(lastIndex)); +} + +function createInlineNode(match) { + if (match[2] && match[3]) { + return createLink(match[2], match[3]); + } + if (match[4]) { + return createElementWithText("code", match[4]); + } + if (match[5]) { + return createElementWithText("strong", match[5]); + } + return createElementWithText("em", match[6]); +} + +function createLink(label, href) { + const safeUrl = toSafeUrl(href); + if (!safeUrl) { + return document.createTextNode(label); + } + + const link = createElementWithText("a", label); + link.href = safeUrl; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + return link; +} + +function toSafeUrl(href) { + try { + const url = new URL(href, window.location.href); + return ["http:", "https:", "mailto:"].includes(url.protocol) + ? url.href + : null; + } catch { + return null; + } +} + +function createElementWithText(tagName, text) { + const element = document.createElement(tagName); + element.textContent = text; + return element; +} + +function appendTextWithLineBreaks(parent, text) { + const lines = text.split("\n"); + + lines.forEach((line, index) => { + if (index > 0) { + parent.append(document.createElement("br")); + } + parent.append(document.createTextNode(line)); + }); +} 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 2fa1e18..8cd7e9f 100644 --- a/styles.css +++ b/styles.css @@ -15,8 +15,8 @@ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, - sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.5; @@ -47,12 +47,21 @@ body { } .task-form { - display: flex; + display: grid; + grid-template-columns: 1fr auto; + align-items: start; gap: 0.5rem; margin-bottom: 1.25rem; } -.task-form input { +.task-fields { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.task-form input, +.task-form textarea { flex: 1; padding: 0.5rem 0.75rem; font: inherit; @@ -62,7 +71,12 @@ body { border-radius: var(--radius); } -.task-form input:focus { +.task-form textarea { + resize: vertical; +} + +.task-form input:focus, +.task-form textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); @@ -106,10 +120,38 @@ body { } .task-title { + display: block; flex: 1; word-break: break-word; } +.task-content { + flex: 1; + min-width: 0; +} + +.task-description { + margin-top: 0.25rem; + color: var(--muted); + font-size: 0.95rem; +} + +.task-description p { + margin: 0.25rem 0 0; +} + +.task-description code { + padding: 0.1rem 0.25rem; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--fg); + font-size: 0.9em; +} + +.task-description a { + color: var(--accent); +} + .task-delete { appearance: none; background: transparent;