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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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/catch around localStorage and a memory fallback.
  • Bad import? Validated shape before saving, timestamp normalized.
  • Tags messy? normalizeTag forces a-z0-9- so search stays clean.
  • Reload? Everything's in localStorage under 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.