feat(comment-cards): 저장 폴더 선택 + 표시된 카드 전체 저장

- '저장 폴더' 버튼(File System Access API)으로 폴더를 한 번 고르면
  ⬇ 저장·전체 저장이 그 폴더로 바로 저장(이 사이트 세션에만 적용)
- '전체 저장' 버튼: 현재 표시된 카드를 순서대로 일괄 저장(진행 표시)
- 미지원 브라우저(파이어폭스/사파리)는 폴더 버튼 숨김·기존 다운로드 폴백

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-07-10 12:05:12 +09:00
parent f109d08539
commit 5ad597c97a
2 changed files with 76 additions and 8 deletions

View File

@ -5,6 +5,7 @@
let mosaicOn = true; // 프로필·아이디 모자이크 기본 ON let mosaicOn = true; // 프로필·아이디 모자이크 기본 ON
let roundedOn = false; // 모서리 둥글게 토글(기본: 각진 모서리) let roundedOn = false; // 모서리 둥글게 토글(기본: 각진 모서리)
let wordMaskOn = true; // 단어 가리기 기본 ON(단어 클릭=가림). 끄면 카드 클릭=복사. let wordMaskOn = true; // 단어 가리기 기본 ON(단어 클릭=가림). 끄면 카드 클릭=복사.
let dirHandle = null; // 저장 폴더(File System Access API). null이면 브라우저 다운로드 폴백.
const DISPLAY_CAP = 2000; // 카드 DOM 렌더 상한(브라우저 보호). 분석은 전체 기준. const DISPLAY_CAP = 2000; // 카드 DOM 렌더 상한(브라우저 보호). 분석은 전체 기준.
@ -406,17 +407,76 @@
return 'comment-' + (who || 'card') + '.png'; 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;
}
// '저장 폴더 선택' — 세션 동안 이 폴더로 저장(이 사이트에만 적용, 크롬 전역 설정 무관)
async function pickDir() {
if (!window.showDirectoryPicker) {
showToast('이 브라우저는 폴더 저장 미지원 (크롬/엣지 권장)');
return;
}
try {
dirHandle = await window.showDirectoryPicker({ id: 'cc-save', mode: 'readwrite' });
$('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 && 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');
ok++;
} catch (e) { fail++; }
}
btn.disabled = false;
btn.textContent = '전체 저장';
showToast('완료: ' + ok + '개 저장' + (fail ? ', ' + fail + ' 실패' : '')
+ (dirHandle ? ' → ' + dirHandle.name : ' (다운로드 폴더)'));
}
window.downloadCard = async function (cardEl, c) { window.downloadCard = async function (cardEl, c) {
if (cardEl.dataset.busy === '1') return; if (cardEl.dataset.busy === '1') return;
cardEl.dataset.busy = '1'; cardEl.dataset.busy = '1';
try { try {
const blob = await captureBlob(cardEl); const blob = await captureBlob(cardEl);
const a = document.createElement('a'); const toDir = await saveBlob(blob, buildFileName(c));
a.href = URL.createObjectURL(blob); showToast(toDir ? '저장됨 → ' + dirHandle.name : '저장됨 (다운로드 폴더)');
a.download = buildFileName(c);
a.click();
URL.revokeObjectURL(a.href);
showToast('저장됨 ⬇ (캡컷에서 불러오기)');
} catch (e) { } catch (e) {
showToast('저장 실패'); showToast('저장 실패');
} finally { } finally {
@ -523,6 +583,11 @@
$('cardGrid').classList.toggle('wordmask', wordMaskOn); $('cardGrid').classList.toggle('wordmask', wordMaskOn);
}); });
$('ccPickDir').addEventListener('click', pickDir);
$('ccSaveAll').addEventListener('click', saveAllVisible);
// 폴더 저장 미지원 브라우저(파이어폭스/사파리)면 폴더 버튼 숨김 → 전체 저장은 다운로드 폴백
if (!window.showDirectoryPicker) $('ccPickDir').style.display = 'none';
// 딥링크: URL ?v=<id>가 있으면 자동 조회(공유·새로고침 시 결과 유지) // 딥링크: URL ?v=<id>가 있으면 자동 조회(공유·새로고침 시 결과 유지)
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const v = params.get('v'); const v = params.get('v');

