feat(comment-cards): 저장한 카드 표시 + 저장 폴더 토글 해제

- 저장 완료 카드에 회색 톤(.saved)과 '저장됨 ✓' 버튼 상태 부여
- 저장 표시는 화면 전용: 캡처 직전 .saved를 벗겨 PNG엔 남지 않음
- 댓글에 고유 id를 붙여 필터·정렬 재렌더 후에도 저장 표시 유지
- '저장 폴더' 버튼 재클릭 시 지정 해제 → 브라우저 다운로드 폴더로 저장
  (해제 이후 '전체 저장'은 폴더를 다시 묻지 않음)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-07-14 12:40:54 +09:00
parent 5ad597c97a
commit 73aebf82d3
2 changed files with 64 additions and 13 deletions

View File

@ -6,6 +6,8 @@
let roundedOn = false; // 모서리 둥글게 토글(기본: 각진 모서리) let roundedOn = false; // 모서리 둥글게 토글(기본: 각진 모서리)
let wordMaskOn = true; // 단어 가리기 기본 ON(단어 클릭=가림). 끄면 카드 클릭=복사. let wordMaskOn = true; // 단어 가리기 기본 ON(단어 클릭=가림). 끄면 카드 클릭=복사.
let dirHandle = null; // 저장 폴더(File System Access API). null이면 브라우저 다운로드 폴백. let dirHandle = null; // 저장 폴더(File System Access API). null이면 브라우저 다운로드 폴백.
let dirOptOut = false; // 사용자가 폴더 지정을 직접 해제 → '전체 저장' 시 다시 묻지 않음
const savedIds = new Set(); // 이미 저장한 댓글 id(재렌더·필터 변경에도 표시 유지)
const DISPLAY_CAP = 2000; // 카드 DOM 렌더 상한(브라우저 보호). 분석은 전체 기준. const DISPLAY_CAP = 2000; // 카드 DOM 렌더 상한(브라우저 보호). 분석은 전체 기준.
@ -117,8 +119,10 @@
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'comment-card' card.className = 'comment-card'
+ (mosaicOn ? ' mosaic' : '') + (mosaicOn ? ' mosaic' : '')
+ (roundedOn ? ' rounded' : ''); + (roundedOn ? ' rounded' : '')
+ (savedIds.has(c.__id) ? ' saved' : ''); // 이미 저장한 카드는 회색 톤 유지
card.dataset.index = i; card.dataset.index = i;
card.dataset.cid = c.__id;
const avatarSrc = c.profileImageUrl const avatarSrc = c.profileImageUrl
? '/api/comment-cards/avatar?url=' + encodeURIComponent(c.profileImageUrl) ? '/api/comment-cards/avatar?url=' + encodeURIComponent(c.profileImageUrl)
@ -155,10 +159,8 @@
const saveBtn = document.createElement('button'); const saveBtn = document.createElement('button');
saveBtn.type = 'button'; saveBtn.type = 'button';
saveBtn.className = 'cc-iconbtn'; saveBtn.className = 'cc-iconbtn';
saveBtn.innerHTML = '<i data-lucide="download" style="width:13px;height:13px;"></i> 저장';
saveBtn.setAttribute('aria-label', 'PNG 파일로 저장');
saveBtn.title = 'PNG 파일로 저장 (캡컷에서 불러오기)';
saveBtn.addEventListener('click', (e) => { e.stopPropagation(); window.downloadCard(card, c); }); saveBtn.addEventListener('click', (e) => { e.stopPropagation(); window.downloadCard(card, c); });
setSaveBtn(saveBtn, savedIds.has(c.__id));
actions.appendChild(saveBtn); actions.appendChild(saveBtn);
card.appendChild(head); card.appendChild(head);
@ -369,14 +371,41 @@
/* ------------------------- 캡처/복사/저장 ------------------------- */ /* ------------------------- 캡처/복사/저장 ------------------------- */
// 카드 저장 버튼의 저장 전/후 모습
function setSaveBtn(btn, saved) {
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 (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);
}
async function captureBlob(cardEl) { async function captureBlob(cardEl) {
const ms = window.modernScreenshot; const ms = window.modernScreenshot;
if (!ms || !ms.domToBlob) throw new Error('캡처 라이브러리 로드 실패'); if (!ms || !ms.domToBlob) throw new Error('캡처 라이브러리 로드 실패');
// 저장 표시(회색 톤)는 화면 전용 → 캡처 동안만 벗겨서 원본 그대로 찍는다
const wasSaved = cardEl.classList.contains('saved');
if (wasSaved) cardEl.classList.remove('saved');
try {
return await ms.domToBlob(cardEl, { return await ms.domToBlob(cardEl, {
backgroundColor: null, backgroundColor: null,
scale: 2, scale: 2,
filter: (node) => !(node instanceof Element && node.classList.contains('cc-noexport')), filter: (node) => !(node instanceof Element && node.classList.contains('cc-noexport')),
}); });
} finally {
if (wasSaved) cardEl.classList.add('saved');
}
} }
function downloadBlob(blob) { function downloadBlob(blob) {
@ -426,14 +455,26 @@
return false; return false;
} }
// '저장 폴더 선택' — 세션 동안 이 폴더로 저장(이 사이트에만 적용, 크롬 전역 설정 무관) // 폴더 지정 해제 → 이후 저장은 브라우저 기본 다운로드 폴더로
function clearDir() {
dirHandle = null;
dirOptOut = true;
$('ccPickDirName').textContent = '저장 폴더';
setToggle($('ccPickDir'), false);
showToast('폴더 지정 해제 → 다운로드 폴더로 저장');
}
// '저장 폴더' 버튼 — 지정돼 있으면 해제(다운로드 폴더), 아니면 폴더 선택
// 선택한 폴더는 세션 동안 이 사이트에만 적용(크롬 전역 다운로드 설정과 무관)
async function pickDir() { async function pickDir() {
if (dirHandle) { clearDir(); return; }
if (!window.showDirectoryPicker) { if (!window.showDirectoryPicker) {
showToast('이 브라우저는 폴더 저장 미지원 (크롬/엣지 권장)'); showToast('이 브라우저는 폴더 저장 미지원 (크롬/엣지 권장)');
return; return;
} }
try { try {
dirHandle = await window.showDirectoryPicker({ id: 'cc-save', mode: 'readwrite' }); dirHandle = await window.showDirectoryPicker({ id: 'cc-save', mode: 'readwrite' });
dirOptOut = false;
$('ccPickDirName').textContent = dirHandle.name; $('ccPickDirName').textContent = dirHandle.name;
setToggle($('ccPickDir'), true); setToggle($('ccPickDir'), true);
showToast('저장 폴더: ' + dirHandle.name); showToast('저장 폴더: ' + dirHandle.name);
@ -444,8 +485,8 @@
async function saveAllVisible() { async function saveAllVisible() {
const cards = Array.from(document.querySelectorAll('#cardGrid .comment-card')); const cards = Array.from(document.querySelectorAll('#cardGrid .comment-card'));
if (!cards.length) { showToast('저장할 카드가 없어요'); return; } if (!cards.length) { showToast('저장할 카드가 없어요'); return; }
// 폴더 미선택 상태에서 폴더 저장 지원 브라우저면 먼저 폴더 고르게 // 폴더 미선택 상태면 먼저 폴더 고르게 한다. 단, 사용자가 직접 해제했으면 묻지 않고 다운로드 폴더로.
if (!dirHandle && window.showDirectoryPicker) { if (!dirHandle && !dirOptOut && window.showDirectoryPicker) {
await pickDir(); await pickDir();
if (!dirHandle) return; // 취소 if (!dirHandle) return; // 취소
} }
@ -461,6 +502,7 @@
try { try {
const blob = await captureBlob(cards[i]); const blob = await captureBlob(cards[i]);
await saveBlob(blob, 'comment-' + String(i + 1).padStart(pad, '0') + '.png'); await saveBlob(blob, 'comment-' + String(i + 1).padStart(pad, '0') + '.png');
markSaved(cards[i]);
ok++; ok++;
} catch (e) { fail++; } } catch (e) { fail++; }
} }
@ -476,6 +518,7 @@
try { try {
const blob = await captureBlob(cardEl); const blob = await captureBlob(cardEl);
const toDir = await saveBlob(blob, buildFileName(c)); const toDir = await saveBlob(blob, buildFileName(c));
markSaved(cardEl);
showToast(toDir ? '저장됨 → ' + dirHandle.name : '저장됨 (다운로드 폴더)'); showToast(toDir ? '저장됨 → ' + dirHandle.name : '저장됨 (다운로드 폴더)');
} catch (e) { } catch (e) {
showToast('저장 실패'); showToast('저장 실패');
@ -527,6 +570,9 @@
const json = await res.json(); const json = await res.json();
if (!json.success) throw new Error(json.message || '가져오기 실패'); if (!json.success) throw new Error(json.message || '가져오기 실패');
window.__all = json.data || []; window.__all = json.data || [];
// 재렌더(필터·정렬)에도 유지되는 고유 id 부여 → 저장 표시가 카드에 붙어 다님
window.__all.forEach((c, i) => { c.__id = i; });
savedIds.clear(); // 새로 수집한 영상이므로 저장 표시 초기화
window.__cards = window.__all; window.__cards = window.__all;
// 딥링크: 공유·새로고침 시 결과 유지되도록 URL에 videoId 반영 // 딥링크: 공유·새로고침 시 결과 유지되도록 URL에 videoId 반영
if (window.__videoId) history.replaceState(null, '', '?v=' + encodeURIComponent(window.__videoId)); if (window.__videoId) history.replaceState(null, '', '?v=' + encodeURIComponent(window.__videoId));

View File

@ -50,6 +50,10 @@
border-radius:6px; font-size:12px; font-weight:600; padding:.4rem .6rem; cursor:pointer; border-radius:6px; font-size:12px; font-weight:600; padding:.4rem .6rem; cursor:pointer;
line-height:1; min-height:32px; display:inline-flex; align-items:center; gap:.25rem; } line-height:1; min-height:32px; display:inline-flex; align-items:center; gap:.25rem; }
.cc-iconbtn:hover { color:var(--text); border-color:var(--primary, #6366f1); } .cc-iconbtn:hover { color:var(--text); border-color:var(--primary, #6366f1); }
/* 저장 완료 표시 — 화면 전용(캡처 시 .saved를 벗겨서 찍으므로 PNG엔 안 남음) */
.comment-card.saved { background:var(--surface-2); border-color:var(--border);
box-shadow:inset 3px 0 0 var(--text-3); opacity:.9; }
.cc-iconbtn.saved { background:var(--surface); color:var(--text-3); }
/* 복사 완료 토스트 — 카드 밖(body)에 떠서 캡처 이미지에 안 찍힘 */ /* 복사 완료 토스트 — 카드 밖(body)에 떠서 캡처 이미지에 안 찍힘 */
.cc-toast { position:fixed; left:50%; bottom:2rem; transform:translateX(-50%) translateY(1rem); .cc-toast { position:fixed; left:50%; bottom:2rem; transform:translateX(-50%) translateY(1rem);
background:var(--text); color:var(--surface); padding:.6rem 1.1rem; border-radius:999px; background:var(--text); color:var(--surface); padding:.6rem 1.1rem; border-radius:999px;
@ -130,7 +134,8 @@
<button class="cc-btn secondary active" id="ccMosaic" aria-pressed="true">모자이크 해제</button> <button class="cc-btn secondary active" id="ccMosaic" aria-pressed="true">모자이크 해제</button>
<button class="cc-btn secondary" id="ccRounded" aria-pressed="false">모서리 둥글게</button> <button class="cc-btn secondary" id="ccRounded" aria-pressed="false">모서리 둥글게</button>
<button class="cc-btn secondary active" id="ccWordMask" aria-pressed="true">단어 가리기: 켜짐</button> <button class="cc-btn secondary active" id="ccWordMask" aria-pressed="true">단어 가리기: 켜짐</button>
<button class="cc-btn secondary" id="ccPickDir" aria-pressed="false"><i data-lucide="folder"></i> <span id="ccPickDirName">저장 폴더</span></button> <button class="cc-btn secondary" id="ccPickDir" aria-pressed="false"
title="클릭: 저장 폴더 지정 · 다시 클릭: 해제(브라우저 다운로드 폴더로 저장)"><i data-lucide="folder"></i> <span id="ccPickDirName">저장 폴더</span></button>
<button class="cc-btn secondary" id="ccSaveAll"><i data-lucide="download"></i> 전체 저장</button> <button class="cc-btn secondary" id="ccSaveAll"><i data-lucide="download"></i> 전체 저장</button>
<span id="ccCount" style="font-size:12px;color:var(--text-2);"></span> <span id="ccCount" style="font-size:12px;color:var(--text-2);"></span>
</div> </div>