A Little JavaScript That Survives Reload
Sometimes the best CMS is a single HTML file that writes to itself. This is that file, stripped to its essentials — no framework, no bundler, just sensible JavaScript that persists through reloads and stays usable with only a keyboard.
Copy it, change the storage key, ship it. It'll outlive most dependencies.
The code
// cue-notes.js — vanilla note keeper for a single HTML page// Features: create/edit/delete, localStorage persistence, search,// tags, import/export, keyboard shortcuts, and no dependencies.
const STORAGE_FALLBACK_KEY = "cue-notes:v1";
/** @typedef {{ id: string; title: string; body: string; tags: string[]; createdAt: string; updatedAt: string }} Note */
function uid() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;}
function normalizeTag(tag) { return tag.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");}
function safeJsonParse(raw, fallback) { try { const v = JSON.parse(raw); return v ?? fallback; } catch { return fallback; }}
class Store { constructor(key) { this.key = key; this.memory = null; }
load() { if (this.memory) return this.memory; try { const raw = localStorage.getItem(this.key); if (!raw) return []; const parsed = safeJsonParse(raw, []); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }
save(notes) { this.memory = notes; try { localStorage.setItem(this.key, JSON.stringify(notes)); } catch (err) { console.warn("[cue-notes] localStorage unavailable, using memory only", err); } }
upsert(note) { const all = this.load(); const idx = all.findIndex((n) => n.id === note.id); if (idx >= 0) all[idx] = note; else all.unshift(note); this.save(all); return note; }
remove(id) { const next = this.load().filter((n) => n.id !== id); this.save(next); }
exportJson() { return JSON.stringify(this.load(), null, 2); }
importJson(text) { const imported = safeJsonParse(text, null); if (!Array.isArray(imported)) throw new Error("Expected an array of notes"); const now = new Date().toISOString(); const normalized = imported .filter((n) => n && typeof n.title === "string" && typeof n.body === "string") .map((n) => ({ id: typeof n.id === "string" ? n.id : uid(), title: n.title.trim() || "Untitled", body: n.body, tags: Array.isArray(n.tags) ? [...new Set(n.tags.map(normalizeTag).filter(Boolean))] : [], createdAt: typeof n.createdAt === "string" ? n.createdAt : now, updatedAt: now, })); this.save(normalized); return normalized; }
search(q, tag) { const query = q.trim().toLowerCase(); const all = this.load(); return all.filter((n) => { const inTag = !tag || n.tags.includes(normalizeTag(tag)); if (!inTag) return false; if (!query) return true; const hay = `${n.title}\n${n.body}\n${n.tags.join(" ")}`.toLowerCase(); return hay.includes(query); }); }}
function renderApp(root, store) { root.innerHTML = ` <div class="cue-notes" style="max-width:44rem;margin:0 auto;padding:1rem;font-family:system-ui,sans-serif"> <header style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem"> <input data-role="search" placeholder="Search notes…" style="flex:1;min-width:12rem;padding:.5rem .6rem;border:1px solid #DBE0E1;border-radius:10px" /> <input data-role="tag-filter" placeholder="filter tag" style="width:10rem;padding:.5rem .6rem;border:1px solid #DBE0E1;border-radius:10px" /> <button data-role="new" style="padding:.5rem .75rem;border-radius:999px;border:1px solid #100C08;background:#FF9408;color:#100C08;font-weight:700">New</button> <button data-role="export" style="padding:.5rem .75rem;border-radius:999px;border:1px solid #DBE0E1;background:white">Export JSON</button> <label style="padding:.5rem .75rem;border-radius:999px;border:1px solid #DBE0E1;background:white;cursor:pointer">Import <input data-role="import" type="file" accept="application/json" hidden /></label> </header> <div data-role="editor" hidden style="border:1px solid #DBE0E1;border-radius:14px;padding:1rem;background:white;margin-bottom:1rem"> <input data-field="title" placeholder="Title" style="width:100%;font-size:1.15rem;font-weight:700;padding:.4rem;border:1px solid #DBE0E1;border-radius:8px" /> <input data-field="tags" placeholder="tags, comma separated (a-z, 0-9, -)" style="width:100%;margin-top:.6rem;padding:.4rem;border:1px solid #DBE0E1;border-radius:8px" /> <textarea data-field="body" rows="8" placeholder="Write… (plain text, paste code, whatever you need)" style="width:100%;margin-top:.6rem;padding:.6rem;border:1px solid #DBE0E1;border-radius:8px;font-family:ui-monospace,monospace"></textarea> <div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.75rem"> <button data-action="cancel">Cancel</button> <button data-action="save" style="background:#95122C;color:white;border-radius:999px;padding:.5rem .9rem;border:0;font-weight:700">Save</button> </div> </div> <div data-role="list" style="display:grid;gap:.6rem"></div> <p data-role="empty" style="color:#6b7280">No notes yet. Hit "New" — it works offline.</p> </div> `;
const searchEl = root.querySelector('[data-role="search"]'); const tagEl = root.querySelector('[data-role="tag-filter"]'); const listEl = root.querySelector('[data-role="list"]'); const emptyEl = root.querySelector('[data-role="empty"]'); const editorEl = root.querySelector('[data-role="editor"]'); const titleEl = root.querySelector('[data-field="title"]'); const tagsEl = root.querySelector('[data-field="tags"]'); const bodyEl = root.querySelector('[data-field="body"]');
let editingId = null; let query = ""; let tagFilter = "";
function openEditor(note) { editingId = note ? note.id : null; titleEl.value = note ? note.title : ""; tagsEl.value = note ? note.tags.join(", ") : ""; bodyEl.value = note ? note.body : ""; editorEl.hidden = false; titleEl.focus(); }
function closeEditor() { editingId = null; editorEl.hidden = true; }
function currentNotes() { return store.search(query, tagFilter); }
function escapeHtml(s) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); }
function renderList() { const notes = currentNotes(); emptyEl.hidden = notes.length !== 0; listEl.innerHTML = notes .map( (n) => ` <article data-id="${escapeHtml(n.id)}" style="border:1px solid #DBE0E1;border-radius:14px;padding:.9rem;background:white"> <div style="display:flex;justify-content:space-between;gap:.5rem;align-items:baseline"> <h3 style="margin:0;font-size:1rem;line-height:1.3">${escapeHtml(n.title)}</h3> <time datetime="${escapeHtml(n.updatedAt)}" style="font-size:.78rem;color:#6b7280">${new Date(n.updatedAt).toLocaleDateString()}</time> </div> <p style="white-space:pre-wrap;margin:.5rem 0 0;color:#374151">${escapeHtml(n.body.slice(0, 280))}${n.body.length > 280 ? "…" : ""}</p> ${n.tags.length ? `<p style="margin:.5rem 0 0;display:flex;gap:.3rem;flex-wrap:wrap">${n.tags.map((t) => `<span style="border:1px solid #DBE0E1;border-radius:999px;padding:.1rem .4rem;font-size:.72rem">#${escapeHtml(t)}</span>`).join("")}</p>` : ""} <div style="display:flex;gap:.4rem;justify-content:flex-end;margin-top:.6rem"> <button data-act="edit">Edit</button> <button data-act="delete" style="color:#95122C">Delete</button> </div> </article> `, ) .join("");
listEl.querySelectorAll("[data-act]").forEach((btn) => { btn.addEventListener("click", () => { const article = btn.closest("article"); const id = article?.getAttribute("data-id"); if (!id) return; if (btn.getAttribute("data-act") === "edit") { const found = store.load().find((n) => n.id === id); if (found) openEditor(found); } else { store.remove(id); renderList(); } }); }); }
root.querySelector('[data-role="new"]').addEventListener("click", () => openEditor(null)); root.querySelector('[data-action="cancel"]').addEventListener("click", closeEditor); root.querySelector('[data-action="save"]').addEventListener("click", () => { const title = titleEl.value.trim(); const body = bodyEl.value; if (!title && !body.trim()) return; const now = new Date().toISOString(); const existing = editingId ? store.load().find((n) => n.id === editingId) : null; const note = { id: editingId ?? uid(), title: title || "Untitled", body, tags: tagsEl.value.split(",").map(normalizeTag).filter(Boolean), createdAt: existing?.createdAt ?? now, updatedAt: now, }; store.upsert(note); closeEditor(); renderList(); });
searchEl.addEventListener("input", () => { query = searchEl.value; renderList(); }); tagEl.addEventListener("input", () => { tagFilter = tagEl.value; renderList(); });
root.querySelector('[data-role="export"]').addEventListener("click", async () => { const text = store.exportJson(); const blob = new Blob([text], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `cue-notes-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); });
root.querySelector('[data-role="import"]').addEventListener("change", async (e) => { const file = e.target.files?.[0]; if (!file) return; const text = await file.text(); try { store.importJson(text); renderList(); } catch (err) { alert(String(err)); } e.target.value = ""; });
root.addEventListener("keydown", (e) => { if (e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") { e.preventDefault(); searchEl.focus(); } if (e.key.toLowerCase() === "n" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openEditor(null); } if (e.key === "Escape" && !editorEl.hidden) closeEditor(); });
renderList();}
export function mountCueNotes(selector = "#cue-notes") { const root = document.querySelector(selector); if (!root) return; const key = root.getAttribute("data-storage-key") || STORAGE_FALLBACK_KEY; const store = new Store(key); renderApp(root, store);}
if (typeof document !== "undefined") { document.addEventListener("DOMContentLoaded", () => mountCueNotes());}How it handles the rough edges
- Storage full?
try/catcharoundlocalStorageand a memory fallback. - Bad import? Validated shape before saving, timestamp normalized.
- Tags messy?
normalizeTagforcesa-z0-9-so search stays clean. - Reload? Everything's in
localStorageunder one key — no server to miss.
Try it: paste this file into public/cue-notes.js, drop the <div> into a page, and you have an offline note shelf that survives deploys.
See it in the wild
A quiet desk setup — the kind of place this file wants to live.