Compare commits

..

10 Commits

Author SHA1 Message Date
dd42499f43 feat(ui): localize production page + page-header
Production -> 프로덕션, Crawl History -> 크롤 이력, table headers to Korean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 06:33:45 +09:00
652d25a483 feat(ui): discover table fits viewport (merged 영상 column)
Merge thumbnail+title+channel into one "영상" column like 수집함 so the table
fits without horizontal scroll. Promote .coll-table/.row-sel to global style.css
for reuse across list pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 06:32:27 +09:00
f3be6bd803 feat(ui): localize channels page to Korean
채널/추적 안내/전체 선택/채널 추가/구독자/영상 수/총 조회수/상세/방문/삭제.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 06:24:49 +09:00
1ebe2dda44 feat(ui): discover filter chips + help modals, localize, tokenize long pages
- discover: filter converted to toggle chips + inline selects (matches 수집함);
  page-header + 사용법 modal (배율 지표/필터/고른 뒤 안내)
- rework: 사용법 modal (전사·세그먼트·무음제거·내보내기·발행 guide) + page-header
- dashboard: h1 "Dashboard" -> "대시보드"
- channel_detail: sort-active header uses accent color (was invisible text-white)
- multi_channel_videos/videos/production_detail: tokenize dark-assuming colors
  and title-link text-white for light-theme readability

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 06:22:55 +09:00
52c6b51e61 feat(ui): roll out light/dark theme to board, publish, rework, discover, etc.
- board: tokenized kanban columns/cards; page-header + 사용법 modal (drag guide)
- publish: tokenized table/tabs; accent tab-active; page-header + 사용법 modal
- rework: tokenized the many dark-assuming inline styles so the editor renders
  correctly in the light theme
