feat: 댓글 선택 화면에 검색(필터) 추가

카드가 수백~수천 장이라 원하는 댓글을 눈으로 찾기 어려웠다. 패널마다 검색창을
두고 모든 카드 섹션이 자기 전체 목록에서 필터링한다 — '더보기'로 안 펼친 카드도
검색되면 나온다.

캡처 누락 방지: 빌드/캡처는 wrapOf() 로 DOM에서 카드를 찾고 없으면 조용히
건너뛴다. 검색으로 선택 카드가 DOM에서 빠질 수 있으므로 buildAll 과
ytCC.capture 시작 시 clearSearch() 로 강제 해제한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 16:05:41 +09:00
parent 93fa0a3a21
commit 20a7c0c0a2
3 changed files with 103 additions and 6 deletions

View File

@ -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).

View File

@ -63,6 +63,16 @@ function wrapOf(hlId,idx){
const CARD_PAGE=30; const CARD_PAGE=30;
// '좋아요 상위에서 채우기' 섹션은 컷마다 반복된다 — 초기 렌더를 줄여 DOM 폭증을 막는다 // '좋아요 상위에서 채우기' 섹션은 컷마다 반복된다 — 초기 렌더를 줄여 DOM 폭증을 막는다
const CUT_FILL_PAGE=6; const CUT_FILL_PAGE=6;
/* 검색용 평문(본문+작성자, 소문자) — 댓글마다 1회 계산해 캐시 */
function searchStr(c){
if(c._st===undefined){
const tmp=document.createElement("div");
tmp.innerHTML=String(c.text||"").replace(/<br\s*\/?>/gi,"\n");
c._st=((tmp.textContent||"")+" "+(c.authorName||"")).toLowerCase();
}
return c._st;
}
const SECS={}; // hlId → [setFilter,…] (searchBar 가 패널 생성 때 초기화)
function cardSection(label,list,hlId,firstBatch,ci){ function cardSection(label,list,hlId,firstBatch,ci){
const sec=document.createElement("div");sec.className="hlsec"; const sec=document.createElement("div");sec.className="hlsec";
const head=document.createElement("div");head.textContent=label; const head=document.createElement("div");head.textContent=label;
@ -71,25 +81,62 @@ function cardSection(label,list,hlId,firstBatch,ci){
sec.appendChild(grid); sec.appendChild(grid);
const more=document.createElement("button"); const more=document.createElement("button");
more.type="button";more.className="ghost cc-more"; 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){ function render(batch){
const end=Math.min(list.length,shown+batch); const end=Math.min(cur.length,shown+batch);
for(;shown<end;shown++){ for(;shown<end;shown++){
const c=byIdx[list[shown]]; const c=byIdx[cur[shown]];
if(!c) continue; if(!c) continue;
const w=cardEl(c,hlId,ci); const w=cardEl(c,hlId,ci);
if(sel[hlId]&&sel[hlId].includes(c.idx)) w.classList.add("sel"); if(sel[hlId]&&sel[hlId].includes(c.idx)) w.classList.add("sel");
grid.appendChild(w); grid.appendChild(w);
} }
if(shown>=list.length) more.remove(); if(cur===list) baseShown=Math.max(baseShown,shown);
else more.textContent="더보기 ▾ (남은 "+(list.length-shown).toLocaleString()+"장)"; if(shown>=cur.length) more.style.display="none";
else{
more.style.display="";
more.textContent="더보기 ▾ (남은 "+(cur.length-shown).toLocaleString()+"장)";
}
applyUsedMarks(hlId); // 새로 그린 카드에도 사용중 표시 applyUsedMarks(hlId); // 새로 그린 카드에도 사용중 표시
} }
more.addEventListener("click",()=>render(CARD_PAGE)); more.addEventListener("click",()=>render(CARD_PAGE));
sec.appendChild(more); 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; return sec;
} }
/* ── 패널 검색창 — 입력하면 그 패널의 모든 섹션이 일치 카드만 다시 그림 ── */
function searchBar(hlId){
SECS[hlId]=[]; // 패널 재생성 시 이전 섹션 필터 폐기
const bar=document.createElement("div");
bar.className="ccsearch";
bar.innerHTML='<input type="search" placeholder="🔍 댓글 검색 (내용·작성자)">'+
'<button type="button" class="ghost ccsx" title="검색 지우기">✕</button>';
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] 항상 순서로 정렬해 둔다 selCut[hlId][idx] = 인덱스. sel[hlId] 항상 순서로 정렬해 둔다
(업로드 순서 = 배치 순서라서). */ (업로드 순서 = 배치 순서라서). */
@ -490,6 +537,7 @@ function onResult(ev){
box.dataset.titles=JSON.stringify(opts); box.dataset.titles=JSON.stringify(opts);
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘 // 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id)); box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
box.appendChild(searchBar(hl.id)); // 카드 섹션들 위 — cardSection 보다 먼저 만들어야 SECS 초기화됨
const WHY={ts:"⭐",ai:"🤖",like:""}; const WHY={ts:"⭐",ai:"🤖",like:""};
const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined); const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
if(hl.cuts){ if(hl.cuts){
@ -589,6 +637,7 @@ async function buildAll(){
let ok=0,fail=0; let ok=0,fail=0;
for(const hl of hls){ for(const hl of hls){
showId(hl.id); // 캡처는 보이는 상태에서 showId(hl.id); // 캡처는 보이는 상태에서
clearSearch(hl.id); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨
await nextFrame(); await nextFrame();
boardSet(hl.id,"🔄 진행","", "active"); boardSet(hl.id,"🔄 진행","", "active");
try{ try{
@ -716,6 +765,7 @@ async function ytMatch(){
'<input type="checkbox" id="ytccFixed" checked style="accent-color:var(--accent);width:14px;height:14px;">'+ '<input type="checkbox" id="ytccFixed" checked style="accent-color:var(--accent);width:14px;height:14px;">'+
' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)</label>'+ ' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)</label>'+
'<div class="selbar" id="hlsel-yt"></div>'; '<div class="selbar" id="hlsel-yt"></div>';
box.appendChild(searchBar("yt"));
box.appendChild(cardSection( box.appendChild(cardSection(
"⭐ 구간을 언급한 댓글 "+matched.length+"장 (좋아요순, 자동 선택)", "⭐ 구간을 언급한 댓글 "+matched.length+"장 (좋아요순, 자동 선택)",
matched,"yt",Math.max(CARD_PAGE,sel["yt"].length))); matched,"yt",Math.max(CARD_PAGE,sel["yt"].length)));
@ -733,6 +783,7 @@ async function ytMatch(){
window.ytCC={ window.ytCC={
active:function(){return !!(YT_HL&&sel["yt"]&&sel["yt"].length);}, active:function(){return !!(YT_HL&&sel["yt"]&&sel["yt"].length);},
capture:async function(){ capture:async function(){
clearSearch("yt"); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨
const out=[]; const out=[];
for(const idx of sel["yt"]){ for(const idx of sel["yt"]){
const w=wrapOf("yt",idx); const w=wrapOf("yt",idx);

View File

@ -251,6 +251,12 @@
.selonly-label{display:inline-flex;align-items:center;gap:6px;color:var(--muted2);font-size:12px; .selonly-label{display:inline-flex;align-items:center;gap:6px;color:var(--muted2);font-size:12px;
cursor:pointer;margin-left:auto;padding:6px;} cursor:pointer;margin-left:auto;padding:6px;}
.hlbox.selonly .cardsec .ccwrap:not(.sel){display:none;} .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{display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;}
.cardsec .ccwrap{margin:0;} .cardsec .ccwrap{margin:0;}