diff --git a/server/static/auto.js b/server/static/auto.js
index 445cf83..7a91d64 100644
--- a/server/static/auto.js
+++ b/server/static/auto.js
@@ -30,6 +30,9 @@ function hlById(id){
function cardEl(c,hlId,ci){
const wrap=document.createElement("div");
wrap.className="ccwrap"; wrap.dataset.cidx=c.idx;
+ // 컷 소속을 DOM에 남긴다 — 없으면(폴백/구간 탭) 어느 복사본이 어느 컷 것인지 구분 불가해
+ // refreshSel 이 모든 복사본에 선택 링을 붙이는 버그가 난다(컷마다 후보 전체가 반복 렌더되므로).
+ if(ci!==undefined) wrap.dataset.ci=ci;
const card=document.createElement("div");
card.className="comment-card mosaic"; // 검정배경은 CSS 기본값
const av=c.profileImageUrl?"/auto/avatar?url="+encodeURIComponent(c.profileImageUrl):"";
@@ -137,6 +140,27 @@ function clearSearch(hlId){
const bar=box&&box.querySelector(".ccsearch");
if(bar&&bar._clear) bar._clear();
}
+/* ── 컷 아코디언 — 컷마다 '추천 + 채우기' 두 섹션을 한 블록으로 접는다.
+ 한 번에 하나만 펼쳐 화면에 후보가 다닥다닥 쌓이는 걸 막는다(카드는 컷 수만큼 DOM에
+ 복제되므로 전부 펼치면 매우 길어진다). 헤더 채움 상태는 refreshSel 에서 갱신한다. */
+function cutGroup(ci,title,sec,quota,isOpen){
+ const grp=document.createElement("div");
+ grp.className="cutgrp"+(isOpen?" open":"");
+ grp.dataset.ci=ci; grp.dataset.sec=sec; grp.dataset.quota=quota;
+ const head=document.createElement("button");
+ head.type="button"; head.className="cutgrp-head";
+ head.innerHTML='▶'+
+ '';
+ head.querySelector(".cgtitle").textContent=title;
+ head.addEventListener("click",()=>{
+ const opening=!grp.classList.contains("open");
+ grp.parentElement.querySelectorAll(":scope > .cutgrp").forEach(g=>g.classList.remove("open"));
+ grp.classList.toggle("open",opening);
+ });
+ const body=document.createElement("div"); body.className="cutgrp-body";
+ grp.appendChild(head); grp.appendChild(body);
+ return {grp,body};
+}
/* ── 카드 패널 렌더 — 자동/구간/붙여넣기 세 탭이 공유한다.
갈라 두면 한 곳만 고치는 실수가 난다(배지·검색·선택 상한이 전부 여기 모여 있다). ── */
function renderCutPanel(box,panelId,data,opts){
@@ -155,15 +179,17 @@ function renderCutPanel(box,panelId,data,opts){
const rest=(data.matched||[]).filter(i=>
byIdx[i]!==undefined&&!rec.includes(i)&&
(byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end));
- box.appendChild(cardSection(
+ const {grp,body}=cutGroup(cu.i,unit+" "+(cu.i+1),cu.sec,cu.quota,cu.i===0);
+ body.appendChild(cardSection(
unit+" "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
(cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""),
rec.concat(rest),panelId,Math.max(CARD_PAGE,rec.length),cu.i));
// 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로
if(cand.length)
- box.appendChild(cardSection(
+ body.appendChild(cardSection(
"↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)",
cand,panelId,CUT_FILL_PAGE,cu.i));
+ box.appendChild(grp);
}
}else{
// 폴백 — cuts 정보가 없으면(구간 탭·구정보 없음) 예전 화면 그대로: ⭐ + 독립 ➕
@@ -196,8 +222,18 @@ function toggle(hlId,idx,ci){
const hl=hlById(hlId), list=sel[hlId];
selCut[hlId]=selCut[hlId]||{};
const at=list.indexOf(idx);
- if(at>=0){ list.splice(at,1); delete selCut[hlId][idx]; }
- else{
+ if(at>=0){
+ const cur=selCut[hlId][idx];
+ // 이미 다른 컷에 배정된 댓글의 쌍둥이 카드를 누르면 해제가 아니라 '그 컷으로 옮기기'다
+ // — 사용자는 이 댓글을 지우려는 게 아니라 지금 보고 있는 컷에 넣으려는 것.
+ if(hl.cuts&&ci!==undefined&&cur!==undefined&&cur!==ci){
+ if(cutFull(hl,ci)) return; // 옮길 컷이 이미 꽉 참 — 아무것도 안 함
+ selCut[hlId][idx]=ci;
+ sortSel(hlId); // 업로드 순서 = 컷 순서 유지
+ }else{
+ list.splice(at,1); delete selCut[hlId][idx];
+ }
+ }else{
if(hl.cuts&&ci!==undefined){
if(cutFull(hl,ci)) return; // 그 컷 장수 초과 방지
selCut[hlId][idx]=ci;
@@ -213,8 +249,25 @@ function toggle(hlId,idx,ci){
function refreshSel(hlId){
const hl=hlById(hlId), list=sel[hlId];
const box=$("#hlbox-"+hlId); if(!box) return;
- box.querySelectorAll(".ccwrap").forEach(w=>
- w.classList.toggle("sel",list.includes(parseInt(w.dataset.cidx,10))));
+ const m=selCut[hlId]||{};
+ // 컷별 채우기 섹션은 같은 댓글의 복사본을 컷마다 다시 그린다 — 링은 '배정된 컷'의
+ // 복사본에만 붙인다(배정이 없으면 폴백 패널이라 항상 붙인다). 그래야 1컷에서 고른 카드가
+ // 2컷 복사본에도 ✓로 보여 헷갈리는 일이 없다. 나머지 복사본은 applyUsedMarks 가 표시한다.
+ box.querySelectorAll(".ccwrap").forEach(w=>{
+ const idx=parseInt(w.dataset.cidx,10);
+ const assigned=m[idx];
+ const wCi=("ci" in w.dataset)?parseInt(w.dataset.ci,10):undefined;
+ const isThisCopy=(assigned===undefined)||(assigned===wCi);
+ w.classList.toggle("sel",list.includes(idx)&&isThisCopy);
+ });
+ // 컷 아코디언 헤더 채움 상태(선택 수/quota) — 선택이 바뀔 때마다 갱신
+ box.querySelectorAll(".cutgrp").forEach(g=>{
+ const ci=parseInt(g.dataset.ci,10);
+ const cnt=list.filter(i=>m[i]===ci).length;
+ const stat=g.querySelector(".cgstat");
+ if(stat) stat.textContent="· "+g.dataset.sec+"초 · "+cnt+"/"+g.dataset.quota+"장";
+ });
+ applyUsedMarks(hlId); // 크로스 ID 사용중 + 같은 ID 안 다른 컷 쌍둥이 표시(한 곳에서 관리)
// 탭 카운터
const tab=$("#idtab-"+hlId);
if(tab){
@@ -276,15 +329,29 @@ function usedByOthers(hlId,idx){
}
function applyUsedMarks(hlId){
const box=$("#hlbox-"+hlId); if(!box) return;
+ const hl=hlById(hlId);
+ const m=(hl&&hl.cuts)?(selCut[hlId]||{}):null; // 컷 개념이 있는 패널만 쌍둥이 체크
box.querySelectorAll(".ccwrap").forEach(w=>{
const idx=parseInt(w.dataset.cidx,10);
- const ids=usedByOthers(hlId,idx);
- w.classList.toggle("used",ids.length>0);
+ let text=null;
+ // ① 같은 ID 안에서, 이 댓글이 실제로 배정된 컷과 다른 컷의 복사본 — '이동' 대상임을 알려준다
+ if(m){
+ const assigned=m[idx];
+ const wCi=("ci" in w.dataset)?parseInt(w.dataset.ci,10):undefined;
+ if(assigned!==undefined&&wCi!==undefined&&assigned!==wCi)
+ text="컷 "+(assigned+1)+"에서 사용중";
+ }
+ // ② 다른 ID(탭)에서 이미 선택한 댓글 — 기존 표시(문구 구분 유지)
+ if(!text){
+ const ids=usedByOthers(hlId,idx);
+ if(ids.length) text="ID "+ids.join("·")+" 사용중";
+ }
+ w.classList.toggle("used",!!text);
let b=w.querySelector(".ccused");
- if(ids.length){
+ if(text){
if(!b){b=document.createElement("div");b.className="ccused";w.appendChild(b);}
- b.textContent="ID "+ids.join("·")+" 사용중";
- w.title="ID "+ids.join(", ")+"에서 이미 선택한 댓글";
+ b.textContent=text;
+ w.title=text;
}else{
if(b) b.remove();
w.removeAttribute("title");
@@ -613,6 +680,15 @@ async function captureCard(wrap){
}
const nextFrame=()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
+/* 빌드/캡처 전 필수(검색 초기화와 같은 이유) — 접힌 컷 섹션은 display:none 이라
+ 레이아웃이 확정되지 않아 그 안의 카드를 domToBlob 으로 찍으면 빈 이미지가 나온다.
+ 캡처는 wrapOf() 가 DOM에서 '첫 번째로 찾은' 복사본을 찍으므로, 그 복사본이 하필
+ 접힌 컷 섹션 안에 있으면 조용히 깨진다 — 다음 사람이 "펼칠 필요 없는데?" 하고
+ 지우면 이 버그가 다시 살아난다. 지우지 말 것. */
+function expandAllCuts(hlId){
+ const box=$("#hlbox-"+hlId); if(!box) return;
+ box.querySelectorAll(".cutgrp").forEach(g=>g.classList.add("open"));
+}
/* 빌드 진행판 — 버튼 아래 ID별 한 줄씩, 전체 상황이 한눈에 보임 */
function boardInit(hls){
const bd=$("#buildBoard");
@@ -642,6 +718,7 @@ async function buildAll(){
for(const hl of hls){
showId(hl.id); // 캡처는 보이는 상태에서
clearSearch(hl.id); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨
+ expandAllCuts(hl.id); // 접힌 컷 섹션의 카드는 빈 이미지로 캡처된다 — 전부 펼침
await nextFrame();
boardSet(hl.id,"🔄 진행","", "active");
try{
diff --git a/server/static/index.html b/server/static/index.html
index 17f668e..dc48226 100644
--- a/server/static/index.html
+++ b/server/static/index.html
@@ -236,6 +236,21 @@
.cutraw pre{margin:0;padding:11px 13px;background:#080A0E;font-family:var(--mono);font-size:11px;
line-height:1.55;color:var(--muted2);max-height:280px;overflow:auto;}
.cutcopy{margin:0 13px 11px;width:auto;min-height:34px;padding:6px 12px;}
+ /* ── 컷별 카드 아코디언 — 한 번에 한 컷만 펼쳐 후보 카드가 한꺼번에 쌓이지 않게 한다 ── */
+ .cutgrp{margin-top:10px;border:1px solid var(--border);border-radius:9px;
+ background:var(--surf2);overflow:hidden;}
+ .cutgrp-head{width:100%;box-sizing:border-box;display:flex;align-items:center;gap:8px;
+ text-align:left;cursor:pointer;background:transparent;border:0;color:var(--muted2);
+ padding:11px 13px;min-height:44px;font-size:12.5px;font-weight:600;transition:background .15s,color .15s;}
+ .cutgrp-head:hover{color:var(--text);background:var(--surf);}
+ .cutgrp.open>.cutgrp-head{color:var(--text);border-bottom:1px solid var(--border);}
+ .cutgrp-head .chev{flex:none;color:var(--muted);font-size:10px;transition:transform .18s ease;}
+ .cutgrp.open .chev{transform:rotate(90deg);}
+ .cutgrp-head .cgstat{margin-left:auto;flex:none;font-family:var(--mono);font-size:11.5px;
+ color:var(--muted);font-weight:400;}
+ .cutgrp.open .cgstat{color:var(--accent);}
+ .cutgrp-body{display:none;padding:0 13px 12px;}
+ .cutgrp.open .cutgrp-body{display:block;}
/* 선택한 댓글 요약 바 */
.selbar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:10px;
padding:10px;background:#101010;border:1px solid var(--border);border-radius:8px;min-height:44px;}