diff --git a/src/main/resources/static/js/comment-cards.js b/src/main/resources/static/js/comment-cards.js index 9466d17..4858ab3 100644 --- a/src/main/resources/static/js/comment-cards.js +++ b/src/main/resources/static/js/comment-cards.js @@ -126,7 +126,7 @@ const head = document.createElement('div'); head.className = 'cc-head'; head.innerHTML = - '' + + '' + '
' + '
' + '
' + @@ -139,7 +139,14 @@ // 단어 가리기 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'); @@ -147,7 +154,8 @@ const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'cc-iconbtn'; - saveBtn.textContent = '⬇ 저장'; + saveBtn.innerHTML = ' 저장'; + saveBtn.setAttribute('aria-label', 'PNG 파일로 저장'); saveBtn.title = 'PNG 파일로 저장 (캡컷에서 불러오기)'; saveBtn.addEventListener('click', (e) => { e.stopPropagation(); window.downloadCard(card, c); }); actions.appendChild(saveBtn); @@ -276,10 +284,10 @@ const words = computeWords(all, weighted); const vid = window.__videoId; - let html = '
📊 분석 ' + let html = '
분석 ' + '' + all.length.toLocaleString() + '개 댓글 기준' + (exTerms.length ? ' · 제외어 적용' : '') + '' - + '
' + + '
' + '
'; html += '
' @@ -290,7 +298,7 @@ + '
'; // 타임라인 - html += '

⏱ 많이 언급된 타임라인

' + html += '

많이 언급된 타임라인

' + ' 좋아요 가중
'; if (words.length) { const mx = words[0][1]; @@ -321,7 +329,7 @@ // 최다 좋아요 댓글 if (stats.max) { - html += '

🏆 최다 좋아요 댓글

' + html += '

최다 좋아요 댓글

' + '
' + esc(stats.max.authorName) + ' · 👍 ' + (stats.max.likeCount || 0).toLocaleString() + '
' + esc(toPlainText(stats.max.text).slice(0, 200)) + '
'; @@ -330,9 +338,10 @@ // 월별 댓글량 if (stats.months.length > 1) { const mx = Math.max(...stats.months.map(m => m[1])); - html += '

📅 월별 댓글량

' + html += '

월별 댓글량

