From 20a7c0c0a2e0053a7354b6dd5a90a3a67daf4e1c Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 16:05:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=97=90=20=EA=B2=80=EC=83=89(=ED=95=84?= =?UTF-8?q?=ED=84=B0)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카드가 수백~수천 장이라 원하는 댓글을 눈으로 찾기 어려웠다. 패널마다 검색창을 두고 모든 카드 섹션이 자기 전체 목록에서 필터링한다 — '더보기'로 안 펼친 카드도 검색되면 나온다. 캡처 누락 방지: 빌드/캡처는 wrapOf() 로 DOM에서 카드를 찾고 없으면 조용히 건너뛴다. 검색으로 선택 카드가 DOM에서 빠질 수 있으므로 buildAll 과 ytCC.capture 시작 시 clearSearch() 로 강제 해제한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-08-04-댓글검색-design.md | 40 ++++++++++++ server/static/auto.js | 63 +++++++++++++++++-- server/static/index.html | 6 ++ 3 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-04-댓글검색-design.md diff --git a/docs/superpowers/specs/2026-08-04-댓글검색-design.md b/docs/superpowers/specs/2026-08-04-댓글검색-design.md new file mode 100644 index 0000000..70e5edf --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-댓글검색-design.md @@ -0,0 +1,40 @@ +# 댓글 선택 화면 검색(필터) — 설계 + +날짜: 2026-08-04 · 대상: `server/static/auto.js`, `server/static/index.html` (서버 변경 없음) + +## 목적 + +댓글 선택 화면(자동 탭 ID 패널 + 유튜브 구간 탭 댓글 매칭)에서 수백~수천 장 카드 중 +원하는 댓글을 텍스트로 찾을 수 있게 한다. + +## 동작 (필터 방식) + +- 각 패널 상단(선택 요약 바 아래, 카드 섹션 위)에 검색창 1개 + 지우기 ✕. +- 입력(디바운스 150ms) 시 그 패널의 **모든 카드 섹션**(컷별 ⭐추천 / ➕채우기)이 + 자기 **전체 목록**에서 일치하는 카드만 다시 그린다 — "더보기"로 안 펼친 카드도 + 일치하면 나온다. 일치가 많으면 기존처럼 30장 + 더보기. +- 일치 기준: 댓글 본문(HTML→평문) + 작성자 이름, 대소문자 무시. + 댓글마다 검색용 평문을 1회 만들어 캐시(`c._st`). +- 일치 0장 섹션은 헤더째 숨김. 검색어를 지우면 원래 화면 복원. +- 선택 로직 불변: 카드는 원래 컷 섹션 소속이므로 클릭하면 그 컷에 들어간다. + 선택 링·사용중 배지·"선택한 것만 보기"는 기존 그대로(검색과 AND). + +## 캡처 누락 방지 (중요) + +빌드/캡처는 `wrapOf()`로 DOM에서 카드를 찾고 **없으면 조용히 건너뛴다** +(`buildAll`, `window.ytCC.capture`). 검색 필터로 선택 카드가 DOM에서 빠진 채 +빌드하면 카드가 누락되므로: + +1. 섹션은 검색 전 렌더 수(`baseShown`)를 기억하고, 검색 해제 시 그만큼 복원한다. + (선택은 렌더된 카드에서만 가능 → 복원하면 선택 카드가 항상 DOM에 있음) +2. `buildAll` 시작 시(각 ID)와 `ytCC.capture` 시작 시 해당 패널의 검색을 + 강제 초기화(`clearSearch(hlId)`)한 뒤 캡처한다. + +## 구현 구조 + +- `SECS = {hlId: [setFilter,…]}` — 패널별 섹션 필터 레지스트리. + `searchBar(hlId)`가 패널 생성 때 배열을 초기화하고, `cardSection()`이 자기 + `setFilter(q)`를 등록한다(재렌더: grid 비우고 필터된 목록으로 다시 페이징). +- `cardSection` 소폭 리팩터: `more.remove()` → `display:none` 토글 + (필터 해제 후 다시 필요할 수 있으므로). +- CSS: `.ccsearch` 몇 줄 (index.html). diff --git a/server/static/auto.js b/server/static/auto.js index e73cf46..c3780ae 100644 --- a/server/static/auto.js +++ b/server/static/auto.js @@ -63,6 +63,16 @@ function wrapOf(hlId,idx){ const CARD_PAGE=30; // '좋아요 상위에서 채우기' 섹션은 컷마다 반복된다 — 초기 렌더를 줄여 DOM 폭증을 막는다 const CUT_FILL_PAGE=6; +/* 검색용 평문(본문+작성자, 소문자) — 댓글마다 1회 계산해 캐시 */ +function searchStr(c){ + if(c._st===undefined){ + const tmp=document.createElement("div"); + tmp.innerHTML=String(c.text||"").replace(//gi,"\n"); + c._st=((tmp.textContent||"")+" "+(c.authorName||"")).toLowerCase(); + } + return c._st; +} +const SECS={}; // hlId → [setFilter,…] (searchBar 가 패널 생성 때 초기화) function cardSection(label,list,hlId,firstBatch,ci){ const sec=document.createElement("div");sec.className="hlsec"; const head=document.createElement("div");head.textContent=label; @@ -71,25 +81,62 @@ function cardSection(label,list,hlId,firstBatch,ci){ sec.appendChild(grid); const more=document.createElement("button"); more.type="button";more.className="ghost cc-more"; - let shown=0; + const first=firstBatch||CARD_PAGE; + let cur=list,shown=0,baseShown=0; // baseShown: 검색 전 렌더 수 — 해제 시 복원해야 + // 선택 카드가 DOM에 남는다(캡처는 wrapOf 로 DOM에서 찾음) function render(batch){ - const end=Math.min(list.length,shown+batch); + const end=Math.min(cur.length,shown+batch); for(;shown=list.length) more.remove(); - else more.textContent="더보기 ▾ (남은 "+(list.length-shown).toLocaleString()+"장)"; + if(cur===list) baseShown=Math.max(baseShown,shown); + if(shown>=cur.length) more.style.display="none"; + else{ + more.style.display=""; + more.textContent="더보기 ▾ (남은 "+(cur.length-shown).toLocaleString()+"장)"; + } applyUsedMarks(hlId); // 새로 그린 카드에도 사용중 표시 } more.addEventListener("click",()=>render(CARD_PAGE)); sec.appendChild(more); - render(firstBatch||CARD_PAGE); + render(first); + (SECS[hlId]=SECS[hlId]||[]).push(function setFilter(q){ + grid.innerHTML="";shown=0; + if(!q){cur=list;sec.style.display="";render(Math.max(baseShown,first));return;} + cur=list.filter(i=>{const c=byIdx[i];return c&&searchStr(c).includes(q);}); + sec.style.display=cur.length?"":"none"; + render(CARD_PAGE); + }); return sec; } +/* ── 패널 검색창 — 입력하면 그 패널의 모든 섹션이 일치 카드만 다시 그림 ── */ +function searchBar(hlId){ + SECS[hlId]=[]; // 패널 재생성 시 이전 섹션 필터 폐기 + const bar=document.createElement("div"); + bar.className="ccsearch"; + bar.innerHTML=''+ + ''; + const inp=bar.querySelector("input"); + let t=null; + function apply(){ + const q=inp.value.trim().toLowerCase(); + (SECS[hlId]||[]).forEach(f=>f(q)); + } + inp.addEventListener("input",()=>{clearTimeout(t);t=setTimeout(apply,150);}); + bar.querySelector(".ccsx").addEventListener("click",()=>{inp.value="";apply();inp.focus();}); + bar._clear=()=>{if(inp.value){inp.value="";clearTimeout(t);apply();}}; + return bar; +} +/* 빌드/캡처 전 필수 — 검색으로 카드가 DOM에서 빠진 채 캡처하면 조용히 누락된다 */ +function clearSearch(hlId){ + const box=$("#hlbox-"+hlId); + const bar=box&&box.querySelector(".ccsearch"); + if(bar&&bar._clear) bar._clear(); +} /* ── 컷별 선택 ── 카드는 '어느 컷 소속'인지가 배치를 정한다. selCut[hlId][idx] = 컷 인덱스. sel[hlId] 는 항상 컷 순서로 정렬해 둔다 (업로드 순서 = 배치 순서라서). */ @@ -490,6 +537,7 @@ function onResult(ev){ box.dataset.titles=JSON.stringify(opts); // 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘 box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id)); + box.appendChild(searchBar(hl.id)); // 카드 섹션들 위 — cardSection 보다 먼저 만들어야 SECS 초기화됨 const WHY={ts:"⭐",ai:"🤖",like:"➕"}; const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined); if(hl.cuts){ @@ -589,6 +637,7 @@ async function buildAll(){ let ok=0,fail=0; for(const hl of hls){ showId(hl.id); // 캡처는 보이는 상태에서 + clearSearch(hl.id); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨 await nextFrame(); boardSet(hl.id,"🔄 진행","", "active"); try{ @@ -716,6 +765,7 @@ async function ytMatch(){ ''+ ' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)'+ '
'; + box.appendChild(searchBar("yt")); box.appendChild(cardSection( "⭐ 구간을 언급한 댓글 "+matched.length+"장 (좋아요순, 자동 선택)", matched,"yt",Math.max(CARD_PAGE,sel["yt"].length))); @@ -733,6 +783,7 @@ async function ytMatch(){ window.ytCC={ active:function(){return !!(YT_HL&&sel["yt"]&&sel["yt"].length);}, capture:async function(){ + clearSearch("yt"); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨 const out=[]; for(const idx of sel["yt"]){ const w=wrapOf("yt",idx); diff --git a/server/static/index.html b/server/static/index.html index b4a1857..17f668e 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -251,6 +251,12 @@ .selonly-label{display:inline-flex;align-items:center;gap:6px;color:var(--muted2);font-size:12px; cursor:pointer;margin-left:auto;padding:6px;} .hlbox.selonly .cardsec .ccwrap:not(.sel){display:none;} + /* 댓글 검색창 */ + .ccsearch{display:flex;gap:6px;align-items:center;margin-top:10px;} + .ccsearch input{flex:1;max-width:420px;min-height:40px;padding:8px 12px;background:var(--surf); + border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;} + .ccsearch input:focus{outline:none;border-color:var(--accent);} + .ccsearch .ccsx{width:auto;min-height:40px;padding:6px 13px;flex:none;margin:0;} /* 카드 그리드 */ .cardsec{display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;} .cardsec .ccwrap{margin:0;}