惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

小众软件
小众软件
V
Visual Studio Blog
博客园 - 三生石上(FineUI控件)
Last Week in AI
Last Week in AI
Blog — PlanetScale
Blog — PlanetScale
爱范儿
爱范儿
J
Java Code Geeks
A
About on SuperTechFans
F
Fortinet All Blogs
B
Blog
aimingoo的专栏
aimingoo的专栏
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Engineering at Meta
Engineering at Meta
Y
Y Combinator Blog
有赞技术团队
有赞技术团队
G
Google Developers Blog
Apple Machine Learning Research
Apple Machine Learning Research
V
V2EX
博客园_首页
博客园 - 叶小钗
罗磊的独立博客
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
D
Docker
云风的 BLOG
云风的 BLOG

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Build a Notes App with ctrodb, Tailwind CSS, and Vanilla ...
Odejobi Abiola Samuel · 2026-06-22 · via DEV Community

Let's build a notes app that runs entirely in the browser. Create, pin, search, and delete notes — all persisted to IndexedDB. No backend, no build step, no npm install.

We'll use ctrodb for the database, Tailwind CSS for styling, and vanilla JavaScript for the glue.

What we're building

A single HTML file with:

  • Add notes with title, body, and tags
  • Pin important notes to the top
  • Search notes by title
  • Delete notes
  • Persistence across page reloads
  • Dark mode UI

Setting up

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>ctrodb Notes</title>
  <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen p-4 sm:p-6">

ctrodb ships as an IIFE that exposes a CtroDB global:

<script src="https://unpkg.com/ctrodb@1.0.1/dist/index.global.js"></script>
<script>
  const { Database } = CtroDB

No bundler, no build step, no npm install.

Schema design

const schema = {
  version: 1,
  collections: {
    notes: {
      fields: {
        title: { type: "string", required: true },
        body: { type: "string", default: "" },
        tags: { type: "array", items: { type: "string" }, default: [] },
        pinned: { type: "boolean", default: false },
        createdAt: { type: "string", default: () => new Date().toISOString() },
      },
      indexes: [
        { field: "createdAt" },
        { field: "pinned" },
      ],
    },
  },
}

The schema validates every write. A note without a title throws a ValidationError. Defaults fill in missing fields.

Connecting

const db = new Database({ name: "notes-app", schema })
await db.connect()
const notes = db.collection("notes")

Rendering notes

Each note card carries its own id directly in the markup via a data-id attribute:

function noteCard(note) {
  const isPinned = note.pinned
  return `
    <div class="bg-gray-800 rounded-lg p-4 border ..." data-id="${note.id}">
      <h3>${escapeHtml(note.title)}</h3>
      <button class="pin-btn ...">${isPinned ? "\u2605" : "\u2606"}</button>
      <button class="delete-btn ...">\u2715</button>
    </div>
  `
}

async function render(message) {
  const query = document.getElementById("search").value.trim()
  const list = query
    ? await notes.query().search("title", query).fetch()
    : await notes.query().sort({ pinned: "desc", createdAt: "desc" }).fetch()

  if (list.length === 0) {
    container.innerHTML = `<p>${message || "No notes yet."}</p>`
    return
  }

  container.innerHTML = list.map(noteCard).join("")
}

Notice render() only builds HTML — it doesn't attach any click handlers. That's deliberate, and it's the key to the next section.

Wiring up pin and delete with event delegation

A common first instinct is to attach a click listener to every .pin-btn and .delete-btn right after rendering, capturing the note from the list array by index:

// Don't do this — see below for why
container.querySelectorAll(".pin-btn").forEach((btn, i) => {
  btn.addEventListener("click", async () => {
    const note = list[i]
    await note.update({ pinned: !note.pinned })
    await render()
  })
})

This works at first glance, but it has two real problems. Every call to render() re-attaches a fresh batch of listeners on top of whatever's still pending from the previous render, and the handler closes over the list array by position — so if a search or sort ever reorders things between render and click, the index can point at the wrong note.

The more robust pattern is event delegation: attach a single listener to the container once, and read the note's id off the clicked card's data-id attribute at click time.

container.addEventListener("click", async (e) => {
  const card = e.target.closest("[data-id]")
  if (!card) return
  const id = Number(card.dataset.id)

  if (e.target.closest(".pin-btn")) {
    const note = await notes.get(id)
    if (note) {
      await note.update({ pinned: !note.pinned })
      await render()
    }
    return
  }

  if (e.target.closest(".delete-btn")) {
    await notes.delete(id)
    await render()
  }
})