- discover/channels/production/channel_detail: tokenized dark-assuming colors
  (white text, translucent-white surfaces, #1e1e2d) for light-theme readability

Verified board/publish/rework/discover render cleanly in light.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 06:07:51 +09:00
616003479b feat(ui): collection — compact category, help modal, fit-to-width table
- Category management collapsed into a slim single-row strip
- "사용법" button opens a reusable help modal (.modal-overlay/.modal-card +
  .help-item) explaining categories, filters, list view, and row actions
- Merge thumbnail+title+channel into one "영상" column and tighten cells so the
  whole table fits the viewport — no horizontal scroll on page or table card
- #mainContent min-width:0 to avoid flex overflow

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:12:03 +09:00
fc58a32a20 feat(ui): collection filter toolbar — toggle chips + tidy inline selects
Replace raw checkboxes + stacked-label dropdowns with pill toggle chips
(.chip, accent when checked via :has) and a single aligned toolbar row;
result count as a badge. Adds reusable .toolbar/.chip/.field components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:45:05 +09:00
7497cb08ce fix(ui): collection page readable in light theme
Tokenized dark-assuming inline colors (white title/dropdown text, translucent
white surfaces, #1e1e2d modal) so the collection table renders correctly in the
light theme; header uses shared page-header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:39:45 +09:00
adb51943a3 feat(ui): light/dark SaaS design system + redesigned sidebar & dashboard
Phase A+dashboard of the UI redesign:
- variables.css: light-default tokens + [data-theme=dark] overrides; old var
  names aliased to theme tokens so existing markup adapts to both themes
- style.css: refined components (card/btn/nav/badge/bar/table/forms), tokenized
  the dark-assuming rgba colors
- theme toggle: pre-paint init in base.html <head> + toggleTheme() in common.js,
  persisted to localStorage; toggle button in sidebar
- sidebar.html: labeled nav with sections (분석/파이프라인/제작), active state,
  account; Korean labels
- dashboard.html: tokenized inline colors; verified light & dark with real data

Spec: docs/superpowers/specs/2026-06-12-ui-redesign-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:26:01 +09:00
9c276789f3 docs: add UI redesign design spec (light/dark SaaS design system)
Light-default + dark toggle, refined SaaS components, phased rollout across
all SSR pages. Approved dashboard mockup as the visual reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:19:09 +09:00
18 changed files with 993 additions and 1033 deletions

View File

@ -0,0 +1,85 @@
# UI 리뉴얼 — 정제된 라이트/다크 SaaS 디자인 시스템
작성일: 2026-06-12
대상: 전체 SSR(Thymeleaf) 화면의 시각 디자인 전면 개편
## 1. 목표
현재 UI(다크 글래스모피즘)가 "너무 어둡고 대비 낮아 뭉개진다". 정제된 SaaS 스타일(Linear/Notion/Vercel 계열)로 전면 리뉴얼하되, **라이트를 기본**으로 하고 **라이트/다크 토글**을 제공한다. 승인된 시안: 대시보드 라이트 mockup.
## 2. 핵심 결정
- **기본 테마 = 라이트.** 다크는 토글로 전환, 선택은 `localStorage`에 저장.
- **무플래시 초기화**: `<head>` 인라인 스크립트가 페인트 전에 `localStorage.theme`(기본 light)를 `<html data-theme>`에 적용.
- **테마 토글 버튼은 사이드바 하단(전역)** — 페이지마다 넣지 않는다.
- **레이아웃/마크업 구조는 유지**(사이드바 + 메인). 시각 스타일과 일부 페이지의 그리드/컴포넌트만 교체.
- **아이콘은 기존 Lucide 유지**(CDN). 폰트 Inter 유지.
## 3. 디자인 토큰 (`variables.css` 재작성)
CSS 변수로 라이트 기본 + `:root[data-theme="dark"]` 오버라이드. 의미 기반 토큰:
| 토큰 | 라이트 | 다크 |
|---|---|---|
| `--bg` | #f6f7f9 | #0a0b0e |
| `--surface` | #ffffff | #131519 |
| `--surface-2` | #f1f3f6 | #191c22 |
| `--inset` | #eef0f3 | #0e1014 |
| `--border` | #e6e8ec | rgba(255,255,255,.07) |
| `--border-strong` | #d9dce1 | rgba(255,255,255,.12) |
| `--text` | #15181d | #e8eaef |
| `--text-2` | #5a626d | #9aa1ad |
| `--text-3` | #9aa1ad | #646b78 |
| `--shadow` | 부드러운 2단 그림자 | 어두운 그림자 |
공통: `--accent #4f7cff`, `--accent-2 #7c5cff`(그라데이션 포인트만 절제), `--accent-soft`, 상태색 `--green/--amber/--red/--purple/--sky`. radius `--r:12px / --r-sm:9px`. 기존 변수명(`--primary`, `--text-primary`, `--text-muted`, `--glass-border` 등)은 **별칭으로 매핑 유지**해 기존 마크업이 깨지지 않게 한다(점진 교체).
## 4. 컴포넌트 (`style.css` 재작성/확장)
- **사이드바**: 라벨 + 섹션 구분(분석 / 파이프라인 / 기타), 활성 상태(accent-soft 배경 + 좌측 accent 바), 하단 사용자 + **테마 토글**. 라벨 한국어 통일(대시보드/발굴/채널/수집함/칸반 보드/재가공/발행 큐/프로덕션/설정).
- **페이지 헤더**: 타이틀 + 서브타이틀 + 우측 액션 영역.
- **버튼**: `.btn`(secondary 기본) / `.btn-primary`(accent) / `.btn.icon`. 그림자·보더 정돈.
- **카드**: `.card`(surface+border+shadow). 섹션 헤더 `.card .ch`.
- **스탯**: `.stat-grid`(반응형 5열) + `.stat`(+`.accent` 강조). 빈공간 없는 균등 그리드.
- **프로그레스바** `.bar`, **배지** `.badge`(비율/상태색), **랭크/리스트행** `.rank`/`.mini`.
- **테이블**: 수집함/발행 목록용 — 헤더, zebra/hover, 셀 패딩 정돈.
- **폼 입력**: `.input`/`select`/`textarea` 통일(재가공·발행 화면).
- **칸반**: `.kb-col`/`.kb-card` 라이트/다크 대응.
- **칩/태그**: 카테고리·상태.
## 5. 파일 변경
- `static/css/variables.css` — 토큰 재작성(라이트 기본 + 다크 오버라이드 + 기존 변수 별칭).
- `static/css/style.css` — 컴포넌트 재작성/확장.
- `static/js/common.js` — 테마 토글 함수 + 적용/저장.
- `templates/layout/base.html``<head>`에 무플래시 테마 init 인라인 스크립트.
- `templates/layout/sidebar.html` — 라벨/섹션/활성/사용자/테마토글 재구성.
- 각 페이지 템플릿 — 페이지별 마크업을 새 컴포넌트로 조정(인라인 스타일 정리).
## 6. 롤아웃 (페이지)
라이브 라우트 12개: dashboard, collection, board, discover, channels, channel_detail, multi_channel_videos, rework, publish, production, production_detail, videos.
> `videos.html`은 레거시 가능성(Video 모델 제거됨) — 적용 전 라우트 실사용 확인, 미사용이면 nav에서 숨김.
**단계:**
- **A. 디자인 시스템 토대** — variables.css + style.css 코어 + common.js 토글 + base.html init + sidebar.html. (적용 즉시 공통 컴포넌트 쓰는 화면 전반이 새 톤으로 전환)
- **B. 페이지 폴리시** — dashboard(시안 일치)부터 → collection → board → rework → publish → discover → channels/channel_detail → production/production_detail. 페이지별 그리드·인라인스타일을 컴포넌트로 교체.
## 7. 검증
- 각 페이지를 라이트/다크 양쪽에서 Playwright 스크린샷으로 확인(주요 화면은 사용자에게 텔레그램 이미지로 공유).
- 테마 토글 동작 + 새로고침 후 유지(localStorage) 확인.
- 기존 JS(데이터 fetch, 드래그앤드롭 등) 회귀 없는지 스모크.
## 8. 범위 밖(YAGNI)
- 반응형 모바일 정밀 대응은 최소(데스크톱 우선, 기존 모바일 토글 유지).
- 컴포넌트 라이브러리/빌드 도구 도입(순수 CSS 유지, SSR 그대로).
- 기능/도메인 로직 변경 없음 — 순수 프레젠테이션.
## 9. 열린 항목
- 액센트 색: 현재 파랑(#4f7cff)→보라(#7c5cff) 그라데이션. 사용자 확정 대기(변경 시 토큰만 수정).
- `videos.html` 레거시 여부 → 숨김/유지 결정.
- 사이드바 사용자 영역 실제 값(현재 "Admin User/Pro Plan" 더미) — 개인용이라 단순화.

View File

@ -1,93 +1,28 @@
/* @import 'variables.css'; - Loaded via HTML */
/* variables.css loaded separately via HTML */
/* Reset & Basics */
.container {
max-width: 1600px;
margin: 0 auto;
padding: 0 2.5rem;
}
/* ===== Reset & Basics ===== */
.container { max-width: 1600px; margin: 0 auto; padding: 0 2.5rem; }
a { text-decoration: none; color: inherit; }
ul { list-style: none; padding: 0; margin: 0; }
a {
text-decoration: none;
color: inherit;
}
/* ===== Utility Classes ===== */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: var(--space-2); }
.gap-4 { gap: 1rem; }
.w-full { width: 100%; }
.text-sm { font-size: 0.875rem; }
.text-lg { font-size: 1.125rem; font-weight: 600; }
.text-xl { font-size: 1.5rem; font-weight: 700; }
.text-muted { color: var(--text-muted); }
.font-bold { font-weight: 700; }
.mt-4 { margin-top: 1rem; }
.mb-4 { margin-bottom: 1rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
ul {
list-style: none;
padding: 0;
margin: 0;
}
/* Utility Classes */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: var(--space-2);
}
.gap-4 {
gap: 1rem;
}
.w-full {
width: 100%;
}
.text-sm {
font-size: 0.875rem;
}
.text-lg {
font-size: 1.125rem;
font-weight: 600;
}
.text-xl {
font-size: 1.5rem;
font-weight: 700;
}
.text-muted {
color: var(--text-muted);
}
.font-bold {
font-weight: 700;
}
.mt-4 {
margin-top: 1rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.p-4 {
padding: 1rem;
}
.p-6 {
padding: 1.5rem;
}
/* ===== 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); }
@ -98,7 +33,6 @@ ul {
.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); }
@ -107,11 +41,9 @@ ul {
.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; }
@ -135,141 +67,96 @@ ul {
.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; }
.text-blue-400 { color: var(--accent); }
.text-cyan-400 { color: var(--sky); }
.text-pink-400 { color: var(--red); }
.text-purple-400 { color: var(--purple); }
.text-orange-400 { color: var(--amber); }
.text-yellow-400 { color: var(--amber); }
.text-emerald-400 { color: var(--green); }
/* 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\:text-white:hover { color: var(--text); }
.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); }
.border-\[var\(--glass-border\)\] { border: 1px solid var(--border); }
.hover\:bg-blue-400\/10:hover { background: rgba(79, 124, 255, 0.10); }
.hover\:bg-cyan-400\/10:hover { background: rgba(14, 165, 233, 0.10); }
.hover\:bg-pink-400\/10:hover { background: rgba(244, 63, 94, 0.10); }
.hover\:bg-purple-400\/10:hover { background: rgba(139, 92, 246, 0.10); }
.hover\:bg-orange-400\/10:hover { background: rgba(245, 158, 11, 0.10); }
.hover\:bg-emerald-400\/10:hover { background: rgba(16, 185, 129, 0.10); }
.hover\:bg-yellow-400\/10:hover { background: rgba(245, 158, 11, 0.10); }
/* 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; }
}
@media (prefers-reduced-motion: reduce) { .animate-spin { animation: none; } }
/* Components */
/* ===== Components ===== */
/* GLASS CARD */
/* CARD — clean surface, subtle border + shadow */
.card {
background: var(--bg-glass);
backdrop-filter: blur(var(--backdrop-blur));
-webkit-backdrop-filter: blur(var(--backdrop-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 1.25rem 1.4rem;
box-shadow: var(--shadow);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
position: relative;
overflow: hidden;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2);
border-color: var(--glass-highlight);
}
.card:hover { border-color: var(--border-strong); }
/* BUTTON */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.6rem 1.25rem;
border-radius: var(--radius-md);
gap: 0.45rem;
padding: 0.55rem 1rem;
border-radius: var(--r-sm);
font-weight: 600;
transition: all 0.2s;
font-size: 0.875rem;
transition: all 0.18s ease;
cursor: pointer;
border: 1px solid transparent;
line-height: 1.2;
}
.btn-primary {
background: var(--primary-gradient);
color: white;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
color: #fff;
box-shadow: 0 2px 8px rgba(79, 99, 235, 0.25);
}
.btn-primary:hover {
box-shadow: 0 6px 20px rgba(37, 99, 235, 0.5);
transform: translateY(-1px);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border-color: var(--glass-border);
}
.btn-primary:hover { filter: brightness(1.05); box-shadow: 0 4px 14px rgba(79, 99, 235, 0.35); }
.btn-ghost { background: transparent; color: var(--text-2); }
.btn-ghost:hover { background: var(--hover); color: var(--text); }
.btn-secondary {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
border: 1px solid var(--glass-border);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border-strong);
box-shadow: var(--shadow);
}
.btn-secondary:hover { background: var(--surface-2); }
.btn-outline { background: transparent; color: var(--text-2); border: 1px solid var(--border-strong); }
.btn-outline:hover { color: var(--text); border-color: var(--primary); }
.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);
}
/* SIDEBAR SPECIFIC */
:root {
--sidebar-width: 260px;
--sidebar-collapsed-width: 80px;
--header-height: 64px;
}
/* ===== Sidebar ===== */
:root { --sidebar-width: 244px; --sidebar-collapsed-width: 76px; --header-height: 64px; }
.sidebar {
width: var(--sidebar-width);
@ -279,468 +166,281 @@ ul {
top: 0;
display: flex;
flex-direction: column;
background-color: var(--bg-glass);
backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border);
background: var(--side-bg);
border-right: 1px solid var(--border);
z-index: 50;
transition: width 0.3s ease;
overflow-x: hidden;
/* Prevent horizontal scroll during transition */
}
/* User Profile Section */
.nav-section {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-3);
font-weight: 600;
padding: 14px 1.25rem 6px;
}
.user-profile-container {
padding: 1rem;
margin: 0 1rem 1rem 1rem;
border-radius: 12px;
border: 1px solid var(--glass-border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0) 100%);
transition: all 0.3s ease;
padding: 0.85rem 1rem;
margin-top: auto;
border-top: 1px solid var(--border);
}
.user-profile-content { display: flex; align-items: center; gap: 0.7rem; overflow: hidden; }
.user-profile-content {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
overflow: hidden;
}
/* Toggle Button */
.sidebar-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px;
border-radius: 6px;
color: var(--text-muted);
color: var(--text-3);
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
background: transparent;
}
.sidebar-toggle-btn:hover { color: var(--text); background: var(--hover); }
.sidebar-toggle-btn:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
border-color: var(--glass-border);
}
/* Collapsed State */
.sidebar.collapsed {
width: var(--sidebar-collapsed-width);
/* Theme toggle */
.theme-toggle {
display: flex; align-items: center; gap: 0.5rem;
width: 100%;
padding: 0.55rem 0.75rem;
margin-bottom: 0.5rem;
border-radius: var(--r-sm);
background: transparent;
border: 1px solid var(--border);
color: var(--text-2);
font-size: 0.8rem; font-weight: 500;
cursor: pointer; transition: all 0.18s ease;
}
.theme-toggle:hover { background: var(--hover); color: var(--text); }
.theme-toggle .sun { display: none; }
.theme-toggle .moon { display: inline-flex; }
:root[data-theme="dark"] .theme-toggle .sun { display: inline-flex; }
:root[data-theme="dark"] .theme-toggle .moon { display: none; }
/* Collapsed state */
.sidebar.collapsed { width: var(--sidebar-collapsed-width); }
.sidebar.collapsed .brand-text,
.sidebar.collapsed .nav-text,
.sidebar.collapsed .user-info {
display: none;
opacity: 0;
}
/* Header in collapsed menu */
.sidebar.collapsed .p-6 {
/* Target the header div which has p-6 */
padding: 1rem;
flex-direction: column;
gap: 1rem;
justify-content: center;
}
.sidebar.collapsed .sidebar-toggle-btn {
transform: rotate(180deg);
/* Rotate chevron */
}
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 0.85rem 0;
}
.sidebar.collapsed .nav-icon {
margin-right: 0;
}
.sidebar.collapsed .user-profile-container {
padding: 0.75rem;
margin: 0 0.5rem 1rem 0.5rem;
border: none;
background: transparent;
}
.sidebar.collapsed .user-profile-content {
justify-content: center;
margin-bottom: 0;
}
.sidebar.collapsed .sign-out-btn span {
display: none;
}
.sidebar.collapsed .sign-out-btn {
padding: 0.5rem;
}
.sidebar.collapsed .nav-section,
.sidebar.collapsed .user-info,
.sidebar.collapsed .theme-toggle span { display: none; }
.sidebar.collapsed .nav-item { justify-content: center; padding: 0.7rem 0; }
.sidebar.collapsed .nav-icon { margin-right: 0; }
.sidebar.collapsed .sidebar-toggle-btn { transform: rotate(180deg); }
.sidebar.collapsed .theme-toggle { justify-content: center; }
/* NAV ITEM */
.nav-item {
display: flex;
align-items: center;
padding: 0.85rem 1rem;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
transition: all 0.2s ease;
color: var(--text-secondary);
border-left: 3px solid transparent;
padding: 0.6rem 0.75rem;
margin: 1px 0;
border-radius: var(--r-sm);
transition: all 0.15s ease;
color: var(--text-2);
font-weight: 500;
font-size: 0.875rem;
white-space: nowrap;
/* Keep text on one line */
overflow: hidden;
position: relative;
}
.nav-item:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.03);
.nav-item:hover { color: var(--text); background: var(--surface-2); }
.nav-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.nav-item.active::before {
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--accent);
}
.nav-icon { margin-right: 0.7rem; color: currentColor; min-width: 18px; width: 18px; height: 18px; }
.nav-item.active {
background: linear-gradient(90deg, rgba(59, 130, 246, 0.15), transparent);
border-left: 3px solid var(--primary);
color: var(--text-primary);
box-shadow: 0 4px 12px -4px rgba(59, 130, 246, 0.2);
font-weight: 600;
}
/* Main content offset */
#mainContent { transition: margin-left 0.3s ease; margin-left: var(--sidebar-width); min-width: 0; }
body.sidebar-collapsed #mainContent { margin-left: var(--sidebar-collapsed-width); }
.nav-icon {
margin-right: 0.875rem;
color: currentColor;
min-width: 24px;
/* Fix icon size */
}
/* ===== Page header (shared) ===== */
.page-header { display: flex; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem; }
.page-header h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.4px; }
.page-header .sub { color: var(--text-3); font-size: 0.85rem; margin-top: 0.3rem; }
.page-header .actions { display: flex; gap: 0.6rem; align-items: center; }
.nav-item.active .nav-icon {
color: var(--primary);
filter: drop-shadow(0 0 8px var(--primary-glow));
}
/* Main Content Adjustment based on sidebar */
#mainContent {
transition: margin-left 0.3s ease;
margin-left: var(--sidebar-width);
}
body.sidebar-collapsed #mainContent {
margin-left: var(--sidebar-collapsed-width);
}
/* Responsive */
/* ===== Responsive ===== */
@media (max-width: 1024px) {
.sidebar {
width: var(--sidebar-collapsed-width);
}
.sidebar .brand-text,
.sidebar .nav-text,
.sidebar .user-info {
display: none;
}
.sidebar .nav-item {
justify-content: center;
padding: 0.85rem 0;
}
.sidebar .nav-icon {
margin-right: 0;
}
/* Auto-collapse helpers handled via JS class, but default CSS fallback */
#mainContent {
margin-left: var(--sidebar-collapsed-width);
}
.sidebar { width: var(--sidebar-collapsed-width); }
.sidebar .brand-text, .sidebar .nav-text, .sidebar .nav-section,
.sidebar .user-info, .sidebar .theme-toggle span { display: none; }
.sidebar .nav-item { justify-content: center; padding: 0.7rem 0; }
.sidebar .nav-icon { margin-right: 0; }
#mainContent { margin-left: var(--sidebar-collapsed-width); }
}
/* Mobile Responsiveness Improvements */
@media (max-width: 768px) {
.mobile-col { flex-direction: column !important; }
.mobile-w-full { width: 100% !important; }
.mobile-items-start { align-items: flex-start !important; }
.mobile-gap-4 { gap: 1rem !important; }
.mobile-border-0 { border: none !important; }
.container { padding: 0 1rem; }
/* Layout Helpers */
.mobile-col {
flex-direction: column !important;
}
:root { --sidebar-collapsed-width: 0px; }
.sidebar { transform: translateX(-100%); }
.sidebar:not(.collapsed) { transform: translateX(0); width: 100%; background: var(--surface); }
body.sidebar-collapsed #mainContent { margin-left: 0; }
#mainContent { margin-left: 0 !important; }
.mobile-w-full {
width: 100% !important;
}
.mobile-items-start {
align-items: flex-start !important;
}
.mobile-gap-4 {
gap: 1rem !important;
}
.mobile-border-0 {
border: none !important;
}
/* Container adjustments */
.container {
padding: 0 1rem;
}
/* Sidebar Overrides for Mobile */
:root {
--sidebar-collapsed-width: 0px;
/* Hide completely on mobile if collapsed */
}
.sidebar {
transform: translateX(-100%);
}
.sidebar:not(.collapsed) {
transform: translateX(0);
width: 100%;
/* Full width sidebar on mobile */
background: rgba(10, 10, 10, 0.95);
/* Darker opaque bg */
}
body.sidebar-collapsed #mainContent {
margin-left: 0;
}
#mainContent {
margin-left: 0 !important;
/* Always 0 on mobile unless overlay logic added, but for now stack */
}
/* Video Detail Specifics */
.video-header-image {
width: 100% !important;
height: auto !important;
aspect-ratio: 16/9;
margin-bottom: 1rem;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
width: 100%;
}
/* Tabs */
.tabs-container {
overflow-x: auto;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
-webkit-overflow-scrolling: touch;
}
.tab-btn {
white-space: nowrap;
}
/* Mobile Menu Toggle */
.video-header-image { width: 100% !important; height: auto !important; aspect-ratio: 16/9; margin-bottom: 1rem; }
.stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; width: 100%; }
.tabs-container { overflow-x: auto; padding-bottom: 0.5rem; margin-bottom: 1rem; -webkit-overflow-scrolling: touch; }
.tab-btn { white-space: nowrap; }
.mobile-menu-btn {
display: flex !important;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-bottom: 1rem;
background: transparent;
color: white;
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
cursor: pointer;
display: flex !important; align-items: center; justify-content: center;
width: 40px; height: 40px; margin-bottom: 1rem;
background: var(--surface); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius-md); cursor: pointer;
}
}
.mobile-menu-btn { display: none; }
/* Default hidden for desktop */
.mobile-menu-btn {
display: none;
}
/* Mobile Responsive Tables (Card View) */
/* Mobile responsive tables (card view) */
@media (max-width: 768px) {
table,
thead,
tbody,
th,
td,
tr {
display: block;
}
/* Hide table headers (but not display:none for accessibility, usually, but here simplicity) */
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
tr {
margin-bottom: 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: 1rem;
}
td {
border: none;
position: relative;
padding-left: 50% !important;
/* Make space for label */
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
min-height: 2rem;
display: flex;
align-items: center;
text-align: right;
justify-content: flex-end;
}
td::before {
content: attr(data-label);
position: absolute;
left: 1rem;
width: 45%;
padding-right: 10px;
white-space: nowrap;
text-align: left;
font-weight: bold;
color: var(--text-muted);
}
/* Specific adjustments for certain columns if needed */
td[data-label="Video"] {
padding-left: 0 !important;
display: block;
text-align: left;
}
td[data-label="Video"]::before {
display: none;
/* Hide label for video title/thumb block to give it full width */
}
td[data-label="Rank"] {
display: inline-block;
padding: 0 !important;
margin-bottom: 0.5rem;
width: auto;
}
td[data-label="Rank"]::before {
display: none;
}
td[data-label="Rank"] span {
font-size: 0.875rem !important;
background: rgba(var(--primary-rgb), 0.1);
/* fallback if var not ready */
padding: 2px 8px;
border-radius: 4px;
color: var(--text-muted);
border: 1px solid var(--glass-border);
}
/* Customize Rank appearance to look like a badge */
table, thead, tbody, th, td, tr { display: block; }
thead tr { position: absolute; top: -9999px; left: -9999px; }
tr { margin-bottom: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 1rem; }
td { border: none; position: relative; padding-left: 50% !important; padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; min-height: 2rem; display: flex; align-items: center; text-align: right; justify-content: flex-end; }
td::before { content: attr(data-label); position: absolute; left: 1rem; width: 45%; padding-right: 10px; white-space: nowrap; text-align: left; font-weight: bold; color: var(--text-muted); }
td[data-label="Video"] { padding-left: 0 !important; display: block; text-align: left; }
td[data-label="Video"]::before { display: none; }
td[data-label="Rank"] { display: inline-block; padding: 0 !important; margin-bottom: 0.5rem; width: auto; }
td[data-label="Rank"]::before { display: none; }
td[data-label="Rank"] span { font-size: 0.875rem !important; background: var(--accent-soft); padding: 2px 8px; border-radius: 4px; color: var(--text-muted); border: 1px solid var(--border); }
}
/* ===== Shared components (added) ===== */
/* ===== Shared components ===== */
/* Progress bar (dashboard funnel, distributions, …) */
.bar-track {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.06);
/* Progress bar */
.bar-track { flex: 1; height: 8px; background: var(--inset); 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; } }
/* Toolbar + filter chips */
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.toolbar .sep { width: 1px; align-self: stretch; background: var(--border); margin: 2px 0.35rem; }
.toolbar .spacer { margin-left: auto; }
.toolbar select {
padding: 0.45rem 0.6rem;
font-size: 0.82rem;
border-radius: var(--r-sm);
}
.field { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.78rem; color: var(--text-3); }
.chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 0.45rem 0.8rem;
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; }
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--text-2);
font-size: 0.82rem; font-weight: 500;
cursor: pointer; user-select: none;
transition: all 0.15s ease;
}
.chip:hover { background: var(--surface-2); color: var(--text); }
.chip input { display: none; }
.chip:has(input:checked) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.chip svg { width: 14px; height: 14px; }
/* 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;
display: inline-flex; align-items: center; gap: 4px;
font-size: 0.7rem; font-weight: 700;
padding: 2px 8px; border-radius: 6px;
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); }
.badge-muted { background: var(--surface-2); color: var(--text-secondary); }
.badge-primary { background: var(--accent-soft); color: var(--accent); }
.badge-success { background: rgba(var(--success-rgb), 0.14); color: var(--success); }
.badge-warning { background: rgba(245, 158, 11, 0.14); color: var(--warning); }
.badge-danger { background: rgba(var(--danger-rgb), 0.14); color: var(--danger); }
/* Skeleton shimmer */
.skeleton {
position: relative;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--radius-md);
}
.skeleton { position: relative; overflow: hidden; background: var(--surface-2); 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);
content: ""; position: absolute; inset: 0; transform: translateX(-100%);
background: linear-gradient(90deg, transparent, var(--hover-strong), transparent);
animation: skeleton-shimmer 1.4s infinite;
}
@keyframes skeleton-shimmer { 100% { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) {
.skeleton::after { animation: none; }
}
@media (prefers-reduced-motion: reduce) { .skeleton::after { animation: none; } }
/* Dark form controls (unify select/input across pages) */
/* Form controls */
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;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
padding: 0.5rem 0.7rem;
font-family: inherit;
font-size: 0.875rem;
transition: border-color 0.2s ease, background 0.2s ease;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
select:focus, input[type="text"]:focus, input[type="search"]:focus, input[type="number"]:focus, input[type="date"]:focus, input[type="datetime-local"]:focus, textarea:focus {
outline: none;
border-color: var(--primary);
background: rgba(255, 255, 255, 0.06);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
select option { background: #0f172a; color: var(--text-primary); }
select option { background: var(--surface); color: var(--text); }
/* Table polish */
/* Table */
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);
text-align: left; font-size: 0.75rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted);
padding: 0.65rem 0.75rem; border-bottom: 1px solid var(--border);
}
tbody td { padding: 0.6rem 0.75rem; border-bottom: 1px solid rgba(255, 255, 255, 0.04); }
tbody td { padding: 0.65rem 0.75rem; border-bottom: 1px solid var(--border); }
tbody tr { transition: background 0.15s ease; }
tbody tr:hover { background: rgba(255, 255, 255, 0.03); }
tbody tr:hover { background: var(--hover); }
/* Compact list table: number/status cells stay one line, "영상" cell stays wide */
.coll-table th, .coll-table td { white-space: nowrap; vertical-align: middle; }
.coll-table td:first-child, .coll-table th:first-child { white-space: normal; width: 42%; min-width: 280px; }
.coll-table thead th { background: var(--surface-2); }
.row-sel { padding: 4px 6px; background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: 6px; color: var(--text); font-size: 0.78rem; outline: none; max-width: 110px; }
.row-sel option { background: var(--surface); color: var(--text); }
/* ===== Modal (reusable) ===== */
.modal-overlay {
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(10, 14, 22, 0.55);
-webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
align-items: center; justify-content: center; padding: 1rem;
}
.modal-overlay.open { display: flex; }
.modal-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r); box-shadow: var(--shadow-lg);
width: 100%; max-width: 580px; max-height: 86vh; overflow: auto;
}
.modal-head {
display: flex; align-items: center; justify-content: space-between;
padding: 1.05rem 1.4rem; border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: var(--surface); z-index: 1;
}
.modal-head h3 { font-size: 1.05rem; font-weight: 700; }
.modal-close { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 1.5rem; line-height: 1; padding: 0 0.2rem; }
.modal-close:hover { color: var(--text); }
.modal-body { padding: 0.6rem 1.4rem 1.2rem; }
/* Help guide items */
.help-item { display: flex; gap: 0.9rem; padding: 0.85rem 0; border-bottom: 1px solid var(--border); }
.help-item:last-child { border-bottom: none; }
.help-item .hi-ic {
width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0;
background: var(--accent-soft); color: var(--accent);
display: flex; align-items: center; justify-content: center;
}
.help-item .hi-ic svg { width: 17px; height: 17px; }
.help-item .hi-t { font-weight: 600; font-size: 0.9rem; margin-bottom: 3px; }
.help-item .hi-d { font-size: 0.82rem; color: var(--text-2); line-height: 1.6; }
.help-item .hi-d b { color: var(--text); font-weight: 600; }

