feat: 댓글 선택 화면에 검색(필터) 추가
카드가 수백~수천 장이라 원하는 댓글을 눈으로 찾기 어려웠다. 패널마다 검색창을 두고 모든 카드 섹션이 자기 전체 목록에서 필터링한다 — '더보기'로 안 펼친 카드도 검색되면 나온다. 캡처 누락 방지: 빌드/캡처는 wrapOf() 로 DOM에서 카드를 찾고 없으면 조용히 건너뛴다. 검색으로 선택 카드가 DOM에서 빠질 수 있으므로 buildAll 과 ytCC.capture 시작 시 clearSearch() 로 강제 해제한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
93fa0a3a21
commit
20a7c0c0a2
40
docs/superpowers/specs/2026-08-04-댓글검색-design.md
Normal file
40
docs/superpowers/specs/2026-08-04-댓글검색-design.md
Normal 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).
|
||||
@ -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(/<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){
|
||||
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<end;shown++){
|
||||
const c=byIdx[list[shown]];
|
||||
const c=byIdx[cur[shown]];
|
||||
if(!c) continue;
|
||||
const w=cardEl(c,hlId,ci);
|
||||
if(sel[hlId]&&sel[hlId].includes(c.idx)) w.classList.add("sel");
|
||||
grid.appendChild(w);
|
||||
}
|
||||
if(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='<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] 는 항상 컷 순서로 정렬해 둔다
|
||||
(업로드 순서 = 배치 순서라서). */
|
||||
@ -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(){
|
||||
'<input type="checkbox" id="ytccFixed" checked style="accent-color:var(--accent);width:14px;height:14px;">'+
|
||||
' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)</label>'+
|
||||
'<div class="selbar" id="hlsel-yt"></div>';
|
||||
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);
|
||||
|
||||
@ -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;}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user