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 singleindex.html
Table of Contents
- The Idea
- Real Project Structure
- Architecture Overview
- Prerequisites
- Step 1 — Set Up HackMD as Your Database
- Step 2 — Create the Netlify Project
- Step 3 — Build the Auth Function
- Step 4 — Build the Tasks Function
- Step 5 — Build the Memory Function
- Step 6 — Configure netlify.toml
- Step 7 — The Frontend: index.html Internals
- 7a. Design System (CSS Variables)
- 7b. App Shell & HTML Structure
- 7c. Auth Flow (Password Gate)
- 7d. Main Tab Switching
- 7e. Advanced Task Markdown Format
- 7f. Markdown Parser (parseTaskMarkdown)
- 7g. Markdown Serializer (toMarkdown)
- 7h. Kanban Board View
- 7i. List View
- 7j. Drag-and-Drop
- 7k. Inline Editing
- 7l. Save Flow
- 7m. Memory Viewer
- Step 8 — Set Environment Variables
- Step 9 — Deploy to Netlify
- How the Auth Token Works
- 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:
- User clicks checkbox in browser
- JS flips
task.checked, callstoMarkdown()→ serializes all tasks back to Markdown string - User clicks “Save to HackMD” button
PATCH /.netlify/functions/taskswith{ content: markdownString }- Function verifies HMAC token, calls
PATCH https://api.hackmd.io/v1/notes/<NOTE_ID> - HackMD stores the updated Markdown
- Browser shows “Saved to HackMD ✓”
4. Prerequisites
- Netlify account (free tier)
- HackMD account (free tier)
- Netlify CLI installed globally
- Node.js 18+
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 and create two new notes.
Note 1: Tasks — This is your Kanban data store. Paste this starter:
# 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- [ ] subtasklines. 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
- HackMD → Settings → API
- Generate new token → name it
dashboard-token - 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
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:
{
"name": "my-dashboard",
"version": "1.0.0"
}
Link to Netlify:
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
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
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
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
[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:
: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)
// 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:
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:
# 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:
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:
{
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:
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 noteon 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:
// 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:
// 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:
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:
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):
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
## Sectionfound 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 table (if the section contains
- 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:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# outputs something like: a3f9c2d1e4b567890abcdef01234567890abcdef01234567890abcdef012345678
Or via CLI:
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
# 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. Sincenetlify.tomlhaspublish = ".", you could also just runnetlify 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 —
sessionStorageclears 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