Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,22 @@ <h1>TaskForge</h1>
<p id="auth-message" class="auth-message" hidden></p>

<form id="task-form" class="task-form" aria-label="Add a new task">
<input
id="task-input"
type="text"
name="title"
placeholder="What needs doing?"
autocomplete="off"
required
/>
<div class="task-fields">
<input
id="task-input"
type="text"
name="title"
placeholder="What needs doing?"
autocomplete="off"
required
/>
<textarea
id="task-description"
name="description"
placeholder="Optional markdown description"
rows="3"
></textarea>
</div>
<button type="submit">Add</button>
</form>

Expand Down
1 change: 1 addition & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}))
Expand Down
18 changes: 16 additions & 2 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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";
Expand All @@ -80,7 +93,7 @@ function render() {
render();
});

li.append(checkbox, label, del);
li.append(checkbox, content, del);
list.appendChild(li);
}
}
Expand All @@ -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();
});
Expand Down
58 changes: 58 additions & 0 deletions src/markdown.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
3 changes: 2 additions & 1 deletion src/tasks.js
Original file line number Diff line number Diff line change
@@ -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(),
};
Expand Down
45 changes: 45 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
20 changes: 17 additions & 3 deletions test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}],
);
});

Expand Down Expand Up @@ -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);
Expand All @@ -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,
},
Expand Down