# Build a Zero-Cost Personal Dashboard
## HackMD as a Database + Netlify Serverless Functions
> **Stack:** Vanilla HTML/CSS/JS · Netlify Functions · HackMD API · HMAC Auth
> **Cost:** $0 · **Build step:** None · **Framework:** None
> **Frontend size:** ~3100 lines in a single `index.html`
---
## Table of Contents
1. [The Idea](#1-the-idea)
2. [Real Project Structure](#2-real-project-structure)
3. [Architecture Overview](#3-architecture-overview)
4. [Prerequisites](#4-prerequisites)
5. [Step 1 — Set Up HackMD as Your Database](#5-step-1--set-up-hackmd-as-your-database)
6. [Step 2 — Create the Netlify Project](#6-step-2--create-the-netlify-project)
7. [Step 3 — Build the Auth Function](#7-step-3--build-the-auth-function)
8. [Step 4 — Build the Tasks Function](#8-step-4--build-the-tasks-function)
9. [Step 5 — Build the Memory Function](#9-step-5--build-the-memory-function)
10. [Step 6 — Configure netlify.toml](#10-step-6--configure-netlifytoml)
11. [Step 7 — The Frontend: index.html Internals](#11-step-7--the-frontend-indexhtml-internals)
- [7a. Design System (CSS Variables)](#7a-design-system-css-variables)
- [7b. App Shell & HTML Structure](#7b-app-shell--html-structure)
- [7c. Auth Flow (Password Gate)](#7c-auth-flow-password-gate)
- [7d. Main Tab Switching](#7d-main-tab-switching)
- [7e. Advanced Task Markdown Format](#7e-advanced-task-markdown-format)
- [7f. Markdown Parser (parseTaskMarkdown)](#7f-markdown-parser-parsetaskmarkdown)
- [7g. Markdown Serializer (toMarkdown)](#7g-markdown-serializer-tomarkdown)
- [7h. Kanban Board View](#7h-kanban-board-view)
- [7i. List View](#7i-list-view)
- [7j. Drag-and-Drop](#7j-drag-and-drop)
- [7k. Inline Editing](#7k-inline-editing)
- [7l. Save Flow](#7l-save-flow)
- [7m. Memory Viewer](#7m-memory-viewer)
12. [Step 8 — Set Environment Variables](#12-step-8--set-environment-variables)
13. [Step 9 — Deploy to Netlify](#13-step-9--deploy-to-netlify)
14. [How the Auth Token Works](#14-how-the-auth-token-works)
15. [Why This Approach?](#15-why-this-approach)
---
## 1. The Idea
Most personal projects hit the same wall: you need *somewhere* to store data, but you don't want to pay for a database or manage a server.
The insight here is simple:
> **HackMD notes are just Markdown files with a REST API on top.**
Store your tasks as Markdown checkboxes. Read and write them via HackMD's API. Use Netlify Functions as a thin, secure proxy. Serve everything as a static HTML file — no build step, no framework, no bundler.
**Result:** A fully functional, password-protected Kanban board + notes viewer — for free.
---
## 2. Real Project Structure
This is the **actual** file tree — 6 files total:
```
my-dashboard/
│
├── index.html ← Entire frontend (~3100 lines)
│ HTML + CSS + Vanilla JS, all in one file
│ No framework, no bundler, no node_modules
│
├── netlify.toml ← Netlify config:
│ - No build command
│ - /api/* → /.netlify/functions/* redirect
│ - esbuild as the functions bundler
│
├── package.json ← Minimal (name + version only, no deps)
│
└── netlify/
└── functions/
├── auth.js ← POST /api/auth (login → JWT-like token)
├── tasks.js ← GET /api/tasks (load Markdown from HackMD)
│ PATCH /api/tasks (save Markdown to HackMD)
└── memory.js ← GET /api/memory (load CLAUDE.md from HackMD)
```
**Key insight:** The entire backend is 3 Node.js files. Each function is completely standalone — no shared imports, no `node_modules`.
---
## 3. Architecture Overview
```
┌──────────────────────────────────────────────────────┐
│ Browser │
│ │
│ index.html (single file: HTML + CSS + JS) │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ Tasks Panel │ │ Memory Panel │ │
│ │ ┌────┐ ┌────┐ │ │ Parsed sections/tables │ │
│ │ │ 📋 │ │ 📋 │ │ │ from CLAUDE.md │ │
│ │ └────┘ └────┘ │ └──────────────────────────┘ │
│ │ Kanban / List │ │
│ └──────────────────┘ │
└───────────────┬──────────────────────────────────────┘
│ fetch('/.netlify/functions/...')
│ Authorization: Bearer <hmac-token>
▼
┌──────────────────────────────────────────────────────┐
│ Netlify Edge / CDN │
│ /api/* → /.netlify/functions/:splat │
└───────────┬──────────────────────────────────────────┘
│
┌──────┼──────┐
▼ ▼ ▼
auth.js tasks.js memory.js
(verify (proxy (proxy
password) reads & reads)
writes)
│ │ │
└──────┼──────┘
│ HackMD REST API
▼ Authorization: Bearer <hackmd-api-token>
┌──────────────────────────────────────────────────────┐
│ HackMD │
│ │
│ Note A → Tasks (Markdown Kanban) │
│ ┌─────────────────────────────────┐ │
│ │ # Tasks │ │
│ │ ## Backlog │ │
│ │ - [ ] **Task title** - note │ │
│ │ - [ ] subtask 1 │ │
│ │ ## In Progress │ │
│ │ - [x] **Done task** │ │
│ └─────────────────────────────────┘ │
│ │
│ Note B → Memory / CLAUDE.md │
│ ┌─────────────────────────────────┐ │
│ │ # Memory │ │
│ │ ## Me │ │
│ │ ## People │ │
│ │ | Who | Role | │ │
│ └─────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
```
**Complete request lifecycle for toggling a task:**
1. User clicks checkbox in browser
2. JS flips `task.checked`, calls `toMarkdown()` → serializes all tasks back to Markdown string
3. User clicks "Save to HackMD" button
4. `PATCH /.netlify/functions/tasks` with `{ content: markdownString }`
5. Function verifies HMAC token, calls `PATCH https://api.hackmd.io/v1/notes/<NOTE_ID>`
6. HackMD stores the updated Markdown
7. Browser shows "Saved to HackMD ✓"
---
## 4. Prerequisites
- [Netlify account](https://netlify.com) (free tier)
- [HackMD account](https://hackmd.io) (free tier)
- [Netlify CLI](https://docs.netlify.com/cli/get-started/) installed globally
- Node.js 18+
```bash
npm install -g netlify-cli
netlify --version # verify install
```
---
## 5. Step 1 — Set Up HackMD as Your Database
### 5.1 Create two notes
Go to [hackmd.io](https://hackmd.io) and create two new notes.
**Note 1: Tasks** — This is your Kanban data store. Paste this starter:
```markdown
# Tasks
## Backlog
- [ ] **Add dark mode** - nice to have
- [ ] **Write documentation**
- [ ] Architecture section
- [ ] Deploy guide
## In Progress
- [ ] **Build the dashboard**
## Done
- [x] **Set up Netlify project**
- [x] **Create HackMD notes**
```
> Notice the format: `- [ ] **Bold title** - optional note`, with indented ` - [ ] subtask` lines.
> This is the exact format the parser expects.
**Note 2: Memory / CLAUDE.md** — Your personal notes. Can be any Markdown. The viewer will parse `## Sections`, key-value pairs (`**Key:** value`), and `| tables |` automatically.
### 5.2 Get Note IDs from the URL
```
https://hackmd.io/XXXXXXXXXXXXXXXXXXXX
└──────────────────────────┘
Note ID (copy this)
```
### 5.3 Generate a HackMD API Token
1. HackMD → **Settings → API**
2. **Generate new token** → name it `dashboard-token`
3. Copy immediately (shown only once)
> ⚠️ Store it in a password manager. Never put it in code or Git.
---
## 6. Step 2 — Create the Netlify Project
```bash
mkdir my-dashboard && cd my-dashboard
git init
mkdir -p netlify/functions
touch index.html netlify.toml package.json
touch netlify/functions/auth.js
touch netlify/functions/tasks.js
touch netlify/functions/memory.js
```
**package.json:**
```json
{
"name": "my-dashboard",
"version": "1.0.0"
}
```
Link to Netlify:
```bash
netlify login
netlify init
# → Create & configure a new site
# → Build command: (leave blank)
# → Publish directory: .
```
---
## 7. Step 3 — Build the Auth Function
`netlify/functions/auth.js`
```javascript
const crypto = require('crypto');
const SECRET = process.env.TOKEN_SECRET; // HMAC signing key
const PASSWORD = process.env.DASHBOARD_PASSWORD; // Login password
// Build a self-contained token: "<expiry_ms>.<hmac_hex>"
function makeToken() {
const expires = Date.now() + 1000 * 60 * 60 * 24 * 7; // 7 days
const payload = String(expires);
const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
return `${payload}.${sig}`;
}
// Verify: recompute HMAC, compare, check expiry
function verifyToken(token) {
if (!token) return false;
const [payload, sig] = token.split('.');
if (!payload || !sig) return false;
const expected = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
if (sig !== expected) return false; // tampered
if (Date.now() > parseInt(payload)) return false; // expired
return true;
}
exports.verifyToken = verifyToken;
exports.handler = async (event) => {
const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type',
'Content-Type': 'application/json',
};
if (event.httpMethod === 'OPTIONS') return { statusCode: 204, headers, body: '' };
if (event.httpMethod !== 'POST') {
return { statusCode: 405, headers, body: JSON.stringify({ error: 'Method not allowed' }) };
}
const { password } = JSON.parse(event.body || '{}');
if (password !== PASSWORD) {
return { statusCode: 401, headers, body: JSON.stringify({ error: 'Wrong password' }) };
}
return { statusCode: 200, headers, body: JSON.stringify({ token: makeToken() }) };
};
```
---
## 8. Step 4 — Build the Tasks Function
`netlify/functions/tasks.js`
```javascript
const crypto = require('crypto');
const HACKMD_TOKEN = process.env.HACKMD_API_TOKEN;
const TASKS_NOTE_ID = process.env.TASKS_NOTE_ID;
const SECRET = process.env.TOKEN_SECRET;
function verifyToken(token) {
if (!token) return false;
const [payload, sig] = token.split('.');
if (!payload || !sig) return false;
const expected = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
if (sig !== expected) return false;
if (Date.now() > parseInt(payload)) return false;
return true;
}
exports.handler = async (event) => {
const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Content-Type': 'application/json',
};
if (event.httpMethod === 'OPTIONS') return { statusCode: 204, headers, body: '' };
// Every request must carry a valid token
const token = (event.headers['authorization'] || '').replace('Bearer ', '');
if (!verifyToken(token)) {
return { statusCode: 401, headers, body: JSON.stringify({ error: 'Unauthorized' }) };
}
// GET — load raw Markdown from HackMD
if (event.httpMethod === 'GET') {
const res = await fetch(`https://api.hackmd.io/v1/notes/${TASKS_NOTE_ID}`, {
headers: { Authorization: `Bearer ${HACKMD_TOKEN}` },
});
if (!res.ok) return { statusCode: res.status, headers, body: JSON.stringify({ error: 'HackMD fetch failed' }) };
const data = await res.json();
// Only expose 'content', not HackMD internal metadata
return { statusCode: 200, headers, body: JSON.stringify({ content: data.content }) };
}
// PATCH — write updated Markdown back to HackMD
if (event.httpMethod === 'PATCH') {
const { content } = JSON.parse(event.body || '{}');
if (!content) return { statusCode: 400, headers, body: JSON.stringify({ error: 'Missing content' }) };
const res = await fetch(`https://api.hackmd.io/v1/notes/${TASKS_NOTE_ID}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${HACKMD_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
return { statusCode: res.status, headers, body: JSON.stringify({ ok: res.status < 300 }) };
}
return { statusCode: 405, headers, body: JSON.stringify({ error: 'Method not allowed' }) };
};
```
---
## 9. Step 5 — Build the Memory Function
`netlify/functions/memory.js`
```javascript
const crypto = require('crypto');
const HACKMD_TOKEN = process.env.HACKMD_API_TOKEN;
const MEMORY_NOTE_ID = process.env.MEMORY_NOTE_ID;
const SECRET = process.env.TOKEN_SECRET;
function verifyToken(token) {
if (!token) return false;
const [payload, sig] = token.split('.');
if (!payload || !sig) return false;
const expected = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
if (sig !== expected) return false;
if (Date.now() > parseInt(payload)) return false;
return true;
}
exports.handler = async (event) => {
const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Content-Type': 'application/json',
};
if (event.httpMethod === 'OPTIONS') return { statusCode: 204, headers, body: '' };
const token = (event.headers['authorization'] || '').replace('Bearer ', '');
if (!verifyToken(token)) {
return { statusCode: 401, headers, body: JSON.stringify({ error: 'Unauthorized' }) };
}
if (event.httpMethod !== 'GET') {
return { statusCode: 405, headers, body: JSON.stringify({ error: 'Method not allowed' }) };
}
const res = await fetch(`https://api.hackmd.io/v1/notes/${MEMORY_NOTE_ID}`, {
headers: { Authorization: `Bearer ${HACKMD_TOKEN}` },
});
if (!res.ok) return { statusCode: res.status, headers, body: JSON.stringify({ error: 'HackMD fetch failed' }) };
const data = await res.json();
return { statusCode: 200, headers, body: JSON.stringify({ content: data.content }) };
};
```
---
## 10. Step 6 — Configure netlify.toml
```toml
[build]
command = "# no build needed"
publish = "."
functions = "netlify/functions" # explicit functions directory
# Clean URL routing: /api/auth → /.netlify/functions/auth
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/:splat"
status = 200
[functions]
node_bundler = "esbuild" # faster cold starts, smaller bundles
```
**Why esbuild?** Netlify uses it to tree-shake and bundle each function into a single file before deploying. Even though there are no `npm` dependencies here, it's a good default for when you add some later.
**The redirect rule means:**
| Browser calls | Netlify routes to |
|---|---|
| `POST /api/auth` | `/.netlify/functions/auth` |
| `GET /api/tasks` | `/.netlify/functions/tasks` |
| `PATCH /api/tasks` | `/.netlify/functions/tasks` |
| `GET /api/memory` | `/.netlify/functions/memory` |
---
## 11. Step 7 — The Frontend: index.html Internals
Everything lives in a single `index.html`. Here's a deep-dive into each layer.
---
### 7a. Design System (CSS Variables)
The entire UI is driven by CSS custom properties defined in `:root`. No Tailwind, no CSS framework:
```css
:root {
/* Warm off-white palette */
--bg-primary: #faf9f5; /* page background */
--bg-secondary: #F5F4EF; /* column / section backgrounds */
--bg-card: #ffffff; /* card surfaces */
/* Text hierarchy */
--text-primary: #141413;
--text-secondary: #5c5c5a;
--text-muted: #8c8c8a;
/* Accent (terracotta orange) */
--accent: #D97757;
--accent-hover: #c4684a;
/* Borders */
--border: #e5e4df;
--border-light: #eeeee9;
/* Shadows */
--shadow: rgba(20, 20, 19, 0.04);
--shadow-hover: rgba(20, 20, 19, 0.08);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
/* Inter loaded via Google Fonts */
}
```
Using variables means you can swap the entire theme in one place.
---
### 7b. App Shell & HTML Structure
```
index.html
│
├── <head>
│ ├── Inter font (Google Fonts)
│ ├── SVG favicon (inline data URI)
│ └── <style> — ALL CSS (~1200 lines)
│
└── <body>
├── #passwordGate ← Full-screen login overlay (fixed, z-index 999)
│ └── .password-box ← Centered card with logo, input, button
│
├── <header> ← Always visible after login
│ ├── Logo SVG + title "Productivity"
│ ├── #mainTabToggle ← [Tasks] [Memory] pill toggle
│ ├── #taskViewToggle ← [Board] [List] pill toggle (tasks only)
│ ├── #syncStatus ← "✓ Synced with HackMD" indicator
│ └── #saveBtn ← "Save to HackMD" (disabled until changes)
│
├── #tasksPanel (.tab-panel.active)
│ ├── #listView ← List view (hidden by default)
│ └── #board ← Kanban board (flex row of columns)
│
├── #memoryPanel (.tab-panel)
│ ├── #memoryEmptyState ← "Loading memory…"
│ └── #memoryMainContent
│ ├── .memory-tabs ← One tab per ## section in CLAUDE.md
│ └── .memory-content-area
│ └── #memoryContentContainer
│
└── .modal-overlay ← Shared modal (used by memory viewer)
└── .modal
├── .modal-header
├── .modal-body
└── .modal-footer [Cancel] [Save]
```
---
### 7c. Auth Flow (Password Gate)
```javascript
// Token persists in sessionStorage — clears when tab/browser closes
let authToken = sessionStorage.getItem('dash_token') || '';
async function unlock() {
const pw = passwordInput.value;
const res = await fetch('/.netlify/functions/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
const data = await res.json();
if (res.ok && data.token) {
authToken = data.token;
sessionStorage.setItem('dash_token', authToken); // persist within tab
passwordGate.classList.add('hidden'); // hide overlay
initDashboard(); // load data
} else {
passwordError.classList.add('visible'); // show error
}
}
// If token already in sessionStorage, skip gate immediately
if (authToken) passwordGate.classList.add('hidden');
```
**Session storage vs local storage:**
- `sessionStorage` → token gone when tab closes (more secure for personal tools)
- `localStorage` → would persist across browser restarts
---
### 7d. Main Tab Switching
Two panels: **Tasks** and **Memory**. Switching shows/hides `.tab-panel` elements:
```javascript
function switchMainTab(tab) {
// Toggle active class on buttons
tasksTabBtn.classList.toggle('active', tab === 'tasks');
memoryTabBtn.classList.toggle('active', tab === 'memory');
// Toggle active class on panels (CSS: .tab-panel { display:none } .tab-panel.active { display:flex })
tasksPanel.classList.toggle('active', tab === 'tasks');
memoryPanel.classList.toggle('active', tab === 'memory');
// Hide task-specific controls when in memory tab
taskViewToggle.style.display = tab === 'tasks' ? 'flex' : 'none';
saveBtn.style.display = tab === 'tasks' ? 'inline-flex' : 'none';
}
```
---
### 7e. Advanced Task Markdown Format
The task format is richer than basic `- [ ] text`. Here's the full spec:
```markdown
# Tasks
## Section Name
- [ ] **Task title** - optional inline note
- [ ] subtask line 1
- [x] subtask line 2 (done)
- [x] **Completed task**
## Another Section
- [ ] **Task with no note**
```
**Rules the parser follows:**
| Pattern | Meaning |
|---|---|
| `# Tasks` | Document title (ignored by parser) |
| `## Section Name` | New Kanban column |
| `## **Section Name**` | Also valid (bold headers) |
| `- [ ] **Title** - note` | Unchecked task with title and note |
| `- [x] **Title**` | Checked (done) task |
| `- [X] **Title**` | Also valid (uppercase X) |
| ` - [ ] subtask` | Subtask (2-space indent) |
---
### 7f. Markdown Parser (parseTaskMarkdown)
The parser converts the raw Markdown string into a structured JS object:
```javascript
function parseTaskMarkdown(content) {
const resultSections = []; // ordered array: [{ id, name }, ...]
const resultTasks = {}; // map: { sectionId: [task, ...] }
let currentSectionId = null;
let currentTask = null;
for (const line of content.split('\n')) {
// Match "## Section" or "## **Section**"
const headerMatch = line.match(/^## \*{0,2}(.+?)\*{0,2}$/);
if (headerMatch) {
// Flush previous task if pending
if (currentTask) resultTasks[currentSectionId].push(currentTask);
currentTask = null;
const name = headerMatch[1].trim();
currentSectionId = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
// e.g. "In Progress" → "in-progress"
resultSections.push({ id: currentSectionId, name });
resultTasks[currentSectionId] = [];
continue;
}
// Match "- [ ] ..." or "- [x] ..." or "- [X] ..."
if (currentSectionId && line.match(/^- \[[ xX]\]/)) {
if (currentTask) resultTasks[currentSectionId].push(currentTask);
const checked = /\[[xX]\]/.test(line);
let text = line.replace(/^- \[[ xX]\]\s*/, '');
// Extract bold title and optional note: "**Title** - note"
let title = text, note = '';
const boldMatch = text.match(/^\*\*(.+?)\*\*(.*)$/);
if (boldMatch) {
title = boldMatch[1];
note = boldMatch[2].replace(/^\s*-\s*/, '').trim();
}
currentTask = { id: Date.now() + Math.random(), title, note, checked, subtasks: [], section: currentSectionId };
continue;
}
// Match " - [ ] subtask" (2-space indent)
if (currentTask && line.match(/^\s+- \[[ xX]\]/)) {
currentTask.subtasks.push({
text: line.replace(/^\s+- \[[ xX]\]\s*/, ''),
checked: /\[[xX]\]/.test(line)
});
}
}
// Flush final pending task
if (currentTask) resultTasks[currentSectionId].push(currentTask);
return { sections: resultSections, tasks: resultTasks };
}
```
**Output shape:**
```javascript
{
sections: [
{ id: 'backlog', name: 'Backlog' },
{ id: 'in-progress', name: 'In Progress' },
{ id: 'done', name: 'Done' }
],
tasks: {
'backlog': [ { id: 123, title: 'Add dark mode', note: 'nice to have', checked: false, subtasks: [] } ],
'in-progress': [ { id: 456, title: 'Build dashboard', note: '', checked: false, subtasks: [
{ text: 'Auth function', checked: true },
{ text: 'Tasks function', checked: false }
]} ],
'done': [ { id: 789, title: 'Set up Netlify', note: '', checked: true, subtasks: [] } ]
}
}
```
---
### 7g. Markdown Serializer (toMarkdown)
Converts the in-memory state back to Markdown for saving:
```javascript
function toMarkdown() {
let md = '# Tasks\n';
sections.forEach(section => {
md += `\n## ${section.name}\n`;
(tasks[section.id] || []).forEach(t => {
const checkbox = t.checked ? '[x]' : '[ ]';
const note = t.note ? ` - ${t.note}` : '';
md += `- ${checkbox} **${t.title}**${note}\n`;
t.subtasks.forEach(st => {
md += ` - ${st.checked ? '[x]' : '[ ]'} ${st.text}\n`;
});
});
});
return md.trimEnd() + '\n';
}
```
**Round-trip guarantee:** `toMarkdown(parseTaskMarkdown(md))` produces equivalent output — tasks are preserved through any number of edit cycles.
---
### 7h. Kanban Board View
Each column is a `div.column` with:
- `.column-header` — section name + task count badge
- `.cards` — scrollable task card list, drop target
- `.add-card` — "+ Add task" button at the bottom
Each card (`.task-card`) contains:
- Checkbox (click to toggle `task.checked`)
- Title (click to inline-edit)
- Note line (click to inline-edit, shows `+ Add note` on hover if empty)
- Subtasks section (each subtask has its own checkbox + inline edit)
- Delete button `×` (appears on hover, top-right corner)
The board has a **Quick-Add bar** at the bottom:
```
[ type a task name... ] [ In Progress ▾ ] [+ Add]
```
Clicking the section pill opens a dropdown to pick which column to add to.
---
### 7i. List View
A flat list of all tasks grouped by section — same data, different layout:
```
BACKLOG
○ Add dark mode nice to have
+ Add note | + Add subtask
IN PROGRESS
○ Build dashboard
✓ Auth function
○ Tasks function
+ Add subtask
```
Switching between Board ↔ List is instant — same `sections`/`tasks` state, just different render function.
---
### 7j. Drag-and-Drop
Pure HTML5 drag-and-drop API (no library):
**Cards between columns:**
```javascript
// On dragstart: store task ID in dataTransfer
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', task.id);
});
// On drop into a column: move task to new section
cardsContainer.addEventListener('drop', (e) => {
const taskId = e.dataTransfer.getData('text/plain');
// find task in old section, push to new section, re-render
});
```
**Column reordering:**
```javascript
// Column headers are draggable
header.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/column', id); // different MIME type!
});
// Drop indicator: a 3px orange line inserted before/after target column
const indicator = document.createElement('div');
indicator.className = 'column-drop-indicator';
// insert before or after based on mouse X position
```
Using different MIME types (`text/plain` vs `text/column`) lets the board distinguish card drags from column drags.
---
### 7k. Inline Editing
All editing is inline — no modals for tasks. The pattern is the same everywhere:
```javascript
function startEditingTitle(titleEl, task) {
// 1. Create an <input> with matching styles
const input = document.createElement('input');
input.value = task.title;
input.style.cssText = 'border: 2px solid var(--accent); ...';
// 2. Replace the display element with the input
titleEl.replaceWith(input);
input.focus();
input.select();
// 3. Guard against double-save
let saved = false;
const saveEdit = () => {
if (saved) return;
saved = true;
task.title = input.value.trim() || task.title;
markChanged(); // enables Save button
renderTasks(); // replace input back with styled display element
};
// 4. Save on Enter or blur, cancel on Escape
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); saveEdit(); }
if (e.key === 'Escape') { saved = true; renderTasks(); }
});
input.addEventListener('blur', saveEdit);
}
```
This same pattern is applied to: task titles, task notes, subtask text, and column names.
---
### 7l. Save Flow
Changes are **not auto-saved**. This is intentional — the user controls when to write to HackMD:
```javascript
let hasChanges = false;
function markChanged() {
hasChanges = true;
saveBtn.disabled = false; // enable the button
saveBtn.textContent = 'Save to HackMD';
}
// Warn before leaving with unsaved changes
window.addEventListener('beforeunload', (e) => {
if (hasChanges) { e.preventDefault(); e.returnValue = ''; }
});
// Save button handler
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
setSyncStatus('⟳ Saving…');
const res = await fetch('/.netlify/functions/tasks', {
method: 'PATCH',
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ content: toMarkdown() })
});
if (res.status === 401) {
// Token expired — force re-login
sessionStorage.removeItem('dash_token');
location.reload();
return;
}
hasChanges = false;
showStatus('Saved to HackMD ✓');
setSyncStatus('✓ Saved', 'var(--accent)');
});
```
**Sync status indicator** (top-right of header):
- `⟳ Loading…` — initial fetch
- `✓ Synced with HackMD` — after load (fades after 3s)
- `⟳ Saving…` — during save
- `✓ Saved` — after successful save (fades after 3s)
- `⚠ Save failed` — on error (stays visible)
---
### 7m. Memory Viewer
The memory panel fetches the CLAUDE.md note and parses it into a navigable UI:
**Parser — `parseMemoryMarkdown(content)`:**
```javascript
function parseMemoryMarkdown(content) {
const parsed = {
title: '',
fields: {}, // **Key:** value pairs
sections: {}, // ## Section → content string
tables: [], // Markdown tables → { headers, rows }
rawContent: content
};
// Line-by-line:
// "# Title" → parsed.title
// "## Section" → creates/enters a section
// "**Key:** value" → parsed.fields object
// Else → appended to current section's content
// Table regex extracts header row + data rows
const tableRegex = /\|(.+)\|\n\|[-| ]+\|\n((?:\|.+\|\n?)+)/g;
// → parsed.tables: [{ headers: ['Who', 'Role'], rows: [['Alice', 'PM']] }]
}
```
**Rendered UI:**
- Tabs: one per `## Section` found in CLAUDE.md
- Each section can render as:
- A **table** (if the section contains `| col | col |` Markdown)
- A **card grid** (for memory entries with frontmatter-style fields)
- A **file card** (collapsible raw content blocks)
- A **flat table** for key-value pairs
- A search box filters across all sections
- Stats bar shows counts per section type
---
## 12. Step 8 — Set Environment Variables
In **Netlify Dashboard → Site settings → Environment variables**:
| Variable | Mock example | Description |
|---|---|---|
| `DASHBOARD_PASSWORD` | `STRONGPASSWORDHERE!` | Login password |
| `TOKEN_SECRET` | `a1b2c3d4...` (64 hex chars) | HMAC signing key |
| `HACKMD_API_TOKEN` | `hmd_api_xxxxxxxx` | HackMD API bearer token |
| `TASKS_NOTE_ID` | `aBcDeFgHiJkLmNoPqRsTuV` | HackMD Note ID for tasks |
| `MEMORY_NOTE_ID` | `xYzAbCdEfGhIjKlMnOpQrS` | HackMD Note ID for CLAUDE.md |
Generate a strong TOKEN_SECRET:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# outputs something like: a3f9c2d1e4b567890abcdef01234567890abcdef01234567890abcdef012345678
```
Or via CLI:
```bash
netlify env:set DASHBOARD_PASSWORD "your-password-here"
netlify env:set TOKEN_SECRET "$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")"
netlify env:set HACKMD_API_TOKEN "your-hackmd-token"
netlify env:set TASKS_NOTE_ID "your-tasks-note-id"
netlify env:set MEMORY_NOTE_ID "your-memory-note-id"
```
---
## 13. Step 9 — Deploy to Netlify
```bash
# Preview deploy (gets a draft URL, not production)
netlify deploy --dir=.
# Production deploy
netlify deploy --prod --dir=.
```
> The `--dir=.` flag tells Netlify to deploy the current directory as-is. Since `netlify.toml` has `publish = "."`, you could also just run `netlify deploy --prod`.
Your site is live at `https://your-site-name.netlify.app` 🎉
---
## 14. How the Auth Token Works
```
Token format:
"<unix_expiry_ms>.<hmac_sha256_hex>"
Example (mocked):
"1743000000000.a3f9c2d1e4b56789abcdef0123456789abcdef0123456789abcdef01234567"
└───────────┘ └──────────────────────────────────────────────────────────────┘
Expiry: Unix HMAC-SHA256("1743000000000", TOKEN_SECRET)
timestamp ms
```
**Login:**
```
Browser auth.js HackMD
│ │ │
│── POST /.netlify/functions/auth
│ { password: "***" } │
│ │ compare password === env var
│ │ makeToken() → expiry + HMAC
│◀── 200 { token: "123.abc" } ──│
│ │
│ sessionStorage.setItem( │
│ 'dash_token', token) │
```
**Subsequent API calls:**
```
Browser tasks.js
│ │
│── GET /.netlify/functions/tasks
│ Authorization: Bearer 123.abc
│ │ split(".") → ["123", "abc"]
│ │ recompute HMAC("123", SECRET)
│ │ compare → match?
│ │ Date.now() > 123? → expired?
│ │ ✓ valid → fetch HackMD
│◀── 200 { content: "# Tasks..." }
```
**Security properties:**
- Stateless — server holds no session state
- Unforgeable — requires knowing `TOKEN_SECRET`
- Self-expiring — timestamp baked into the token
- Tab-scoped — `sessionStorage` clears on tab close
- 401 auto-logout — any expired/invalid token triggers `location.reload()`
---
## 15. Why This Approach?
| Concern | Traditional approach | This approach |
|---|---|---|
| Data store | PostgreSQL / MongoDB | HackMD Note (plain Markdown) |
| Backend | Express on a $5–20/mo VPS | Netlify Functions (free, serverless) |
| Auth | JWT lib + session database | 10 lines of `crypto` + HMAC |
| Build pipeline | Webpack / Vite / Docker / CI | None — deploy the HTML file |
| Data portability | Locked in DB format | Edit the note directly on HackMD |
| Emergency fix | SSH + DB client + migration | Open HackMD, edit the Markdown |
| Cold start | Always-on server | ~100ms Netlify function cold start |
**Tradeoffs to know:**
- HackMD API rate limits — fine for personal use, not team use
- No offline support
- Single-user only (one shared password)
- Not suitable for truly sensitive data without additional encryption at rest
**Perfect for:**
- Personal productivity tools
- Quick prototypes that need real persistence
- Learning serverless + API integration without infrastructure cost
- Tools where you want to edit data both programmatically *and* by hand
---
*Built by Ali Gamal · Frontend Team Lead · Luftborn*
*Stack: Vanilla JS · Netlify Functions (esbuild) · HackMD API · HMAC-SHA256*