# 댓글 카드(Comment Cards) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 유튜브 링크의 댓글을 가져와 유튜브 스타일 카드로 보여주고, 프로필·아이디 모자이크 후 각 카드를 PNG로 클립보드에 복사하는 독립 페이지 `/comment-cards`를 만든다. **Architecture:** 기존 `web/` + `service/` 레이어 패턴(YoutubeSearchService/Controller와 동일)을 따른다. 백엔드는 YouTube Data API `commentThreads.list`로 댓글을 수집(DB 저장 없음)하고, 프로필 이미지는 canvas 오염 회피를 위해 서버 프록시로 동일 출처화한다. 프론트는 SSR 템플릿 + 정적 JS로 카드를 렌더하고, `modern-screenshot`로 DOM→PNG 캡처 후 클립보드에 복사한다. **Tech Stack:** Spring Boot 3.4 / Java 21, RestTemplate, Jackson, Thymeleaf(layout-dialect), 정적 JS, modern-screenshot(CDN), Clipboard API. ## Global Constraints - 서버 포트 8088. YouTube API 키는 `${youtube.api.key}` (env `YOUTUBE_API_KEY`). - JSON 응답은 `global/common/ApiResponse`로 감싼다 (`ApiResponse.ok(...)`). - 에러는 `IllegalArgumentException`을 던지면 `GlobalExceptionHandler`가 400 + `ApiResponse.error(message)`로 처리한다. (프론트는 `success=false`의 `message`를 표시) - YouTube 호출은 `global/schedule/YoutubeQuotaGuard.tryConsume(units)`로 쿼터 가드. `commentThreads.list`는 호출(페이지)당 1유닛. - Thymeleaf 템플릿은 `layout/base.html` 사용, 다크모드 디자인시스템(`variables.css`) 준수, 페이지 컨트롤러는 `currentPage` 설정. - 커밋 메시지: 타입 접두사(feat/docs 등)는 영문, 제목·본문은 한글. 끝에 `Co-Authored-By: Claude Opus 4.8 (1M context) `. - 빌드: `.\gradlew.bat build`, 테스트: `.\gradlew.bat test`, 실행: `.\gradlew.bat bootRun`. - 이 프로젝트는 외부 API/브라우저 의존 코드는 자동 테스트가 없다. 순수 로직(videoId 파서)만 JUnit으로 TDD하고, 서비스/컨트롤러/프론트는 bootRun + curl/브라우저 수동 검증한다. --- ## File Structure - `src/main/java/com/hlab/yanalyst/service/YoutubeVideoIdParser.java` (신규) — URL/ID → videoId 추출 순수 유틸 - `src/test/java/com/hlab/yanalyst/service/YoutubeVideoIdParserTest.java` (신규) — 파서 단위 테스트 - `src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java` (신규) — 댓글 카드 DTO - `src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java` (신규) — commentThreads 수집 - `src/main/java/com/hlab/yanalyst/web/CommentCardApiController.java` (신규) — `/api/comment-cards/fetch`, `/api/comment-cards/avatar` - `src/main/java/com/hlab/yanalyst/web/WebController.java` (수정) — `/comment-cards` 라우트 추가 - `src/main/resources/templates/layout/sidebar.html` (수정) — "댓글 카드" 메뉴 추가 - `src/main/resources/templates/comment-cards.html` (신규) — 페이지 셸 - `src/main/resources/static/js/comment-cards.js` (신규) — fetch/렌더/정렬/필터/모자이크/복사 --- ## Task 1: videoId 파서 (TDD) **Files:** - Create: `src/main/java/com/hlab/yanalyst/service/YoutubeVideoIdParser.java` - Test: `src/test/java/com/hlab/yanalyst/service/YoutubeVideoIdParserTest.java` **Interfaces:** - Produces: `YoutubeVideoIdParser.parse(String input)` → `String` videoId(11자) 또는 `null`(추출 실패) - [ ] **Step 1: Write the failing test** `src/test/java/com/hlab/yanalyst/service/YoutubeVideoIdParserTest.java`: ```java package com.hlab.yanalyst.service; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class YoutubeVideoIdParserTest { @Test void parsesWatchUrl() { assertEquals("dQw4w9WgXcQ", YoutubeVideoIdParser.parse("https://www.youtube.com/watch?v=dQw4w9WgXcQ")); } @Test void parsesWatchUrlWithExtraParams() { assertEquals("dQw4w9WgXcQ", YoutubeVideoIdParser.parse("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s&list=ABC")); } @Test void parsesShortLink() { assertEquals("dQw4w9WgXcQ", YoutubeVideoIdParser.parse("https://youtu.be/dQw4w9WgXcQ?si=xyz")); } @Test void parsesShortsLink() { assertEquals("abc12345678", YoutubeVideoIdParser.parse("https://www.youtube.com/shorts/abc12345678")); } @Test void parsesBareId() { assertEquals("dQw4w9WgXcQ", YoutubeVideoIdParser.parse("dQw4w9WgXcQ")); } @Test void returnsNullForGarbage() { assertNull(YoutubeVideoIdParser.parse("not a youtube link")); assertNull(YoutubeVideoIdParser.parse("")); assertNull(YoutubeVideoIdParser.parse(null)); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.service.YoutubeVideoIdParserTest"` Expected: 컴파일 실패 또는 FAIL (`YoutubeVideoIdParser` 미존재) - [ ] **Step 3: Write minimal implementation** `src/main/java/com/hlab/yanalyst/service/YoutubeVideoIdParser.java`: ```java package com.hlab.yanalyst.service; import java.util.regex.Pattern; import java.util.regex.Matcher; /** 유튜브 URL 또는 11자리 영상 ID에서 videoId를 추출한다. 실패 시 null. */ public final class YoutubeVideoIdParser { private static final Pattern BARE = Pattern.compile("^[A-Za-z0-9_-]{11}$"); private static final Pattern[] PATTERNS = { Pattern.compile("[?&]v=([A-Za-z0-9_-]{11})"), Pattern.compile("youtu\\.be/([A-Za-z0-9_-]{11})"), Pattern.compile("/shorts/([A-Za-z0-9_-]{11})"), Pattern.compile("/embed/([A-Za-z0-9_-]{11})"), }; private YoutubeVideoIdParser() {} public static String parse(String input) { if (input == null) return null; String s = input.trim(); if (s.isEmpty()) return null; if (BARE.matcher(s).matches()) return s; for (Pattern p : PATTERNS) { Matcher m = p.matcher(s); if (m.find()) return m.group(1); } return null; } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.service.YoutubeVideoIdParserTest"` Expected: PASS (6 tests) - [ ] **Step 5: Commit** ```bash git add src/main/java/com/hlab/yanalyst/service/YoutubeVideoIdParser.java src/test/java/com/hlab/yanalyst/service/YoutubeVideoIdParserTest.java git commit -m "feat(comment-cards): 유튜브 videoId 파서 추가 watch/youtu.be/shorts/embed/순수ID 추출, 단위 테스트 포함 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 2: CommentCardDto + YoutubeCommentService **Files:** - Create: `src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java` - Create: `src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java` **Interfaces:** - Consumes: `YoutubeVideoIdParser.parse(String)`, `YoutubeQuotaGuard.tryConsume(long)` - Produces: - `CommentCardDto` 필드(게터): `String getAuthorName()`, `String getProfileImageUrl()`, `String getText()`, `long getLikeCount()`, `long getReplyCount()`, `String getPublishedAt()` - `YoutubeCommentService.fetchComments(String urlOrId)` → `List` (잘못된 링크/댓글 비활성 시 `IllegalArgumentException`) - [ ] **Step 1: Create the DTO** `src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java`: ```java package com.hlab.yanalyst.web.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class CommentCardDto { private String authorName; private String profileImageUrl; private String text; // YouTube textDisplay (HTML 포함 가능 — 프론트에서 안전 렌더) private long likeCount; private long replyCount; private String publishedAt; // ISO-8601 문자열 } ``` - [ ] **Step 2: Create the service** `src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java`: ```java package com.hlab.yanalyst.service; import com.fasterxml.jackson.databind.JsonNode; import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; import com.hlab.yanalyst.web.dto.CommentCardDto; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; import java.util.ArrayList; import java.util.List; /** * YouTube Data API commentThreads.list 로 영상의 최상위 댓글을 수집한다. * DB 저장 없이 일회성 조회. 댓글 카드(/comment-cards) 페이지에서 사용. */ @Service @RequiredArgsConstructor public class YoutubeCommentService { private final RestTemplate restTemplate; private final YoutubeQuotaGuard quotaGuard; @Value("${youtube.api.key}") private String youtubeApiKey; /** 응답 크기/쿼터 보호용 최대 페이지 수 (페이지당 최대 100개). */ private static final int MAX_PAGES = 5; public List fetchComments(String urlOrId) { String videoId = YoutubeVideoIdParser.parse(urlOrId); if (videoId == null) { throw new IllegalArgumentException("유효한 유튜브 링크가 아닙니다."); } List result = new ArrayList<>(); String apiUrl = "https://www.googleapis.com/youtube/v3/commentThreads"; String pageToken = null; for (int page = 0; page < MAX_PAGES; page++) { if (!quotaGuard.tryConsume(1)) break; // 쿼터 소진 시 모은 만큼 반환 UriComponentsBuilder b = UriComponentsBuilder.fromHttpUrl(apiUrl) .queryParam("part", "snippet") .queryParam("videoId", videoId) .queryParam("order", "relevance") .queryParam("maxResults", 100) .queryParam("key", youtubeApiKey); if (pageToken != null) { b.queryParam("pageToken", pageToken); } JsonNode root; try { root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class); } catch (HttpClientErrorException.Forbidden e) { if (result.isEmpty()) { throw new IllegalArgumentException("이 영상은 댓글이 비활성화되어 있거나 접근할 수 없습니다."); } break; } if (root == null || !root.has("items")) break; for (JsonNode item : root.get("items")) { JsonNode top = item.path("snippet").path("topLevelComment").path("snippet"); CommentCardDto dto = CommentCardDto.builder() .authorName(top.path("authorDisplayName").asText("")) .profileImageUrl(top.path("authorProfileImageUrl").asText("")) .text(top.path("textDisplay").asText("")) .likeCount(top.path("likeCount").asLong(0)) .replyCount(item.path("snippet").path("totalReplyCount").asLong(0)) .publishedAt(top.path("publishedAt").asText("")) .build(); result.add(dto); } if (!root.has("nextPageToken")) break; pageToken = root.get("nextPageToken").asText(); } return result; } } ``` - [ ] **Step 3: Verify it compiles** Run: `.\gradlew.bat compileJava` Expected: BUILD SUCCESSFUL - [ ] **Step 4: Commit** ```bash git add src/main/java/com/hlab/yanalyst/web/dto/CommentCardDto.java src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java git commit -m "feat(comment-cards): commentThreads 댓글 수집 서비스 추가 CommentCardDto + YoutubeCommentService(최대 5페이지, 쿼터가드, 댓글비활성 처리) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 3: CommentCardApiController (fetch + avatar 프록시) **Files:** - Create: `src/main/java/com/hlab/yanalyst/web/CommentCardApiController.java` **Interfaces:** - Consumes: `YoutubeCommentService.fetchComments(String)`, `RestTemplate`(기존 빈), `ApiResponse` - Produces (HTTP): - `POST /api/comment-cards/fetch` body `{ "url": "<유튜브링크>" }` → `ApiResponse>` - `GET /api/comment-cards/avatar?url=<프로필이미지URL>` → 이미지 바이트(동일 출처 프록시), 화이트리스트 외 400 - [ ] **Step 1: Create the controller** `src/main/java/com/hlab/yanalyst/web/CommentCardApiController.java`: ```java package com.hlab.yanalyst.web; import com.hlab.yanalyst.global.common.ApiResponse; import com.hlab.yanalyst.service.YoutubeCommentService; import com.hlab.yanalyst.web.dto.CommentCardDto; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.client.RestTemplate; import java.net.URI; import java.time.Duration; import java.util.List; @RestController @RequestMapping("/api/comment-cards") @RequiredArgsConstructor public class CommentCardApiController { private final YoutubeCommentService commentService; private final RestTemplate restTemplate; @PostMapping("/fetch") public ApiResponse> fetch(@RequestBody FetchRequest req) { return ApiResponse.ok(commentService.fetchComments(req.getUrl())); } /** 프로필 이미지를 동일 출처로 프록시(canvas taint 회피). 구글 도메인만 허용(SSRF 방지). */ @GetMapping("/avatar") public ResponseEntity avatar(@RequestParam("url") String url) { if (!isAllowed(url)) { return ResponseEntity.badRequest().build(); } ResponseEntity resp = restTemplate.getForEntity(URI.create(url), byte[].class); MediaType ct = resp.getHeaders().getContentType(); return ResponseEntity.ok() .contentType(ct != null ? ct : MediaType.IMAGE_JPEG) .cacheControl(CacheControl.maxAge(Duration.ofHours(6)).cachePublic()) .body(resp.getBody()); } private boolean isAllowed(String url) { try { String host = URI.create(url).getHost(); if (host == null) return false; host = host.toLowerCase(); return host.endsWith("ggpht.com") || host.endsWith("googleusercontent.com"); } catch (Exception e) { return false; } } @Data public static class FetchRequest { private String url; } } ``` - [ ] **Step 2: Compile** Run: `.\gradlew.bat compileJava` Expected: BUILD SUCCESSFUL - [ ] **Step 3: Manual verify — fetch** `.\gradlew.bat bootRun` 실행 후 다른 터미널에서: ```bash curl -s -X POST http://localhost:8088/api/comment-cards/fetch \ -H "Content-Type: application/json" \ -d '{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}' ``` Expected: `{"success":true,...,"data":[{"authorName":...,"profileImageUrl":"https://...ggpht.com...","text":...,"likeCount":...,"replyCount":...,"publishedAt":...}, ...]}` (댓글 다수) 잘못된 링크 확인: ```bash curl -s -X POST http://localhost:8088/api/comment-cards/fetch -H "Content-Type: application/json" -d '{"url":"garbage"}' ``` Expected: HTTP 400, `{"success":false,"message":"유효한 유튜브 링크가 아닙니다.",...}` - [ ] **Step 4: Manual verify — avatar 프록시** 위 fetch 응답의 `profileImageUrl` 하나를 복사해 URL 인코딩 후: ```bash curl -s -o /tmp/avatar.jpg "http://localhost:8088/api/comment-cards/avatar?url=<인코딩된_profileImageUrl>" -w "%{http_code} %{content_type}\n" ``` Expected: `200 image/jpeg`(또는 image/webp 등), `/tmp/avatar.jpg`에 이미지 저장됨. 화이트리스트 외 차단: ```bash curl -s "http://localhost:8088/api/comment-cards/avatar?url=https://example.com/x.jpg" -w "%{http_code}\n" -o /dev/null ``` Expected: `400` - [ ] **Step 5: Commit** ```bash git add src/main/java/com/hlab/yanalyst/web/CommentCardApiController.java git commit -m "feat(comment-cards): 댓글 fetch + 프로필 프록시 API 추가 POST /api/comment-cards/fetch, GET /api/comment-cards/avatar(구글 도메인 화이트리스트) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 4: 페이지 라우트 + 사이드바 + 템플릿 + 렌더/정렬/필터 JS **Files:** - Modify: `src/main/java/com/hlab/yanalyst/web/WebController.java` (라우트 추가) - Modify: `src/main/resources/templates/layout/sidebar.html` ("제작" 그룹에 메뉴) - Create: `src/main/resources/templates/comment-cards.html` - Create: `src/main/resources/static/js/comment-cards.js` **Interfaces:** - Consumes(HTTP): `POST /api/comment-cards/fetch`, `GET /api/comment-cards/avatar?url=` - Produces(JS, Task 5에서 사용): 전역에서 호출 가능한 상태 — `window.__cards` (현재 카드 DTO 배열), 함수 `renderCards()`(현재 정렬/필터 기준으로 `#cardGrid` 다시 그림). 각 카드 DOM은 `.comment-card` 클래스 + `data-index` 속성을 가진다. 카드 내부 구조: `.cc-avatar`(프로필 img), `.cc-author`(아이디), `.cc-copy`(복사 버튼). - [ ] **Step 1: Add page route in WebController** `WebController.java`의 `production` 메서드 위(아무 위치)에 추가: ```java @GetMapping("/comment-cards") public String commentCards(Model model) { model.addAttribute("currentPage", "comment-cards"); return "comment-cards"; } ``` - [ ] **Step 2: Add sidebar menu** `layout/sidebar.html`의 "제작" 그룹(프로덕션 `` 다음, 같은 nav 안)에 추가: ```html 댓글 카드 ``` - [ ] **Step 3: Create the template** `src/main/resources/templates/comment-cards.html`: ```html h-lab - 댓글 카드

