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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions eslint.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module.exports = [
{
files: ["src/**/*.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: {
crypto: "readonly",
document: "readonly",
URL: "readonly",
window: "readonly",
},
},
},
];
26 changes: 17 additions & 9 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
Expand All @@ -14,14 +14,22 @@ <h1>TaskForge</h1>

<main class="app-main">
<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"
rows="3"
placeholder="Add details with Markdown (optional)"
></textarea>
</div>
<button type="submit">Add</button>
</form>

Expand Down
19 changes: 17 additions & 2 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -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");

Expand All @@ -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";
Expand All @@ -44,18 +57,20 @@ function render() {
render();
});

li.append(checkbox, label, del);
li.append(checkbox, content, del);
list.appendChild(li);
}
}

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();
});
Expand Down
85 changes: 85 additions & 0 deletions src/markdown.js
Original file line number Diff line number Diff line change
@@ -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));
});
}
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
52 changes: 47 additions & 5 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down