본문 바로가기
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/56 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.

상태 관리 library 같은 건 필요 없어. storage가 원본이고 panel은 거기서 파생된 화면일 뿐이거든. panel 안에 clip 배열을 따로 들고 있는 자신을 발견하면 그건 상태를 두 벌 갖고 있는 거야. 그릴 때마다 storage에서 읽든지, 배열을 캐시해 두더라도 onChanged가 울릴 때마다 통째로 다시 만들어.

상대 시간

요즘 브라우저 (Chrome 71부터) 에는 사람이 읽기 좋은 시간 문자열을 만들어 주는 Intl.RelativeTimeFormat이 있어. 제대로 쓰면 언어 처리까지 알아서 해 줘. 한국어면 "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이 몇백 개쯤이면 그릴 때마다 문자열 포함 여부만 봐도 충분히 빨라. 색인 같은 건 필요 없어. 양쪽 다 소문자로 낮추고, 들어 있나 보고, 끝. 몇천 개를 넘어가면 목록을 나눠 불러오거나 검색 색인을 만들 값어치가 생길 수도 있는데, 그건 v1이 감당할 범위를 한참 넘어가.

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의 본문이든 제목이든 화면에 넣기 전에 반드시 HTML escape를 거쳐. 씻지 않은 남의 텍스트를 innerHTML에 꽂는 게 side panel에서 가장 큰 XSS 구멍이야. clip은 user가 돌아다닌 아무 페이지에서나 올 수 있고, 그 페이지는 user 것도 아니거든. 앞 lesson의 escapeHtml 정도가 최소선이야 (서식 있는 HTML을 다뤄야 하면 DOMPurify). v1은 텍스트만렌더, 그래서 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)
    .toSorted((a, b) => b.savedAt - a.savedAt);
  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 class="sourceLink" href="${escapeHtml(c.url)}" data-url="${escapeHtml(c.url)}">${escapeHtml(c.title || c.url)}</a>
          <time>${relativeTime(c.savedAt)}</time>
        </div>
        <p>${escapeHtml(c.text.length > 500 ? c.text.slice(0, 500) + "…" : c.text)}</p>
      </div>`)
    .join("");
}

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

document.addEventListener("click", async (event) => {
  if (event.target.classList?.contains("sourceLink")) {
    event.preventDefault();
    const url = event.target.dataset.url;
    try {
      const parsed = new URL(url);
      if (!["http:", "https:"].includes(parsed.protocol)) return;
      await chrome.tabs.create({ url: parsed.href, active: false });
    } catch {}
    return;
  }
  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

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

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