검색 입력을 쉼표로 구분하면 각 단어 중 하나라도 포함된 댓글을 표시한다. 공백은 구분자가 아니므로 여러 단어를 이어 쓴 구절 검색은 그대로 동작한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
690 lines
31 KiB
JavaScript
690 lines
31 KiB
JavaScript
(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(); }
|
||
});
|
||
})();
|