One listener, attached once in init(), handles every note — present and future — since clicks bubble up to container regardless of how many times innerHTML gets replaced.

The one gotcha: dataset.id is always a string

This is the part that trips people up, so it's worth calling out explicitly. element.dataset.id always returns a string"1", "2", and so on — because HTML attributes are text. But ctrodb's default browser adapter is IndexedDB, and with keyPath: "id", autoIncrement: true, the ids it actually stores are numbers.

IndexedDB key lookups are type-sensitive: the string "1" and the number 1 are different keys. So notes.get(card.dataset.id) silently fails to find anything — no error, no thrown exception, just an undefined result that your if (note) guard quietly swallows. From the UI, it just looks like the pin and delete buttons don't do anything.

The fix is the Number(...) call above:

const id = Number(card.dataset.id)

Easy to miss, easy to fix, and a good reminder that anything pulled from dataset needs to be coerced back to its real type before you use it as a database key.

Adding notes

document.getElementById("add-note-btn").addEventListener("click", async () => {
  const title = document.getElementById("title").value.trim()
  const body = document.getElementById("body").value.trim()
  const tags = document.getElementById("tags").value
    .split(",").map(t => t.trim()).filter(Boolean)

  if (!title) { alert("Title is required."); return }

  await notes.create({ title, body, tags })
  clearForm()
  await render()
})

Search

A 200ms debounce avoids excessive queries:

let searchTimer
document.getElementById("search").addEventListener("input", () => {
  clearTimeout(searchTimer)
  searchTimer = setTimeout(() => render(), 200)
})

query().search("title", query) does case-insensitive substring matching.

Complete code

