Phase 1 (CSS tokens, utility backfill, shared components) with complete CSS; Phase 2 (dashboard visuals + deep links, collection URL filters, per-page sweep). Verification via bootRun + browser observation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
546 lines
26 KiB
Markdown
546 lines
26 KiB
Markdown
# 전체 페이지 디자인/UX 개선 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:** 공유 CSS(변수+스타일)를 끌어올려 12개 Thymeleaf 페이지를 한 번에 개선하고(Phase 1), 대시보드/수집함 등 페이지별 폴리시와 가벼운 인터랙션을 더한다(Phase 2).
|
|
|
|
**Architecture:** Tailwind 없이 인라인/유틸 클래스에 의존하던 SSR 페이지들에서, 실제 사용 중인 미정의 유틸 클래스를 `style.css`에 백필하고 공유 컴포넌트(카드/버튼/배지/막대/폼/테이블/스켈레톤)를 정련한다. 그 위에 페이지별 마크업을 소폭 손보고, 대시보드 클릭→수집함 필터 딥링크를 추가한다.
|
|
|
|
**Tech Stack:** Spring Boot 3.4 + Thymeleaf(SSR), 바닐라 CSS/JS, Lucide 아이콘. 백엔드/새 라이브러리 변경 없음.
|
|
|
|
**검증 방식:** 이 프로젝트는 자동 테스트 인프라가 없다(`src/test` 없음). 각 태스크의 "테스트"는 **앱 기동 후 브라우저(Playwright)로 해당 페이지를 열어 특정 요소를 관찰**하는 것이다. JAVA_HOME은 `D:\Development\app\JDK\jdk-21.0.5`, 포트 8088.
|
|
|
|
**앱 기동/재기동 공통 절차 (여러 태스크에서 참조):**
|
|
```
|
|
# 기동 (백그라운드)
|
|
$env:JAVA_HOME="D:\Development\app\JDK\jdk-21.0.5"; .\gradlew.bat bootRun *> bootrun.log
|
|
# "Started ...Application in" 로그가 나오면 준비 완료
|
|
# CSS/템플릿만 바뀐 경우: 정적 리소스라 재빌드 불필요하나, 확실히 하려면 앱 재기동.
|
|
# 종료: 포트 8088 점유 프로세스 Stop-Process
|
|
```
|
|
CSS/HTML은 정적 리소스이므로 변경 후 브라우저 강력 새로고침(Ctrl+F5) 또는 Playwright 재네비게이션으로 반영된다. Java 코드 변경이 없으므로 `compileJava`는 불필요하다.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- `src/main/resources/static/css/variables.css` — 디자인 토큰. 토큰 추가만(기존 유지).
|
|
- `src/main/resources/static/css/style.css` — 유틸 클래스 + 공유 컴포넌트. 백필/정련의 주력.
|
|
- `src/main/resources/templates/dashboard.html` — Phase 2 비주얼+인터랙션.
|
|
- `src/main/resources/templates/collection.html` — Phase 2 딥링크 수신 + 폴리시.
|
|
- 기타 템플릿(board/publish/discover/channels/videos/production/production_detail/channel_detail/multi_channel_videos/rework) — Phase 2 페이지 스윕(공유 CSS 위 소폭).
|
|
|
|
페이지 고유 클래스(`sortable`, `filter-sel`, `pub-in`, `kb-*`, `tab`, `custom-checkbox`, `st-badge` 등)는 각 페이지 `<style>` 블록 또는 JS 훅이므로 **공유 CSS에서 건드리지 않는다**.
|
|
|
|
---
|
|
|
|
## PHASE 1 — 공유 파운데이션
|
|
|
|
### Task 1: variables.css 토큰 확장
|
|
|
|
**Files:**
|
|
- Modify: `src/main/resources/static/css/variables.css`
|
|
|
|
- [ ] **Step 1: `--primary-rgb`·`--warning`·간격/그림자 토큰 추가**
|
|
|
|
`:root { ... }` 블록 내 `--font-sans` 줄 바로 앞에 다음을 추가:
|
|
|
|
```css
|
|
/* Semantic (added) */
|
|
--warning: #f59e0b;
|
|
--primary-rgb: 59, 130, 246; /* matches --primary #3b82f6; used by rgba(var(--primary-rgb), …) */
|
|
--success-rgb: 16, 185, 129;
|
|
--danger-rgb: 239, 68, 68;
|
|
|
|
/* Spacing scale */
|
|
--space-1: 0.25rem;
|
|
--space-2: 0.5rem;
|
|
--space-3: 0.75rem;
|
|
--space-4: 1rem;
|
|
--space-6: 1.5rem;
|
|
--space-8: 2rem;
|
|
|
|
/* Elevation */
|
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
|
--shadow-md: 0 4px 12px -2px rgba(0, 0, 0, 0.3);
|
|
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.35);
|
|
```
|
|
|
|
- [ ] **Step 2: 본문에 tabular-nums 적용**
|
|
|
|
`body { ... }` 블록 안 `font-family: var(--font-sans);` 줄 바로 아래에 추가:
|
|
|
|
```css
|
|
font-feature-settings: "tnum" 1, "cv01" 1;
|
|
```
|
|
|
|
- [ ] **Step 3: 기동해 회귀 없는지 확인**
|
|
|
|
위 "앱 기동" 절차로 기동(이미 떠 있으면 생략). Playwright로 `http://localhost:8088/` 접속 → 콘솔 에러 0건, 레이아웃 깨짐 없는지 스냅샷 확인.
|
|
Expected: 시각 변화는 미미(토큰 추가뿐). `--primary-rgb` 사용처(모바일 테이블 Rank 뱃지)가 더 이상 깨지지 않음.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/main/resources/static/css/variables.css
|
|
git commit -m "style(css): add design tokens (primary-rgb, warning, spacing, shadow, tabular-nums)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: style.css 유틸 클래스 백필
|
|
|
|
템플릿에서 실제 사용되나 미정의인 유틸만 정의한다(인벤토리 기반, 미사용 유틸 양산 금지). 인라인 스타일보다 우선순위가 낮아 회귀 위험이 낮다.
|
|
|
|
**Files:**
|
|
- Modify: `src/main/resources/static/css/style.css`
|
|
|
|
- [ ] **Step 1: 유틸 블록 추가**
|
|
|
|
`style.css`의 `/* Components */` 주석 **바로 앞**(기존 유틸 영역 끝, 현재 `.p-6` 정의 다음 줄)에 아래 블록을 삽입:
|
|
|
|
```css
|
|
/* ===== Utility backfill (classes already used in templates, previously undefined) ===== */
|
|
|
|
/* Spacing — padding */
|
|
.p-0 { padding: 0; }
|
|
.p-2 { padding: var(--space-2); }
|
|
.p-3 { padding: var(--space-3); }
|
|
.p-8 { padding: var(--space-8); }
|
|
.px-3 { padding-left: var(--space-3); padding-right: var(--space-3); }
|
|
.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); }
|
|
.px-8 { padding-left: var(--space-8); padding-right: var(--space-8); }
|
|
.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); }
|
|
.pr-4 { padding-right: var(--space-4); }
|
|
|
|
/* Spacing — margin */
|
|
.mt-1 { margin-top: var(--space-1); }
|
|
.mt-2 { margin-top: var(--space-2); }
|
|
.mb-1 { margin-bottom: var(--space-1); }
|
|
.mb-2 { margin-bottom: var(--space-2); }
|
|
.mb-3 { margin-bottom: var(--space-3); }
|
|
.mb-6 { margin-bottom: var(--space-6); }
|
|
.ml-2 { margin-left: var(--space-2); }
|
|
|
|
/* Spacing — gap */
|
|
.gap-1 { gap: var(--space-1); }
|
|
.gap-3 { gap: var(--space-3); }
|
|
|
|
/* Fl/grid helpers */
|
|
.flex-wrap { flex-wrap: wrap; }
|
|
.flex-shrink-0 { flex-shrink: 0; }
|
|
.justify-center { justify-content: center; }
|
|
.justify-end { justify-content: flex-end; }
|
|
.items-start { align-items: flex-start; }
|
|
.block { display: block; }
|
|
.hidden { display: none; }
|
|
.relative { position: relative; }
|
|
.absolute { position: absolute; }
|
|
.inset-0 { inset: 0; }
|
|
.top-4 { top: var(--space-4); }
|
|
.left-4 { left: var(--space-4); }
|
|
.z-10 { z-index: 10; }
|
|
.z-20 { z-index: 20; }
|
|
.w-6 { width: 1.5rem; }
|
|
.h-6 { height: 1.5rem; }
|
|
.h-full { height: 100%; }
|
|
.rounded-lg { border-radius: var(--radius-md); }
|
|
.opacity-0 { opacity: 0; }
|
|
.cursor-pointer { cursor: pointer; }
|
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.text-center { text-align: center; }
|
|
|
|
/* Typography */
|
|
.text-xs { font-size: 0.75rem; }
|
|
.text-2xl { font-size: 1.875rem; font-weight: 700; }
|
|
.font-medium { font-weight: 500; }
|
|
.font-semibold { font-weight: 600; }
|
|
.font-normal { font-weight: 400; }
|
|
|
|
/* Colors */
|
|
.text-white { color: #fff; }
|
|
.text-secondary { color: var(--text-secondary); }
|
|
.text-danger { color: var(--danger); }
|
|
.text-success { color: var(--success); }
|
|
.text-blue-400 { color: #60a5fa; }
|
|
.text-cyan-400 { color: #22d3ee; }
|
|
.text-pink-400 { color: #f472b6; }
|
|
.text-purple-400 { color: #c084fc; }
|
|
.text-orange-400 { color: #fb923c; }
|
|
.text-yellow-400 { color: #facc15; }
|
|
.text-emerald-400 { color: #34d399; }
|
|
|
|
/* Transitions */
|
|
.transition-colors { transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease; }
|
|
.transition-opacity { transition: opacity 0.2s ease; }
|
|
.transition-all { transition: all 0.2s ease; }
|
|
|
|
/* Hover utilities (escaped selectors) */
|
|
.hover\:text-white:hover { color: #fff; }
|
|
.hover\:underline:hover { text-decoration: underline; }
|
|
.hover\:text-\[var\(--primary\)\]:hover { color: var(--primary); }
|
|
.hover\:border-\[var\(--primary\)\]:hover { border-color: var(--primary); }
|
|
.border-\[var\(--glass-border\)\] { border: 1px solid var(--glass-border); }
|
|
.hover\:bg-blue-400\/10:hover { background: rgba(96, 165, 250, 0.1); }
|
|
.hover\:bg-cyan-400\/10:hover { background: rgba(34, 211, 238, 0.1); }
|
|
.hover\:bg-pink-400\/10:hover { background: rgba(244, 114, 182, 0.1); }
|
|
.hover\:bg-purple-400\/10:hover { background: rgba(192, 132, 252, 0.1); }
|
|
.hover\:bg-orange-400\/10:hover { background: rgba(251, 146, 60, 0.1); }
|
|
.hover\:bg-emerald-400\/10:hover { background: rgba(52, 211, 153, 0.1); }
|
|
.hover\:bg-yellow-400\/10:hover { background: rgba(250, 204, 21, 0.1); }
|
|
|
|
/* Group hover reveal */
|
|
.group:hover .group-hover\:opacity-100 { opacity: 1; }
|
|
|
|
/* Spin animation */
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.animate-spin { animation: spin 1s linear infinite; }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.animate-spin { animation: none; }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 기동 후 대표 페이지 4곳 관찰**
|
|
|
|
앱 기동(또는 떠 있으면 Ctrl+F5). Playwright로 순서대로 접속·스크린샷:
|
|
- `http://localhost:8088/` (dashboard)
|
|
- `http://localhost:8088/collection`
|
|
- `http://localhost:8088/board`
|
|
- `http://localhost:8088/publish`
|
|
|
|
Expected: 텍스트 색(text-danger 등)·정렬(text-center)·말줄임(truncate)·여백(p-3/p-8)이 의도대로 적용되어 정돈됨. 깨짐/겹침 없음. 콘솔 에러 0.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/main/resources/static/css/style.css
|
|
git commit -m "style(css): backfill utility classes used across templates"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: style.css 공유 컴포넌트 정련
|
|
|
|
**Files:**
|
|
- Modify: `src/main/resources/static/css/style.css`
|
|
|
|
- [ ] **Step 1: 버튼 변형 보강**
|
|
|
|
`.btn-ghost:hover { ... }` 정의 **다음 줄**에 추가(`btn-secondary` 23곳, `btn-outline` 2곳에서 사용되나 미정의):
|
|
|
|
```css
|
|
.btn-secondary {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--glass-border);
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-color: var(--glass-highlight);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.btn-outline {
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
border: 1px solid var(--glass-border);
|
|
}
|
|
|
|
.btn-outline:hover {
|
|
color: var(--text-primary);
|
|
border-color: var(--primary);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: 공통 막대·배지·스켈레톤 추가**
|
|
|
|
`style.css` 맨 끝(파일 마지막 줄 뒤)에 추가:
|
|
|
|
```css
|
|
/* ===== Shared components (added) ===== */
|
|
|
|
/* Progress bar (dashboard funnel, distributions, …) */
|
|
.bar-track {
|
|
flex: 1;
|
|
height: 8px;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border-radius: var(--radius-full);
|
|
overflow: hidden;
|
|
}
|
|
.bar-fill {
|
|
height: 100%;
|
|
border-radius: var(--radius-full);
|
|
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.bar-fill { transition: none; }
|
|
}
|
|
|
|
/* Badge */
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
padding: 2px 8px;
|
|
border-radius: var(--radius-full);
|
|
border: 1px solid transparent;
|
|
line-height: 1.4;
|
|
}
|
|
.badge-muted { background: rgba(255,255,255,0.06); color: var(--text-secondary); }
|
|
.badge-primary { background: rgba(var(--primary-rgb), 0.15); color: var(--primary); }
|
|
.badge-success { background: rgba(var(--success-rgb), 0.15); color: var(--success); }
|
|
.badge-warning { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
|
.badge-danger { background: rgba(var(--danger-rgb), 0.15); color: var(--danger); }
|
|
|
|
/* Skeleton shimmer */
|
|
.skeleton {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
.skeleton::after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
transform: translateX(-100%);
|
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);
|
|
animation: skeleton-shimmer 1.4s infinite;
|
|
}
|
|
@keyframes skeleton-shimmer { 100% { transform: translateX(100%); } }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.skeleton::after { animation: none; }
|
|
}
|
|
|
|
/* Dark form controls (unify select/input across pages) */
|
|
select, input[type="text"], input[type="search"], input[type="number"], input[type="date"], input[type="datetime-local"], textarea {
|
|
background: rgba(255, 255, 255, 0.04);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--glass-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 0.5rem 0.75rem;
|
|
font-family: inherit;
|
|
font-size: 0.875rem;
|
|
transition: border-color 0.2s ease, background 0.2s ease;
|
|
}
|
|
select:focus, input:focus, textarea:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
}
|
|
select option { background: #0f172a; color: var(--text-primary); }
|
|
|
|
/* Table polish */
|
|
table { width: 100%; border-collapse: collapse; }
|
|
thead th {
|
|
text-align: left;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--text-muted);
|
|
padding: 0.6rem 0.75rem;
|
|
border-bottom: 1px solid var(--glass-border);
|
|
}
|
|
tbody td { padding: 0.6rem 0.75rem; border-bottom: 1px solid rgba(255, 255, 255, 0.04); }
|
|
tbody tr { transition: background 0.15s ease; }
|
|
tbody tr:hover { background: rgba(255, 255, 255, 0.03); }
|
|
```
|
|
|
|
주의: 위 `table`/`select` 전역 스타일은 회귀 위험이 상대적으로 높다(페이지별 기존 스타일과 겹칠 수 있음). Step 3에서 테이블·폼이 있는 페이지(collection, videos, production, discover)를 반드시 확인한다.
|
|
|
|
- [ ] **Step 3: 기동 후 테이블/폼 페이지 관찰**
|
|
|
|
앱 기동(또는 Ctrl+F5). Playwright 접속·스크린샷:
|
|
- `http://localhost:8088/collection` (테이블 + 필터 select)
|
|
- `http://localhost:8088/videos`
|
|
- `http://localhost:8088/production`
|
|
- `http://localhost:8088/discover`
|
|
|
|
Expected: 테이블 헤더 대문자·구분선, 행 hover 음영, select/input 다크 통일. 모바일 카드 테이블(≤768px) 회귀 없는지 Playwright `browser_resize`로 폭 375 설정해 collection 재확인.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/main/resources/static/css/style.css
|
|
git commit -m "style(css): refine shared components (buttons, badge, bar, forms, table, skeleton)"
|
|
```
|
|
|
|
---
|
|
|
|
## PHASE 2 — 페이지별 폴리시 & 인터랙션
|
|
|
|
### Task 4: dashboard.html — 비주얼 + 클릭 딥링크
|
|
|
|
**Files:**
|
|
- Modify: `src/main/resources/templates/dashboard.html`
|
|
|
|
현재 dashboard.html은 KPI 카드, 깔때기, 떡상 TOP5, 발행 현황, 카테고리/출처 막대를 JS로 렌더한다. 인라인 `<style>`의 `.bar-track/.bar-fill/.st-badge`는 Task 3에서 공유로 옮겼으므로 인라인 정의를 제거하고 공유 클래스를 쓴다. 막대 색은 진행도 3톤으로 통일한다.
|
|
|
|
- [ ] **Step 1: KPI 카드를 클릭 가능한 링크로 + 카운트업 준비**
|
|
|
|
`<div class="card flex flex-col justify-between">` 5개를 각각 `<a>` 래퍼로 감싸 딥링크를 건다. 5개 카드의 href와 대상:
|
|
- "수집 영상" → `th:href="@{/collection}"`
|
|
- "미검토 (NEW)" → `th:href="@{/collection(status='NEW')}"`
|
|
- "작업대상 (TARGET)" → `th:href="@{/collection(status='TARGET')}"`
|
|
- "완료 (DONE)" → `th:href="@{/collection(status='DONE')}"`
|
|
- "발행완료" → `th:href="@{/publish}"`
|
|
|
|
각 카드 루트 요소에 `class="card ... cursor-pointer"` 추가. 숫자를 표시하는 `<h3 id="kTotal">` 등에는 카운트업을 위해 `data-count` 속성을 JS에서 세팅(아래 Step 3).
|
|
|
|
예: "미검토" 카드:
|
|
```html
|
|
<a th:href="@{/collection(status='NEW')}" class="card flex flex-col justify-between cursor-pointer" style="text-decoration:none;">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<div><p class="text-sm text-muted">미검토 (NEW)</p><h3 class="text-2xl font-bold" id="kNew">-</h3></div>
|
|
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover, rgba(255,255,255,0.06));"><i data-lucide="inbox" color="var(--primary)"></i></div>
|
|
</div>
|
|
<div class="text-sm text-muted" id="kReviewCap">검토중 -</div>
|
|
</a>
|
|
```
|
|
나머지 4개도 동일 패턴으로 `<a>`화. (아이콘/캡션 id는 기존 유지)
|
|
|
|
- [ ] **Step 2: 인라인 `<style>` 정리 + 깔때기/막대 색 3톤화**
|
|
|
|
dashboard.html 내 `<style>` 블록에서 `.bar-track`, `.bar-fill`, `.st-badge` 정의를 **삭제**(공유 CSS로 이동됨). 빈 `<style>`이면 블록 자체 삭제.
|
|
|
|
- [ ] **Step 3: JS — 카운트업·딥링크 막대·배지 색 통일**
|
|
|
|
`<script th:inline="javascript">` 내부를 다음 규칙으로 수정:
|
|
|
|
1) 카운트업 헬퍼 추가(`esc`/`fmt` 근처):
|
|
```javascript
|
|
function countUp(el, target){
|
|
target = Number(target||0);
|
|
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches){ el.textContent = fmt(target); return; }
|
|
const dur = 600, t0 = performance.now();
|
|
function tick(now){
|
|
const p = Math.min(1, (now - t0) / dur);
|
|
const eased = 1 - Math.pow(1 - p, 3);
|
|
el.textContent = fmt(Math.round(target * eased));
|
|
if (p < 1) requestAnimationFrame(tick);
|
|
}
|
|
requestAnimationFrame(tick);
|
|
}
|
|
```
|
|
2) KPI 세팅에서 `textContent = fmt(...)` 5곳을 `countUp(el, value)`로 교체(kTotal, kNew, kTarget, kDone, kPublished). 캡션은 그대로 textContent.
|
|
3) 깔때기·분포 막대 색을 진행도 3톤으로:
|
|
- 미검토 = `var(--text-muted)` 톤 → `#64748b` 유지
|
|
- 검토중·작업대상·출처·카테고리 = `var(--primary)`
|
|
- 완료·발행완료·롱폼 = `var(--success)`
|
|
- Shorts·제외 강조 = `var(--danger)`
|
|
기존 `bar('검토중', …, '#38bdf8')` 등 인라인 hex를 위 토큰값(`getComputedStyle` 불필요, 직접 `'var(--primary)'` 문자열을 `background`에 넣어도 됨)으로 교체.
|
|
4) 떡상 목록: 각 행 앞에 순위 번호 추가(`op.map((v,i)=> ... '<span class="badge badge-muted">'+(i+1)+'</span>' ...)`), 배수 표시를 `badge`로:
|
|
```javascript
|
|
function ratioBadgeClass(r){ const v=Number(r); return v>=10?'badge-danger':(v>=2?'badge-warning':'badge-success'); }
|
|
```
|
|
기존 빨강 고정 배수 스팬을 `'<span class="badge '+ratioBadgeClass(v.viewsPerSubRatio)+'">'+ratio+'</span>'`로 교체.
|
|
5) 발행 최근 목록의 `PUB_ST` 인라인 색 배지를 `badge badge-muted/primary/success`로 매핑(DRAFT=muted, READY=warning, PUBLISHED=success).
|
|
6) 카테고리 막대 클릭 딥링크: 카테고리 행을 `<a href="/collection?categoryId=ID">`로 감싼다. 미분류는 `/collection`(파라미터 없이). 출처 막대 → `/collection?source=CHANNEL|SEARCH`, Shorts → `/collection?shortsOnly=true`, 롱폼 → `/collection`(롱폼 전용 필터 비범위).
|
|
7) 로딩 상태: 초기 `'로딩 중...'` 텍스트를 스켈레톤으로 — funnel/opList/catList/sourceFormat의 초기 innerHTML을 `'<div class="skeleton" style="height:48px;"></div>'` 류로 교체(선택, 깔끔하면 적용).
|
|
8) 새로고침 버튼: `loadDashboard()` 시작 시 새로고침 아이콘에 `animate-spin` 추가, 완료(finally) 시 제거.
|
|
|
|
- [ ] **Step 4: 기동 후 대시보드 관찰 + 클릭 검증**
|
|
|
|
앱 기동(또는 Ctrl+F5). Playwright `http://localhost:8088/`:
|
|
- 스크린샷: KPI 숫자 카운트업(정적 캡처는 최종값), 막대 3톤, 떡상 순위·배수 배지, 발행 배지.
|
|
- 클릭 검증: KPI "미검토" 카드 클릭 → URL이 `/collection?status=NEW`로 이동하는지 확인(이 시점엔 collection이 아직 파라미터 미수신이라 필터 적용은 Task 5 후 검증).
|
|
|
|
Expected: 콘솔 에러 0, 모든 섹션 정상, 미검토 카드 클릭 시 `/collection?status=NEW`로 네비게이션.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/main/resources/templates/dashboard.html
|
|
git commit -m "feat(dashboard): count-up, 3-tone bars, rank/ratio badges, click-through deep links"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: collection.html — URL 초기 필터 수신 + 폴리시
|
|
|
|
**Files:**
|
|
- Modify: `src/main/resources/templates/collection.html`
|
|
|
|
collection.html은 폼 컨트롤(`fStatus`, `fCategory`, `fSource`, `fShorts`)로 필터를 만들어 `loadVideos()`를 호출한다(이미 `URLSearchParams`로 쿼리 빌드). 페이지 진입 시 URL 파라미터를 읽어 폼에 세팅하면 대시보드 딥링크가 실제 필터된다.
|
|
|
|
- [ ] **Step 1: 진입 시 URL 파라미터 → 폼 세팅 함수 추가**
|
|
|
|
`loadVideos()` 정의 근처(혹은 초기화 IIFE/`DOMContentLoaded`)에 추가하고, **초기 `loadVideos()` 호출 직전**에 1회 실행:
|
|
|
|
```javascript
|
|
function applyUrlFilters(){
|
|
const q = new URLSearchParams(window.location.search);
|
|
const setSel = (id, val) => {
|
|
if(val == null) return;
|
|
const el = document.getElementById(id);
|
|
if(!el) return;
|
|
// <select>에 해당 option이 있을 때만 적용(없으면 무시 → 전체 폴백)
|
|
if(el.tagName === 'SELECT'){
|
|
if([...el.options].some(o => o.value === String(val))) el.value = String(val);
|
|
} else { el.value = String(val); }
|
|
};
|
|
setSel('fStatus', q.get('status'));
|
|
setSel('fCategory', q.get('categoryId'));
|
|
setSel('fSource', q.get('source'));
|
|
if(q.get('shortsOnly') === 'true'){ const c = document.getElementById('fShorts'); if(c) c.checked = true; }
|
|
if(q.get('bookmarkedOnly') === 'true'){ const c = document.getElementById('fBookmarked'); if(c) c.checked = true; }
|
|
}
|
|
```
|
|
|
|
`fCategory` select의 option들이 카테고리 로드 후 채워진다면, `applyUrlFilters()`는 **카테고리 option을 채운 직후 + 첫 loadVideos() 전**에 호출해야 한다. 현재 초기화 순서를 읽고 그 위치에 삽입한다. (카테고리가 비동기 로드면 그 `await` 다음 줄)
|
|
|
|
- [ ] **Step 2: 로딩 상태 스켈레톤(선택) + 필터바 정렬 점검**
|
|
|
|
기존 `'<tr><td colspan="10" ... >로딩 중...</td></tr>'`는 유지 가능. 필터바는 Task 3의 폼 통일로 자동 개선됨 — 정렬만 점검(필요 시 `flex items-center gap-2 flex-wrap`).
|
|
|
|
- [ ] **Step 3: 기동 후 딥링크 end-to-end 검증**
|
|
|
|
앱 기동(또는 Ctrl+F5). Playwright:
|
|
- `http://localhost:8088/collection?status=NEW` 직접 접속 → 상태 select가 NEW로 세팅되고 결과가 NEW만 표시되는지 스냅샷 확인.
|
|
- `http://localhost:8088/` → "미검토" KPI 클릭 → collection으로 이동 후 NEW 필터 적용 확인(Task 4+5 통합 동작).
|
|
- `http://localhost:8088/collection?categoryId=999`(존재X) → 무시되고 전체 표시(폴백) 확인.
|
|
|
|
Expected: 유효 파라미터는 필터 적용, 무효 값은 전체 폴백, 콘솔 에러 0.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/main/resources/templates/collection.html
|
|
git commit -m "feat(collection): apply initial filters from URL params (dashboard deep-link target)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: 나머지 페이지 스윕
|
|
|
|
공유 CSS(Phase 1) 적용 후 대부분 자동 개선되므로, 이 태스크는 페이지별로 **육안 확인 → 남은 거슬림만 소폭 수정**하는 가이드 스윕이다. 각 페이지는 독립 커밋.
|
|
|
|
**Files (해당되는 것만 Modify):**
|
|
- `board.html`, `publish.html`, `discover.html`, `channels.html`, `videos.html`, `production.html`, `production_detail.html`, `channel_detail.html`, `multi_channel_videos.html`, `rework.html`
|
|
|
|
페이지별 절차(각 페이지 반복):
|
|
|
|
- [ ] **Step A: 페이지 열어 관찰** — 앱 기동 상태에서 Playwright로 해당 URL 접속, 스크린샷.
|
|
- URL 매핑: `/board`, `/publish`, `/discover`, `/channels`, `/videos`, `/production`. 상세 페이지는 목록에서 진입(존재하는 id로).
|
|
- [ ] **Step B: 거슬림 식별** — 다음만 본다: ① 인라인 hex 색이 토큰과 안 맞는 곳 ② 로딩 "로딩 중…" 텍스트(→ `.skeleton` 적용 여부 판단) ③ 클릭 가능한데 hover/cursor 없는 요소 ④ 막대/배지를 공유 컴포넌트(`bar-track`/`badge`)로 바꿀 수 있는 인라인 ⑤ 간격/정렬 불일치.
|
|
- [ ] **Step C: 최소 수정** — 식별된 것만 공유 클래스로 치환(새 구조 변경 금지, 동작 보존). 페이지 고유 컴포넌트(kb-*, pub-in 등)는 색/여백 토큰화 정도만.
|
|
- [ ] **Step D: 재관찰** — Ctrl+F5/재네비게이션 후 스크린샷으로 개선 확인, 회귀 없는지 확인.
|
|
- [ ] **Step E: 페이지별 Commit** — 예: `git add src/main/resources/templates/board.html && git commit -m "style(board): tokenize colors, add hover/skeleton polish"`
|
|
|
|
우선순위(영향도 순): board → publish → discover → channels → videos → production → 상세 4종(production_detail/channel_detail/multi_channel_videos/rework). 시간/거슬림 없으면 일부 페이지는 "변경 없음(공유 CSS로 충분)"으로 스킵 가능 — 스킵한 페이지는 최종 보고에 명시.
|
|
|
|
- [ ] **Step F: 전체 마무리 검증** — 모든 페이지를 한 번씩 순회하며 콘솔 에러 0·레이아웃 정상 확인. 모바일 폭(375)으로 dashboard/collection/board 재확인.
|
|
|
|
---
|
|
|
|
## Self-Review (작성자 점검 결과)
|
|
|
|
- **Spec 커버리지**: Phase 1 토큰(Task 1)·유틸 백필(Task 2)·공유 컴포넌트(Task 3) ✓. Phase 2 dashboard 비주얼+딥링크(Task 4)·collection 딥링크 수신(Task 5)·기타 페이지 스윕(Task 6) ✓. 비목표(백엔드/라이브러리/시계열) 준수 ✓.
|
|
- **플레이스홀더**: Phase 1·Task 4·5는 완전한 코드 제공. Task 6은 코드가 아니라 **가이드 스윕**(각 페이지 현 마크업은 실행 시 읽어야 정확)이며, 추측 diff를 지어내지 않기 위해 의도적으로 절차+수용기준으로 구성. 이는 "공유 CSS가 대부분 자동 개선"이라는 설계 전제의 결과.
|
|
- **타입/이름 일관성**: `.bar-track/.bar-fill`(Task 3 정의 → Task 4 사용), `.badge*`(Task 3 → Task 4), `--primary-rgb`(Task 1 → Task 3 badge), `applyUrlFilters`/폼 id `fStatus`(collection 기존)·딥링크 파라미터명 `status/categoryId/source/shortsOnly`(Task 4 생성 ↔ Task 5 수신) 일치 ✓.
|
|
- **위험**: Task 3의 전역 `table`/`select` 스타일이 최고 회귀 위험 → Step 3에서 테이블·폼 페이지 + 모바일 폭 확인으로 가드.
|