View File

@ -1,60 +1,104 @@
/* ===========================================================================
Design tokens Refined SaaS, LIGHT default + DARK via [data-theme="dark"].
Old variable names are aliased to theme tokens so existing markup adapts.
=========================================================================== */
:root {
/* Premium Dark Theme Palette (Cyber/Space) */
/* Surfaces (light) */
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f1f3f6;
--inset: #eef0f3;
--border: #e6e8ec;
--border-strong: #d9dce1;
/* Backgrounds */
--bg-primary: #050508;
/* Ultra dark, almost black */
--bg-glass: rgba(15, 23, 42, 0.6);
/* Text (light) */
--text: #15181d;
--text-2: #5a626d;
--text-3: #9aa1ad;
/* Glassmorphism */
--glass-border: rgba(255, 255, 255, 0.08);
--glass-highlight: rgba(255, 255, 255, 0.15);
--backdrop-blur: 12px;
/* On-surface translucent (hover/active) */
--hover: rgba(16, 24, 40, .045);
--hover-strong: rgba(16, 24, 40, .07);
/* Typography */
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
/* Brand Colors (Neon/Vibrant) */
--primary: #3b82f6;
--primary-glow: rgba(59, 130, 246, 0.5);
--primary-gradient: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
/* Elevation */
--shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 4px 12px rgba(16, 24, 40, .05);
--shadow-lg: 0 10px 30px rgba(16, 24, 40, .10);
--side-bg: #ffffff;
/* Accent + status (shared across themes) */
--accent: #4f7cff;
--accent-2: #7c5cff;
--accent-soft: rgba(79, 124, 255, .10);
--primary: #4f7cff;
--primary-gradient: linear-gradient(135deg, #4f7cff 0%, #7c5cff 100%);
--primary-glow: rgba(79, 124, 255, .35);
--success: #10b981;
--danger: #ef4444;
/* UI Elements */
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 9999px;
/* Semantic (added) */
--danger: #f43f5e;
--warning: #f59e0b;
--primary-rgb: 59, 130, 246; /* matches --primary #3b82f6; used by rgba(var(--primary-rgb), …) */
--green: #10b981;
--amber: #f59e0b;
--red: #f43f5e;
--purple: #8b5cf6;
--sky: #0ea5e9;
--primary-rgb: 79, 124, 255;
--success-rgb: 16, 185, 129;
--danger-rgb: 239, 68, 68;
--danger-rgb: 244, 63, 94;
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
/* === Back-compat aliases (old names → theme tokens) === */
--bg-primary: var(--bg);
--bg-glass: var(--surface);
--glass-border: var(--border);
--glass-highlight: var(--border-strong);
--text-primary: var(--text);
--text-secondary: var(--text-2);
--text-muted: var(--text-3);
--bg-hover: var(--surface-2);
--backdrop-blur: 0px;
/* Radius / spacing / font */
--radius-md: 10px;
--radius-lg: 14px;
--radius-full: 9999px;
--r: 12px;
--r-sm: 9px;
--space-1: .25rem;
--space-2: .5rem;
--space-3: .75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* ===== Dark theme overrides ===== */
:root[data-theme="dark"] {
--bg: #0a0b0e;
--surface: #131519;
--surface-2: #191c22;
--inset: #0e1014;
--border: rgba(255, 255, 255, .08);
--border-strong: rgba(255, 255, 255, .13);
--text: #e8eaef;
--text-2: #9aa1ad;
--text-3: #6b7280;
--hover: rgba(255, 255, 255, .05);
--hover-strong: rgba(255, 255, 255, .09);
--shadow: 0 1px 2px rgba(0, 0, 0, .3);
--shadow-lg: 0 12px 30px rgba(0, 0, 0, .45);
--side-bg: linear-gradient(180deg, #0c0e12, #0a0b0e);
--accent-soft: rgba(79, 124, 255, .16);
}
body {
background-color: var(--bg-primary);
background-image: radial-gradient(circle at 15% 50%, rgba(56, 189, 248, 0.08), transparent 25%),
radial-gradient(circle at 85% 30%, rgba(99, 102, 241, 0.08), transparent 25%);
background-attachment: fixed;
color: var(--text-primary);
background-color: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-feature-settings: "tnum" 1, "cv01" 1;
margin: 0;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
transition: background-color .2s ease, color .2s ease;
}

View File

@ -1,3 +1,12 @@
// Theme toggle (light/dark). State applied pre-paint in base.html <head>.
function toggleTheme() {
const root = document.documentElement;
const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ }
if (window.lucide) window.lucide.createIcons();
}
// Sidebar Logic
document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('sidebar');

View File

@ -8,29 +8,51 @@
<body>
<div layout:fragment="content">
<header class="mb-4 flex items-center justify-between">
<div class="page-header">
<div>
<h1 class="text-2xl font-bold mb-1">작업 보드</h1>
<p class="text-muted">카드를 드래그해 단계를 옮기세요. 수집 → 검토 → 작업대상 → 완료.</p>
<h1>칸반 보드</h1>
<p class="sub">카드를 드래그해 단계를 옮기세요. 수집 → 검토 → 작업대상 → 완료.</p>
</div>
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="loadBoard()">
<i data-lucide="refresh-cw" style="width:16px;"></i> 새로고침
</button>
</header>
<div class="actions">
<button class="btn btn-secondary" onclick="openHelp()"><i data-lucide="help-circle" style="width:15px;"></i> 사용법</button>
<button class="btn btn-secondary" onclick="loadBoard()"><i data-lucide="refresh-cw" style="width:15px;"></i> 새로고침</button>
</div>
</div>
<!-- 사용법 모달 -->
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()">
<div class="modal-card">
<div class="modal-head"><h3>📖 칸반 보드 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div>
<div class="modal-body">
<div class="help-item">
<div class="hi-ic"><i data-lucide="columns-3"></i></div>
<div><div class="hi-t">단계(컬럼)</div><div class="hi-d"><b>수집됨 → 검토중 → 작업대상 → 완료</b> 순으로 영상의 진행 상태를 나타냅니다. <b>제외</b>는 작업하지 않을 영상입니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="move"></i></div>
<div><div class="hi-t">드래그로 단계 이동</div><div class="hi-d">카드를 <b>끌어다 다른 컬럼에 놓으면</b> 상태가 바로 바뀝니다(저장됨). 상단 숫자는 컬럼별 카드 수입니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="wand-2"></i></div>
<div><div class="hi-t">카드</div><div class="hi-d">썸네일·제목·지표(조회·시간당·길이)와 배율을 보여줍니다. <b>🪄 아이콘</b>으로 재가공 에디터로 이동합니다.</div></div>
</div>
</div>
</div>
</div>
<div id="board" style="display:grid; grid-template-columns: repeat(5, minmax(220px, 1fr)); gap:1rem; align-items:start; overflow-x:auto;">
<!-- columns injected -->
</div>
<style>
.kb-col { background: rgba(255,255,255,0.02); border:1px solid var(--glass-border); border-radius: var(--radius-md); display:flex; flex-direction:column; min-height:200px; }
.kb-col-head { padding:12px; border-bottom:1px solid var(--glass-border); display:flex; align-items:center; justify-content:space-between; position:sticky; top:0; }
.kb-col { background: var(--surface-2); border:1px solid var(--border); border-radius: var(--r); display:flex; flex-direction:column; min-height:200px; }
.kb-col-head { padding:12px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; position:sticky; top:0; background:var(--surface-2); border-radius:var(--r) var(--r) 0 0; }
.kb-body { padding:10px; display:flex; flex-direction:column; gap:8px; min-height:120px; flex:1; transition: background 0.15s; }
.kb-body.drag-over { background: rgba(124,58,237,0.12); }
.kb-card { background: var(--glass-bg,#1e1e2d); border:1px solid var(--glass-border); border-radius:8px; padding:8px; cursor:grab; transition: transform 0.1s, border-color 0.15s; }
.kb-card:hover { border-color:#7C3AED88; }
.kb-body.drag-over { background: var(--accent-soft); }
.kb-card { background: var(--surface); border:1px solid var(--border); border-radius:9px; padding:9px; cursor:grab; box-shadow:var(--shadow); transition: transform 0.1s, border-color 0.15s; }
.kb-card:hover { border-color: var(--accent); }
.kb-card.dragging { opacity:0.5; }
.kb-count { font-size:0.75rem; background:rgba(255,255,255,0.08); padding:2px 8px; border-radius:999px; }
.kb-count { font-size:0.75rem; font-weight:600; background:var(--surface); border:1px solid var(--border); color:var(--text-2); padding:2px 9px; border-radius:999px; }
</style>
<script th:inline="javascript">
@ -46,6 +68,14 @@
let dragId = null;
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
function durBadge(v){
if(v.isShorts) return 'Shorts';
const s = v.durationSec;
if(s==null || s<=0) return '';
const m = Math.floor(s/60), sec = s%60;
return m+':'+String(sec).padStart(2,'0');
}
async function api(url, opts){
const res = await fetch(url, opts);
const json = await res.json().catch(()=>({}));
@ -56,6 +86,12 @@
function cardHtml(v){
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '';
const ratioColor = (v.viewsPerSubRatio>=10)?'#ef4444':((v.viewsPerSubRatio>=2)?'#f59e0b':'#10b981');
const db = durBadge(v);
const metrics = [
v.viewCount!=null ? '👁 '+fmt(v.viewCount) : '',
v.viewsPerHour!=null ? '⚡ '+fmt(Math.round(v.viewsPerHour))+'/h' : '',
db ? '⏱ '+db : ''
].filter(Boolean).join('&nbsp;&nbsp;·&nbsp;&nbsp;');
return `<div class="kb-card" draggable="true" data-id="${v.id}">
<div style="display:flex; gap:8px;">
<img src="${esc(v.thumbnailUrl)}" style="width:64px; height:36px; object-fit:cover; border-radius:4px; flex-shrink:0;">
@ -63,6 +99,7 @@
<div class="text-sm font-bold" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; line-height:1.3;">${esc(v.title)}</div>
</div>
</div>
${metrics?`<div class="text-muted mt-2" style="font-size:0.68rem; line-height:1.4;">${metrics}</div>`:''}
<div class="flex items-center justify-between mt-2">
<span class="text-muted" style="font-size:0.7rem;">${esc(v.channelTitle||'')}</span>
<div class="flex items-center gap-2">
@ -145,6 +182,10 @@
});
}
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); });
loadBoard();
/*]]>*/
</script>

View File

@ -70,7 +70,7 @@
<!-- Video List Table -->
<div class="card p-0" style="overflow-x: auto;">
<table class="w-full" style="border-collapse: collapse; text-align: left;">
<thead style="background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--glass-border);">
<thead style="background: var(--surface-2); border-bottom: 1px solid var(--glass-border);">
<tr>
<th class="p-4 text-sm font-bold text-muted">Video</th>
<th class="p-4 text-sm font-bold text-muted text-center">Script</th>
@ -239,10 +239,10 @@
if (icon) {
const colName = th.getAttribute('onclick').match(/'([^']+)'/)[1];
if (colName === sortState.column) {
th.classList.add('text-white');
th.style.color = 'var(--accent)';
icon.setAttribute('data-lucide', sortState.direction === 'asc' ? 'chevron-up' : 'chevron-down');
} else {
th.classList.remove('text-white');
th.style.color = '';
icon.setAttribute('data-lucide', 'arrow-up-down');
}
}
@ -315,10 +315,10 @@
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { labels: { color: '#cbd5e1' } } },
plugins: { legend: { labels: { color: 'var(--text-2)' } } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(255,255,255,0.05)' } },
y: { position: 'left', ticks: { color: '#a78bfa' }, grid: { color: 'rgba(255,255,255,0.05)' }, title: { display: true, text: '구독자', color: '#a78bfa' } },
x: { ticks: { color: '#94a3b8' }, grid: { color: 'var(--surface-2)' } },
y: { position: 'left', ticks: { color: '#a78bfa' }, grid: { color: 'var(--surface-2)' }, title: { display: true, text: '구독자', color: '#a78bfa' } },
y1: { position: 'right', ticks: { color: '#34d399' }, grid: { drawOnChartArea: false }, title: { display: true, text: '조회수', color: '#34d399' } }
}
}

View File

@ -10,19 +10,19 @@
<div layout:fragment="content">
<header class="flex justify-between items-center mb-4" style="margin-bottom: 2rem;">
<div>
<h1 class="text-xl font-bold mb-4">Channels</h1>
<p class="text-muted">Manage the YouTube channels you are tracking.</p>
<h1 class="text-xl font-bold mb-4">채널</h1>
<p class="text-muted">추적 중인 YouTube 채널을 관리합니다.</p>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer text-sm text-muted hover:text-white transition-colors">
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
Select All
전체 선택
</label>
<button id="viewSelectedBtn" class="btn btn-outline" onclick="viewSelectedVideos()" style="display: none;">
<i data-lucide="play" style="width: 18px; margin-right: 8px;"></i> View Selected Videos (<span id="selectedCount">0</span>)
</button>
<button class="btn btn-primary" onclick="openAddModal()">
<i data-lucide="plus" style="width: 18px; margin-right: 8px;"></i> Add Channel
<i data-lucide="plus" style="width: 18px; margin-right: 8px;"></i> 채널 추가
</button>
</div>
</header>
@ -40,13 +40,13 @@
onchange="updateSelectedCount(); event.stopPropagation();">
<div class="custom-checkbox flex items-center justify-center" style="
width: 24px; height: 24px;
border: 2px solid rgba(255,255,255,0.2);
border: 2px solid var(--border-strong);
border-radius: 6px;
background: rgba(255,255,255,0.05);
background: var(--surface-2);
transition: all 0.2s ease;
pointer-events: none;
">
<i data-lucide="check" style="width: 16px; height: 16px; color: white; display: none;"></i>
<i data-lucide="check" style="width: 16px; height: 16px; color: var(--text); display: none;"></i>
</div>
</div>
@ -57,8 +57,8 @@
<div>
<h3 class="font-bold" th:text="${channel.title}">Tech Reviewer A</h3>
<div class="text-muted text-sm"
th:text="${#numbers.formatInteger(channel.subscriberCount ?: 0, 0, 'COMMA')} + ' Subscribers'">
1.2M Subscribers</div>
th:text="${#numbers.formatInteger(channel.subscriberCount ?: 0, 0, 'COMMA')} + ' 구독자'">
1.2M 구독자</div>
</div>
</div>
</div>
@ -75,14 +75,14 @@
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;">
<div class="p-4"
style="background: rgba(255,255,255,0.03); border-radius: 8px; text-align: center;">
<div class="text-muted mb-4" style="font-size: 0.75rem">Videos</div>
style="background: var(--surface-2); border-radius: 8px; text-align: center;">
<div class="text-muted mb-4" style="font-size: 0.75rem">영상 수</div>
<div class="font-bold" th:text="${#numbers.formatInteger(channel.videoCount ?: 0, 0, 'COMMA')}">450
</div>
</div>
<div class="p-4"
style="background: rgba(255,255,255,0.03); border-radius: 8px; text-align: center;">
<div class="text-muted mb-4" style="font-size: 0.75rem">Total Views</div>
style="background: var(--surface-2); border-radius: 8px; text-align: center;">
<div class="text-muted mb-4" style="font-size: 0.75rem">총 조회수</div>
<div class="font-bold" th:text="${#numbers.formatInteger(channel.viewCount ?: 0, 0, 'COMMA')}">45K
</div>
</div>
@ -91,15 +91,15 @@
<div class="flex gap-2" onclick="event.stopPropagation()">
<a th:href="@{/channels/{id}(id=${channel.id})}" class="btn btn-primary w-full"
style="font-size: 0.8rem; text-decoration: none; display: flex; align-items: center; justify-content: center;">
<i data-lucide="list" style="width: 14px; margin-right: 6px;"></i> Detail
<i data-lucide="list" style="width: 14px; margin-right: 6px;"></i> 상세
</a>
<a th:href="'https://www.youtube.com/channel/' + ${channel.channelId}" target="_blank"
class="btn btn-ghost w-full"
style="font-size: 0.8rem; text-decoration: none; display: flex; align-items: center; justify-content: center;">
<i data-lucide="external-link" style="width: 14px; margin-right: 6px;"></i> Visit
<i data-lucide="external-link" style="width: 14px; margin-right: 6px;"></i> 방문
</a>
<button class="btn btn-ghost w-full" style="color: var(--danger); font-size: 0.8rem;" th:onclick="'deleteChannel(' + ${channel.id} + '); event.stopPropagation();'">
<i data-lucide="trash-2" style="width: 14px; margin-right: 6px;"></i> Remove
<i data-lucide="trash-2" style="width: 14px; margin-right: 6px;"></i> 삭제
</button>
</div>
</div>
@ -118,7 +118,7 @@
</div>
<div class="flex justify-end gap-2">
<button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitAddChannel()">Add Channel</button>
<button class="btn btn-primary" onclick="submitAddChannel()">채널 추가</button>
</div>
</div>
</div>
@ -224,8 +224,8 @@
} else {
card.style.borderColor = 'var(--glass-border)';
card.style.background = 'var(--glass-bg)';
customCb.style.background = 'rgba(255,255,255,0.05)';
customCb.style.borderColor = 'rgba(255,255,255,0.2)';
customCb.style.background = 'var(--surface-2)';
customCb.style.borderColor = 'var(--border-strong)';
checkIcon.style.display = 'none';
}
});

View File

@ -8,53 +8,47 @@
<body>
<div layout:fragment="content">
<header class="mb-4">
<h1 class="text-2xl font-bold mb-2">수집함 (Collection)</h1>
<p class="text-muted">수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.</p>
</header>
<!-- 카테고리 관리 -->
<div class="card mb-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">카테고리</h3>
<button class="btn btn-secondary p-2" onclick="document.getElementById('catForm').classList.toggle('hidden')">
<i data-lucide="plus" style="width:16px;"></i>
<div class="page-header">
<div>
<h1>수집함</h1>
<p class="sub">수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openHelp()">
<i data-lucide="help-circle" style="width:15px;"></i> 사용법
</button>
</div>
<div id="categoryChips" class="flex flex-wrap gap-2 mb-2">
<span class="text-muted text-sm">로딩...</span>
</div>
<div id="catForm" class="hidden flex gap-2 mt-4" style="flex-wrap: wrap; align-items:flex-end;">
<div style="display:flex; flex-direction:column; gap:4px;">
<label class="text-sm text-muted">이름</label>
<input id="catName" type="text" placeholder="예: 동물 썰"
style="padding:8px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:white; outline:none;">
</div>
<div style="display:flex; flex-direction:column; gap:4px;">
<label class="text-sm text-muted">색상</label>
<input id="catColor" type="color" value="#7C3AED"
style="padding:2px; height:42px; width:56px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:var(--radius-md);">
</div>
<button class="btn btn-primary px-4 py-2" onclick="addCategory()">추가</button>
</div>
</div>
<!-- 필터 바 -->
<div class="card mb-4">
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end;">
<label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
<input type="checkbox" id="fOutperformers" onchange="loadVideos()"> <span class="text-sm font-bold">🚀 떡상 후보만</span>
</label>
<label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
<input type="checkbox" id="fShorts" onchange="loadVideos()"> <span class="text-sm">Shorts만</span>
</label>
<label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
<input type="checkbox" id="fBookmarked" onchange="loadVideos()"> <span class="text-sm">⭐ 북마크만</span>
</label>
<!-- 카테고리 관리 (컴팩트) -->
<div class="card mb-4" style="padding:0.75rem 1.1rem;">
<div class="flex items-center gap-3" style="flex-wrap:wrap;">
<span class="text-sm font-semibold" style="color:var(--text-2);">카테고리</span>
<div id="categoryChips" class="flex flex-wrap gap-2" style="flex:1; min-width:120px;">
<span class="text-muted text-sm">로딩...</span>
</div>
<button class="btn btn-secondary" style="padding:0.4rem 0.7rem;" onclick="document.getElementById('catForm').classList.toggle('hidden')">
<i data-lucide="plus" style="width:15px;"></i> 추가
</button>
</div>
<div id="catForm" class="hidden" style="display:flex; gap:0.6rem; flex-wrap:wrap; align-items:center; margin-top:0.7rem; padding-top:0.7rem; border-top:1px solid var(--border);">
<input id="catName" type="text" placeholder="이름 (예: 동물 썰)" style="flex:1; min-width:140px;">
<input id="catColor" type="color" value="#7C3AED" title="색상" style="padding:2px; height:38px; width:48px;">
<button class="btn btn-primary" onclick="addCategory()">추가</button>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:4px; min-width:130px;">
<label class="text-sm text-muted">상태</label>
<select id="fStatus" onchange="loadVideos()" class="filter-sel">
<!-- 필터 툴바 -->
<div class="card mb-4">
<div class="toolbar">
<label class="chip"><input type="checkbox" id="fOutperformers" onchange="loadVideos()"> 🚀 떡상 후보</label>
<label class="chip"><input type="checkbox" id="fShorts" onchange="loadVideos()"> Shorts</label>
<label class="chip"><input type="checkbox" id="fBookmarked" onchange="loadVideos()"> ⭐ 북마크</label>
<span class="sep"></span>
<label class="field">상태
<select id="fStatus" onchange="loadVideos()">
<option value="">전체</option>
<option value="NEW">NEW (수집됨)</option>
<option value="REVIEWING">REVIEWING (검토중)</option>
@ -62,63 +56,98 @@
<option value="DONE">DONE (완료)</option>
<option value="EXCLUDED">EXCLUDED (제외)</option>
</select>
</div>
<div style="display:flex; flex-direction:column; gap:4px; min-width:150px;">
<label class="text-sm text-muted">카테고리</label>
<select id="fCategory" onchange="loadVideos()" class="filter-sel">
<option value="">전체</option>
</select>
</div>
<div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
<label class="text-sm text-muted">출처</label>
<select id="fSource" onchange="loadVideos()" class="filter-sel">
</label>
<label class="field">카테고리
<select id="fCategory" onchange="loadVideos()"><option value="">전체</option></select>
</label>
<label class="field">출처
<select id="fSource" onchange="loadVideos()">
<option value="">전체</option>
<option value="CHANNEL">채널 수집</option>
<option value="SEARCH">검색 수집</option>
</select>
</div>
<div style="display:flex; flex-direction:column; gap:4px; min-width:150px;">
<label class="text-sm text-muted">정렬</label>
<select id="fSort" onchange="loadVideos()" class="filter-sel">
</label>
<label class="field">정렬
<select id="fSort" onchange="loadVideos()">
<option value="viewsPerSubRatio">배율(조회/구독) ↓</option>
<option value="viewsPerHour">시간당 조회수 ↓</option>
<option value="viewCount">조회수 ↓</option>
<option value="publishedAt">최신순 ↓</option>
</select>
</div>
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="loadVideos()">
<i data-lucide="refresh-cw" style="width:16px;"></i> 새로고침
</label>
<span class="spacer"></span>
<span id="resultCount" class="badge badge-muted"></span>
<button class="btn btn-secondary" onclick="loadVideos()" title="새로고침">
<i data-lucide="refresh-cw" style="width:15px;"></i>
</button>
<span id="resultCount" class="text-sm text-muted" style="margin-left:auto; height:42px; display:flex; align-items:center;"></span>
</div>
</div>
<!-- 결과 테이블 -->
<div class="card p-0" style="overflow-x:auto;">
<table class="w-full" style="border-collapse:collapse; text-align:left;">
<thead style="background:rgba(255,255,255,0.02); border-bottom:1px solid var(--glass-border);">
<table class="w-full coll-table" style="border-collapse:collapse; text-align:left;">
<thead>
<tr>
<th class="p-3 text-sm font-bold text-muted">썸네일</th>
<th class="p-3 text-sm font-bold text-muted">제목</th>
<th class="p-3 text-sm font-bold text-muted">채널</th>
<th class="p-3 text-sm font-bold text-muted">구독자</th>
<th class="p-3 text-sm font-bold text-muted">조회수</th>
<th class="p-3 text-sm font-bold text-muted">시간당</th>
<th class="p-3 text-sm font-bold text-muted">배율</th>
<th class="p-3 text-sm font-bold text-muted">상태</th>
<th class="p-3 text-sm font-bold text-muted">카테고리</th>
<th class="p-3 text-sm font-bold text-muted">관리</th>
<th>영상</th>
<th>구독자</th>
<th>조회수</th>
<th>시간당</th>
<th>배율</th>
<th>상태</th>
<th>카테고리</th>
<th style="text-align:right;">관리</th>
</tr>
</thead>
<tbody id="resultBody">
<tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>
<tr><td colspan="8" class="p-8 text-center text-muted">로딩 중...</td></tr>
</tbody>
</table>
</div>
<!-- 사용법 모달 -->
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()">
<div class="modal-card">
<div class="modal-head">
<h3>📖 수집함 사용법</h3>
<button class="modal-close" onclick="closeHelp()">&times;</button>
</div>
<div class="modal-body">
<div class="help-item">
<div class="hi-ic"><i data-lucide="tag"></i></div>
<div>
<div class="hi-t">카테고리</div>
<div class="hi-d">영상을 주제별로 분류하는 라벨입니다. 우측 <b>[+ 추가]</b>로 이름·색을 정해 만들고, 칩의 <b>×</b>로 삭제합니다. 표의 <b>‘카테고리’ 드롭다운</b>으로 각 영상에 지정하세요.</div>
</div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="filter"></i></div>
<div>
<div class="hi-t">필터 · 정렬</div>
<div class="hi-d"><b></b>(🚀 떡상 후보 · Shorts · ⭐ 북마크)을 눌러 켜고 끕니다. 드롭다운으로 <b>상태·카테고리·출처</b>를 좁히고 <b>정렬</b>(배율/시간당/조회수/최신순)을 바꿉니다. 우측 배지에 결과 수가 표시됩니다.</div>
</div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="table-2"></i></div>
<div>
<div class="hi-t">목록 보기</div>
<div class="hi-d"><b>썸네일 클릭</b> = 팝업 미리보기, <b>제목 클릭</b> = YouTube 새 탭. <b>배율</b>은 조회수÷구독자로, 높을수록 ‘떡상’(10x↑ 빨강, 2x↑ 주황). 상태·카테고리 드롭다운은 바꾸면 <b>즉시 저장</b>됩니다.</div>
</div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="mouse-pointer-click"></i></div>
<div>
<div class="hi-t">행 버튼 (관리)</div>
<div class="hi-d"><b>🪄 재가공</b> = 자막 추출·재작성 에디터로 이동 · <b>⭐ 북마크</b> = 즐겨찾기 토글 · <b>🗑 삭제</b> = 수집함에서 제거(스크립트도 함께 삭제).</div>
</div>
</div>
</div>
</div>
</div>
<!-- 영상 모달 -->
<div id="videoModal" style="display:none; position:fixed; z-index:1000; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.85); align-items:center; justify-content:center;" onclick="if(event.target===this) closeVideoModal()">
<div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,#1e1e2d); border-radius:12px; padding:16px; border:1px solid var(--glass-border);">
<div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,var(--surface)); border-radius:12px; padding:16px; border:1px solid var(--glass-border);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video</h3>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
@ -129,9 +158,13 @@
<style>
.hidden { display:none !important; }
.row-sel { padding:4px 6px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:6px; color:white; font-size:0.8rem; outline:none; }
.row-sel option { background:#1e1e2d; color:white; }
.row-sel { padding:4px 6px; background:var(--surface-2); border:1px solid var(--border-strong); border-radius:6px; color:var(--text); font-size:0.78rem; outline:none; max-width:110px; }
.row-sel option { background:var(--surface); color:var(--text); }
.cat-chip { padding:4px 10px; border-radius:999px; font-size:0.8rem; display:inline-flex; align-items:center; gap:6px; }
/* 컴팩트 표: 숫자/상태 칸은 줄바꿈 없이, 영상 칸만 넓게 */
.coll-table th, .coll-table td { white-space:nowrap; vertical-align:middle; }
.coll-table td:first-child, .coll-table th:first-child { white-space:normal; width:42%; min-width:300px; }
.coll-table th { background:var(--surface-2); }
</style>
<script th:inline="javascript">
@ -239,7 +272,7 @@
const body = document.getElementById('resultBody');
document.getElementById('resultCount').textContent = list.length + '건';
if(list.length===0){
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">조건에 맞는 영상이 없습니다.</td></tr>';
body.innerHTML = '<tr><td colspan="8" class="p-8 text-center text-muted">조건에 맞는 영상이 없습니다.</td></tr>';
return;
}
body.innerHTML = list.map(v=>{
@ -248,32 +281,31 @@
const statusOpts = STATUS_KEYS.map(k=>
`<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join('');
const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)';
const shortsBadge = v.isShorts ? '<span style="font-size:0.65rem; background:#7C3AED33; color:#a78bfa; padding:1px 5px; border-radius:4px; margin-left:4px;">Shorts</span>' : '';
return `<tr style="border-bottom:1px solid var(--glass-border);">
<td class="p-3">
<div style="position:relative; cursor:pointer; width:96px;" data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)">
<img src="${esc(v.thumbnailUrl)}" style="width:96px; height:54px; object-fit:cover; border-radius:6px;">
const shortsBadge = v.isShorts ? '<span class="badge badge-primary" style="margin-left:5px;">Shorts</span>' : '';
return `<tr>
<td>
<div class="flex items-center gap-3" style="min-width:0;">
<img src="${esc(v.thumbnailUrl)}" data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)"
style="width:84px; height:47px; object-fit:cover; border-radius:6px; cursor:pointer; flex-shrink:0;">
<div style="min-width:0;">
<div class="font-semibold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; line-height:1.35;">
<a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline">${esc(v.title)}</a>${shortsBadge}
</div>
<a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:var(--text-3); font-size:0.72rem; display:block; margin-top:2px;">${esc(v.channelTitle||'-')}</a>
</div>
</div>
</td>
<td class="p-3" style="max-width:280px;">
<div class="font-bold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">
<a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline text-white">${esc(v.title)}</a>${shortsBadge}
</div>
</td>
<td class="p-3 text-sm text-muted" style="max-width:130px;">
<a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:#e2e8f0;">${esc(v.channelTitle||'-')}</a>
</td>
<td class="p-3 text-sm">${fmt(v.subscriberCount)}</td>
<td class="p-3 text-sm">${fmt(v.viewCount)}</td>
<td class="p-3 text-sm">${fmt(v.viewsPerHour)}</td>
<td class="p-3 text-sm">${ratioBadge(v.viewsPerSubRatio)}</td>
<td class="p-3"><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td>
<td class="p-3"><select class="row-sel" onchange="setCategory(${v.id}, this.value)">${catOpts}</select></td>
<td class="p-3">
<div class="flex items-center gap-1">
<a class="btn btn-primary p-2" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a>
<button class="btn btn-secondary p-2" title="북마크" onclick="toggleBookmark(${v.id}, ${!!v.bookmarked})"><i data-lucide="star" style="width:15px; color:${star};"></i></button>
<button class="btn btn-secondary p-2" title="삭제" onclick="deleteVideo(${v.id})"><i data-lucide="trash-2" style="width:15px;"></i></button>
<td class="text-sm">${fmt(v.subscriberCount)}</td>
<td class="text-sm">${fmt(v.viewCount)}</td>
<td class="text-sm">${fmt(v.viewsPerHour)}</td>
<td class="text-sm">${ratioBadge(v.viewsPerSubRatio)}</td>
<td><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td>
<td><select class="row-sel" onchange="setCategory(${v.id}, this.value)">${catOpts}</select></td>
<td>
<div class="flex items-center gap-1 justify-end">
<a class="btn btn-primary" style="padding:0.4rem;" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a>
<button class="btn btn-secondary" style="padding:0.4rem;" title="북마크" onclick="toggleBookmark(${v.id}, ${!!v.bookmarked})"><i data-lucide="star" style="width:15px; color:${star};"></i></button>
<button class="btn btn-secondary" style="padding:0.4rem;" title="삭제" onclick="deleteVideo(${v.id})"><i data-lucide="trash-2" style="width:15px;"></i></button>
</div>
</td>
</tr>`;
@ -312,6 +344,11 @@
document.getElementById('videoModal').style.display = 'none';
}
// ----- 사용법 모달 -----
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeHelp(); closeVideoModal(); } });
// ----- URL 파라미터 필터 적용 -----
function applyUrlFilters(){
const q = new URLSearchParams(window.location.search);

View File

@ -11,7 +11,7 @@
<header class="mb-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-bold mb-1">Dashboard</h1>
<h1 class="text-xl font-bold mb-1">대시보드</h1>
<p class="text-muted">수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황</p>
</div>
<button id="refreshBtn" class="btn btn-secondary px-3 py-2 flex items-center gap-1" onclick="loadDashboard()">
@ -193,7 +193,7 @@
else {
opList.innerHTML = op.map((v,i)=>{
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '-';
return '<a href="https://www.youtube.com/watch?v='+v.videoId+'" target="_blank" class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03); text-decoration:none;">' +
return '<a href="https://www.youtube.com/watch?v='+v.videoId+'" target="_blank" class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:var(--surface-2); text-decoration:none;">' +
'<div class="flex items-center gap-2" style="min-width:0;">' +
'<span class="badge badge-muted" style="flex-shrink:0;">'+(i+1)+'</span>' +
'<img src="'+esc(v.thumbnailUrl)+'" style="width:48px;height:27px;object-fit:cover;border-radius:4px;flex-shrink:0;">' +
@ -220,8 +220,8 @@
? '<p class="text-muted text-sm" style="margin-top:4px;">발행 패키지가 없습니다.</p>'
: '<div class="text-sm text-muted" style="margin:6px 0 2px;">최근</div>' + recent.map(p=>{
const st = PUB_ST[p.status] || { t:p.status, cls:'badge-muted' };
return '<a href="/rework/'+p.channelVideoId+'" class="flex items-center justify-between p-2" style="border-radius:6px; background:rgba(255,255,255,0.03); text-decoration:none;">' +
'<span class="text-sm truncate" style="max-width:170px; color:#e2e8f0;">'+esc(p.title||'(제목 없음)')+'</span>' +
return '<a href="/rework/'+p.channelVideoId+'" class="flex items-center justify-between p-2" style="border-radius:6px; background:var(--surface-2); text-decoration:none;">' +
'<span class="text-sm truncate" style="max-width:170px; color:var(--text);">'+esc(p.title||'(제목 없음)')+'</span>' +
'<span class="badge '+st.cls+'" style="flex-shrink:0;">'+st.t+'</span></a>';
}).join('');

View File

@ -8,88 +8,104 @@
<body>
<div layout:fragment="content">
<header class="mb-4">
<h1 class="text-2xl font-bold mb-2">발굴 (Discovery)</h1>
<p class="text-muted">수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)</p>
</header>
<div class="page-header">
<div>
<h1>발굴</h1>
<p class="sub">수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openHelp()"><i data-lucide="help-circle" style="width:15px;"></i> 사용법</button>
</div>
</div>
<!-- 필터 바 -->
<!-- 사용법 모달 -->
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()">
<div class="modal-card">
<div class="modal-head"><h3>📖 발굴 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div>
<div class="modal-body">
<div class="help-item">
<div class="hi-ic"><i data-lucide="trending-up"></i></div>
<div><div class="hi-t">배율(떡상 지표)</div><div class="hi-d"><b>조회수 ÷ 구독자</b>로, 구독자 대비 조회수가 폭발한 정도입니다. 높을수록 ‘떡상’(10x↑ 빨강, 2x↑ 주황). 작은 채널의 대박 영상을 찾는 데 유용합니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="filter"></i></div>
<div><div class="hi-t">필터</div><div class="hi-d"><b>기간·최소 배율·출처</b>로 좁히고 <b>정렬</b>을 바꿉니다. 칩 <b>Shorts</b>·<b>미처리만</b>으로 빠르게 거릅니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="wand-2"></i></div>
<div><div class="hi-t">고른 뒤</div><div class="hi-d">표의 <b>🪄</b>로 재가공 에디터로 보내거나, 상태를 바꿔 칸반 보드/수집함에서 이어서 관리합니다.</div></div>
</div>
</div>
</div>
</div>
<!-- 필터 툴바 -->
<div class="card mb-4">
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end;">
<div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
<label class="text-sm text-muted">기간</label>
<select id="fPeriod" onchange="loadVideos()" class="filter-sel">
<div class="toolbar">
<label class="chip"><input type="checkbox" id="fShorts" onchange="loadVideos()"> Shorts</label>
<label class="chip"><input type="checkbox" id="fUnprocessed" onchange="loadVideos()"> 미처리만</label>
<span class="sep"></span>
<label class="field">기간
<select id="fPeriod" onchange="loadVideos()">
<option value="">전체</option>
<option value="7">최근 7일</option>
<option value="14">최근 14일</option>
<option value="30">최근 30일</option>
</select>
</div>
<div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
<label class="text-sm text-muted">최소 배율</label>
<select id="fMinRatio" onchange="loadVideos()" class="filter-sel">
</label>
<label class="field">최소 배율
<select id="fMinRatio" onchange="loadVideos()">
<option value="">전체</option>
<option value="2">≥ 2x</option>
<option value="3">≥ 3x</option>
<option value="5">≥ 5x</option>
</select>
</div>
<div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
<label class="text-sm text-muted">출처</label>
<select id="fSource" onchange="loadVideos()" class="filter-sel">
</label>
<label class="field">출처
<select id="fSource" onchange="loadVideos()">
<option value="">전체</option>
<option value="CHANNEL">채널 수집</option>
<option value="SEARCH">검색 수집</option>
</select>
</div>
<div style="display:flex; flex-direction:column; gap:4px; min-width:160px;">
<label class="text-sm text-muted">정렬</label>
<select id="fSort" onchange="loadVideos()" class="filter-sel">
</label>
<label class="field">정렬
<select id="fSort" onchange="loadVideos()">
<option value="viewsPerSubRatio">배율(조회/구독) ↓</option>
<option value="viewsPerHour">시간당 조회수 ↓</option>
<option value="viewCount">조회수 ↓</option>
<option value="publishedAt">최신순 ↓</option>
</select>
</div>
<label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
<input type="checkbox" id="fShorts" onchange="loadVideos()"> <span class="text-sm">Shorts만</span>
</label>
<label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
<input type="checkbox" id="fUnprocessed" onchange="loadVideos()"> <span class="text-sm">미처리만(NEW/검토중)</span>
</label>
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="loadVideos()">
<i data-lucide="refresh-cw" style="width:16px;"></i> 새로고침
</button>
<span id="resultCount" class="text-sm text-muted" style="margin-left:auto; height:42px; display:flex; align-items:center;"></span>
<span class="spacer"></span>
<span id="resultCount" class="badge badge-muted"></span>
<button class="btn btn-secondary" onclick="loadVideos()" title="새로고침"><i data-lucide="refresh-cw" style="width:15px;"></i></button>
</div>
</div>
<!-- 결과 테이블 -->
<div class="card p-0" style="overflow-x:auto;">
<table class="w-full" style="border-collapse:collapse; text-align:left;">
<thead style="background:rgba(255,255,255,0.02); border-bottom:1px solid var(--glass-border);">
<table class="w-full coll-table" style="border-collapse:collapse; text-align:left;">
<thead>
<tr>
<th class="p-3 text-sm font-bold text-muted">썸네일</th>
<th class="p-3 text-sm font-bold text-muted">제목</th>
<th class="p-3 text-sm font-bold text-muted">채널</th>
<th class="p-3 text-sm font-bold text-muted">구독자</th>
<th class="p-3 text-sm font-bold text-muted">조회수</th>
<th class="p-3 text-sm font-bold text-muted">시간당</th>
<th class="p-3 text-sm font-bold text-muted">배율</th>
<th class="p-3 text-sm font-bold text-muted">업로드</th>
<th class="p-3 text-sm font-bold text-muted">상태</th>
<th class="p-3 text-sm font-bold text-muted">관리</th>
<th>영상</th>
<th>구독자</th>
<th>조회수</th>
<th>시간당</th>
<th>배율</th>
<th>업로드</th>
<th>상태</th>
<th style="text-align:right;">관리</th>
</tr>
</thead>
<tbody id="resultBody">
<tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>
<tr><td colspan="8" class="p-8 text-center text-muted">로딩 중...</td></tr>
</tbody>
</table>
</div>
<!-- 영상 모달 -->
<div id="videoModal" style="display:none; position:fixed; z-index:1000; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.85); align-items:center; justify-content:center;" onclick="if(event.target===this) closeVideoModal()">
<div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,#1e1e2d); border-radius:12px; padding:16px; border:1px solid var(--glass-border);">
<div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,var(--surface)); border-radius:12px; padding:16px; border:1px solid var(--glass-border);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video</h3>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
@ -99,8 +115,8 @@
</div>
<style>
.row-sel { padding:4px 6px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:6px; color:white; font-size:0.8rem; outline:none; }
.row-sel option { background:#1e1e2d; color:white; }
.row-sel { padding:4px 6px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:6px; color:var(--text); font-size:0.8rem; outline:none; }
.row-sel option { background:var(--surface); color:var(--text); }
</style>
<script th:inline="javascript">
@ -138,7 +154,7 @@
let data;
try { data = await api(API + '/discover?' + p.toString()); }
catch(e){
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>';
body.innerHTML = '<tr><td colspan="8" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>';
return;
}
renderRows(data || []);
@ -156,38 +172,37 @@
const body = document.getElementById('resultBody');
document.getElementById('resultCount').textContent = list.length + '건';
if(list.length===0){
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">조건에 맞는 발굴 후보가 없습니다.</td></tr>';
body.innerHTML = '<tr><td colspan="8" class="p-8 text-center text-muted">조건에 맞는 발굴 후보가 없습니다.</td></tr>';
return;
}
body.innerHTML = list.map(v=>{
const statusOpts = STATUS_KEYS.map(k=>
`<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join('');
const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)';
const shortsBadge = v.isShorts ? '<span style="font-size:0.65rem; background:#7C3AED33; color:#a78bfa; padding:1px 5px; border-radius:4px; margin-left:4px;">Shorts</span>' : '';
return `<tr style="border-bottom:1px solid var(--glass-border);">
<td class="p-3">
<div style="position:relative; cursor:pointer; width:96px;" data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)">
<img src="${esc(v.thumbnailUrl)}" style="width:96px; height:54px; object-fit:cover; border-radius:6px;">
const shortsBadge = v.isShorts ? '<span class="badge badge-primary" style="margin-left:5px;">Shorts</span>' : '';
return `<tr>
<td>
<div class="flex items-center gap-3" style="min-width:0;">
<img src="${esc(v.thumbnailUrl)}" data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)"
style="width:84px; height:47px; object-fit:cover; border-radius:6px; cursor:pointer; flex-shrink:0;">
<div style="min-width:0;">
<div class="font-semibold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; line-height:1.35;">
<a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline">${esc(v.title)}</a>${shortsBadge}
</div>
<a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:var(--text-3); font-size:0.72rem; display:block; margin-top:2px;">${esc(v.channelTitle||'-')}</a>
</div>
</div>
</td>
<td class="p-3" style="max-width:280px;">
<div class="font-bold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">
<a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline text-white">${esc(v.title)}</a>${shortsBadge}
</div>
</td>
<td class="p-3 text-sm text-muted" style="max-width:130px;">
<a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:#e2e8f0;">${esc(v.channelTitle||'-')}</a>
</td>
<td class="p-3 text-sm">${fmt(v.subscriberCount)}</td>
<td class="p-3 text-sm">${fmt(v.viewCount)}</td>
<td class="p-3 text-sm">${fmt(v.viewsPerHour)}</td>
<td class="p-3 text-sm">${ratioBadge(v.viewsPerSubRatio)}</td>
<td class="p-3 text-sm text-muted">${fmtDate(v.publishedAt)}</td>
<td class="p-3"><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td>
<td class="p-3">
<div class="flex items-center gap-1">
<a class="btn btn-primary p-2" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a>
<button class="btn btn-secondary p-2" title="북마크" onclick="toggleBookmark(${v.id}, ${!!v.bookmarked})"><i data-lucide="star" style="width:15px; color:${star};"></i></button>
<td class="text-sm">${fmt(v.subscriberCount)}</td>
<td class="text-sm">${fmt(v.viewCount)}</td>
<td class="text-sm">${fmt(v.viewsPerHour)}</td>
<td class="text-sm">${ratioBadge(v.viewsPerSubRatio)}</td>
<td class="text-sm text-muted">${fmtDate(v.publishedAt)}</td>
<td><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td>
<td>
<div class="flex items-center gap-1 justify-end">
<a class="btn btn-primary" style="padding:0.4rem;" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a>
<button class="btn btn-secondary" style="padding:0.4rem;" title="북마크" onclick="toggleBookmark(${v.id}, ${!!v.bookmarked})"><i data-lucide="star" style="width:15px; color:${star};"></i></button>
</div>
</td>
</tr>`;
@ -214,6 +229,10 @@
document.getElementById('videoModal').style.display = 'none';
}
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeHelp(); closeVideoModal(); } });
loadVideos();
/*]]>*/
</script>

View File

@ -6,6 +6,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>h-lab - Dashboard</title>
<!-- Theme init (before paint, no flash) -->
<script>
(function () {
try { document.documentElement.setAttribute('data-theme', localStorage.getItem('theme') || 'light'); }
catch (e) { document.documentElement.setAttribute('data-theme', 'light'); }
})();
</script>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@ -3,116 +3,64 @@
<body th:fragment="sidebar">
<aside id="sidebar" class="sidebar">
<div class="p-6 mb-4 flex items-center justify-between">
<!-- Brand -->
<div class="flex items-center justify-between" style="padding: 1.1rem 1rem 0.5rem;">
<div class="flex items-center gap-3">
<div style="
width: 32px; height: 32px;
background: var(--primary-gradient);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 15px var(--primary-glow);
flex-shrink: 0;
">
<span style="font-weight: bold; color: white; font-size: 18px;">H</span>
<div style="width:30px;height:30px;background:var(--primary-gradient);border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<span style="font-weight:800;color:#fff;font-size:15px;">H</span>
</div>
<div class="brand-text" style="white-space:nowrap;">
<div style="font-size:15px;font-weight:700;letter-spacing:-0.2px;color:var(--text);line-height:1.1;">h-lab</div>
<div style="font-size:11px;color:var(--text-3);font-weight:500;">yanalyst</div>
</div>
<h1 class="text-xl font-bold brand-text" style="
background: linear-gradient(to right, #fff, #94a3b8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
white-space: nowrap;
">
h-lab
</h1>
</div>
<!-- Toggle Button -->
<button id="sidebarToggle" class="sidebar-toggle-btn">
<i data-lucide="chevron-left" style="width: 18px; height: 18px;"></i>
<i data-lucide="chevron-left" style="width:18px;height:18px;"></i>
</button>
</div>
<nav style="flex: 1; padding: 0 1rem; overflow-y: auto;">
<ul class="flex-col gap-2">
<li>
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">
<i data-lucide="layout-dashboard" class="nav-icon"></i>
<span class="nav-text">Dashboard</span>
</a>
</li>
<li>
<a th:href="@{/channels}" class="nav-item" th:classappend="${currentPage == 'channels'} ? 'active'">
<i data-lucide="users" class="nav-icon"></i>
<span class="nav-text">Channels</span>
</a>
</li>
<li>
<a th:href="@{/videos}" class="nav-item" th:classappend="${currentPage == 'videos'} ? 'active'">
<i data-lucide="video" class="nav-icon"></i>
<span class="nav-text">Videos</span>
</a>
</li>
<li>
<a th:href="@{/collection}" class="nav-item"
th:classappend="${currentPage == 'collection'} ? 'active'">
<i data-lucide="library" class="nav-icon"></i>
<span class="nav-text">수집함</span>
</a>
</li>
<li>
<a th:href="@{/discover}" class="nav-item"
th:classappend="${currentPage == 'discover'} ? 'active'">
<i data-lucide="radar" class="nav-icon"></i>
<span class="nav-text">발굴</span>
</a>
</li>
<li>
<a th:href="@{/board}" class="nav-item"
th:classappend="${currentPage == 'board'} ? 'active'">
<i data-lucide="kanban-square" class="nav-icon"></i>
<span class="nav-text">보드</span>
</a>
</li>
<li>
<a th:href="@{/publish}" class="nav-item"
th:classappend="${currentPage == 'publish'} ? 'active'">
<i data-lucide="send" class="nav-icon"></i>
<span class="nav-text">발행</span>
</a>
</li>
<li>
<a th:href="@{/production}" class="nav-item"
th:classappend="${currentPage == 'production'} ? 'active'">
<i data-lucide="clapperboard" class="nav-icon"></i>
<span class="nav-text">Production</span>
</a>
</li>
<li>
<a href="#" class="nav-item">
<i data-lucide="settings" class="nav-icon"></i>
<span class="nav-text">Settings</span>
</a>
</li>
</ul>
<nav style="flex:1;padding:0.5rem 0.75rem;overflow-y:auto;">
<div class="nav-section">분석</div>
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">
<i data-lucide="layout-dashboard" class="nav-icon"></i><span class="nav-text">대시보드</span>
</a>
<a th:href="@{/discover}" class="nav-item" th:classappend="${currentPage == 'discover'} ? 'active'">
<i data-lucide="radar" class="nav-icon"></i><span class="nav-text">발굴</span>
</a>
<a th:href="@{/channels}" class="nav-item" th:classappend="${currentPage == 'channels'} ? 'active'">
<i data-lucide="users" class="nav-icon"></i><span class="nav-text">채널</span>
</a>
<div class="nav-section">파이프라인</div>
<a th:href="@{/collection}" class="nav-item" th:classappend="${currentPage == 'collection'} ? 'active'">
<i data-lucide="library" class="nav-icon"></i><span class="nav-text">수집함</span>
</a>
<a th:href="@{/board}" class="nav-item" th:classappend="${currentPage == 'board'} ? 'active'">
<i data-lucide="kanban-square" class="nav-icon"></i><span class="nav-text">칸반 보드</span>
</a>
<a th:href="@{/publish}" class="nav-item" th:classappend="${currentPage == 'publish'} ? 'active'">
<i data-lucide="send" class="nav-icon"></i><span class="nav-text">발행 큐</span>
</a>
<div class="nav-section">제작</div>
<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>
</a>
</nav>
<div class="user-profile-container">
<button class="theme-toggle" onclick="toggleTheme()" type="button" title="테마 전환">
<span class="moon"><i data-lucide="moon" style="width:15px;height:15px;"></i></span>
<span class="sun"><i data-lucide="sun" style="width:15px;height:15px;"></i></span>
<span class="nav-text">테마 전환</span>
</button>
<div class="user-profile-content">
<div style="
width: 36px; height: 36px; border-radius: 50%;
background: linear-gradient(135deg, #475569 0%, #0f172a 100%);
border: 2px solid rgba(255,255,255,0.1);
flex-shrink: 0;
"></div>
<div class="user-info">
<div class="text-sm font-bold" style="color: var(--text-primary); white-space: nowrap;">Admin User
</div>
<div class="text-sm" style="color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;">Pro
Plan</div>
<div style="width:30px;height:30px;border-radius:50%;background:var(--surface-2);border:1px solid var(--border);flex-shrink:0;"></div>
<div class="user-info" style="white-space:nowrap;">
<div style="font-size:12.5px;font-weight:600;color:var(--text);">hehiho</div>
<div style="font-size:11px;color:var(--text-3);">개인 작업공간</div>
</div>
</div>
<button class="w-full btn-ghost sign-out-btn"
style="justify-content: center; padding: 0.5rem; gap: 0.5rem; border-radius: 4px; background: rgba(0,0,0,0.2);">
<i data-lucide="log-out" style="width: 14px; height: 14px;"></i> <span>Sign Out</span>
</button>
</div>
</aside>
</body>

View File

@ -19,7 +19,7 @@
<div class="card p-6 mb-6">
<div class="text-sm font-bold text-muted mb-4">Tracking <span th:text="${channels != null ? #lists.size(channels) : 0}">0</span> Channels</div>
<div class="flex flex-wrap gap-4" th:if="${channels != null}">
<div th:each="channel : ${channels}" class="flex items-center gap-2 p-2 rounded-lg bg-[rgba(255,255,255,0.02)] border border-[var(--glass-border)]">
<div th:each="channel : ${channels}" class="flex items-center gap-2 p-2 rounded-lg bg-[var(--surface-2)] border border-[var(--glass-border)]">
<img th:src="${channel.thumbnailUrl}" alt="Thumbnail"
style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;" th:if="${channel.thumbnailUrl != null}">
<span class="text-sm font-medium" th:text="${channel.title}">Channel Name</span>
@ -36,20 +36,20 @@
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 140px;">
<label class="text-sm font-bold text-muted">Period</label>
<select id="periodDays" class="w-full"
style="padding: 8px; height: 42px; background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: white; outline: none;">
<option value="1" style="background: #1e1e2d; color: white;">Within 1 Day</option>
<option value="7" style="background: #1e1e2d; color: white;" selected>Within 7 Days</option>
<option value="10" style="background: #1e1e2d; color: white;">Within 10 Days</option>
<option value="15" style="background: #1e1e2d; color: white;">Within 15 Days</option>
<option value="30" style="background: #1e1e2d; color: white;">Within 30 Days</option>
<option value="0" style="background: #1e1e2d; color: white;">전부 (All)</option>
style="padding: 8px; height: 42px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: var(--text); outline: none;">
<option value="1" style="background: var(--surface); color: var(--text);">Within 1 Day</option>
<option value="7" style="background: var(--surface); color: var(--text);" selected>Within 7 Days</option>
<option value="10" style="background: var(--surface); color: var(--text);">Within 10 Days</option>
<option value="15" style="background: var(--surface); color: var(--text);">Within 15 Days</option>
<option value="30" style="background: var(--surface); color: var(--text);">Within 30 Days</option>
<option value="0" style="background: var(--surface); color: var(--text);">전부 (All)</option>
</select>
</div>
<!-- Format -->
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 160px;">
<label class="text-sm font-bold text-muted">Format</label>
<div class="flex gap-4 w-full" style="padding: 8px; background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); border-radius: var(--radius-md); justify-content: center; height: 42px; align-items: center;">
<div class="flex gap-4 w-full" style="padding: 8px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); justify-content: center; height: 42px; align-items: center;">
<label class="flex items-center gap-1 cursor-pointer hover:text-white">
<input type="radio" name="format" value="SHORTS" checked> Shorts
</label>
@ -63,10 +63,10 @@
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 120px;">
<label class="text-sm font-bold text-muted">Load Size</label>
<select id="pageSize" class="w-full"
style="padding: 8px; height: 42px; background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: white; outline: none;">
<option value="20" style="background: #1e1e2d; color: white;">20 items</option>
<option value="50" style="background: #1e1e2d; color: white;" selected>50 items</option>
<option value="100" style="background: #1e1e2d; color: white;">100 items</option>
style="padding: 8px; height: 42px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: var(--text); outline: none;">
<option value="20" style="background: var(--surface); color: var(--text);">20 items</option>
<option value="50" style="background: var(--surface); color: var(--text);" selected>50 items</option>
<option value="100" style="background: var(--surface); color: var(--text);">100 items</option>
</select>
</div>
</div>
@ -95,7 +95,7 @@
<!-- Video List Table -->
<div class="card p-0" style="overflow-x: auto;">
<table class="w-full" style="border-collapse: collapse; text-align: left;">
<thead style="background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--glass-border);">
<thead style="background: var(--surface-2); border-bottom: 1px solid var(--glass-border);">
<tr>
<th class="p-4 text-sm font-bold text-muted"><input type="checkbox" id="selectAll" onclick="toggleSelectAll(this.checked)" title="전체 선택"></th>
<th class="p-4 text-sm font-bold text-muted">Thumbnail</th>
@ -130,7 +130,7 @@
<!-- Video Player Modal -->
<div id="videoModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.85); align-items: center; justify-content: center;" onclick="if(event.target === this) closeVideoModal()">
<div style="position: relative; width: 95%; max-width: 1200px; background-color: var(--glass-bg, #1e1e2d); border-radius: 12px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid var(--glass-border);">
<div style="position: relative; width: 95%; max-width: 1200px; background-color: var(--glass-bg, var(--surface)); border-radius: 12px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid var(--glass-border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video Player</h3>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size: 28px; line-height: 1; background: none; border: none; cursor: pointer;">&times;</button>
@ -327,14 +327,14 @@
'<div style="position: relative; display: inline-block; cursor: pointer;" data-videoid="' + video.videoId + '" data-videotitle="' + safeTitle + '" onclick="openVideoModal(this.dataset.videoid, this.dataset.videotitle)">' +
'<img src="' + video.thumbnailUrl + '" alt="Thumb" style="width: 120px; height: 68px; object-fit: cover; border-radius: 6px; transition: transform 0.2s;" onmouseover="this.style.transform=\'scale(1.05)\'" onmouseout="this.style.transform=\'scale(1)\'">' +
'<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.6); border-radius: 50%; padding: 8px; display: flex; align-items: center; justify-content: center;">' +
'<i data-lucide="play" style="color: white; width: 16px; height: 16px; fill: white;"></i>' +
'<i data-lucide="play" style="color: var(--text); width: 16px; height: 16px; fill: white;"></i>' +
'</div>' +
'</div>' +
'</td>' +
'<td class="p-4">' +
'<div class="flex items-start justify-between gap-2" style="max-width: 300px;">' +
'<div class="font-bold text-sm" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex: 1;">' +
'<a href="https://www.youtube.com/watch?v=' + video.videoId + '" target="_blank" class="hover:underline text-white">' + video.title + '</a>' +
'<a href="https://www.youtube.com/watch?v=' + video.videoId + '" target="_blank" class="hover:underline">' + video.title + '</a>' +
'</div>' +
'<button onclick="copyToClipboard(\'https://www.youtube.com/watch?v=' + video.videoId + '\')" class="text-muted hover:text-white flex-shrink-0" title="URL 복사" style="background: none; border: none; cursor: pointer; padding: 2px;">' +
'<i data-lucide="copy" style="width: 14px; height: 14px;"></i>' +

View File

@ -4,12 +4,12 @@
<body>
<div layout:fragment="content">
<header class="mb-4 flex items-center justify-between">
<div class="page-header">
<div>
<h1 class="text-xl font-bold mb-2">Production</h1>
<p class="text-muted">Manage video production and rankings.</p>
<h1>프로덕션</h1>
<p class="sub">n8n 크롤 랭킹 스냅샷을 가져와 영상 제작에 활용합니다.</p>
</div>
</header>
</div>
<div class="card mb-4">
<button id="fetchRankingsBtn" class="btn btn-primary">
@ -18,21 +18,21 @@
</div>
<div class="card p-0" style="overflow-x: auto;">
<h3 class="p-4 text-lg font-bold border-b border-[var(--glass-border)]">Crawl History</h3>
<h3 class="p-4 text-lg font-bold" style="border-bottom:1px solid var(--border);">크롤 이력</h3>
<table class="w-full" style="border-collapse: collapse; text-align: left;">
<thead style="background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--glass-border);">
<thead>
<tr>
<th class="p-4 text-sm font-bold text-muted">ID</th>
<th class="p-4 text-sm font-bold text-muted">Crawled Date</th>
<th class="p-4 text-sm font-bold text-muted">Top N</th>
<th class="p-4 text-sm font-bold text-muted">Videos Count</th>
<th class="p-4">ID</th>
<th class="p-4">크롤 일시</th>
<th class="p-4">Top N</th>
<th class="p-4">영상 수</th>
</tr>
</thead>
<tbody>
<tr th:each="history : ${historyList}"
th:onclick="'location.href=\'/production/' + ${history.id} + '\''"
style="border-bottom: 1px solid var(--glass-border); cursor: pointer; transition: background 0.2s;"
onmouseover="this.style.background='rgba(255,255,255,0.03)'"
onmouseover="this.style.background='var(--surface-2)'"
onmouseout="this.style.background='transparent'">
<td class="p-4 text-sm" th:text="${history.id}">1</td>
<td class="p-4 text-sm text-muted"

View File

@ -24,7 +24,7 @@
<button id="thumbnailToggle" onclick="toggleThumbnails()"
style="position: relative; width: 48px; height: 26px; background-color: #374151; border-radius: 13px; border: none; cursor: pointer; transition: background-color 0.3s;">
<span id="toggleKnob"
style="position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; background-color: white; border-radius: 50%; transition: transform 0.3s;"></span>
style="position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; background-color: var(--text); border-radius: 50%; transition: transform 0.3s;"></span>
</button>
</div>
</header>
@ -32,7 +32,7 @@
<!-- Video List Table -->
<div class="card p-0" style="overflow-x: auto;">
<table class="w-full" style="border-collapse: collapse; text-align: left;">
<thead style="background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--glass-border);">
<thead style="background: var(--surface-2); border-bottom: 1px solid var(--glass-border);">
<tr>
<th class="p-4 text-sm font-bold text-muted sortable" data-sort="rank"
onclick="sortTable('rank')" style="cursor: pointer;">
@ -66,7 +66,7 @@
<tbody>
<tr th:each="video : ${history.videos}"
style="border-bottom: 1px solid var(--glass-border); transition: background 0.2s;"
onmouseover="this.style.background='rgba(255,255,255,0.03)'"
onmouseover="this.style.background='var(--surface-2)'"
onmouseout="this.style.background='transparent'">
<td class="p-4" data-label="Rank" th:data-value="${video.rank}">
@ -207,13 +207,13 @@
<!-- Popup Container (Centered Box) -->
<div
style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90%; max-width: 900px; max-height: 85vh; background-color: #1a1b26; border-radius: 12px; box-shadow: 0 25px 50px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgba(255,255,255,0.1);">
style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90%; max-width: 900px; max-height: 85vh; background-color: #1a1b26; border-radius: 12px; box-shadow: 0 25px 50px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--hover-strong);">
<!-- Header -->
<div
style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background-color: #1a1b26; border-bottom: 1px solid rgba(255,255,255,0.1);">
style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background-color: #1a1b26; border-bottom: 1px solid var(--hover-strong);">
<h3
style="font-size: 18px; font-weight: bold; color: white; margin: 0; display: flex; align-items: center; gap: 8px;">
style="font-size: 18px; font-weight: bold; color: var(--text); margin: 0; display: flex; align-items: center; gap: 8px;">
<i data-lucide="file-text" style="color: #6366f1; width: 20px; height: 20px;"></i>
Transcript
</h3>
@ -239,13 +239,13 @@
<!-- Footer -->
<div
style="padding: 16px 24px; background-color: #1a1b26; border-top: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: flex-end; gap: 12px;">
style="padding: 16px 24px; background-color: #1a1b26; border-top: 1px solid var(--hover-strong); display: flex; justify-content: flex-end; gap: 12px;">
<button onclick="copyScript()"
style="padding: 8px 16px; background-color: #374151; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; display: flex; align-items: center; gap: 6px;">
style="padding: 8px 16px; background-color: #374151; color: var(--text); border: none; border-radius: 8px; cursor: pointer; font-weight: 500; display: flex; align-items: center; gap: 6px;">
<i data-lucide="copy" style="width: 16px; height: 16px;"></i> Copy All
</button>
<button onclick="closeScriptModal()"
style="padding: 8px 24px; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500;">Close</button>
style="padding: 8px 24px; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: var(--text); border: none; border-radius: 8px; cursor: pointer; font-weight: 500;">Close</button>
</div>
</div>
</div>
@ -261,13 +261,13 @@
<!-- Popup Container (Centered Box) -->
<div
style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90%; max-width: 1200px; max-height: 85vh; background-color: #1a1b26; border-radius: 12px; box-shadow: 0 25px 50px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgba(255,255,255,0.1);">
style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90%; max-width: 1200px; max-height: 85vh; background-color: #1a1b26; border-radius: 12px; box-shadow: 0 25px 50px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--hover-strong);">
<!-- Header -->
<div
style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background-color: #1a1b26; border-bottom: 1px solid rgba(255,255,255,0.1);">
style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background-color: #1a1b26; border-bottom: 1px solid var(--hover-strong);">
<h3
style="font-size: 18px; font-weight: bold; color: white; margin: 0; display: flex; align-items: center; gap: 8px;">
style="font-size: 18px; font-weight: bold; color: var(--text); margin: 0; display: flex; align-items: center; gap: 8px;">
<i data-lucide="book-open" style="color: #34d399; width: 20px; height: 20px;"></i>
Script Summaries
</h3>
@ -281,7 +281,7 @@
<div style="display: flex; flex: 1; overflow: hidden;">
<!-- Left Column: old_script_summary -->
<div
style="flex: 1; padding: 24px; overflow-y: auto; border-right: 1px solid rgba(255,255,255,0.1); background-color: rgba(22,22,30,0.5);">
style="flex: 1; padding: 24px; overflow-y: auto; border-right: 1px solid var(--hover-strong); background-color: rgba(22,22,30,0.5);">
<h4 style="font-size: 14px; font-weight: bold; color: #facc15; margin: 0 0 16px 0;">
old_script_summary</h4>
<div id="oldSummaryContent"
@ -318,9 +318,9 @@
<!-- Footer -->
<div
style="padding: 16px 24px; background-color: #1a1b26; border-top: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: flex-end;">
style="padding: 16px 24px; background-color: #1a1b26; border-top: 1px solid var(--hover-strong); display: flex; justify-content: flex-end;">
<button onclick="closeSummaryModal()" class="btn btn-primary"
style="padding: 8px 24px; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500;">Close</button>
style="padding: 8px 24px; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: var(--text); border: none; border-radius: 8px; cursor: pointer; font-weight: 500;">Close</button>
</div>
</div>
</div>
@ -337,13 +337,13 @@
<!-- Popup Container (Centered Box) -->
<div
style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 95%; max-width: 1600px; height: 90vh; background-color: #1a1b26; border-radius: 12px; box-shadow: 0 25px 50px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgba(255,255,255,0.1);">
style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 95%; max-width: 1600px; height: 90vh; background-color: #1a1b26; border-radius: 12px; box-shadow: 0 25px 50px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--hover-strong);">
<!-- Header -->
<div
style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background-color: #1a1b26; border-bottom: 1px solid rgba(255,255,255,0.1);">
style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background-color: #1a1b26; border-bottom: 1px solid var(--hover-strong);">
<h3
style="font-size: 18px; font-weight: bold; color: white; margin: 0; display: flex; align-items: center; gap: 8px;">
style="font-size: 18px; font-weight: bold; color: var(--text); margin: 0; display: flex; align-items: center; gap: 8px;">
<i data-lucide="file-check" style="color: #22d3ee; width: 20px; height: 20px;"></i>
Final Script
</h3>
@ -375,17 +375,17 @@
<!-- Footer -->
<div
style="padding: 16px 24px; background-color: #1a1b26; border-top: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: flex-end; gap: 12px;">
style="padding: 16px 24px; background-color: #1a1b26; border-top: 1px solid var(--hover-strong); display: flex; justify-content: flex-end; gap: 12px;">
<button onclick="saveFinalScript()"
style="padding: 8px 16px; background-color: #059669; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; display: flex; align-items: center; gap: 6px;">
style="padding: 8px 16px; background-color: #059669; color: var(--text); border: none; border-radius: 8px; cursor: pointer; font-weight: 500; display: flex; align-items: center; gap: 6px;">
<i data-lucide="save" style="width: 16px; height: 16px;"></i> Save
</button>
<button onclick="copyFinalScript()"
style="padding: 8px 16px; background-color: #374151; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; display: flex; align-items: center; gap: 6px;">
style="padding: 8px 16px; background-color: #374151; color: var(--text); border: none; border-radius: 8px; cursor: pointer; font-weight: 500; display: flex; align-items: center; gap: 6px;">
<i data-lucide="copy" style="width: 16px; height: 16px;"></i> Copy All
</button>
<button onclick="closeFinalScriptModal()"
style="padding: 8px 24px; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500;">Close</button>
style="padding: 8px 24px; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: var(--text); border: none; border-radius: 8px; cursor: pointer; font-weight: 500;">Close</button>
</div>
</div>
</div>

View File

@ -8,29 +8,55 @@
<body>
<div layout:fragment="content">
<header class="mb-4">
<h1 class="text-2xl font-bold mb-1">발행 큐</h1>
<p class="text-muted">재가공한 영상의 발행 패키지를 단계별로 관리합니다. (실제 업로드는 수동 — 여기서 준비·추적)</p>
</header>
<div class="page-header">
<div>
<h1>발행 큐</h1>
<p class="sub">재가공한 영상의 발행 패키지를 단계별로 관리합니다. (실제 업로드는 수동 — 여기서 준비·추적)</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openHelp()"><i data-lucide="help-circle" style="width:15px;"></i> 사용법</button>
</div>
</div>
<div class="flex gap-2 mb-4" id="tabs">
<button class="btn btn-secondary px-4 py-2 tab active" data-status="" onclick="setTab(this,'')">전체</button>
<button class="btn btn-secondary px-4 py-2 tab" data-status="DRAFT" onclick="setTab(this,'DRAFT')">작성중</button>
<button class="btn btn-secondary px-4 py-2 tab" data-status="READY" onclick="setTab(this,'READY')">발행대기</button>
<button class="btn btn-secondary px-4 py-2 tab" data-status="PUBLISHED" onclick="setTab(this,'PUBLISHED')">발행완료</button>
<span id="cnt" class="text-sm text-muted" style="margin-left:auto; align-self:center;"></span>
<!-- 사용법 모달 -->
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()">
<div class="modal-card">
<div class="modal-head"><h3>📖 발행 큐 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div>
<div class="modal-body">
<div class="help-item">
<div class="hi-ic"><i data-lucide="list-checks"></i></div>
<div><div class="hi-t">상태 탭</div><div class="hi-d"><b>작성중 → 발행대기 → 발행완료</b>로 발행 준비 단계를 거릅니다. 탭으로 상태별로 필터합니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="pencil"></i></div>
<div><div class="hi-t">발행안 만들기</div><div class="hi-d">발행 패키지는 <b>재가공 화면 하단</b>에서 제목·설명·해시태그·플랫폼·예약을 저장하면 생성됩니다. 표의 <b>✏️</b>로 다시 편집합니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="upload-cloud"></i></div>
<div><div class="hi-t">업로드 & 기록</div><div class="hi-d">실제 업로드는 <b>플랫폼에서 수동</b>으로 합니다. 업로드 후 URL을 기록하면 ‘발행완료’로 추적됩니다. <b>📋</b>로 설명+해시태그를 복사하세요.</div></div>
</div>
</div>
</div>
</div>
<div class="flex gap-2 mb-4" id="tabs" style="flex-wrap:wrap; align-items:center;">
<button class="btn btn-secondary tab active" data-status="" onclick="setTab(this,'')">전체</button>
<button class="btn btn-secondary tab" data-status="DRAFT" onclick="setTab(this,'DRAFT')">작성중</button>
<button class="btn btn-secondary tab" data-status="READY" onclick="setTab(this,'READY')">발행대기</button>
<button class="btn btn-secondary tab" data-status="PUBLISHED" onclick="setTab(this,'PUBLISHED')">발행완료</button>
<span id="cnt" class="badge badge-muted" style="margin-left:auto;"></span>
</div>
<div class="card p-0" style="overflow-x:auto;">
<table class="w-full" style="border-collapse:collapse; text-align:left;">
<thead style="background:rgba(255,255,255,0.02); border-bottom:1px solid var(--glass-border);">
<thead>
<tr>
<th class="p-3 text-sm font-bold text-muted">상태</th>
<th class="p-3 text-sm font-bold text-muted">플랫폼</th>
<th class="p-3 text-sm font-bold text-muted">제목</th>
<th class="p-3 text-sm font-bold text-muted">예약</th>
<th class="p-3 text-sm font-bold text-muted">발행 URL</th>
<th class="p-3 text-sm font-bold text-muted">관리</th>
<th>상태</th>
<th>플랫폼</th>
<th>제목</th>
<th>예약</th>
<th>발행 URL</th>
<th>관리</th>
</tr>
</thead>
<tbody id="body">
@ -40,7 +66,8 @@
</div>
<style>
.tab.active { border-color:#7C3AED; color:#fff; }
.tab { padding:0.5rem 1rem; }
.tab.active { background:var(--accent-soft); border-color:var(--accent); color:var(--accent); }
</style>
<script th:inline="javascript">
@ -74,7 +101,7 @@
body.innerHTML = data.map(p=>{
const st = ST[p.status] || {t:p.status,cls:'badge-muted'};
const sched = p.scheduledAt ? String(p.scheduledAt).substring(0,16).replace('T',' ') : '-';
const urlCell = p.publishedUrl ? `<a href="${esc(p.publishedUrl)}" target="_blank" class="hover:underline" style="color:#60a5fa;">열기</a>` : '-';
const urlCell = p.publishedUrl ? `<a href="${esc(p.publishedUrl)}" target="_blank" class="hover:underline" style="color:var(--accent);">열기</a>` : '-';
return `<tr style="border-bottom:1px solid var(--glass-border);">
<td class="p-3"><span class="badge ${st.cls}">${st.t}</span></td>
<td class="p-3 text-sm">${esc(p.platform||'')}</td>
@ -96,6 +123,10 @@
navigator.clipboard.writeText(text).then(()=>alert('설명+해시태그가 복사되었습니다.'));
}
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); });
load();
/*]]>*/
</script>

