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

ClipDeck List View — Panel 에 CRUD-R 도착

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

Level 0Extension 입덕
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Track 3 가 clip 을 썼고. Track 4 가 clip 을 읽어. Side panel 이 진짜 library 로 끝남 — searchable, copy-to-clipboard, source 로 다시 link. CRUD 두 글자가 이제 board 에."

여기서 'R' 의 의미

CRUD 의 R 은 그냥 "display" 아냐. 진짜 read view 는 scannable, searchable, actionable 함으로써 자기 surface 를 정당화. ClipDeck 에 구체적으로:

  • 최신 clip 이 맨 위 (savedAt 내림차순 정렬).
  • 각 row 가 source title (clickable link), 상대 시간 ("5m ago"), clip text (긴 clip 엔 read-more affordance 가진 truncate) 표시.
  • User 가 타이핑하는 동안 filter 하는 search box (clip text 와 title 의 substring match).
  • Row 당 system clipboard 에 write 하는 "copy text" 버튼.
  • 뭘 해야 할지 설명하는 empty state: "No clips yet. Select text on any page and press Ctrl+Shift+K."

Render loop

Track 2 의 패턴이 곧장 carry:

  1. Panel mount. chrome.storage.local 에서 clip 한 번 read.
  2. Search input 의 현재 filter 포함해서 render.
  3. chrome.storage.onChanged 구독. clips 바뀌면 re-render.
  4. Search input 의 input event 구독. 매 keystroke 마다 새 filter 와 re-render.

State-management library 필요 없음. Storage 가 source of truth; panel 은 derived view. Panel 에 parallel in-memory clip array 유지 발견되면 state 중복 — 매 render 마다 storage 에서 read, 또는 array cache 하되 매 onChanged fire 마다 rebuild.

상대 시간

현대 browser (Chrome 71+) 가 human-friendly 시간 문자열에 Intl.RelativeTimeFormat 포함. 제대로 쓰면 localization 자동 처리 — 한국어로 "5분 전", 영어로 "5 minutes ago", 추가 코드 없음.

Bucket logic 은 직설적: 초 단위 차이 계산, bucket 통과 (초 → 분 → 시간 → 일), 적절한 unit 으로 format. setInterval 통해 렌더된 시간 매 분 refresh, 또는 user interact 까지 stale 되는 거 받아들이기.

Clipboard 에 copy

현대 clipboard API (navigator.clipboard.writeText) 가 extra permission 없이 extension page 에서 동작, 단 user gesture 필요 — 버튼 click handler 카운트. Legacy document.execCommand('copy') 경로도 옛 Chrome 의 fallback 으로 동작, 대부분 install 은 그거 건너뛸 만큼 최근.

패턴: copy 버튼 click, navigator.clipboard.writeText(clip.text) await, 1.5 초 후 auto-fade 하는 짧은 확인 toast ("Copied!") 표시. alert 사용 충동 저항 — alert 가 user 가 읽는 page 에서 focus 훔치고 persistent-panel UX 깸.

Filter

몇 백 clip 엔 render 마다 단순 substring matching 이 충분히 빠름 — index 필요 없음. 양쪽 lowercase, inclusion 확인, 끝. 몇 천 넘어가면 lazy-loaded list 나 fuzzy-search index 만들 가치 있을 수도, 그건 v1 scope 한참 넘음.

Source URL 열기

Clip 의 title 이 원본 URL 로 link. 클릭이 새 tab 열기 (anchor 의 target="_blank", 또는 programmatic chrome.tabs.create({ url, active: false })). active: false 변형이 tab 을 background 로 열어 user 가 side panel 자리 안 잃음 — 보통 library view 의 맞는 default.

R 은 display 이상. 동작하는 library view 가 필요한 거: sort, search, copy, source 로 jump, empty-state, storage 에서 live update. 다 만들기 저렴, 다 user 가 기대.
Escape 습관. 모든 clip text 와 title 이 삽입 전 HTML escape 통과. Unescaped user-provided text 의 innerHTML 이 side panel 의 가장 큰 XSS 위험 — clip 이 user 가 방문하는 어떤 page, user 제어 안 하는 거 포함, 에서 올 수 있음. 이전 lesson 의 escapeHtml helper (또는 풍부한 HTML 엔 DOMPurify) 가 최소 baseline; v1 엔 text 만 렌더, 그래서 escape helper 충분.

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 — search / copy / 상대 시간 가진 full library view·javascript
// panel.js — copy + open-source 가진 search-filtered render
const rtf = new Intl.RelativeTimeFormat(navigator.language || "en", { numeric: "auto" });

function relativeTime(ms) {
  const diff = (ms - Date.now()) / 1000; // 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();

// 상대 시간 stale 안 되게 매 분 refresh.
setInterval(render, 60_000);

External links

Exercise

clipdeck/panel.html 을 첫 번째 code block 으로, clipdeck/panel.js 를 두 번째로 교체. Extension reload. Side panel 열기 — 이전 exercise 의 clip 있으면 timestamp 와 row 당 Copy 버튼과 함께 렌더. 다른 page 에서 Ctrl+Shift+K 로 몇 개 더 저장, search box 에 뭔가 타이핑 — list 가 타이핑할 때 좁아져야 함. 아무 clip 의 Copy 클릭 후 어딘가에 paste — toast 가 확인, paste 가 매칭. Title link 클릭 — source URL 의 새 background tab 열고, panel 자리 유지. 다음 ClipDeck 이 blocklisted (Lesson 4 exercise) 인 tab 으로 click — 그 tab 에서 side-panel chooser 가 ClipDeck 숨기는 거 확인.
Hint
Timestamp 가 NaN minutes ago 나 invalid output 보이면 어딘가 곱셈/나눗셈 잊은 거 — diff 변수가 bucket 전에 초 단위여야 함. Clipboard.writeText 가 security error 로 fail 하면 user gesture 외에서 panel 열린 거 (user 가 능동적으로 연 panel 엔 매우 드묾) — panel toggle 통해 새로고침 후 재시도. Search box 가 filter 안 하면 document.getElementById("search").addEventListener("input", render) 가 실제로 fire 하는지 확인 — render() 안에 console.log 추가해 매 keystroke 의 query 값 확인.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.