C.W.K.
Stream
Lesson 06 of 06 · published

ClipDeck List View — CRUD-R Lands in the Panel

~14 min · clipdeck, crud, side-panel, search, clipboard

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Track 3 wrote the clip. Track 4 reads the clip. The side panel ends as a real library — searchable, copy-to-clipboard, links back to the source. Two letters of CRUD now on the board."

What 'R' Means Here

The R in CRUD is not just "display." A real read view earns its surface by being scannable, searchable, and actionable. For ClipDeck specifically:

  • Newest clip at the top (sorted by savedAt descending).
  • Each row shows the source title (clickable link), the relative time ("5m ago"), and the clip text (truncated with a read-more affordance for long clips).
  • A search box that filters as the user types (substring match against clip text and title).
  • A "copy text" button per row that writes to the system clipboard.
  • Empty state that explains what to do: "No clips yet. Select text on any page and press Ctrl+Shift+K."

The Render Loop

The pattern from Track 2 carries straight through:

  1. Panel mounts. Read clips once from chrome.storage.local.
  2. Render, including current search filter from the search input.
  3. Subscribe to chrome.storage.onChanged. When clips changes, re-render.
  4. Subscribe to the search input's input event. On every keystroke, re-render with the new filter.

No state-management library needed. Storage is the source of truth; the panel is a derived view. If you find yourself maintaining a parallel in-memory clips array in the panel, you're duplicating state — read from storage every render, or cache the array but rebuild it on every onChanged fire.

Relative Time

Modern browsers (Chrome 71+) include Intl.RelativeTimeFormat for human-friendly time strings. Used right, it handles localization automatically — Korean shows "5분 전," English shows "5 minutes ago," no extra code.

The bucket logic is straightforward: compute the difference in seconds, fall through buckets (seconds → minutes → hours → days), and format with the appropriate unit. Refresh the rendered times every minute via setInterval, or accept that they go stale until the user interacts.

Copy to Clipboard

The modern clipboard API (navigator.clipboard.writeText) works in extension pages with no extra permission, but it requires a user gesture — the button click handler counts. The legacy document.execCommand('copy') path also works as a fallback for older Chrome, but most installs are recent enough that you can skip it.

The pattern: click the copy button, await navigator.clipboard.writeText(clip.text), then show a brief confirmation toast ("Copied!") that auto-fades after 1.5 seconds. Resist the urge to use an alert — alerts steal focus from the page the user is reading and break the persistent-panel UX.

The Filter

For a few hundred clips, simple substring matching on each render is fast enough — no need for an index. Lowercase both sides, check for inclusion, done. Past a few thousand clips it might be worth building a lazy-loaded list or a fuzzy-search index, but that is well past v1 scope.

Open Source URL

The clip's title is a link to the original URL. Clicking it opens a new tab (target="_blank" in the anchor, or programmatically via chrome.tabs.create({ url, active: false })). The active: false variant opens the tab in the background so the user does not lose their place in the side panel — usually the right default for a library view.

R is more than display. A working library view needs: sort, search, copy, jump-to-source, empty-state, and live updates from storage. All cheap to build, all expected by users.
The escape habit. Every clip text and title goes through HTML escaping before insertion. innerHTML with unescaped user-provided text is the side panel's biggest XSS risk — clips can come from any page the user visits, including ones the user does not control. The escapeHtml helper from earlier lessons (or DOMPurify for richer HTML) is the bare-minimum baseline; for v1 we render text only, so the escape helper is sufficient.

Code