View File

@ -13,6 +13,7 @@
.cc-btn { background:var(--primary-gradient); color:#fff; border:none; border-radius:8px; .cc-btn { background:var(--primary-gradient); color:#fff; border:none; border-radius:8px;
padding:.55rem 1rem; font-weight:600; cursor:pointer; font-size:14px; min-height:40px; } padding:.55rem 1rem; font-weight:600; cursor:pointer; font-size:14px; min-height:40px; }
.cc-btn.secondary { background:var(--surface-2); color:var(--text); border:1px solid var(--border); } .cc-btn.secondary { background:var(--surface-2); color:var(--text); border:1px solid var(--border); }
.cc-btn [data-lucide] { width:15px; height:15px; vertical-align:-3px; }
/* 토글 ON 상태: accent로 구분 */ /* 토글 ON 상태: accent로 구분 */
.cc-btn.secondary.active { background:var(--accent-soft); border-color:var(--primary); color:var(--primary); } .cc-btn.secondary.active { background:var(--accent-soft); border-color:var(--primary); color:var(--primary); }
/* 키보드 포커스 링(모든 상호작용 요소) */ /* 키보드 포커스 링(모든 상호작용 요소) */
@ -105,7 +106,7 @@
<h1 style="font-size:1.5rem;font-weight:700;margin-bottom:.3rem;">댓글 카드</h1> <h1 style="font-size:1.5rem;font-weight:700;margin-bottom:.3rem;">댓글 카드</h1>
<p style="color:var(--text-3);margin-bottom:1.2rem;font-size:14px;"> <p style="color:var(--text-3);margin-bottom:1.2rem;font-size:14px;">
유튜브 링크의 <b>전체 댓글</b>을 가져와 분석(많이 언급된 타임라인·자주 나온 단어·요약 통계)하고 카드로 만듭니다. 유튜브 링크의 <b>전체 댓글</b>을 가져와 분석(많이 언급된 타임라인·자주 나온 단어·요약 통계)하고 카드로 만듭니다.
모자이크는 기본 켜짐, <b>모서리 둥글게</b>로 각진/라운드 전환. 기본은 <b>단어 클릭=가리기</b>(블러), <b>단어 가리기</b> 토글을 끄면 <b>카드 클릭=이미지 복사</b>. <b>저장 버튼=PNG 파일 다운로드</b>(캡컷은 붙여넣기가 안 되니 저장한 파일을 "불러오기" 하세요). 모자이크는 기본 켜짐, <b>모서리 둥글게</b>로 각진/라운드 전환. 기본은 <b>단어 클릭=가리기</b>(블러), <b>단어 가리기</b> 토글을 끄면 <b>카드 클릭=이미지 복사</b>. <b>저장 버튼=PNG 파일 다운로드</b>(캡컷은 붙여넣기가 안 되니 저장한 파일을 "불러오기" 하세요). <b>저장 폴더</b>를 고르면 저장·<b>전체 저장</b>이 그 폴더로 바로 갑니다(크롬/엣지).
</p> </p>
<div class="cc-toolbar"> <div class="cc-toolbar">
@ -129,6 +130,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="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>
@ -141,7 +144,7 @@
<th:block layout:fragment="script"> <th:block layout:fragment="script">
<!-- DOM→이미지 캡처 (Task 5에서 사용). blur 필터 지원 위해 modern-screenshot 사용. --> <!-- DOM→이미지 캡처 (Task 5에서 사용). blur 필터 지원 위해 modern-screenshot 사용. -->
<script src="https://cdn.jsdelivr.net/npm/modern-screenshot@4/dist/index.js"></script> <script src="https://cdn.jsdelivr.net/npm/modern-screenshot@4/dist/index.js"></script>
<script th:src="@{/js/comment-cards.js(v=20260710)}"></script> <script th:src="@{/js/comment-cards.js(v=20260710b)}"></script>
</th:block> </th:block>
</body> </body>
</html> </html>