# 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*