댓글 카드

유튜브 링크의 댓글을 가져와 카드로 만들고, 프로필·아이디를 모자이크해 영상 소스로 복사하세요.

``` - [ ] **Step 4: Create the JS (fetch + render + sort/filter)** `src/main/resources/static/js/comment-cards.js`: ```javascript (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) → 안전한 평문 (태그 제거,
→줄바꿈) function toPlainText(html) { const tmp = document.createElement('div'); tmp.innerHTML = String(html).replace(//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 = '' + '
' + '
' + '
' + '
👍 ' + (c.likeCount || 0).toLocaleString() + '' + '💬 ' + (c.replyCount || 0).toLocaleString() + '
' + '
'; 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)); }); }); })(); ``` - [ ] **Step 5: Manual verify in browser** `.\gradlew.bat bootRun` → 브라우저 `http://localhost:8088/comment-cards` - 사이드바 "제작"에 "댓글 카드" 메뉴 보이고 active 표시됨 - 링크 붙여넣고 가져오기 → 카드들이 프로필(프록시 로드)·아이디·시간·댓글·👍·💬와 함께 렌더 - 정렬 변경(좋아요/답글/최신), 좋아요 임계값, "답글 있는 것만" 토글 → 목록 즉시 갱신, 개수 표시 - "전체 모자이크" 클릭 → 모든 프로필+아이디 블러, 버튼 라벨 토글 - 잘못된 링크 → "⚠️ 유효한 유튜브 링크가 아닙니다." - (복사 버튼은 아직 동작 안 함 — Task 5) - [ ] **Step 6: Commit** ```bash git add src/main/java/com/hlab/yanalyst/web/WebController.java src/main/resources/templates/layout/sidebar.html src/main/resources/templates/comment-cards.html src/main/resources/static/js/comment-cards.js git commit -m "feat(comment-cards): 댓글 카드 페이지·사이드바·렌더/정렬/필터 추가 /comment-cards 라우트, 유튜브 스타일 카드 렌더, 정렬/임계값 필터, 전체 모자이크 토글 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 5: 카드 PNG 클립보드 복사 **Files:** - Modify: `src/main/resources/static/js/comment-cards.js` (`window.copyCard` 추가) **Interfaces:** - Consumes: 전역 `modernScreenshot.domToBlob`(CDN UMD), 카드 DOM(`.comment-card`) - Produces: `window.copyCard(cardEl, btnEl)` — 카드를 투명배경 PNG로 캡처해 클립보드에 복사. 클립보드 미지원 시 PNG 다운로드 폴백. - [ ] **Step 1: Implement copyCard** `comment-cards.js`의 IIFE 내부(예: `window.renderCards` 정의 아래)에 추가: ```javascript async function captureBlob(cardEl) { // modern-screenshot UMD 전역: window.modernScreenshot const ms = window.modernScreenshot; if (!ms || !ms.domToBlob) throw new Error('캡처 라이브러리 로드 실패'); // backgroundColor:null → 카드 바깥 투명. scale 2 → 또렷하게. return await ms.domToBlob(cardEl, { backgroundColor: null, scale: 2 }); } function downloadBlob(blob) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'comment-card.png'; a.click(); URL.revokeObjectURL(a.href); } window.copyCard = async function (cardEl, btnEl) { const original = btnEl.textContent; btnEl.disabled = true; btnEl.textContent = '복사 중…'; try { const blob = await captureBlob(cardEl); if (navigator.clipboard && window.ClipboardItem) { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); btnEl.textContent = '복사됨 ✓'; } else { downloadBlob(blob); btnEl.textContent = '다운로드됨'; } } catch (e) { // 클립보드 권한 거부 등 → 다운로드 폴백 try { const blob = await captureBlob(cardEl); downloadBlob(blob); btnEl.textContent = '다운로드됨'; } catch (e2) { btnEl.textContent = '실패'; } } finally { setTimeout(() => { btnEl.disabled = false; btnEl.textContent = original; }, 1500); } }; ``` - [ ] **Step 2: Bump cache-buster** `comment-cards.html`에서 `comment-cards.js(v=20260629)` → `v=20260629b`로 변경(브라우저 캐시 무효화). - [ ] **Step 3: Manual verify in browser** `.\gradlew.bat bootRun` → `http://localhost:8088/comment-cards` (크롬/엣지) - 댓글 가져오기 → "전체 모자이크" ON - 한 카드의 [복사] 클릭 → "복사됨 ✓" 표시 - 그림판(또는 캡컷)에 Ctrl+V → 카드 이미지가 붙여넣어지고, **프로필+아이디가 블러된 상태로** 캡처됨, 카드 바깥은 투명 - 모자이크 OFF 상태에서 복사 → 블러 없이 캡처됨 - (선택) 비-HTTPS/미지원 환경 시 PNG 다운로드 폴백 확인 - [ ] **Step 4: Commit** ```bash git add src/main/resources/static/js/comment-cards.js src/main/resources/templates/comment-cards.html git commit -m "feat(comment-cards): 카드 PNG 클립보드 복사 추가 modern-screenshot로 카드 캡처→클립보드(투명배경, 모자이크 반영), 미지원 시 다운로드 폴백 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Self-Review 결과 **Spec coverage:** - 독립 페이지/사이드바 메뉴 → Task 4 ✓ - 링크 입력→댓글 수집(commentThreads, 페이지네이션, 쿼터) → Task 2 ✓ - 정렬(좋아요/답글/최신)·필터(임계값/답글만) → Task 4 ✓ - 유튜브 스타일 카드(프로필·아이디·시간·댓글·좋아요·답글) → Task 4 ✓ - 전체 모자이크 → Task 4(토글/CSS) + Task 5(캡처 반영) ✓ - 카드별 복사(PNG→클립보드, 폴백) → Task 5 ✓ - 프로필 프록시(CORS/SSRF) → Task 3 ✓ - 에러 처리(잘못된 링크/댓글 비활성) → Task 2(throw) + Task 3/4(검증/표시) ✓ **Placeholder scan:** 모든 step에 실제 코드/명령/기대결과 포함, "TBD"·"적절히 처리" 없음 ✓ **Type consistency:** `fetchComments(String)→List`, DTO 게터명(`getLikeCount` 등), JS `window.__cards`/`renderCards`/`copyCard(cardEl, btnEl)` 일치 ✓