View File

@ -8,13 +8,47 @@
<body>
<div layout:fragment="content">
<header class="mb-4">
<div class="flex items-center gap-2 mb-2">
<a th:href="@{/collection}" class="text-muted hover:text-white"><i data-lucide="arrow-left" style="width:20px;"></i></a>
<h1 class="text-2xl font-bold">재가공 작업공간</h1>
<div class="page-header">
<div>
<div class="flex items-center gap-2 mb-1">
<a th:href="@{/collection}" class="text-muted hover:text-white"><i data-lucide="arrow-left" style="width:20px;"></i></a>
<h1>재가공 작업공간</h1>
</div>
<p class="sub" id="metaLine">로딩 중...</p>
</div>
<p class="text-muted" id="metaLine">로딩 중...</p>
</header>
<div class="actions">
<button class="btn btn-secondary" onclick="openHelp()"><i data-lucide="help-circle" style="width:15px;"></i> 사용법</button>
</div>
</div>
<!-- 사용법 모달 -->
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()">
<div class="modal-card">
<div class="modal-head"><h3>📖 재가공 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div>
<div class="modal-body">
<div class="help-item">
<div class="hi-ic"><i data-lucide="upload"></i></div>
<div><div class="hi-t">영상 업로드 · 전사</div><div class="hi-d">영상 파일을 올리면 <b>Whisper</b>가 말소리를 <b>시간 싱크 자막</b>으로 추출합니다. 언어는 자동감지(틀리면 옆에서 ko/en/zh/ja 선택). URL자막은 YouTube 자막을 평문으로(시간 없음) 가져옵니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="list-video"></i></div>
<div><div class="hi-t">세그먼트 리스트</div><div class="hi-d"><b>[00:12] 자막</b> 줄을 클릭하면 영상이 그 지점으로 이동하고, 재생 중 현재 자막이 하이라이트됩니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="scissors"></i></div>
<div><div class="hi-t">말 없는 구간 제거 · 배속</div><div class="hi-d"><b>말 없는 구간 제거</b>를 켜면 아무도 말 안 하는 부분을 잘라 영상을 타이트하게 만듭니다. <b>배속</b>도 적용(자막 타임스탬프 자동 보정). ‘미리보기’로 결과 길이를 확인하세요.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="file-down"></i></div>
<div><div class="hi-t">내보내기 → CapCut</div><div class="hi-d"><b>SRT 자막</b><b>영상</b>을 내보내 CapCut에 <b>둘 다 import</b>하면 자막·컷·배속이 맞춰집니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="send"></i></div>
<div><div class="hi-t">재작성 · 발행</div><div class="hi-d">‘재작성(내 버전)’에 각색해 저장하면 상태가 <b>TARGET</b>으로 바뀝니다. 하단 <b>발행 준비</b>에서 제목·해시태그를 저장하면 발행 큐로 들어갑니다.</div></div>
</div>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns: 360px 1fr; gap:1.5rem; align-items:start;">
<!-- 좌: 영상 + 정보 -->
@ -60,32 +94,32 @@
<div id="transcribeStatus" class="text-sm mb-2" style="display:none;"></div>
<!-- 영상 싱크 세그먼트 리스트 (CapCut형) -->
<div id="segmentList" style="display:none; max-height:340px; overflow:auto; border:1px solid var(--glass-border); border-radius:var(--radius-md); background:rgba(255,255,255,0.02);"></div>
<div id="segmentList" style="display:none; max-height:340px; overflow:auto; border:1px solid var(--glass-border); border-radius:var(--radius-md); background:var(--surface-2);"></div>
<!-- 평문 폴백 (URL 자막 추출 시) -->
<textarea id="transcript" readonly placeholder="‘영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, URL자막으로 YouTube 자막을 가져오세요."
style="width:100%; min-height:140px; resize:vertical; padding:12px; background:rgba(255,255,255,0.03); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:#cbd5e1; outline:none; font-size:0.9rem; line-height:1.6;"></textarea>
style="width:100%; min-height:140px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text-2); outline:none; font-size:0.9rem; line-height:1.6;"></textarea>
<!-- 내보내기 (배속 + 말 없는 구간 제거 + SRT/영상) -->
<div id="exportBar" style="display:none; margin-top:14px; padding-top:12px; border-top:1px solid var(--glass-border);">
<div class="flex items-center gap-2 mb-3" style="flex-wrap:wrap;">
<label class="text-sm text-muted">배속</label>
<input id="srtSpeed" type="number" value="1.0" step="0.1" min="0.1" max="2.0"
style="width:64px; padding:7px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:white; outline:none; font-size:0.9rem;">
style="width:64px; padding:7px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.9rem;">
<span class="text-xs text-muted">배속 적용 시 타임스탬프 자동 보정 (0.5~2.0)</span>
</div>
<div style="background:rgba(255,255,255,0.03); border:1px solid var(--glass-border); border-radius:var(--radius-md); padding:10px 12px;">
<div style="background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); padding:10px 12px;">
<div class="flex items-center gap-2 mb-2" style="flex-wrap:wrap;">
<label class="flex items-center gap-1 text-sm" style="cursor:pointer;">
<input type="checkbox" id="trimOn" onchange="onTrimToggle()"> 말 없는 구간 제거
</label>
<span class="text-xs text-muted">앞뒤 여백</span>
<input id="trimPad" type="number" value="0.15" step="0.05" min="0" onchange="if(trimApplied)previewTrim()"
style="width:60px; padding:5px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:6px; color:white; font-size:0.85rem;">
style="width:60px; padding:5px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:6px; color:var(--text); font-size:0.85rem;">
<span class="text-xs text-muted">최소 간격</span>
<input id="trimGap" type="number" value="0.3" step="0.1" min="0" onchange="if(trimApplied)previewTrim()"
style="width:60px; padding:5px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:6px; color:white; font-size:0.85rem;">
style="width:60px; padding:5px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:6px; color:var(--text); font-size:0.85rem;">
</div>
<div id="trimInfo" class="text-xs text-muted"></div>
</div>
@ -104,11 +138,11 @@
</div>
<style>
.seg-row { display:flex; gap:10px; padding:7px 12px; cursor:pointer; border-bottom:1px solid rgba(255,255,255,0.05); font-size:0.9rem; line-height:1.5; }
.seg-row:hover { background:rgba(255,255,255,0.05); }
.seg-row { display:flex; gap:10px; padding:7px 12px; cursor:pointer; border-bottom:1px solid var(--surface-2); font-size:0.9rem; line-height:1.5; }
.seg-row:hover { background:var(--surface-2); }
.seg-row.active { background:rgba(96,165,250,0.18); }
.seg-time { color:#60a5fa; font-variant-numeric:tabular-nums; flex-shrink:0; font-size:0.82rem; padding-top:1px; }
.seg-text { color:#e2e8f0; }
.seg-text { color:var(--text-2); }
</style>
<!-- 재작성 에디터 -->
@ -120,7 +154,7 @@
</button>
</div>
<textarea id="reworkText" placeholder="원본을 참고해 각색/수정한 스크립트를 여기에 작성하세요. 저장하면 상태가 TARGET(작업대상)으로 바뀝니다."
style="width:100%; min-height:280px; resize:vertical; padding:12px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:white; outline:none; font-size:0.95rem; line-height:1.7;"></textarea>
style="width:100%; min-height:280px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.95rem; line-height:1.7;"></textarea>
<div class="text-sm text-muted mt-2" id="saveInfo"></div>
</div>
@ -181,8 +215,8 @@
</div>
<style>
.pub-in { width:100%; padding:9px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:white; outline:none; font-size:0.9rem; }
.pub-in option { background:#1e1e2d; color:white; }
.pub-in { width:100%; padding:9px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.9rem; }
.pub-in option { background:var(--surface); color:var(--text); }
</style>
<script th:inline="javascript">
@ -303,7 +337,7 @@
document.getElementById('trimOn').checked = true;
const sp = curSpeed();
const kept = (p.keptDuration||0) / sp;
info.style.color = '#cbd5e1';
info.style.color = 'var(--text-2)';
info.innerHTML = '제거 ' + (p.removedCount||0) + '구간 · 결과 길이 ≈ <b>' + kept.toFixed(1) + 's</b>'
+ (sp!==1 ? (' (배속 ' + sp + 'x 포함)') : '')
+ ' · SRT/영상이 이 타임라인으로 출력됩니다';
@ -506,6 +540,10 @@
} catch(e){ alert('발행 완료 처리 실패: ' + e.message); }
}
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); });
(async ()=>{ await load(); await loadPublish(document.getElementById('vTitle').textContent); })();
/*]]>*/
</script>