panel.html — search + list + toast scaffolding·html
<!-- panel.html — search + list scaffolding -->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <style>
      body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; color: #222; }
      h1 { font-size: 18px; margin: 0 0 12px; }
      #search { width: 100%; padding: 8px 10px; font: inherit; border: 1px solid #ccc; border-radius: 4px; margin-bottom: 12px; }
      .row { padding: 10px 0; border-top: 1px solid #eee; }
      .row .meta { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; color: #555; margin-bottom: 4px; }
      .row a { color: #1a6bd6; text-decoration: none; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      .row p { margin: 0; font-size: 14px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
      .copyBtn { float: right; margin-top: 4px; font-size: 11px; cursor: pointer; background: none; border: 1px solid #ccc; padding: 2px 8px; border-radius: 3px; }
      .empty { color: #888; font-style: italic; text-align: center; padding: 40px 0; }
      #toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: #222; color: #fff; padding: 6px 12px; border-radius: 4px; opacity: 0; transition: opacity .2s ease; pointer-events: none; }
      #toast.show { opacity: 1; }
    </style>
  </head>
  <body>
    <h1>ClipDeck</h1>
    <input id="search" placeholder="Search clips…" autocomplete="off" />
    <div id="list"></div>
    <div id="toast"></div>
    <script src="panel.js"></script>
  </body>
</html>
panel.js — full library view with search, copy, relative time·javascript
// panel.js — search-filtered render with copy + open-source
const rtf = new Intl.RelativeTimeFormat(navigator.language || "en", { numeric: "auto" });

function relativeTime(ms) {
  const diff = (ms - Date.now()) / 1000; // negative for past
  const abs = Math.abs(diff);
  if (abs < 60) return rtf.format(Math.round(diff), "second");
  if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
  if (abs < 86400) return rtf.format(Math.round(diff / 3600), "hour");
  return rtf.format(Math.round(diff / 86400), "day");
}

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, (ch) => ({
    "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
  })[ch]);
}

function toast(message) {
  const el = document.getElementById("toast");
  el.textContent = message;
  el.classList.add("show");
  setTimeout(() => el.classList.remove("show"), 1500);
}

async function render() {
  const { clips = [] } = await chrome.storage.local.get("clips");
  const query = document.getElementById("search").value.trim().toLowerCase();
  const filtered = query
    ? clips.filter((c) => c.text.toLowerCase().includes(query) || (c.title || "").toLowerCase().includes(query))
    : clips;
  const root = document.getElementById("list");
  if (filtered.length === 0) {
    root.innerHTML = `<p class="empty">${query ? "No clips match \u2018" + escapeHtml(query) + "\u2019." : "No clips yet. Select text on any page and press Ctrl+Shift+K."}</p>`;
    return;
  }
  root.innerHTML = filtered
    .map((c) => `
      <div class="row" data-id="${escapeHtml(c.id)}">
        <button class="copyBtn">Copy</button>
        <div class="meta">
          <a href="${escapeHtml(c.url)}" target="_blank" rel="noopener">${escapeHtml(c.title || c.url)}</a>
          <time>${relativeTime(c.savedAt)}</time>
        </div>
        <p>${escapeHtml(c.text)}</p>
      </div>`)
    .join("");
}

document.getElementById("search").addEventListener("input", render);

document.addEventListener("click", async (event) => {
  if (event.target.classList?.contains("copyBtn")) {
    const row = event.target.closest(".row");
    const id = row?.dataset.id;
    if (!id) return;
    const { clips = [] } = await chrome.storage.local.get("clips");
    const clip = clips.find((c) => c.id === id);
    if (!clip) return;
    await navigator.clipboard.writeText(clip.text);
    toast("Copied!");
  }
});

chrome.storage.onChanged.addListener((c, a) => a === "local" && "clips" in c && render());
render();

// Refresh relative times every minute so they don't go stale.
setInterval(render, 60_000);

External links

Exercise

Replace clipdeck/panel.html with the first code block and clipdeck/panel.js with the second. Reload the extension. Open the side panel — if you have clips from earlier exercises, they render with timestamps and a Copy button per row. Save a few more via Ctrl+Shift+K on different pages, then type something into the search box — the list should narrow as you type. Click Copy on any clip and paste somewhere — the toast confirms, the paste matches. Click the title link — a new background tab opens to the source URL, panel stays put. Then click into a tab where ClipDeck is blocklisted (from Lesson 4's exercise) — confirm the side-panel chooser hides ClipDeck on that tab.
Hint
If timestamps show NaN minutes ago or invalid output, you forgot to multiply/divide somewhere — the diff variable should be in seconds before bucketing. If clipboard.writeText fails with a security error, your panel was opened from something other than a user gesture (very unusual for a panel that the user actively opened) — refresh the panel via its toggle and try again. If the search box does not filter, check that document.getElementById("search").addEventListener("input", render) actually fires — add a console.log inside render() to see the query value each keystroke.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.