h-lab/src/main/resources/static/js/comment-cards.js
hehihoho3@gmail.com b6bfa5046d feat: 댓글 카드 검색어 쉼표 OR 검색 지원
검색 입력을 쉼표로 구분하면 각 단어 중 하나라도 포함된 댓글을 표시한다.
공백은 구분자가 아니므로 여러 단어를 이어 쓴 구절 검색은 그대로 동작한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:24:47 +09:00

690 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
window.__all = []; // 전체 수집 댓글(분석 기준)
window.__cards = []; // 호환용 별칭
window.__videoId = null; // 타임라인 링크용
let mosaicOn = true; // 프로필·아이디 모자이크 기본 ON
let roundedOn = true; // 모서리 둥글게 토글(기본: 둥근 모서리)
let wordMaskOn = true; // 단어 가리기 기본 ON(단어 클릭=가림). 끄면 카드 클릭=복사.
let textScale = 1; // 내용 글자 크기 배율(카드 크기는 유지, .cc-text만 확대/축소)
let bgMode = 'black'; // 카드 배경 기본값: default|white|black (black이면 글자 흰색)
let exportScale = 4; // PNG 저장 배율(카드 CSS 픽셀 × 배율). 영상에서 확대해도 안 깨지게 기본 4배.
let dirHandle = null; // 저장 폴더(File System Access API). null이면 브라우저 다운로드 폴백.
let dirOptOut = false; // 사용자가 폴더 지정을 직접 해제 → '전체 저장' 시 다시 묻지 않음
const savedIds = new Set(); // 이미 저장한 댓글 id(재렌더·필터 변경에도 표시 유지)
const DISPLAY_CAP = 2000; // 카드 DOM 렌더 상한(브라우저 보호). 분석은 전체 기준.
const $ = (id) => document.getElementById(id);
function esc(s) {
const d = document.createElement('div');
d.textContent = String(s == null ? '' : s);
return d.innerHTML;
}
function timeAgo(iso) {
if (!iso) return '';
const then = new Date(iso).getTime();
if (isNaN(then)) return '';
const sec = Math.floor((Date.now() - then) / 1000);
const units = [['년', 31536000], ['개월', 2592000], ['일', 86400], ['시간', 3600], ['분', 60]];
for (const [label, s] of units) {
const v = Math.floor(sec / s);
if (v >= 1) return v + label + ' 전';
}
return '방금 전';
}
// YouTube textDisplay(HTML) → 안전한 평문 (태그 제거, <br>→줄바꿈)
function toPlainText(html) {
const tmp = document.createElement('div');
tmp.innerHTML = String(html).replace(/<br\s*\/?>/gi, '\n');
return tmp.textContent || '';
}
// URL/ID → videoId (백엔드 YoutubeVideoIdParser 미러)
function parseVideoId(s) {
s = (s || '').trim();
if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
const pats = [
/[?&]v=([A-Za-z0-9_-]{11})/,
/youtu\.be\/([A-Za-z0-9_-]{11})/,
/\/shorts\/([A-Za-z0-9_-]{11})/,
/\/embed\/([A-Za-z0-9_-]{11})/,
];
for (const p of pats) { const m = s.match(p); if (m) return m[1]; }
return null;
}
// 제외 단어: 쉼표/공백으로 구분. 하나라도 포함된 댓글은 카드·분석 모두에서 제외.
function getExcludeTerms() {
const raw = ($('ccExclude') && $('ccExclude').value) || '';
return raw.split(/[,\s]+/).map(s => s.trim().toLowerCase()).filter(Boolean);
}
// 검색어: 쉼표로 구분하면 OR 검색(하나라도 포함되면 표시). 공백은 구분자가 아님 → 구절 검색 유지.
function getSearchTerms() {
const raw = ($('ccSearch') && $('ccSearch').value) || '';
return raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
}
function excludeComments(list, terms) {
if (!terms || !terms.length) return list;
return list.filter(c => {
const t = toPlainText(c.text).toLowerCase();
return !terms.some(term => t.includes(term));
});
}
function debounce(fn, ms) {
let h = null;
return function () { clearTimeout(h); h = setTimeout(fn, ms); };
}
// 댓글 텍스트를 단어 span으로 분해(공백/줄바꿈 보존). 단어 가리기 ON일 때 클릭하면 해당 단어만 블러.
function buildWords(el, plain) {
el.textContent = '';
for (const p of plain.split(/(\s+)/)) {
if (!p) continue;
if (/^\s+$/.test(p)) { el.appendChild(document.createTextNode(p)); continue; }
const s = document.createElement('span');
s.className = 'cc-word';
s.textContent = p;
s.addEventListener('click', (e) => {
if (!wordMaskOn) return; // 꺼짐 모드: 무시 → 이벤트 버블 → 카드 클릭=복사
e.stopPropagation();
s.classList.toggle('masked');
});
el.appendChild(s);
}
}
function applyFilterSort(cards) {
const sort = $('ccSort').value;
const minLikes = parseInt($('ccMinLikes').value, 10) || 0;
const repliesOnly = $('ccRepliesOnly').checked;
const terms = getSearchTerms();
let list = cards.filter(c => {
if ((c.likeCount || 0) < minLikes) return false;
if (repliesOnly && (c.replyCount || 0) <= 0) return false;
if (!terms.length) return true;
const t = toPlainText(c.text).toLowerCase(); // 내용에만 매칭
return terms.some(term => t.includes(term)); // 쉼표 구분 = OR
});
if (sort === 'likes') list.sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0));
else if (sort === 'replies') list.sort((a, b) => (b.replyCount || 0) - (a.replyCount || 0));
else if (sort === 'latest') list.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
return list;
}
window.renderCards = function () {
const grid = $('cardGrid');
const base = excludeComments(window.__all, getExcludeTerms());
let list = applyFilterSort(base);
const total = list.length;
const capped = total > DISPLAY_CAP;
if (capped) list = list.slice(0, DISPLAY_CAP);
$('ccCount').textContent = total.toLocaleString() + '개'
+ (capped ? ' (상위 ' + DISPLAY_CAP.toLocaleString() + '개 표시)' : '');
grid.classList.toggle('wordmask', wordMaskOn);
grid.innerHTML = '';
list.forEach((c, i) => {
const card = document.createElement('div');
card.className = 'comment-card'
+ (mosaicOn ? ' mosaic' : '')
+ (roundedOn ? ' rounded' : '')
+ (bgMode === 'white' ? ' bg-white' : bgMode === 'black' ? ' bg-black' : '')
+ (savedIds.has(c.__id) ? ' saved' : ''); // 이미 저장한 카드는 회색 톤 유지
if (textScale !== 1) card.style.setProperty('--cc-text-scale', String(textScale));
card.dataset.index = i;
card.dataset.cid = c.__id;
const avatarSrc = c.profileImageUrl
? '/api/comment-cards/avatar?url=' + encodeURIComponent(c.profileImageUrl)
: '';
const head = document.createElement('div');
head.className = 'cc-head';
head.innerHTML =
'<img class="cc-avatar" crossorigin="anonymous" loading="lazy" decoding="async" alt="" src="' + avatarSrc + '">' +
'<div class="cc-meta">' +
'<div><span class="cc-author"></span><span class="cc-time"></span></div>' +
'<div class="cc-text"></div>' +
'<div class="cc-stats"><span>👍 ' + (c.likeCount || 0).toLocaleString() + '</span>' +
'<span>💬 ' + (c.replyCount || 0).toLocaleString() + '</span></div>' +
'</div>';
head.querySelector('.cc-author').textContent = c.authorName || '';
head.querySelector('.cc-time').textContent = timeAgo(c.publishedAt);
buildWords(head.querySelector('.cc-text'), toPlainText(c.text));
// 단어 가리기 ON: 카드 클릭 복사 잠금(단어 클릭으로 가림). OFF: 카드 클릭=복사.
card.title = '단어 클릭 = 가리기 · 토글을 끄면 카드 클릭 = 복사';
card.setAttribute('role', 'button');
card.setAttribute('tabindex', '0');
card.setAttribute('aria-label', (c.authorName || '댓글') + ' 카드 · 복사');
card.addEventListener('click', () => { if (wordMaskOn) return; window.copyCard(card); });
card.addEventListener('keydown', (e) => { // 키보드 접근: Enter/Space로 복사
if (wordMaskOn) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); window.copyCard(card); }
});
// 저장(다운로드) 버튼 — cc-noexport라 캡처 이미지엔 안 찍힘. 캡컷엔 이 파일을 불러오기.
const actions = document.createElement('div');
actions.className = 'cc-actions cc-noexport';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'cc-iconbtn';
saveBtn.addEventListener('click', (e) => { e.stopPropagation(); window.downloadCard(card, c); });
setSaveBtn(saveBtn, savedIds.has(c.__id), false);
actions.appendChild(saveBtn);
card.appendChild(head);
card.appendChild(actions);
grid.appendChild(card);
});
if (window.lucide) window.lucide.createIcons();
};
/* ---------------------------------------------------------------------
* 분석 (전부 클라이언트 계산 — 추가 API 쿼터 0)
* ------------------------------------------------------------------- */
const KO_STOP = new Set(('그리고 그런데 그래서 하지만 그러나 그냥 정말 진짜 너무 완전 이거 저거 그거 이건 저건 그건 근데 ' +
'이렇게 저렇게 그렇게 하는 하고 해서 있는 있고 없는 같아요 같은 같다 되게 무슨 어떤 이런 저런 그런 여기 저기 거기 ' +
'우리 저는 제가 요즘 지금 오늘 내가 니가 네가 저도 나도 이제 그리 그럼 아니 아마 많이 조금 그때 이때 대한 위해 ' +
'부분 사람 생각 때문 정도 그게 이게 저게 그건데 뭔가 거의 계속 진짜로 그거는').split(/\s+/));
const EN_STOP = new Set(('the a an and or but if is are was were be been to of in on for it this that with you your my ' +
'me we he she they i so just not no yes at as by from about into out up down all can will do does did have has had ' +
'im its lol').split(/\s+/));
// 조사 꼬리 제거(휴리스틱) — 긴 것부터
const JOSA = ['으로써', '으로서', '에서는', '에게서', '이라는', '이라고', '으로', '로써', '로서', '에서', '에게', '한테',
'까지', '부터', '조차', '마저', '밖에', '처럼', '만큼', '보다', '이나', '이랑', '라는', '라고', '다는', '에는', '에도',
'든지', '이며', '이고', '은', '는', '이', '가', '을', '를', '에', '의', '도', '로', '과', '와', '만', '랑', '님', '들']
.sort((a, b) => b.length - a.length);
function stripJosa(w) {
for (const j of JOSA) {
if (w.length > j.length + 1 && w.endsWith(j)) return w.slice(0, -j.length);
}
return w;
}
function tokenize(text) {
const t = toPlainText(text).toLowerCase().replace(/https?:\/\/\S+/g, ' ');
const raw = t.replace(/[^\p{L}\p{N}\sㄱ-ㅎㅏ-ㅣ]/gu, ' ').split(/\s+/);
const out = [];
for (let w of raw) {
if (!w) continue;
if (/^[0-9]+$/.test(w)) continue; // 순수 숫자 제외
if (/[가-힣]/.test(w)) w = stripJosa(w); // 한글 어절이면 조사 제거
if (w.length < 2) continue;
if (KO_STOP.has(w) || EN_STOP.has(w)) continue;
out.push(w);
}
return out;
}
function computeWords(all, weighted) {
const map = new Map();
for (const c of all) {
const wt = weighted ? (1 + (c.likeCount || 0)) : 1;
for (const w of tokenize(c.text)) map.set(w, (map.get(w) || 0) + wt);
}
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20);
}
// 댓글 속 타임스탬프(mm:ss / h:mm:ss) 추출
const TS_RE = /(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d)/g;
function fmtTime(s) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(ss).padStart(2, '0');
}
function computeTimeline(all) {
const map = new Map();
for (const c of all) {
const txt = toPlainText(c.text);
let m; TS_RE.lastIndex = 0;
const seen = new Set(); // 한 댓글 내 같은 시각 중복 제거
while ((m = TS_RE.exec(txt))) {
const sec = m[3] !== undefined
? (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3])
: (+m[1]) * 60 + (+m[2]);
if (seen.has(sec)) continue; seen.add(sec);
map.set(sec, (map.get(sec) || 0) + 1);
}
}
return [...map.entries()]
.sort((a, b) => b[1] - a[1]).slice(0, 15)
.map(([sec, cnt]) => ({ sec, cnt, label: fmtTime(sec) }));
}
function computeStats(all) {
let likes = 0, max = null;
const byAuthor = new Map(), byMonth = new Map();
for (const c of all) {
likes += c.likeCount || 0;
if (!max || (c.likeCount || 0) > (max.likeCount || 0)) max = c;
byAuthor.set(c.authorName, (byAuthor.get(c.authorName) || 0) + 1);
const mo = (c.publishedAt || '').slice(0, 7);
if (mo) byMonth.set(mo, (byMonth.get(mo) || 0) + 1);
}
const topAuthor = [...byAuthor.entries()].sort((a, b) => b[1] - a[1])[0];
const months = [...byMonth.entries()].sort((a, b) => a[0].localeCompare(b[0]));
return {
count: all.length, likes, avg: all.length ? likes / all.length : 0,
max, authors: byAuthor.size, topAuthor, months,
};
}
function tile(l, v) {
return '<div class="cc-tile"><div class="cc-tile-v">' + esc(v) + '</div><div class="cc-tile-l">' + esc(l) + '</div></div>';
}
function bar(label, val, max, suffix) {
const pct = max ? Math.max(4, Math.round(val / max * 100)) : 0;
return '<div class="cc-bar-row">'
+ '<span class="cc-bar-label">' + esc(label) + '</span>'
+ '<span class="cc-bar-track"><span class="cc-bar-fill" style="width:' + pct + '%"></span></span>'
+ '<span class="cc-bar-val">' + esc(val.toLocaleString()) + (suffix || '') + '</span></div>';
}
function renderAnalytics(all) {
const box = $('ccAnalytics');
const exTerms = getExcludeTerms();
all = excludeComments(all || [], exTerms);
if (!all.length) { box.style.display = 'none'; box.innerHTML = ''; return; }
const weighted = !!($('ccWeighted') && $('ccWeighted').checked);
const tlSort = ($('ccTlSort') && $('ccTlSort').value) || 'count';
const stats = computeStats(all);
let tl = computeTimeline(all); // 언급 많은 순 top-N 선별
if (tlSort === 'time') tl = tl.slice().sort((a, b) => a.sec - b.sec); // 선별본을 시간순 재정렬
const words = computeWords(all, weighted);
const vid = window.__videoId;
let html = '<div class="cc-an-head"><b><i data-lucide="bar-chart-3"></i> 분석</b> '
+ '<span class="cc-an-sub">' + all.length.toLocaleString() + '개 댓글 기준'
+ (exTerms.length ? ' · 제외어 적용' : '') + '</span>'
+ '<button type="button" class="cc-iconbtn" id="ccAnToggle" aria-expanded="true">접기</button></div>'
+ '<div id="ccAnBody">';
html += '<div class="cc-tiles">'
+ tile('총 댓글', stats.count.toLocaleString())
+ tile('총 좋아요', stats.likes.toLocaleString())
+ tile('평균 좋아요', stats.avg.toFixed(1))
+ tile('참여자', stats.authors.toLocaleString() + '명')
+ '</div>';
// 타임라인
html += '<div class="cc-an-sec"><div class="cc-an-sechead"><h4><i data-lucide="clock"></i> 많이 언급된 타임라인</h4>'
+ '<select id="ccTlSort" class="cc-mini-select">'
+ '<option value="count"' + (tlSort === 'count' ? ' selected' : '') + '>언급순</option>'
+ '<option value="time"' + (tlSort === 'time' ? ' selected' : '') + '>시간순</option>'
+ '</select></div>';
if (tl.length) {
const mx = tl[0].cnt;
html += '<div class="cc-bars">' + tl.map(t => {
const inner = bar(t.label, t.cnt, mx, '회');
return vid
? '<a class="cc-tl-link" href="https://www.youtube.com/watch?v=' + encodeURIComponent(vid) + '&t=' + t.sec + 's" target="_blank" rel="noopener">' + inner + '</a>'
: inner;
}).join('') + '</div>';
} else {
html += '<p class="cc-an-empty">댓글에 타임스탬프 언급이 없습니다.</p>';
}
html += '</div>';
// 단어
html += '<div class="cc-an-sec"><div class="cc-an-sechead"><h4><i data-lucide="type"></i> 자주 나온 단어</h4>'
+ '<label class="cc-wlabel"><input type="checkbox" id="ccWeighted"' + (weighted ? ' checked' : '') + '> 좋아요 가중</label></div>';
if (words.length) {
const mx = words[0][1];
html += '<div class="cc-bars">' + words.map(([w, n]) => bar(w, Math.round(n), mx)).join('') + '</div>';
} else {
html += '<p class="cc-an-empty">분석할 단어가 부족합니다.</p>';
}
html += '</div>';
// 최다 좋아요 댓글
if (stats.max) {
html += '<div class="cc-an-sec"><h4><i data-lucide="award"></i> 최다 좋아요 댓글</h4>'
+ '<div class="cc-top-comment"><b>' + esc(stats.max.authorName) + '</b> · 👍 '
+ (stats.max.likeCount || 0).toLocaleString()
+ '<div class="cc-top-text">' + esc(toPlainText(stats.max.text).slice(0, 200)) + '</div></div></div>';
}
// 월별 댓글량
if (stats.months.length > 1) {
const mx = Math.max(...stats.months.map(m => m[1]));
html += '<div class="cc-an-sec"><h4><i data-lucide="calendar"></i> 월별 댓글량</h4><div class="cc-months">'
+ stats.months.slice(-18).map(([mo, n]) =>
'<div class="cc-mo" title="' + esc(mo) + ': ' + n + '">'
+ '<span class="cc-mo-val">' + n.toLocaleString() + '</span>' // 값 상시 노출
+ '<span class="cc-mo-bar" style="height:' + Math.max(6, Math.round(n / mx * 70)) + 'px"></span>'
+ '<span class="cc-mo-lb">' + esc(mo.slice(2)) + '</span></div>').join('')
+ '</div></div>';
}
html += '</div>'; // #ccAnBody
box.innerHTML = html;
box.style.display = 'block';
if (window.lucide) window.lucide.createIcons(); // 섹션 헤더 아이콘(이모지 대체) 렌더
const tgl = $('ccAnToggle');
if (tgl) tgl.addEventListener('click', () => {
const b = $('ccAnBody');
const hidden = b.style.display === 'none';
b.style.display = hidden ? '' : 'none';
tgl.textContent = hidden ? '접기' : '펼치기';
tgl.setAttribute('aria-expanded', String(hidden));
});
const wt = $('ccWeighted');
if (wt) wt.addEventListener('change', () => renderAnalytics(window.__all));
const tls = $('ccTlSort');
if (tls) tls.addEventListener('change', () => renderAnalytics(window.__all));
}
/* ------------------------- 캡처/복사/저장 ------------------------- */
// 카드 저장 버튼의 저장 전/후 모습.
// renderCards 루프에서는 renderIcons=false — 아이콘은 렌더 끝에 한 번만 그린다(카드 수만큼 전체 스캔 방지).
function setSaveBtn(btn, saved, renderIcons) {
btn.classList.toggle('saved', saved);
btn.innerHTML = saved
? '<i data-lucide="check" style="width:13px;height:13px;"></i> 저장됨'
: '<i data-lucide="download" style="width:13px;height:13px;"></i> 저장';
btn.setAttribute('aria-label', saved ? '저장 완료 (다시 저장하려면 클릭)' : 'PNG 파일로 저장');
btn.title = saved ? '이미 저장한 카드 · 다시 저장하려면 클릭' : 'PNG 파일로 저장 (캡컷에서 불러오기)';
if (renderIcons && window.lucide) window.lucide.createIcons();
}
// 저장 완료 표시: 카드 회색 톤(.saved) + 버튼 '저장됨'. 캡처 이미지엔 반영 안 됨(captureBlob에서 제외).
function markSaved(cardEl) {
const cid = cardEl.dataset.cid;
if (cid !== undefined) savedIds.add(Number(cid));
cardEl.classList.add('saved');
const btn = cardEl.querySelector('.cc-actions .cc-iconbtn');
if (btn) setSaveBtn(btn, true, true);
}
async function captureBlob(cardEl) {
const ms = window.modernScreenshot;
if (!ms || !ms.domToBlob) throw new Error('캡처 라이브러리 로드 실패');
// 저장 표시(회색 톤)는 화면 전용 → 캡처 동안만 벗겨서 원본 그대로 찍는다
const wasSaved = cardEl.classList.contains('saved');
if (wasSaved) cardEl.classList.remove('saved');
try {
// scale = 저장 배율. 카드 폭이 ~320px라 2배로는 영상에서 확대 시 글자가 뭉개짐 → 기본 4배.
return await ms.domToBlob(cardEl, {
backgroundColor: null,
scale: exportScale,
filter: (node) => !(node instanceof Element && node.classList.contains('cc-noexport')),
});
} finally {
if (wasSaved) cardEl.classList.add('saved');
}
}
function downloadBlob(blob) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'comment-card.png';
a.click();
URL.revokeObjectURL(a.href);
}
let toastTimer = null;
function showToast(msg) {
let t = document.getElementById('ccToast');
if (!t) {
t = document.createElement('div');
t.id = 'ccToast';
t.className = 'cc-toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 1500);
}
function buildFileName(c) {
var who = (c && c.authorName) ? c.authorName.replace(/[^\w가-힣]+/g, '').slice(0, 20) : 'card';
return 'comment-' + (who || 'card') + '.png';
}
// 선택한 폴더에 blob을 파일로 씀(File System Access API)
async function writeToDir(blob, filename) {
const fh = await dirHandle.getFileHandle(filename, { create: true });
const w = await fh.createWritable();
await w.write(blob);
await w.close();
}
// 폴더가 선택돼 있으면 그 폴더로, 아니면 브라우저 다운로드 폴더로 저장
async function saveBlob(blob, filename) {
if (dirHandle) { await writeToDir(blob, filename); return true; }
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
return false;
}
// 폴더 지정 해제 → 이후 저장은 브라우저 기본 다운로드 폴더로
function clearDir() {
dirHandle = null;
dirOptOut = true;
$('ccPickDirName').textContent = '저장 폴더';
setToggle($('ccPickDir'), false);
showToast('폴더 지정 해제 → 다운로드 폴더로 저장');
}
// '저장 폴더' 버튼 — 지정돼 있으면 해제(다운로드 폴더), 아니면 폴더 선택
// 선택한 폴더는 세션 동안 이 사이트에만 적용(크롬 전역 다운로드 설정과 무관)
async function pickDir() {
if (dirHandle) { clearDir(); return; }
if (!window.showDirectoryPicker) {
showToast('이 브라우저는 폴더 저장 미지원 (크롬/엣지 권장)');
return;
}
try {
dirHandle = await window.showDirectoryPicker({ id: 'cc-save', mode: 'readwrite' });
dirOptOut = false;
$('ccPickDirName').textContent = dirHandle.name;
setToggle($('ccPickDir'), true);
showToast('저장 폴더: ' + dirHandle.name);
} catch (e) { /* 사용자가 취소 → 무시 */ }
}
// '전체 저장' — 현재 표시된 카드를 순서대로 저장(폴더 선택 시 그 폴더로 일괄)
async function saveAllVisible() {
const cards = Array.from(document.querySelectorAll('#cardGrid .comment-card'));
if (!cards.length) { showToast('저장할 카드가 없어요'); return; }
// 폴더 미선택 상태면 먼저 폴더를 고르게 한다. 단, 사용자가 직접 해제했으면 묻지 않고 다운로드 폴더로.
if (!dirHandle && !dirOptOut && window.showDirectoryPicker) {
await pickDir();
if (!dirHandle) return; // 취소
}
if (!dirHandle && !confirm(cards.length + '개 파일이 각각 다운로드됩니다. 계속할까요?')) return;
if (cards.length > 50 && !confirm(cards.length + '개를 저장할까요? 시간이 걸릴 수 있어요.')) return;
const btn = $('ccSaveAll');
btn.disabled = true;
const pad = String(cards.length).length;
let ok = 0, fail = 0;
for (let i = 0; i < cards.length; i++) {
btn.textContent = '저장 중… ' + (i + 1) + '/' + cards.length;
try {
const blob = await captureBlob(cards[i]);
await saveBlob(blob, 'comment-' + String(i + 1).padStart(pad, '0') + '.png');
markSaved(cards[i]);
ok++;
} catch (e) { fail++; }
}
btn.disabled = false;
btn.textContent = '전체 저장';
showToast('완료: ' + ok + '개 저장' + (fail ? ', ' + fail + ' 실패' : '')
+ (dirHandle ? ' → ' + dirHandle.name : ' (다운로드 폴더)'));
}
window.downloadCard = async function (cardEl, c) {
if (cardEl.dataset.busy === '1') return;
cardEl.dataset.busy = '1';
try {
const blob = await captureBlob(cardEl);
const toDir = await saveBlob(blob, buildFileName(c));
markSaved(cardEl);
showToast(toDir ? '저장됨 → ' + dirHandle.name : '저장됨 (다운로드 폴더)');
} catch (e) {
showToast('저장 실패');
} finally {
setTimeout(() => { delete cardEl.dataset.busy; }, 600);
}
};
window.copyCard = async function (cardEl) {
if (cardEl.dataset.copying === '1') return; // 중복 클릭 방지
cardEl.dataset.copying = '1';
try {
const blob = await captureBlob(cardEl);
if (navigator.clipboard && window.ClipboardItem) {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
showToast('복사됨 ✓');
} else {
downloadBlob(blob);
showToast('다운로드됨');
}
} catch (e) {
// 클립보드 권한 거부 등 → 다운로드 폴백
try {
const blob = await captureBlob(cardEl);
downloadBlob(blob);
showToast('다운로드됨');
} catch (e2) {
showToast('복사 실패');
}
} finally {
setTimeout(() => { delete cardEl.dataset.copying; }, 600);
}
};
async function fetchComments() {
const url = $('ccUrl').value.trim();
if (!url) return;
window.__videoId = parseVideoId(url);
const btn = $('ccFetch');
btn.disabled = true; btn.textContent = '수집 중…';
$('ccEmpty').innerHTML = '<span class="cc-spinner" role="status" aria-label="수집 중"></span>'
+ '전체 댓글을 모으는 중입니다. 댓글이 많으면 시간이 걸릴 수 있어요…';
try {
const res = await fetch('/api/comment-cards/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const json = await res.json();
if (!json.success) throw new Error(json.message || '가져오기 실패');
window.__all = json.data || [];
// 재렌더(필터·정렬)에도 유지되는 고유 id 부여 → 저장 표시가 카드에 붙어 다님
window.__all.forEach((c, i) => { c.__id = i; });
savedIds.clear(); // 새로 수집한 영상이므로 저장 표시 초기화
window.__cards = window.__all;
// 딥링크: 공유·새로고침 시 결과 유지되도록 URL에 videoId 반영
if (window.__videoId) history.replaceState(null, '', '?v=' + encodeURIComponent(window.__videoId));
$('ccFilters').style.display = window.__all.length ? 'flex' : 'none';
$('ccEmpty').textContent = window.__all.length ? '' : '댓글이 없습니다.';
renderAnalytics(window.__all);
window.renderCards();
} catch (e) {
$('ccFilters').style.display = 'none';
$('ccAnalytics').style.display = 'none';
$('cardGrid').innerHTML = '';
$('ccEmpty').textContent = '⚠️ ' + e.message;
} finally {
btn.disabled = false; btn.textContent = '가져오기';
}
}
// 토글 버튼 공통: 상태 클래스(.active)·aria-pressed 갱신
function setToggle(btn, on) {
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', String(on));
}
document.addEventListener('DOMContentLoaded', () => {
$('ccFetch').addEventListener('click', fetchComments);
$('ccUrl').addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchComments(); });
// 즉시 반영해도 가벼운 것(정렬·체크박스)
['ccSort', 'ccRepliesOnly'].forEach(id =>
$(id).addEventListener('input', () => window.renderCards()));
// 대량 카드(최대 2,000장) 대비 검색·좋아요 필터는 디바운스
const debouncedRender = debounce(() => window.renderCards(), 200);
['ccSearch', 'ccMinLikes'].forEach(id => $(id).addEventListener('input', debouncedRender));
// 제외 단어는 카드+분석 둘 다 갱신.
$('ccExclude').addEventListener('input', debounce(() => {
window.renderCards();
renderAnalytics(window.__all);
}, 250));
$('ccMosaic').addEventListener('click', () => {
mosaicOn = !mosaicOn;
$('ccMosaic').textContent = mosaicOn ? '모자이크 해제' : '전체 모자이크';
setToggle($('ccMosaic'), mosaicOn);
document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('mosaic', mosaicOn));
});
$('ccRounded').addEventListener('click', () => {
roundedOn = !roundedOn;
$('ccRounded').textContent = roundedOn ? '모서리 각지게' : '모서리 둥글게';
setToggle($('ccRounded'), roundedOn);
document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('rounded', roundedOn));
});
$('ccWordMask').addEventListener('click', () => {
wordMaskOn = !wordMaskOn;
$('ccWordMask').textContent = wordMaskOn ? '단어 가리기: 켜짐' : '단어 가리기: 꺼짐(복사)';
setToggle($('ccWordMask'), wordMaskOn);
$('cardGrid').classList.toggle('wordmask', wordMaskOn);
});
// 내용 글자 크기: 카드는 그대로, .cc-text만 배율 조정(재렌더·캡처에도 유지되도록 카드 인라인 변수)
function applyTextScale() {
$('ccFontLabel').textContent = Math.round(textScale * 100) + '%';
document.querySelectorAll('#cardGrid .comment-card').forEach(el =>
el.style.setProperty('--cc-text-scale', String(textScale)));
}
$('ccFontDown').addEventListener('click', () => {
textScale = Math.max(0.6, +(textScale - 0.1).toFixed(2));
applyTextScale();
});
$('ccFontUp').addEventListener('click', () => {
textScale = Math.min(2, +(textScale + 0.1).toFixed(2));
applyTextScale();
});
// 저장 화질(배율) — 캡처 시에만 쓰이므로 재렌더 불필요
$('ccScale').addEventListener('change', () => {
exportScale = parseInt($('ccScale').value, 10) || 4;
});
// 배경색: 기본 → 흰색 → 검정 순환. 검정이면 글자는 흰색(대비).
const BG_MODES = ['default', 'white', 'black'];
const BG_LABEL = { default: '배경: 기본', white: '배경: 흰색', black: '배경: 검정' };
$('ccBg').addEventListener('click', () => {
bgMode = BG_MODES[(BG_MODES.indexOf(bgMode) + 1) % BG_MODES.length];
$('ccBg').textContent = BG_LABEL[bgMode];
setToggle($('ccBg'), bgMode !== 'default');
document.querySelectorAll('#cardGrid .comment-card').forEach(el => {
el.classList.toggle('bg-white', bgMode === 'white');
el.classList.toggle('bg-black', bgMode === 'black');
});
});
$('ccPickDir').addEventListener('click', pickDir);
$('ccSaveAll').addEventListener('click', saveAllVisible);
// 폴더 저장 미지원 브라우저(파이어폭스/사파리)면 폴더 버튼 숨김 → 전체 저장은 다운로드 폴백
if (!window.showDirectoryPicker) $('ccPickDir').style.display = 'none';
// 딥링크: URL ?v=<id>가 있으면 자동 조회(공유·새로고침 시 결과 유지)
const params = new URLSearchParams(location.search);
const v = params.get('v');
if (v) { $('ccUrl').value = v; fetchComments(); }
});
})();