' + stats.months.slice(-18).map(([mo, n]) => '
' + + '' + n.toLocaleString() + '' // 값 상시 노출 + '' + '' + esc(mo.slice(2)) + '
').join('') + '
'; @@ -341,6 +350,7 @@ html += '
'; // #ccAnBody box.innerHTML = html; box.style.display = 'block'; + if (window.lucide) window.lucide.createIcons(); // 섹션 헤더 아이콘(이모지 대체) 렌더 const tgl = $('ccAnToggle'); if (tgl) tgl.addEventListener('click', () => { @@ -348,6 +358,7 @@ 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)); @@ -445,7 +456,8 @@ window.__videoId = parseVideoId(url); const btn = $('ccFetch'); btn.disabled = true; btn.textContent = '수집 중…'; - $('ccEmpty').textContent = '전체 댓글을 모으는 중입니다. 댓글이 많으면 시간이 걸릴 수 있어요…'; + $('ccEmpty').innerHTML = '' + + '전체 댓글을 모으는 중입니다. 댓글이 많으면 시간이 걸릴 수 있어요…'; try { const res = await fetch('/api/comment-cards/fetch', { method: 'POST', @@ -456,6 +468,8 @@ if (!json.success) throw new Error(json.message || '가져오기 실패'); window.__all = json.data || []; 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); @@ -470,12 +484,22 @@ } } + // 토글 버튼 공통: 상태 클래스(.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(); }); - ['ccSearch', 'ccSort', 'ccMinLikes', 'ccRepliesOnly'].forEach(id => + // 즉시 반영해도 가벼운 것(정렬·체크박스) + ['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); @@ -483,17 +507,25 @@ $('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 ? '🖍 단어 가리기: 켜짐' : '🖍 단어 가리기: 꺼짐(복사)'; + $('ccWordMask').textContent = wordMaskOn ? '단어 가리기: 켜짐' : '단어 가리기: 꺼짐(복사)'; + setToggle($('ccWordMask'), wordMaskOn); $('cardGrid').classList.toggle('wordmask', wordMaskOn); }); + + // 딥링크: URL ?v=가 있으면 자동 조회(공유·새로고침 시 결과 유지) + const params = new URLSearchParams(location.search); + const v = params.get('v'); + if (v) { $('ccUrl').value = v; fetchComments(); } }); })(); diff --git a/src/main/resources/templates/comment-cards.html b/src/main/resources/templates/comment-cards.html index 69bc37b..5aa2252 100644 --- a/src/main/resources/templates/comment-cards.html +++ b/src/main/resources/templates/comment-cards.html @@ -7,12 +7,26 @@ .cc-toolbar { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; margin-bottom:1rem; } .cc-toolbar input[type=text], .cc-toolbar select, .cc-toolbar input[type=number] { background:var(--surface-2); color:var(--text); border:1px solid var(--border); - border-radius:8px; padding:.5rem .7rem; font-size:14px; + border-radius:8px; padding:.5rem .7rem; font-size:16px; /* 16px: iOS 포커스 자동확대 방지 */ } .cc-url { flex:1; min-width:240px; } .cc-btn { background:var(--primary-gradient); color:#fff; border:none; border-radius:8px; - padding:.55rem 1rem; font-weight:600; cursor:pointer; font-size:14px; } + 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); } + /* 토글 ON 상태: accent로 구분 */ + .cc-btn.secondary.active { background:var(--accent-soft); border-color:var(--primary); color:var(--primary); } + /* 키보드 포커스 링(모든 상호작용 요소) */ + .cc-btn:focus-visible, .cc-iconbtn:focus-visible, .cc-mini-select:focus-visible, + .cc-toolbar input:focus-visible, .comment-card:focus-visible, .cc-tl-link:focus-visible { + outline:2px solid var(--primary); outline-offset:2px; } + /* 수집 중 스피너 */ + .cc-spinner { display:inline-block; width:14px; height:14px; border:2px solid var(--border); + border-top-color:var(--primary); border-radius:50%; animation:cc-spin .7s linear infinite; + vertical-align:-2px; margin-right:.45rem; } + @keyframes cc-spin { to { transform:rotate(360deg); } } + @media (prefers-reduced-motion: reduce) { + .cc-spinner { animation:none; } .cc-toast { transition:none; } + } /* 메이슨리(다단): 카드가 내용 높이에 딱 맞게 쌓여 짧은 글 아래 흰 여백이 안 생김 */ .cc-grid { column-width:320px; column-gap:1rem; } /* 카드: 영상 위에 얹을 소스 → 투명 캡처 기준. 미리보기는 surface 배경. */ @@ -25,14 +39,15 @@ .cc-avatar { width:40px; height:40px; border-radius:50%; flex-shrink:0; object-fit:cover; background:var(--surface-2); } .cc-meta { flex:1; min-width:0; } .cc-author { font-weight:600; font-size:13.5px; color:var(--text); } - .cc-time { font-size:11.5px; color:var(--text-3); margin-left:.4rem; } - .cc-text { font-size:14px; color:var(--text); margin-top:.35rem; white-space:pre-wrap; word-break:break-word; line-height:1.45; } - .cc-stats { font-size:12px; color:var(--text-3); margin-top:.5rem; display:flex; gap:1rem; } + .cc-time { font-size:11.5px; color:var(--text-2); margin-left:.4rem; } + .cc-text { font-size:14px; color:var(--text); margin-top:.35rem; white-space:pre-wrap; word-break:break-word; line-height:1.5; } + .cc-stats { font-size:12px; color:var(--text-2); margin-top:.5rem; display:flex; gap:1rem; font-variant-numeric:tabular-nums; } .comment-card { cursor:pointer; } /* 저장 버튼 — cc-noexport라 캡처 이미지에서 제외됨 */ .cc-actions { position:absolute; top:.5rem; right:.5rem; display:flex; gap:.3rem; } .cc-iconbtn { background:var(--surface-2); color:var(--text-2); border:1px solid var(--border); - border-radius:6px; font-size:11.5px; font-weight:600; padding:.25rem .5rem; cursor:pointer; line-height:1; } + 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; } .cc-iconbtn:hover { color:var(--text); border-color:var(--primary, #6366f1); } /* 복사 완료 토스트 — 카드 밖(body)에 떠서 캡처 이미지에 안 찍힘 */ .cc-toast { position:fixed; left:50%; bottom:2rem; transform:translateX(-50%) translateY(1rem); @@ -53,34 +68,36 @@ .cc-analytics { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:1rem 1.1rem; margin-bottom:1.2rem; } .cc-an-head { display:flex; align-items:center; gap:.6rem; margin-bottom:.9rem; font-size:15px; } - .cc-an-sub { color:var(--text-3); font-size:12.5px; font-weight:400; } + .cc-an-sub { color:var(--text-2); font-size:12.5px; font-weight:400; } .cc-an-head .cc-iconbtn { margin-left:auto; } + .cc-an-head [data-lucide], .cc-an-sec h4 [data-lucide] { width:16px; height:16px; vertical-align:-3px; } .cc-tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:.6rem; margin-bottom:1rem; } .cc-tile { background:var(--surface-2); border:1px solid var(--border); border-radius:10px; padding:.6rem .8rem; } - .cc-tile-v { font-size:1.25rem; font-weight:700; color:var(--text); line-height:1.2; } - .cc-tile-l { font-size:11.5px; color:var(--text-3); margin-top:.15rem; } + .cc-tile-v { font-size:1.25rem; font-weight:700; color:var(--text); line-height:1.2; font-variant-numeric:tabular-nums; } + .cc-tile-l { font-size:12px; color:var(--text-2); margin-top:.15rem; } .cc-an-sec { margin-top:1rem; } .cc-an-sec h4 { font-size:13.5px; font-weight:600; color:var(--text-2); margin:0 0 .5rem; } .cc-an-sechead { display:flex; align-items:center; justify-content:space-between; gap:.6rem; } .cc-wlabel { font-size:12px; color:var(--text-2); display:flex; align-items:center; gap:.3rem; cursor:pointer; } .cc-mini-select { background:var(--surface-2); color:var(--text); border:1px solid var(--border); - border-radius:8px; padding:.25rem .5rem; font-size:12px; cursor:pointer; } + border-radius:8px; padding:.35rem .6rem; font-size:13px; cursor:pointer; min-height:32px; } .cc-an-empty { color:var(--text-3); font-size:13px; margin:.2rem 0; } .cc-bars { display:flex; flex-direction:column; gap:.35rem; } .cc-bar-row { display:grid; grid-template-columns:76px 1fr 52px; align-items:center; gap:.5rem; font-size:12.5px; } .cc-bar-label { color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .cc-bar-track { background:var(--surface-2); border-radius:999px; height:9px; overflow:hidden; } .cc-bar-fill { display:block; height:100%; background:var(--primary-gradient); border-radius:999px; } - .cc-bar-val { color:var(--text-3); text-align:right; } + .cc-bar-val { color:var(--text-2); text-align:right; font-variant-numeric:tabular-nums; } a.cc-tl-link { text-decoration:none; } a.cc-tl-link:hover .cc-bar-label { text-decoration:underline; color:var(--primary,#6366f1); } .cc-top-comment { background:var(--surface-2); border:1px solid var(--border); border-radius:10px; padding:.6rem .8rem; font-size:12.5px; color:var(--text-2); } .cc-top-text { color:var(--text); margin-top:.3rem; white-space:pre-wrap; word-break:break-word; } - .cc-months { display:flex; align-items:flex-end; gap:.35rem; height:92px; overflow-x:auto; padding-top:.3rem; } - .cc-mo { display:flex; flex-direction:column; align-items:center; justify-content:flex-end; gap:.25rem; min-width:26px; } + .cc-months { display:flex; align-items:flex-end; gap:.35rem; height:108px; overflow-x:auto; padding-top:.3rem; } + .cc-mo { display:flex; flex-direction:column; align-items:center; justify-content:flex-end; gap:.2rem; min-width:28px; } + .cc-mo-val { font-size:10px; color:var(--text-2); font-variant-numeric:tabular-nums; } /* 값 상시 노출(hover 의존 X) */ .cc-mo-bar { display:block; width:16px; background:var(--primary-gradient); border-radius:4px 4px 0 0; } - .cc-mo-lb { font-size:10px; color:var(--text-3); white-space:nowrap; } + .cc-mo-lb { font-size:11px; color:var(--text-2); white-space:nowrap; } @@ -88,30 +105,31 @@

댓글 카드

유튜브 링크의 전체 댓글을 가져와 분석(많이 언급된 타임라인·자주 나온 단어·요약 통계)하고 카드로 만듭니다. - 모자이크는 기본 켜짐, 모서리 둥글게로 각진/라운드 전환. 기본은 단어 클릭=가리기(블러), 🖍 단어 가리기를 끄면 카드 클릭=이미지 복사. ⬇ 저장=PNG 파일 다운로드(캡컷은 붙여넣기가 안 되니 저장한 파일을 "불러오기" 하세요). + 모자이크는 기본 켜짐, 모서리 둥글게로 각진/라운드 전환. 기본은 단어 클릭=가리기(블러), 단어 가리기 토글을 끄면 카드 클릭=이미지 복사. 저장 버튼=PNG 파일 다운로드(캡컷은 붙여넣기가 안 되니 저장한 파일을 "불러오기" 하세요).

- +
@@ -123,7 +141,7 @@ - +