feat(comment-cards): 댓글 카드 페이지·사이드바·렌더/정렬/필터 추가
/comment-cards 라우트, 유튜브 스타일 카드 렌더, 정렬/임계값 필터, 전체 모자이크 토글 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0e84f5de08
commit
418242f47d
@ -71,6 +71,12 @@ public class WebController {
|
|||||||
return "rework";
|
return "rework";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/comment-cards")
|
||||||
|
public String commentCards(Model model) {
|
||||||
|
model.addAttribute("currentPage", "comment-cards");
|
||||||
|
return "comment-cards";
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/production")
|
@GetMapping("/production")
|
||||||
public String production(Model model) {
|
public String production(Model model) {
|
||||||
model.addAttribute("currentPage", "production");
|
model.addAttribute("currentPage", "production");
|
||||||
|
|||||||
116
src/main/resources/static/js/comment-cards.js
Normal file
116
src/main/resources/static/js/comment-cards.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
(function () {
|
||||||
|
window.__cards = [];
|
||||||
|
let mosaicOn = false;
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
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 || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilterSort(cards) {
|
||||||
|
const sort = $('ccSort').value;
|
||||||
|
const minLikes = parseInt($('ccMinLikes').value, 10) || 0;
|
||||||
|
const repliesOnly = $('ccRepliesOnly').checked;
|
||||||
|
let list = cards.filter(c => (c.likeCount || 0) >= minLikes && (!repliesOnly || (c.replyCount || 0) > 0));
|
||||||
|
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 list = applyFilterSort(window.__cards);
|
||||||
|
$('ccCount').textContent = list.length + '개';
|
||||||
|
grid.innerHTML = '';
|
||||||
|
list.forEach((c, i) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'comment-card' + (mosaicOn ? ' mosaic' : '');
|
||||||
|
card.dataset.index = i;
|
||||||
|
|
||||||
|
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" 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);
|
||||||
|
head.querySelector('.cc-text').textContent = toPlainText(c.text);
|
||||||
|
|
||||||
|
const copyBtn = document.createElement('button');
|
||||||
|
copyBtn.className = 'cc-btn secondary cc-copy';
|
||||||
|
copyBtn.textContent = '복사';
|
||||||
|
copyBtn.addEventListener('click', () => window.copyCard(card, copyBtn)); // Task 5에서 정의
|
||||||
|
|
||||||
|
card.appendChild(head);
|
||||||
|
card.appendChild(copyBtn);
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchComments() {
|
||||||
|
const url = $('ccUrl').value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
const btn = $('ccFetch');
|
||||||
|
btn.disabled = true; btn.textContent = '가져오는 중…';
|
||||||
|
$('ccEmpty').textContent = '';
|
||||||
|
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.__cards = json.data || [];
|
||||||
|
$('ccFilters').style.display = window.__cards.length ? 'flex' : 'none';
|
||||||
|
$('ccEmpty').textContent = window.__cards.length ? '' : '댓글이 없습니다.';
|
||||||
|
window.renderCards();
|
||||||
|
} catch (e) {
|
||||||
|
$('ccFilters').style.display = 'none';
|
||||||
|
$('cardGrid').innerHTML = '';
|
||||||
|
$('ccEmpty').textContent = '⚠️ ' + e.message;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = '가져오기';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
$('ccFetch').addEventListener('click', fetchComments);
|
||||||
|
$('ccUrl').addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchComments(); });
|
||||||
|
['ccSort', 'ccMinLikes', 'ccRepliesOnly'].forEach(id =>
|
||||||
|
$(id).addEventListener('input', () => window.renderCards()));
|
||||||
|
$('ccMosaic').addEventListener('click', () => {
|
||||||
|
mosaicOn = !mosaicOn;
|
||||||
|
$('ccMosaic').textContent = mosaicOn ? '모자이크 해제' : '전체 모자이크';
|
||||||
|
document.querySelectorAll('.comment-card').forEach(el => el.classList.toggle('mosaic', mosaicOn));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
70
src/main/resources/templates/comment-cards.html
Normal file
70
src/main/resources/templates/comment-cards.html
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/base}">
|
||||||
|
<head>
|
||||||
|
<title>h-lab - 댓글 카드</title>
|
||||||
|
<style>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
.cc-btn.secondary { background:var(--surface-2); color:var(--text); border:1px solid var(--border); }
|
||||||
|
.cc-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:1rem; }
|
||||||
|
/* 카드: 영상 위에 얹을 소스 → 투명 캡처 기준. 미리보기는 surface 배경. */
|
||||||
|
.comment-card { background:var(--surface); border:1px solid var(--border); border-radius:12px;
|
||||||
|
padding:1rem; position:relative; }
|
||||||
|
.cc-head { display:flex; gap:.6rem; align-items:flex-start; }
|
||||||
|
.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-copy { position:absolute; top:.6rem; right:.6rem; }
|
||||||
|
/* 모자이크 */
|
||||||
|
.comment-card.mosaic .cc-avatar { filter: blur(6px); }
|
||||||
|
.comment-card.mosaic .cc-author { filter: blur(5px); }
|
||||||
|
.cc-empty { color:var(--text-3); padding:2rem 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div layout:fragment="content">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="cc-toolbar">
|
||||||
|
<input type="text" id="ccUrl" class="cc-url" placeholder="유튜브 링크 또는 영상 ID 붙여넣기" />
|
||||||
|
<button class="cc-btn" id="ccFetch">가져오기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cc-toolbar" id="ccFilters" style="display:none;">
|
||||||
|
<select id="ccSort">
|
||||||
|
<option value="likes">좋아요순</option>
|
||||||
|
<option value="replies">답글순</option>
|
||||||
|
<option value="latest">최신순</option>
|
||||||
|
</select>
|
||||||
|
<label style="font-size:13px;color:var(--text-2);">좋아요
|
||||||
|
<input type="number" id="ccMinLikes" value="0" min="0" style="width:90px;" /> 이상</label>
|
||||||
|
<label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:.3rem;">
|
||||||
|
<input type="checkbox" id="ccRepliesOnly" /> 답글 있는 것만</label>
|
||||||
|
<button class="cc-btn secondary" id="ccMosaic">전체 모자이크</button>
|
||||||
|
<span id="ccCount" style="font-size:12px;color:var(--text-3);"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cc-grid" id="cardGrid"></div>
|
||||||
|
<div class="cc-empty" id="ccEmpty"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<th:block layout:fragment="script">
|
||||||
|
<!-- DOM→이미지 캡처 (Task 5에서 사용). blur 필터 지원 위해 modern-screenshot 사용. -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/modern-screenshot@4/dist/index.js"></script>
|
||||||
|
<script th:src="@{/js/comment-cards.js(v=20260629)}"></script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -49,6 +49,9 @@
|
|||||||
<a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'">
|
<a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'">
|
||||||
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">프로덕션</span>
|
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">프로덕션</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a th:href="@{/comment-cards}" class="nav-item" th:classappend="${currentPage == 'comment-cards'} ? 'active'">
|
||||||
|
<i data-lucide="message-square-quote" class="nav-icon"></i><span class="nav-text">댓글 카드</span>
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="user-profile-container">
|
<div class="user-profile-container">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user