Here's the full HTML file:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>ctrodb Notes</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script>
    tailwind.config = { darkMode: "class" }
  </script>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen p-4 sm:p-6">
  <div class="max-w-2xl mx-auto">

    <h1 class="text-3xl font-bold mb-8 flex items-center gap-3">
      <span class="text-blue-400">&#9670;</span>
      ctrodb Notes
    </h1>

    <!-- Search -->
    <div class="bg-gray-800 rounded-lg p-4 mb-6 border border-gray-700">
      <input
        id="search"
        type="text"
        placeholder="Search notes by title..."
        class="w-full bg-gray-700 rounded px-3 py-2 text-gray-100 placeholder-gray-400 border border-gray-600 focus:outline-none focus:border-blue-500 transition-colors"
      />
    </div>

    <!-- New Note Form -->
    <div class="bg-gray-800 rounded-lg p-4 mb-6 border border-gray-700">
      <h2 class="font-semibold mb-3 text-gray-300">New Note</h2>
      <input
        id="title"
        type="text"
        placeholder="Title (required)"
        required
        class="w-full bg-gray-700 rounded px-3 py-2 mb-3 text-gray-100 placeholder-gray-400 border border-gray-600 focus:outline-none focus:border-blue-500 transition-colors"
      />
      <textarea
        id="body"
        placeholder="Body (optional)"
        rows="3"
        class="w-full bg-gray-700 rounded px-3 py-2 mb-3 text-gray-100 placeholder-gray-400 border border-gray-600 focus:outline-none focus:border-blue-500 transition-colors resize-none"
      ></textarea>
      <input
        id="tags"
        type="text"
        placeholder="Tags (comma-separated)"
        class="w-full bg-gray-700 rounded px-3 py-2 mb-3 text-gray-100 placeholder-gray-400 border border-gray-600 focus:outline-none focus:border-blue-500 transition-colors"
      />
      <button
        id="add-note-btn"
        class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded font-medium transition-colors"
      >
        + Add Note
      </button>
    </div>

    <!-- Notes List -->
    <div id="notes-container" class="space-y-3"></div>

  </div>

  <script src="https://unpkg.com/ctrodb@1.0.1/dist/index.global.js"></script>
  <script>
  const { Database } = CtroDB

  const schema = {
    version: 1,
    collections: {
      notes: {
        fields: {
          title: { type: "string", required: true },
          body: { type: "string", default: "" },
          tags: { type: "array", items: { type: "string" }, default: [] },
          pinned: { type: "boolean", default: false },
          createdAt: { type: "string", default: () => new Date().toISOString() },
        },
        indexes: [
          { field: "createdAt" },
          { field: "pinned" },
        ],
      },
    },
  }

  let db, notes, container

  function escapeHtml(str) {
    const div = document.createElement("div")
    div.textContent = str
    return div.innerHTML
  }

  function noteCard(note) {
    const isPinned = note.pinned
    return `
      <div class="bg-gray-800 rounded-lg p-4 border transition-colors ${isPinned ? "border-yellow-500/60" : "border-gray-700"}" data-id="${note.id}">
        <div class="flex items-start justify-between gap-4">
          <div class="flex-1 min-w-0">
            <h3 class="font-semibold text-lg truncate">${escapeHtml(note.title)}</h3>
            ${note.body ? `<p class="text-gray-400 mt-1 line-clamp-3">${escapeHtml(note.body)}</p>` : ""}
            ${note.tags.length ? `
              <div class="flex flex-wrap gap-2 mt-3">
                ${note.tags.map(t => `<span class="text-xs bg-gray-700 text-gray-300 px-2 py-0.5 rounded-full">${escapeHtml(t)}</span>`).join("")}
              </div>
            ` : ""}
            <p class="text-xs text-gray-500 mt-3">${new Date(note.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
          </div>
          <div class="flex gap-1 shrink-0">
            <button class="pin-btn p-1.5 rounded hover:bg-gray-700 transition-colors ${isPinned ? "text-yellow-400" : "text-gray-500 hover:text-yellow-400"}" title="${isPinned ? "Unpin" : "Pin"}">
              ${isPinned ? "\u2605" : "\u2606"}
            </button>
            <button class="delete-btn p-1.5 rounded hover:bg-gray-700 text-gray-500 hover:text-red-400 transition-colors" title="Delete">\u2715</button>
          </div>
        </div>
      </div>
    `
  }

  function clearForm() {
    document.getElementById("title").value = ""
    document.getElementById("body").value = ""
    document.getElementById("tags").value = ""
  }

  async function render(message) {
    const query = document.getElementById("search").value.trim()
    const list = query
      ? await notes.query().search("title", query).fetch()
      : await notes.query().sort({ pinned: "desc", createdAt: "desc" }).fetch()

    if (list.length === 0) {
      container.innerHTML = `<p class="text-gray-500 text-center py-8">${message || (query ? "No notes match your search." : "No notes yet. Create one above!")}</p>`
      return
    }

    container.innerHTML = list.map(noteCard).join("")
  }

  async function init() {
    db = new Database({ name: "notes-app", schema })
    await db.connect()
    notes = db.collection("notes")
    container = document.getElementById("notes-container")

    // Add note
    document.getElementById("add-note-btn").addEventListener("click", async () => {
      const title = document.getElementById("title").value.trim()
      const body = document.getElementById("body").value.trim()
      const rawTags = document.getElementById("tags").value

      if (!title) { alert("Title is required."); return }

      const tags = rawTags.split(",").map(t => t.trim()).filter(Boolean)
      await notes.create({ title, body, tags })
      clearForm()
      await render()
    })

    // Search with debounce
    let searchTimer
    document.getElementById("search").addEventListener("input", () => {
      clearTimeout(searchTimer)
      searchTimer = setTimeout(() => render(), 200)
    })

    // Event delegation for pin and delete
    container.addEventListener("click", async (e) => {
      const card = e.target.closest("[data-id]")
      if (!card) return
      const id = Number(card.dataset.id)

      if (e.target.closest(".pin-btn")) {
        const note = await notes.get(id)
        if (note) {
          await note.update({ pinned: !note.pinned })
          await render()
        }
        return
      }

      if (e.target.closest(".delete-btn")) {
        await notes.delete(id)
        await render()
      }
    })

    await render()
  }

  init()
  </script>
</body>
</html>

Save it as notes.html and open it in any browser. Add notes, pin them, search, delete — all operations work with full IndexedDB persistence.

What's happening

  • ctrodb connects to IndexedDB, creates the notes-app database
  • The schema validates every write — wrong types or missing required fields throw
  • query().search("title", query) does case-insensitive substring matching
  • A single delegated click listener on #notes-container handles pin and delete for every note, current and future — no listeners to re-attach on each render
  • Ids read from data-id are coerced with Number(...) before being passed back to ctrodb, since IndexedDB's autoincrement keys are numeric but dataset values are always strings
  • Everything runs in-process — no network requests, no server

Try the browser playground at https://ctrodb.vercel.app/playground.


ctrodb is open source. GitHub: https://github.com/ctrotech-tutor/ctrodb. Docs: https://ctrodb.vercel.app/docs/getting-started/quick-start.