View File

@ -18,14 +18,14 @@
<label class="text-sm font-bold text-muted">Keyword</label>
<input type="text" id="keyword" placeholder="Search keyword..."
class="p-2 w-full"
style="background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: white; outline: none; transition: border-color 0.2s;"
style="background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: var(--text); outline: none; transition: border-color 0.2s;"
onfocus="this.style.borderColor='#3b82f6'" onblur="this.style.borderColor='var(--glass-border)'">
</div>
<!-- Country -->
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 180px;">
<label class="text-sm font-bold text-muted">Country</label>
<div class="flex gap-4 w-full" style="padding: 8px; background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); border-radius: var(--radius-md); justify-content: center; height: 42px; align-items: center;">
<div class="flex gap-4 w-full" style="padding: 8px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); justify-content: center; height: 42px; align-items: center;">
<label class="flex items-center gap-1 cursor-pointer hover:text-white">
<input type="checkbox" name="region" value="JP" checked> JP
</label>
@ -42,20 +42,20 @@
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 140px;">
<label class="text-sm font-bold text-muted">Period</label>
<select id="periodDays" class="w-full"
style="padding: 8px; height: 42px; background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: white; outline: none;">
<option value="1" style="background: #1e1e2d; color: white;">Within 1 Day</option>
<option value="7" style="background: #1e1e2d; color: white;">Within 7 Days</option>
<option value="10" style="background: #1e1e2d; color: white;">Within 10 Days</option>
<option value="15" style="background: #1e1e2d; color: white;">Within 15 Days</option>
<option value="30" style="background: #1e1e2d; color: white;">Within 30 Days</option>
<option value="0" style="background: #1e1e2d; color: white;">전부 (All)</option>
style="padding: 8px; height: 42px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: var(--text); outline: none;">
<option value="1" style="background: var(--surface); color: var(--text);">Within 1 Day</option>
<option value="7" style="background: var(--surface); color: var(--text);">Within 7 Days</option>
<option value="10" style="background: var(--surface); color: var(--text);">Within 10 Days</option>
<option value="15" style="background: var(--surface); color: var(--text);">Within 15 Days</option>
<option value="30" style="background: var(--surface); color: var(--text);">Within 30 Days</option>
<option value="0" style="background: var(--surface); color: var(--text);">전부 (All)</option>
</select>
</div>
<!-- Format -->
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 160px;">
<label class="text-sm font-bold text-muted">Format</label>
<div class="flex gap-4 w-full" style="padding: 8px; background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); border-radius: var(--radius-md); justify-content: center; height: 42px; align-items: center;">
<div class="flex gap-4 w-full" style="padding: 8px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); justify-content: center; height: 42px; align-items: center;">
<label class="flex items-center gap-1 cursor-pointer hover:text-white">
<input type="radio" name="format" value="SHORTS" checked> Shorts
</label>
@ -69,10 +69,10 @@
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 120px;">
<label class="text-sm font-bold text-muted">Load Size</label>
<select id="pageSize" class="w-full"
style="padding: 8px; height: 42px; background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: white; outline: none;">
<option value="20" style="background: #1e1e2d; color: white;">20 items</option>
<option value="50" style="background: #1e1e2d; color: white;" selected>50 items</option>
<option value="100" style="background: #1e1e2d; color: white;">100 items</option>
style="padding: 8px; height: 42px; background: var(--surface-2); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: var(--text); outline: none;">
<option value="20" style="background: var(--surface); color: var(--text);">20 items</option>
<option value="50" style="background: var(--surface); color: var(--text);" selected>50 items</option>
<option value="100" style="background: var(--surface); color: var(--text);">100 items</option>
</select>
</div>
</div>
@ -101,7 +101,7 @@
<!-- Video List Table -->
<div class="card p-0" style="overflow-x: auto;">
<table class="w-full" style="border-collapse: collapse; text-align: left;">
<thead style="background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--glass-border);">
<thead style="background: var(--surface-2); border-bottom: 1px solid var(--glass-border);">
<tr>
<th class="p-4 text-sm font-bold text-muted"><input type="checkbox" id="selectAll" onclick="toggleSelectAll(this.checked)" title="전체 선택"></th>
<th class="p-4 text-sm font-bold text-muted">Thumbnail</th>
@ -135,7 +135,7 @@
<!-- Video Player Modal -->
<div id="videoModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.85); align-items: center; justify-content: center;" onclick="if(event.target === this) closeVideoModal()">
<div style="position: relative; width: 95%; max-width: 1200px; background-color: var(--glass-bg, #1e1e2d); border-radius: 12px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid var(--glass-border);">
<div style="position: relative; width: 95%; max-width: 1200px; background-color: var(--glass-bg, var(--surface)); border-radius: 12px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid var(--glass-border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video Player</h3>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size: 28px; line-height: 1; background: none; border: none; cursor: pointer;">&times;</button>
@ -362,14 +362,14 @@
'<div style="position: relative; display: inline-block; cursor: pointer;" data-videoid="' + video.videoId + '" data-videotitle="' + safeTitle + '" onclick="openVideoModal(this.dataset.videoid, this.dataset.videotitle)">' +
'<img src="' + video.thumbnailUrl + '" alt="Thumb" style="width: 120px; height: 68px; object-fit: cover; border-radius: 6px; transition: transform 0.2s;" onmouseover="this.style.transform=\'scale(1.05)\'" onmouseout="this.style.transform=\'scale(1)\'">' +
'<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.6); border-radius: 50%; padding: 8px; display: flex; align-items: center; justify-content: center;">' +
'<i data-lucide="play" style="color: white; width: 16px; height: 16px; fill: white;"></i>' +
'<i data-lucide="play" style="color: var(--text); width: 16px; height: 16px; fill: white;"></i>' +
'</div>' +
'</div>' +
'</td>' +
'<td class="p-4">' +
'<div class="flex items-start justify-between gap-2" style="max-width: 300px;">' +
'<div class="font-bold text-sm" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex: 1;">' +
'<a href="https://www.youtube.com/watch?v=' + video.videoId + '" target="_blank" class="hover:underline text-white">' + video.title + '</a>' +
'<a href="https://www.youtube.com/watch?v=' + video.videoId + '" target="_blank" class="hover:underline">' + video.title + '</a>' +
'</div>' +
'<button onclick="copyToClipboard(\'https://www.youtube.com/watch?v=' + video.videoId + '\')" class="text-muted hover:text-white flex-shrink-0" title="URL 복사" style="background: none; border: none; cursor: pointer; padding: 2px; margin-top: 2px;">' +
'<i data-lucide="copy" style="width: 14px; height: 14px;"></i>' +
@ -379,7 +379,7 @@
'<td class="p-4 text-sm text-muted">' +
'<div class="flex items-center gap-2">' +
'<a href="https://www.youtube.com/channel/' + video.channelId + '" target="_blank" class="hover:underline font-semibold" style="color: #e2e8f0;">' + video.channelTitle + '</a>' +
'<button onclick="registerChannel(\'https://www.youtube.com/channel/' + video.channelId + '\')" class="text-xs" style="background: #3b82f6; color: white; border: none; padding: 2px 6px; border-radius: 4px; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background=\'#2563eb\'" onmouseout="this.style.background=\'#3b82f6\'">등록</button>' +
'<button onclick="registerChannel(\'https://www.youtube.com/channel/' + video.channelId + '\')" class="text-xs" style="background: #3b82f6; color: var(--text); border: none; padding: 2px 6px; border-radius: 4px; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background=\'#2563eb\'" onmouseout="this.style.background=\'#3b82f6\'">등록</button>' +
'</div>' +
tagsHtml +
'</td>' +