Compare commits

..

No commits in common. "dd42499f43c466f7c8b38f553ea5ec5cb64ee060" and "fa6342f97eb43c8a999d2f694b3d51430c72cd7d" have entirely different histories.

18 changed files with 1043 additions and 1003 deletions

View File

@ -1,85 +0,0 @@
# 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" 더미) — 개인용이라 단순화.

File diff suppressed because it is too large Load Diff

View File

@ -1,104 +1,60 @@
/* ===========================================================================
Design tokens Refined SaaS, LIGHT default + DARK via [data-theme="dark"].
Old variable names are aliased to theme tokens so existing markup adapts.
=========================================================================== */
:root { :root {
/* Surfaces (light) */ /* Premium Dark Theme Palette (Cyber/Space) */
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f1f3f6;
--inset: #eef0f3;
--border: #e6e8ec;
--border-strong: #d9dce1;
/* Text (light) */ /* Backgrounds */
--text: #15181d; --bg-primary: #050508;
--text-2: #5a626d; /* Ultra dark, almost black */
--text-3: #9aa1ad; --bg-glass: rgba(15, 23, 42, 0.6);
/* On-surface translucent (hover/active) */ /* Glassmorphism */
--hover: rgba(16, 24, 40, .045); --glass-border: rgba(255, 255, 255, 0.08);
--hover-strong: rgba(16, 24, 40, .07); --glass-highlight: rgba(255, 255, 255, 0.15);
--backdrop-blur: 12px;
/* Elevation */ /* Typography */
--shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 4px 12px rgba(16, 24, 40, .05); --text-primary: #f1f5f9;
--shadow-lg: 0 10px 30px rgba(16, 24, 40, .10); --text-secondary: #94a3b8;
--side-bg: #ffffff; --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%);
/* 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; --success: #10b981;
--danger: #f43f5e; --danger: #ef4444;
--warning: #f59e0b;
--green: #10b981;
--amber: #f59e0b;
--red: #f43f5e;
--purple: #8b5cf6;
--sky: #0ea5e9;
--primary-rgb: 79, 124, 255;
--success-rgb: 16, 185, 129;
--danger-rgb: 244, 63, 94;
/* === Back-compat aliases (old names → theme tokens) === */ /* UI Elements */
--bg-primary: var(--bg); --radius-md: 12px;
--bg-glass: var(--surface); --radius-lg: 20px;
--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; --radius-full: 9999px;
--r: 12px;
--r-sm: 9px; /* Semantic (added) */
--space-1: .25rem; --warning: #f59e0b;
--space-2: .5rem; --primary-rgb: 59, 130, 246; /* matches --primary #3b82f6; used by rgba(var(--primary-rgb), …) */
--space-3: .75rem; --success-rgb: 16, 185, 129;
--danger-rgb: 239, 68, 68;
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem; --space-4: 1rem;
--space-6: 1.5rem; --space-6: 1.5rem;
--space-8: 2rem; --space-8: 2rem;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --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 { body {
background-color: var(--bg); background-color: var(--bg-primary);
color: var(--text); 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);
font-family: var(--font-sans); font-family: var(--font-sans);
font-feature-settings: "tnum" 1, "cv01" 1; font-feature-settings: "tnum" 1, "cv01" 1;
margin: 0; margin: 0;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
min-height: 100vh; min-height: 100vh;
transition: background-color .2s ease, color .2s ease;
} }

View File

@ -1,12 +1,3 @@
// 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 // Sidebar Logic
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('sidebar'); const sidebar = document.getElementById('sidebar');

View File

@ -8,51 +8,29 @@
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<div class="page-header"> <header class="mb-4 flex items-center justify-between">
<div> <div>
<h1>칸반 보드</h1> <h1 class="text-2xl font-bold mb-1">작업 보드</h1>
<p class="sub">카드를 드래그해 단계를 옮기세요. 수집 → 검토 → 작업대상 → 완료.</p> <p class="text-muted">카드를 드래그해 단계를 옮기세요. 수집 → 검토 → 작업대상 → 완료.</p>
</div>
<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>
<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 id="board" style="display:grid; grid-template-columns: repeat(5, minmax(220px, 1fr)); gap:1rem; align-items:start; overflow-x:auto;"> <div id="board" style="display:grid; grid-template-columns: repeat(5, minmax(220px, 1fr)); gap:1rem; align-items:start; overflow-x:auto;">
<!-- columns injected --> <!-- columns injected -->
</div> </div>
<style> <style>
.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 { 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(--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-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-body { padding:10px; display:flex; flex-direction:column; gap:8px; min-height:120px; flex:1; transition: background 0.15s; } .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: var(--accent-soft); } .kb-body.drag-over { background: rgba(124,58,237,0.12); }
.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 { 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: var(--accent); } .kb-card:hover { border-color:#7C3AED88; }
.kb-card.dragging { opacity:0.5; } .kb-card.dragging { opacity:0.5; }
.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; } .kb-count { font-size:0.75rem; background:rgba(255,255,255,0.08); padding:2px 8px; border-radius:999px; }
</style> </style>
<script th:inline="javascript"> <script th:inline="javascript">
@ -68,14 +46,6 @@
let dragId = null; let dragId = null;
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } 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){ async function api(url, opts){
const res = await fetch(url, opts); const res = await fetch(url, opts);
const json = await res.json().catch(()=>({})); const json = await res.json().catch(()=>({}));
@ -86,12 +56,6 @@
function cardHtml(v){ function cardHtml(v){
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : ''; const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '';
const ratioColor = (v.viewsPerSubRatio>=10)?'#ef4444':((v.viewsPerSubRatio>=2)?'#f59e0b':'#10b981'); 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}"> return `<div class="kb-card" draggable="true" data-id="${v.id}">
<div style="display:flex; gap:8px;"> <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;"> <img src="${esc(v.thumbnailUrl)}" style="width:64px; height:36px; object-fit:cover; border-radius:4px; flex-shrink:0;">
@ -99,7 +63,6 @@
<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 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>
</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"> <div class="flex items-center justify-between mt-2">
<span class="text-muted" style="font-size:0.7rem;">${esc(v.channelTitle||'')}</span> <span class="text-muted" style="font-size:0.7rem;">${esc(v.channelTitle||'')}</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -182,10 +145,6 @@
}); });
} }
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(); loadBoard();
/*]]>*/ /*]]>*/
</script> </script>

View File

@ -70,7 +70,7 @@
<!-- Video List Table --> <!-- Video List Table -->
<div class="card p-0" style="overflow-x: auto;"> <div class="card p-0" style="overflow-x: auto;">
<table class="w-full" style="border-collapse: collapse; text-align: left;"> <table class="w-full" style="border-collapse: collapse; text-align: left;">
<thead style="background: var(--surface-2); border-bottom: 1px solid var(--glass-border);"> <thead style="background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--glass-border);">
<tr> <tr>
<th class="p-4 text-sm font-bold text-muted">Video</th> <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> <th class="p-4 text-sm font-bold text-muted text-center">Script</th>
@ -239,10 +239,10 @@
if (icon) { if (icon) {
const colName = th.getAttribute('onclick').match(/'([^']+)'/)[1]; const colName = th.getAttribute('onclick').match(/'([^']+)'/)[1];
if (colName === sortState.column) { if (colName === sortState.column) {
th.style.color = 'var(--accent)'; th.classList.add('text-white');
icon.setAttribute('data-lucide', sortState.direction === 'asc' ? 'chevron-up' : 'chevron-down'); icon.setAttribute('data-lucide', sortState.direction === 'asc' ? 'chevron-up' : 'chevron-down');
} else { } else {
th.style.color = ''; th.classList.remove('text-white');
icon.setAttribute('data-lucide', 'arrow-up-down'); icon.setAttribute('data-lucide', 'arrow-up-down');
} }
} }
@ -315,10 +315,10 @@
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
plugins: { legend: { labels: { color: 'var(--text-2)' } } }, plugins: { legend: { labels: { color: '#cbd5e1' } } },
scales: { scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: 'var(--surface-2)' } }, x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(255,255,255,0.05)' } },
y: { position: 'left', ticks: { color: '#a78bfa' }, grid: { color: 'var(--surface-2)' }, title: { display: true, text: '구독자', color: '#a78bfa' } }, y: { position: 'left', ticks: { color: '#a78bfa' }, grid: { color: 'rgba(255,255,255,0.05)' }, title: { display: true, text: '구독자', color: '#a78bfa' } },
y1: { position: 'right', ticks: { color: '#34d399' }, grid: { drawOnChartArea: false }, title: { display: true, text: '조회수', color: '#34d399' } } 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"> <div layout:fragment="content">
<header class="flex justify-between items-center mb-4" style="margin-bottom: 2rem;"> <header class="flex justify-between items-center mb-4" style="margin-bottom: 2rem;">
<div> <div>
<h1 class="text-xl font-bold mb-4">채널</h1> <h1 class="text-xl font-bold mb-4">Channels</h1>
<p class="text-muted">추적 중인 YouTube 채널을 관리합니다.</p> <p class="text-muted">Manage the YouTube channels you are tracking.</p>
</div> </div>
<div class="flex items-center gap-4"> <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"> <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;"> <input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()" style="width: 18px; height: 18px; cursor: pointer;">
전체 선택 Select All
</label> </label>
<button id="viewSelectedBtn" class="btn btn-outline" onclick="viewSelectedVideos()" style="display: none;"> <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>) <i data-lucide="play" style="width: 18px; margin-right: 8px;"></i> View Selected Videos (<span id="selectedCount">0</span>)
</button> </button>
<button class="btn btn-primary" onclick="openAddModal()"> <button class="btn btn-primary" onclick="openAddModal()">
<i data-lucide="plus" style="width: 18px; margin-right: 8px;"></i> 채널 추가 <i data-lucide="plus" style="width: 18px; margin-right: 8px;"></i> Add Channel
</button> </button>
</div> </div>
</header> </header>
@ -40,13 +40,13 @@
onchange="updateSelectedCount(); event.stopPropagation();"> onchange="updateSelectedCount(); event.stopPropagation();">
<div class="custom-checkbox flex items-center justify-center" style=" <div class="custom-checkbox flex items-center justify-center" style="
width: 24px; height: 24px; width: 24px; height: 24px;
border: 2px solid var(--border-strong); border: 2px solid rgba(255,255,255,0.2);
border-radius: 6px; border-radius: 6px;
background: var(--surface-2); background: rgba(255,255,255,0.05);
transition: all 0.2s ease; transition: all 0.2s ease;
pointer-events: none; pointer-events: none;
"> ">
<i data-lucide="check" style="width: 16px; height: 16px; color: var(--text); display: none;"></i> <i data-lucide="check" style="width: 16px; height: 16px; color: white; display: none;"></i>
</div> </div>
</div> </div>
@ -57,8 +57,8 @@
<div> <div>
<h3 class="font-bold" th:text="${channel.title}">Tech Reviewer A</h3> <h3 class="font-bold" th:text="${channel.title}">Tech Reviewer A</h3>
<div class="text-muted text-sm" <div class="text-muted text-sm"
th:text="${#numbers.formatInteger(channel.subscriberCount ?: 0, 0, 'COMMA')} + ' 구독자'"> th:text="${#numbers.formatInteger(channel.subscriberCount ?: 0, 0, 'COMMA')} + ' Subscribers'">
1.2M 구독자</div> 1.2M Subscribers</div>
</div> </div>
</div> </div>
</div> </div>
@ -75,14 +75,14 @@
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;">
<div class="p-4" <div class="p-4"
style="background: var(--surface-2); border-radius: 8px; text-align: center;"> 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">영상 수</div> <div class="text-muted mb-4" style="font-size: 0.75rem">Videos</div>
<div class="font-bold" th:text="${#numbers.formatInteger(channel.videoCount ?: 0, 0, 'COMMA')}">450 <div class="font-bold" th:text="${#numbers.formatInteger(channel.videoCount ?: 0, 0, 'COMMA')}">450
</div> </div>
</div> </div>
<div class="p-4" <div class="p-4"
style="background: var(--surface-2); border-radius: 8px; text-align: center;"> 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">총 조회수</div> <div class="text-muted mb-4" style="font-size: 0.75rem">Total Views</div>
<div class="font-bold" th:text="${#numbers.formatInteger(channel.viewCount ?: 0, 0, 'COMMA')}">45K <div class="font-bold" th:text="${#numbers.formatInteger(channel.viewCount ?: 0, 0, 'COMMA')}">45K
</div> </div>
</div> </div>
@ -91,15 +91,15 @@
<div class="flex gap-2" onclick="event.stopPropagation()"> <div class="flex gap-2" onclick="event.stopPropagation()">
<a th:href="@{/channels/{id}(id=${channel.id})}" class="btn btn-primary w-full" <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;"> 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> 상세 <i data-lucide="list" style="width: 14px; margin-right: 6px;"></i> Detail
</a> </a>
<a th:href="'https://www.youtube.com/channel/' + ${channel.channelId}" target="_blank" <a th:href="'https://www.youtube.com/channel/' + ${channel.channelId}" target="_blank"
class="btn btn-ghost w-full" class="btn btn-ghost w-full"
style="font-size: 0.8rem; text-decoration: none; display: flex; align-items: center; justify-content: center;"> 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> 방문 <i data-lucide="external-link" style="width: 14px; margin-right: 6px;"></i> Visit
</a> </a>
<button class="btn btn-ghost w-full" style="color: var(--danger); font-size: 0.8rem;" th:onclick="'deleteChannel(' + ${channel.id} + '); event.stopPropagation();'"> <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> 삭제 <i data-lucide="trash-2" style="width: 14px; margin-right: 6px;"></i> Remove
</button> </button>
</div> </div>
</div> </div>
@ -118,7 +118,7 @@
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button> <button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitAddChannel()">채널 추가</button> <button class="btn btn-primary" onclick="submitAddChannel()">Add Channel</button>
</div> </div>
</div> </div>
</div> </div>
@ -224,8 +224,8 @@
} else { } else {
card.style.borderColor = 'var(--glass-border)'; card.style.borderColor = 'var(--glass-border)';
card.style.background = 'var(--glass-bg)'; card.style.background = 'var(--glass-bg)';
customCb.style.background = 'var(--surface-2)'; customCb.style.background = 'rgba(255,255,255,0.05)';
customCb.style.borderColor = 'var(--border-strong)'; customCb.style.borderColor = 'rgba(255,255,255,0.2)';
checkIcon.style.display = 'none'; checkIcon.style.display = 'none';
} }
}); });

View File

@ -8,47 +8,53 @@
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<div class="page-header"> <header class="mb-4">
<div> <h1 class="text-2xl font-bold mb-2">수집함 (Collection)</h1>
<h1>수집함</h1> <p class="text-muted">수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.</p>
<p class="sub">수집한 영상을 분류·발굴하고 재가공 대상을 관리합니다.</p> </header>
</div>
<div class="actions"> <!-- 카테고리 관리 -->
<button class="btn btn-secondary" onclick="openHelp()"> <div class="card mb-4">
<i data-lucide="help-circle" style="width:15px;"></i> 사용법 <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>
</button> </button>
</div> </div>
</div> <div id="categoryChips" class="flex flex-wrap gap-2 mb-2">
<!-- 카테고리 관리 (컴팩트) -->
<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> <span class="text-muted text-sm">로딩...</span>
</div> </div>
<button class="btn btn-secondary" style="padding:0.4rem 0.7rem;" onclick="document.getElementById('catForm').classList.toggle('hidden')"> <div id="catForm" class="hidden flex gap-2 mt-4" style="flex-wrap: wrap; align-items:flex-end;">
<i data-lucide="plus" style="width:15px;"></i> 추가 <div style="display:flex; flex-direction:column; gap:4px;">
</button> <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>
<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);"> <div style="display:flex; flex-direction:column; gap:4px;">
<input id="catName" type="text" placeholder="이름 (예: 동물 썰)" style="flex:1; min-width:140px;"> <label class="text-sm text-muted">색상</label>
<input id="catColor" type="color" value="#7C3AED" title="색상" style="padding:2px; height:38px; width:48px;"> <input id="catColor" type="color" value="#7C3AED"
<button class="btn btn-primary" onclick="addCategory()">추가</button> 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> </div>
<!-- 필터 --> <!-- 필터 -->
<div class="card mb-4"> <div class="card mb-4">
<div class="toolbar"> <div style="display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end;">
<label class="chip"><input type="checkbox" id="fOutperformers" onchange="loadVideos()"> 🚀 떡상 후보</label> <label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
<label class="chip"><input type="checkbox" id="fShorts" onchange="loadVideos()"> Shorts</label> <input type="checkbox" id="fOutperformers" onchange="loadVideos()"> <span class="text-sm font-bold">🚀 떡상 후보만</span>
<label class="chip"><input type="checkbox" id="fBookmarked" onchange="loadVideos()"> ⭐ 북마크</label> </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>
<span class="sep"></span> <div style="display:flex; flex-direction:column; gap:4px; min-width:130px;">
<label class="text-sm text-muted">상태</label>
<label class="field">상태 <select id="fStatus" onchange="loadVideos()" class="filter-sel">
<select id="fStatus" onchange="loadVideos()">
<option value="">전체</option> <option value="">전체</option>
<option value="NEW">NEW (수집됨)</option> <option value="NEW">NEW (수집됨)</option>
<option value="REVIEWING">REVIEWING (검토중)</option> <option value="REVIEWING">REVIEWING (검토중)</option>
@ -56,98 +62,63 @@
<option value="DONE">DONE (완료)</option> <option value="DONE">DONE (완료)</option>
<option value="EXCLUDED">EXCLUDED (제외)</option> <option value="EXCLUDED">EXCLUDED (제외)</option>
</select> </select>
</label> </div>
<label class="field">카테고리 <div style="display:flex; flex-direction:column; gap:4px; min-width:150px;">
<select id="fCategory" onchange="loadVideos()"><option value="">전체</option></select> <label class="text-sm text-muted">카테고리</label>
</label> <select id="fCategory" onchange="loadVideos()" class="filter-sel">
<label class="field">출처 <option value="">전체</option>
<select id="fSource" onchange="loadVideos()"> </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">
<option value="">전체</option> <option value="">전체</option>
<option value="CHANNEL">채널 수집</option> <option value="CHANNEL">채널 수집</option>
<option value="SEARCH">검색 수집</option> <option value="SEARCH">검색 수집</option>
</select> </select>
</label> </div>
<label class="field">정렬 <div style="display:flex; flex-direction:column; gap:4px; min-width:150px;">
<select id="fSort" onchange="loadVideos()"> <label class="text-sm text-muted">정렬</label>
<select id="fSort" onchange="loadVideos()" class="filter-sel">
<option value="viewsPerSubRatio">배율(조회/구독) ↓</option> <option value="viewsPerSubRatio">배율(조회/구독) ↓</option>
<option value="viewsPerHour">시간당 조회수 ↓</option> <option value="viewsPerHour">시간당 조회수 ↓</option>
<option value="viewCount">조회수 ↓</option> <option value="viewCount">조회수 ↓</option>
<option value="publishedAt">최신순 ↓</option> <option value="publishedAt">최신순 ↓</option>
</select> </select>
</label> </div>
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="loadVideos()">
<span class="spacer"></span> <i data-lucide="refresh-cw" style="width:16px;"></i> 새로고침
<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> </button>
<span id="resultCount" class="text-sm text-muted" style="margin-left:auto; height:42px; display:flex; align-items:center;"></span>
</div> </div>
</div> </div>
<!-- 결과 테이블 --> <!-- 결과 테이블 -->
<div class="card p-0" style="overflow-x:auto;"> <div class="card p-0" style="overflow-x:auto;">
<table class="w-full coll-table" style="border-collapse:collapse; text-align:left;"> <table class="w-full" style="border-collapse:collapse; text-align:left;">
<thead> <thead style="background:rgba(255,255,255,0.02); border-bottom:1px solid var(--glass-border);">
<tr> <tr>
<th>영상</th> <th class="p-3 text-sm font-bold text-muted">썸네일</th>
<th>구독자</th> <th class="p-3 text-sm font-bold text-muted">제목</th>
<th>조회수</th> <th class="p-3 text-sm font-bold text-muted">채널</th>
<th>시간당</th> <th class="p-3 text-sm font-bold text-muted">구독자</th>
<th>배율</th> <th class="p-3 text-sm font-bold text-muted">조회수</th>
<th>상태</th> <th class="p-3 text-sm font-bold text-muted">시간당</th>
<th>카테고리</th> <th class="p-3 text-sm font-bold text-muted">배율</th>
<th style="text-align:right;">관리</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>
</tr> </tr>
</thead> </thead>
<tbody id="resultBody"> <tbody id="resultBody">
<tr><td colspan="8" class="p-8 text-center text-muted">로딩 중...</td></tr> <tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>
</tbody> </tbody>
</table> </table>
</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="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 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,var(--surface)); border-radius:12px; padding:16px; border:1px solid var(--glass-border);"> <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="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;"> <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> <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> <button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
@ -158,13 +129,9 @@
<style> <style>
.hidden { display:none !important; } .hidden { display:none !important; }
.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 { 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:var(--surface); color:var(--text); } .row-sel option { background:#1e1e2d; color:white; }
.cat-chip { padding:4px 10px; border-radius:999px; font-size:0.8rem; display:inline-flex; align-items:center; gap:6px; } .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> </style>
<script th:inline="javascript"> <script th:inline="javascript">
@ -272,7 +239,7 @@
const body = document.getElementById('resultBody'); const body = document.getElementById('resultBody');
document.getElementById('resultCount').textContent = list.length + '건'; document.getElementById('resultCount').textContent = list.length + '건';
if(list.length===0){ if(list.length===0){
body.innerHTML = '<tr><td colspan="8" class="p-8 text-center text-muted">조건에 맞는 영상이 없습니다.</td></tr>'; body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">조건에 맞는 영상이 없습니다.</td></tr>';
return; return;
} }
body.innerHTML = list.map(v=>{ body.innerHTML = list.map(v=>{
@ -281,31 +248,32 @@
const statusOpts = STATUS_KEYS.map(k=> const statusOpts = STATUS_KEYS.map(k=>
`<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join(''); `<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join('');
const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)'; const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)';
const shortsBadge = v.isShorts ? '<span class="badge badge-primary" style="margin-left:5px;">Shorts</span>' : ''; 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> return `<tr style="border-bottom:1px solid var(--glass-border);">
<td> <td class="p-3">
<div class="flex items-center gap-3" style="min-width:0;"> <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)}" 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;">
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> </div>
</td> </td>
<td class="text-sm">${fmt(v.subscriberCount)}</td> <td class="p-3" style="max-width:280px;">
<td class="text-sm">${fmt(v.viewCount)}</td> <div class="font-bold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">
<td class="text-sm">${fmt(v.viewsPerHour)}</td> <a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline text-white">${esc(v.title)}</a>${shortsBadge}
<td class="text-sm">${ratioBadge(v.viewsPerSubRatio)}</td> </div>
<td><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td> </td>
<td><select class="row-sel" onchange="setCategory(${v.id}, this.value)">${catOpts}</select></td> <td class="p-3 text-sm text-muted" style="max-width:130px;">
<td> <a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:#e2e8f0;">${esc(v.channelTitle||'-')}</a>
<div class="flex items-center gap-1 justify-end"> </td>
<a class="btn btn-primary" style="padding:0.4rem;" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a> <td class="p-3 text-sm">${fmt(v.subscriberCount)}</td>
<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> <td class="p-3 text-sm">${fmt(v.viewCount)}</td>
<button class="btn btn-secondary" style="padding:0.4rem;" title="삭제" onclick="deleteVideo(${v.id})"><i data-lucide="trash-2" style="width:15px;"></i></button> <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>
</div> </div>
</td> </td>
</tr>`; </tr>`;
@ -344,11 +312,6 @@
document.getElementById('videoModal').style.display = 'none'; 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 파라미터 필터 적용 ----- // ----- URL 파라미터 필터 적용 -----
function applyUrlFilters(){ function applyUrlFilters(){
const q = new URLSearchParams(window.location.search); const q = new URLSearchParams(window.location.search);

View File

@ -11,7 +11,7 @@
<header class="mb-4"> <header class="mb-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-xl font-bold mb-1">대시보드</h1> <h1 class="text-xl font-bold mb-1">Dashboard</h1>
<p class="text-muted">수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황</p> <p class="text-muted">수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황</p>
</div> </div>
<button id="refreshBtn" class="btn btn-secondary px-3 py-2 flex items-center gap-1" onclick="loadDashboard()"> <button id="refreshBtn" class="btn btn-secondary px-3 py-2 flex items-center gap-1" onclick="loadDashboard()">
@ -193,7 +193,7 @@
else { else {
opList.innerHTML = op.map((v,i)=>{ opList.innerHTML = op.map((v,i)=>{
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '-'; 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:var(--surface-2); 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:rgba(255,255,255,0.03); text-decoration:none;">' +
'<div class="flex items-center gap-2" style="min-width:0;">' + '<div class="flex items-center gap-2" style="min-width:0;">' +
'<span class="badge badge-muted" style="flex-shrink:0;">'+(i+1)+'</span>' + '<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;">' + '<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>' ? '<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=>{ : '<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' }; 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:var(--surface-2); text-decoration:none;">' + 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:var(--text);">'+esc(p.title||'(제목 없음)')+'</span>' + '<span class="text-sm truncate" style="max-width:170px; color:#e2e8f0;">'+esc(p.title||'(제목 없음)')+'</span>' +
'<span class="badge '+st.cls+'" style="flex-shrink:0;">'+st.t+'</span></a>'; '<span class="badge '+st.cls+'" style="flex-shrink:0;">'+st.t+'</span></a>';
}).join(''); }).join('');

View File

@ -8,104 +8,88 @@
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<div class="page-header"> <header class="mb-4">
<div> <h1 class="text-2xl font-bold mb-2">발굴 (Discovery)</h1>
<h1>발굴</h1> <p class="text-muted">수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)</p>
<p class="sub">수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)</p> </header>
</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 class="card mb-4">
<div class="toolbar"> <div style="display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end;">
<label class="chip"><input type="checkbox" id="fShorts" onchange="loadVideos()"> Shorts</label> <div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
<label class="chip"><input type="checkbox" id="fUnprocessed" onchange="loadVideos()"> 미처리만</label> <label class="text-sm text-muted">기간</label>
<span class="sep"></span> <select id="fPeriod" onchange="loadVideos()" class="filter-sel">
<label class="field">기간
<select id="fPeriod" onchange="loadVideos()">
<option value="">전체</option> <option value="">전체</option>
<option value="7">최근 7일</option> <option value="7">최근 7일</option>
<option value="14">최근 14일</option> <option value="14">최근 14일</option>
<option value="30">최근 30일</option> <option value="30">최근 30일</option>
</select> </select>
</label> </div>
<label class="field">최소 배율 <div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
<select id="fMinRatio" onchange="loadVideos()"> <label class="text-sm text-muted">최소 배율</label>
<select id="fMinRatio" onchange="loadVideos()" class="filter-sel">
<option value="">전체</option> <option value="">전체</option>
<option value="2">≥ 2x</option> <option value="2">≥ 2x</option>
<option value="3">≥ 3x</option> <option value="3">≥ 3x</option>
<option value="5">≥ 5x</option> <option value="5">≥ 5x</option>
</select> </select>
</label> </div>
<label class="field">출처 <div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
<select id="fSource" onchange="loadVideos()"> <label class="text-sm text-muted">출처</label>
<select id="fSource" onchange="loadVideos()" class="filter-sel">
<option value="">전체</option> <option value="">전체</option>
<option value="CHANNEL">채널 수집</option> <option value="CHANNEL">채널 수집</option>
<option value="SEARCH">검색 수집</option> <option value="SEARCH">검색 수집</option>
</select> </select>
</label> </div>
<label class="field">정렬 <div style="display:flex; flex-direction:column; gap:4px; min-width:160px;">
<select id="fSort" onchange="loadVideos()"> <label class="text-sm text-muted">정렬</label>
<select id="fSort" onchange="loadVideos()" class="filter-sel">
<option value="viewsPerSubRatio">배율(조회/구독) ↓</option> <option value="viewsPerSubRatio">배율(조회/구독) ↓</option>
<option value="viewsPerHour">시간당 조회수 ↓</option> <option value="viewsPerHour">시간당 조회수 ↓</option>
<option value="viewCount">조회수 ↓</option> <option value="viewCount">조회수 ↓</option>
<option value="publishedAt">최신순 ↓</option> <option value="publishedAt">최신순 ↓</option>
</select> </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>
<span class="spacer"></span> <label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
<span id="resultCount" class="badge badge-muted"></span> <input type="checkbox" id="fUnprocessed" onchange="loadVideos()"> <span class="text-sm">미처리만(NEW/검토중)</span>
<button class="btn btn-secondary" onclick="loadVideos()" title="새로고침"><i data-lucide="refresh-cw" style="width:15px;"></i></button> </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>
</div> </div>
</div> </div>
<!-- 결과 테이블 --> <!-- 결과 테이블 -->
<div class="card p-0" style="overflow-x:auto;"> <div class="card p-0" style="overflow-x:auto;">
<table class="w-full coll-table" style="border-collapse:collapse; text-align:left;"> <table class="w-full" style="border-collapse:collapse; text-align:left;">
<thead> <thead style="background:rgba(255,255,255,0.02); border-bottom:1px solid var(--glass-border);">
<tr> <tr>
<th>영상</th> <th class="p-3 text-sm font-bold text-muted">썸네일</th>
<th>구독자</th> <th class="p-3 text-sm font-bold text-muted">제목</th>
<th>조회수</th> <th class="p-3 text-sm font-bold text-muted">채널</th>
<th>시간당</th> <th class="p-3 text-sm font-bold text-muted">구독자</th>
<th>배율</th> <th class="p-3 text-sm font-bold text-muted">조회수</th>
<th>업로드</th> <th class="p-3 text-sm font-bold text-muted">시간당</th>
<th>상태</th> <th class="p-3 text-sm font-bold text-muted">배율</th>
<th style="text-align:right;">관리</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>
</tr> </tr>
</thead> </thead>
<tbody id="resultBody"> <tbody id="resultBody">
<tr><td colspan="8" class="p-8 text-center text-muted">로딩 중...</td></tr> <tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>
</tbody> </tbody>
</table> </table>
</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 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,var(--surface)); border-radius:12px; padding:16px; border:1px solid var(--glass-border);"> <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="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;"> <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> <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> <button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
@ -115,8 +99,8 @@
</div> </div>
<style> <style>
.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 { 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:var(--surface); color:var(--text); } .row-sel option { background:#1e1e2d; color:white; }
</style> </style>
<script th:inline="javascript"> <script th:inline="javascript">
@ -154,7 +138,7 @@
let data; let data;
try { data = await api(API + '/discover?' + p.toString()); } try { data = await api(API + '/discover?' + p.toString()); }
catch(e){ catch(e){
body.innerHTML = '<tr><td colspan="8" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>'; body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>';
return; return;
} }
renderRows(data || []); renderRows(data || []);
@ -172,37 +156,38 @@
const body = document.getElementById('resultBody'); const body = document.getElementById('resultBody');
document.getElementById('resultCount').textContent = list.length + '건'; document.getElementById('resultCount').textContent = list.length + '건';
if(list.length===0){ if(list.length===0){
body.innerHTML = '<tr><td colspan="8" class="p-8 text-center text-muted">조건에 맞는 발굴 후보가 없습니다.</td></tr>'; body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">조건에 맞는 발굴 후보가 없습니다.</td></tr>';
return; return;
} }
body.innerHTML = list.map(v=>{ body.innerHTML = list.map(v=>{
const statusOpts = STATUS_KEYS.map(k=> const statusOpts = STATUS_KEYS.map(k=>
`<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join(''); `<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join('');
const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)'; const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)';
const shortsBadge = v.isShorts ? '<span class="badge badge-primary" style="margin-left:5px;">Shorts</span>' : ''; 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> return `<tr style="border-bottom:1px solid var(--glass-border);">
<td> <td class="p-3">
<div class="flex items-center gap-3" style="min-width:0;"> <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)}" 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;">
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> </div>
</td> </td>
<td class="text-sm">${fmt(v.subscriberCount)}</td> <td class="p-3" style="max-width:280px;">
<td class="text-sm">${fmt(v.viewCount)}</td> <div class="font-bold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">
<td class="text-sm">${fmt(v.viewsPerHour)}</td> <a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline text-white">${esc(v.title)}</a>${shortsBadge}
<td class="text-sm">${ratioBadge(v.viewsPerSubRatio)}</td> </div>
<td class="text-sm text-muted">${fmtDate(v.publishedAt)}</td> </td>
<td><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td> <td class="p-3 text-sm text-muted" style="max-width:130px;">
<td> <a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:#e2e8f0;">${esc(v.channelTitle||'-')}</a>
<div class="flex items-center gap-1 justify-end"> </td>
<a class="btn btn-primary" style="padding:0.4rem;" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a> <td class="p-3 text-sm">${fmt(v.subscriberCount)}</td>
<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> <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>
</div> </div>
</td> </td>
</tr>`; </tr>`;
@ -229,10 +214,6 @@
document.getElementById('videoModal').style.display = 'none'; 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(); loadVideos();
/*]]>*/ /*]]>*/
</script> </script>

View File

@ -6,14 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>h-lab - Dashboard</title> <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 --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@ -3,64 +3,116 @@
<body th:fragment="sidebar"> <body th:fragment="sidebar">
<aside id="sidebar" class="sidebar"> <aside id="sidebar" class="sidebar">
<!-- Brand --> <div class="p-6 mb-4 flex items-center justify-between">
<div class="flex items-center justify-between" style="padding: 1.1rem 1rem 0.5rem;">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div style="width:30px;height:30px;background:var(--primary-gradient);border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;"> <div style="
<span style="font-weight:800;color:#fff;font-size:15px;">H</span> width: 32px; height: 32px;
</div> background: var(--primary-gradient);
<div class="brand-text" style="white-space:nowrap;"> border-radius: 8px;
<div style="font-size:15px;font-weight:700;letter-spacing:-0.2px;color:var(--text);line-height:1.1;">h-lab</div> display: flex; align-items: center; justify-content: center;
<div style="font-size:11px;color:var(--text-3);font-weight:500;">yanalyst</div> box-shadow: 0 0 15px var(--primary-glow);
flex-shrink: 0;
">
<span style="font-weight: bold; color: white; font-size: 18px;">H</span>
</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> </div>
<!-- Toggle Button -->
<button id="sidebarToggle" class="sidebar-toggle-btn"> <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> </button>
</div> </div>
<nav style="flex:1;padding:0.5rem 0.75rem;overflow-y:auto;"> <nav style="flex: 1; padding: 0 1rem; overflow-y: auto;">
<div class="nav-section">분석</div> <ul class="flex-col gap-2">
<li>
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'"> <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> <i data-lucide="layout-dashboard" class="nav-icon"></i>
</a> <span class="nav-text">Dashboard</span>
<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>
</li>
<li>
<a th:href="@{/channels}" class="nav-item" th:classappend="${currentPage == 'channels'} ? 'active'"> <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> <i data-lucide="users" class="nav-icon"></i>
<span class="nav-text">Channels</span>
</a> </a>
</li>
<div class="nav-section">파이프라인</div> <li>
<a th:href="@{/collection}" class="nav-item" th:classappend="${currentPage == 'collection'} ? 'active'"> <a th:href="@{/videos}" class="nav-item" th:classappend="${currentPage == 'videos'} ? 'active'">
<i data-lucide="library" class="nav-icon"></i><span class="nav-text">수집함</span> <i data-lucide="video" class="nav-icon"></i>
<span class="nav-text">Videos</span>
</a> </a>
<a th:href="@{/board}" class="nav-item" th:classappend="${currentPage == 'board'} ? 'active'"> </li>
<i data-lucide="kanban-square" class="nav-icon"></i><span class="nav-text">칸반 보드</span> <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> </a>
<a th:href="@{/publish}" class="nav-item" th:classappend="${currentPage == 'publish'} ? 'active'"> </li>
<i data-lucide="send" class="nav-icon"></i><span class="nav-text">발행 큐</span> <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> </a>
</li>
<div class="nav-section">제작</div> <li>
<a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'"> <a th:href="@{/board}" class="nav-item"
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">프로덕션</span> th:classappend="${currentPage == 'board'} ? 'active'">
<i data-lucide="kanban-square" class="nav-icon"></i>
<span class="nav-text">보드</span>
</a> </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> </nav>
<div class="user-profile-container"> <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 class="user-profile-content">
<div style="width:30px;height:30px;border-radius:50%;background:var(--surface-2);border:1px solid var(--border);flex-shrink:0;"></div> <div style="
<div class="user-info" style="white-space:nowrap;"> width: 36px; height: 36px; border-radius: 50%;
<div style="font-size:12.5px;font-weight:600;color:var(--text);">hehiho</div> background: linear-gradient(135deg, #475569 0%, #0f172a 100%);
<div style="font-size:11px;color:var(--text-3);">개인 작업공간</div> 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> </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> </div>
</aside> </aside>
</body> </body>

View File

@ -19,7 +19,7 @@
<div class="card p-6 mb-6"> <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="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 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-[var(--surface-2)] border border-[var(--glass-border)]"> <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)]">
<img th:src="${channel.thumbnailUrl}" alt="Thumbnail" <img th:src="${channel.thumbnailUrl}" alt="Thumbnail"
style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;" th:if="${channel.thumbnailUrl != null}"> 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> <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;"> <div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 140px;">
<label class="text-sm font-bold text-muted">Period</label> <label class="text-sm font-bold text-muted">Period</label>
<select id="periodDays" class="w-full" <select id="periodDays" class="w-full"
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;"> 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: var(--surface); color: var(--text);">Within 1 Day</option> <option value="1" style="background: #1e1e2d; color: white;">Within 1 Day</option>
<option value="7" style="background: var(--surface); color: var(--text);" selected>Within 7 Days</option> <option value="7" style="background: #1e1e2d; color: white;" selected>Within 7 Days</option>
<option value="10" style="background: var(--surface); color: var(--text);">Within 10 Days</option> <option value="10" style="background: #1e1e2d; color: white;">Within 10 Days</option>
<option value="15" style="background: var(--surface); color: var(--text);">Within 15 Days</option> <option value="15" style="background: #1e1e2d; color: white;">Within 15 Days</option>
<option value="30" style="background: var(--surface); color: var(--text);">Within 30 Days</option> <option value="30" style="background: #1e1e2d; color: white;">Within 30 Days</option>
<option value="0" style="background: var(--surface); color: var(--text);">전부 (All)</option> <option value="0" style="background: #1e1e2d; color: white;">전부 (All)</option>
</select> </select>
</div> </div>
<!-- Format --> <!-- Format -->
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 160px;"> <div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 160px;">
<label class="text-sm font-bold text-muted">Format</label> <label class="text-sm font-bold text-muted">Format</label>
<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;"> <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;">
<label class="flex items-center gap-1 cursor-pointer hover:text-white"> <label class="flex items-center gap-1 cursor-pointer hover:text-white">
<input type="radio" name="format" value="SHORTS" checked> Shorts <input type="radio" name="format" value="SHORTS" checked> Shorts
</label> </label>
@ -63,10 +63,10 @@
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 120px;"> <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> <label class="text-sm font-bold text-muted">Load Size</label>
<select id="pageSize" class="w-full" <select id="pageSize" class="w-full"
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;"> 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: var(--surface); color: var(--text);">20 items</option> <option value="20" style="background: #1e1e2d; color: white;">20 items</option>
<option value="50" style="background: var(--surface); color: var(--text);" selected>50 items</option> <option value="50" style="background: #1e1e2d; color: white;" selected>50 items</option>
<option value="100" style="background: var(--surface); color: var(--text);">100 items</option> <option value="100" style="background: #1e1e2d; color: white;">100 items</option>
</select> </select>
</div> </div>
</div> </div>
@ -95,7 +95,7 @@
<!-- Video List Table --> <!-- Video List Table -->
<div class="card p-0" style="overflow-x: auto;"> <div class="card p-0" style="overflow-x: auto;">
<table class="w-full" style="border-collapse: collapse; text-align: left;"> <table class="w-full" style="border-collapse: collapse; text-align: left;">
<thead style="background: var(--surface-2); border-bottom: 1px solid var(--glass-border);"> <thead style="background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--glass-border);">
<tr> <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"><input type="checkbox" id="selectAll" onclick="toggleSelectAll(this.checked)" title="전체 선택"></th>
<th class="p-4 text-sm font-bold text-muted">Thumbnail</th> <th class="p-4 text-sm font-bold text-muted">Thumbnail</th>
@ -130,7 +130,7 @@
<!-- Video Player Modal --> <!-- 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 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, 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="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="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;"> <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> <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> <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)">' + '<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)\'">' + '<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;">' + '<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: var(--text); width: 16px; height: 16px; fill: white;"></i>' + '<i data-lucide="play" style="color: white; width: 16px; height: 16px; fill: white;"></i>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</td>' + '</td>' +
'<td class="p-4">' + '<td class="p-4">' +
'<div class="flex items-start justify-between gap-2" style="max-width: 300px;">' + '<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;">' + '<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">' + video.title + '</a>' + '<a href="https://www.youtube.com/watch?v=' + video.videoId + '" target="_blank" class="hover:underline text-white">' + video.title + '</a>' +
'</div>' + '</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;">' + '<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>' + '<i data-lucide="copy" style="width: 14px; height: 14px;"></i>' +

View File

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

View File

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

View File

@ -8,55 +8,29 @@
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<div class="page-header"> <header class="mb-4">
<div> <h1 class="text-2xl font-bold mb-1">발행 큐</h1>
<h1>발행 큐</h1> <p class="text-muted">재가공한 영상의 발행 패키지를 단계별로 관리합니다. (실제 업로드는 수동 — 여기서 준비·추적)</p>
<p class="sub">재가공한 영상의 발행 패키지를 단계별로 관리합니다. (실제 업로드는 수동 — 여기서 준비·추적)</p> </header>
</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">
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()"> <button class="btn btn-secondary px-4 py-2 tab active" data-status="" onclick="setTab(this,'')">전체</button>
<div class="modal-card"> <button class="btn btn-secondary px-4 py-2 tab" data-status="DRAFT" onclick="setTab(this,'DRAFT')">작성중</button>
<div class="modal-head"><h3>📖 발행 큐 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div> <button class="btn btn-secondary px-4 py-2 tab" data-status="READY" onclick="setTab(this,'READY')">발행대기</button>
<div class="modal-body"> <button class="btn btn-secondary px-4 py-2 tab" data-status="PUBLISHED" onclick="setTab(this,'PUBLISHED')">발행완료</button>
<div class="help-item"> <span id="cnt" class="text-sm text-muted" style="margin-left:auto; align-self:center;"></span>
<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>
<div class="card p-0" style="overflow-x:auto;"> <div class="card p-0" style="overflow-x:auto;">
<table class="w-full" style="border-collapse:collapse; text-align:left;"> <table class="w-full" style="border-collapse:collapse; text-align:left;">
<thead> <thead style="background:rgba(255,255,255,0.02); border-bottom:1px solid var(--glass-border);">
<tr> <tr>
<th>상태</th> <th class="p-3 text-sm font-bold text-muted">상태</th>
<th>플랫폼</th> <th class="p-3 text-sm font-bold text-muted">플랫폼</th>
<th>제목</th> <th class="p-3 text-sm font-bold text-muted">제목</th>
<th>예약</th> <th class="p-3 text-sm font-bold text-muted">예약</th>
<th>발행 URL</th> <th class="p-3 text-sm font-bold text-muted">발행 URL</th>
<th>관리</th> <th class="p-3 text-sm font-bold text-muted">관리</th>
</tr> </tr>
</thead> </thead>
<tbody id="body"> <tbody id="body">
@ -66,8 +40,7 @@
</div> </div>
<style> <style>
.tab { padding:0.5rem 1rem; } .tab.active { border-color:#7C3AED; color:#fff; }
.tab.active { background:var(--accent-soft); border-color:var(--accent); color:var(--accent); }
</style> </style>
<script th:inline="javascript"> <script th:inline="javascript">
@ -101,7 +74,7 @@
body.innerHTML = data.map(p=>{ body.innerHTML = data.map(p=>{
const st = ST[p.status] || {t:p.status,cls:'badge-muted'}; const st = ST[p.status] || {t:p.status,cls:'badge-muted'};
const sched = p.scheduledAt ? String(p.scheduledAt).substring(0,16).replace('T',' ') : '-'; 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:var(--accent);">열기</a>` : '-'; const urlCell = p.publishedUrl ? `<a href="${esc(p.publishedUrl)}" target="_blank" class="hover:underline" style="color:#60a5fa;">열기</a>` : '-';
return `<tr style="border-bottom:1px solid var(--glass-border);"> 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"><span class="badge ${st.cls}">${st.t}</span></td>
<td class="p-3 text-sm">${esc(p.platform||'')}</td> <td class="p-3 text-sm">${esc(p.platform||'')}</td>
@ -123,10 +96,6 @@
navigator.clipboard.writeText(text).then(()=>alert('설명+해시태그가 복사되었습니다.')); 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(); load();
/*]]>*/ /*]]>*/
</script> </script>

View File

@ -8,47 +8,13 @@
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<div class="page-header"> <header class="mb-4">
<div> <div class="flex items-center gap-2 mb-2">
<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> <a th:href="@{/collection}" class="text-muted hover:text-white"><i data-lucide="arrow-left" style="width:20px;"></i></a>
<h1>재가공 작업공간</h1> <h1 class="text-2xl font-bold">재가공 작업공간</h1>
</div>
<p class="sub" id="metaLine">로딩 중...</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="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>
<p class="text-muted" id="metaLine">로딩 중...</p>
</header>
<div style="display:grid; grid-template-columns: 360px 1fr; gap:1.5rem; align-items:start;"> <div style="display:grid; grid-template-columns: 360px 1fr; gap:1.5rem; align-items:start;">
<!-- 좌: 영상 + 정보 --> <!-- 좌: 영상 + 정보 -->
@ -94,32 +60,32 @@
<div id="transcribeStatus" class="text-sm mb-2" style="display:none;"></div> <div id="transcribeStatus" class="text-sm mb-2" style="display:none;"></div>
<!-- 영상 싱크 세그먼트 리스트 (CapCut형) --> <!-- 영상 싱크 세그먼트 리스트 (CapCut형) -->
<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> <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>
<!-- 평문 폴백 (URL 자막 추출 시) --> <!-- 평문 폴백 (URL 자막 추출 시) -->
<textarea id="transcript" readonly placeholder="‘영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, URL자막으로 YouTube 자막을 가져오세요." <textarea id="transcript" readonly placeholder="‘영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, URL자막으로 YouTube 자막을 가져오세요."
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> 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>
<!-- 내보내기 (배속 + 말 없는 구간 제거 + SRT/영상) --> <!-- 내보내기 (배속 + 말 없는 구간 제거 + SRT/영상) -->
<div id="exportBar" style="display:none; margin-top:14px; padding-top:12px; border-top:1px solid var(--glass-border);"> <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;"> <div class="flex items-center gap-2 mb-3" style="flex-wrap:wrap;">
<label class="text-sm text-muted">배속</label> <label class="text-sm text-muted">배속</label>
<input id="srtSpeed" type="number" value="1.0" step="0.1" min="0.1" max="2.0" <input id="srtSpeed" type="number" value="1.0" step="0.1" min="0.1" max="2.0"
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;"> 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;">
<span class="text-xs text-muted">배속 적용 시 타임스탬프 자동 보정 (0.5~2.0)</span> <span class="text-xs text-muted">배속 적용 시 타임스탬프 자동 보정 (0.5~2.0)</span>
</div> </div>
<div style="background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); padding:10px 12px;"> <div style="background:rgba(255,255,255,0.03); 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;"> <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;"> <label class="flex items-center gap-1 text-sm" style="cursor:pointer;">
<input type="checkbox" id="trimOn" onchange="onTrimToggle()"> 말 없는 구간 제거 <input type="checkbox" id="trimOn" onchange="onTrimToggle()"> 말 없는 구간 제거
</label> </label>
<span class="text-xs text-muted">앞뒤 여백</span> <span class="text-xs text-muted">앞뒤 여백</span>
<input id="trimPad" type="number" value="0.15" step="0.05" min="0" onchange="if(trimApplied)previewTrim()" <input id="trimPad" type="number" value="0.15" step="0.05" min="0" onchange="if(trimApplied)previewTrim()"
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;"> 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;">
<span class="text-xs text-muted">최소 간격</span> <span class="text-xs text-muted">최소 간격</span>
<input id="trimGap" type="number" value="0.3" step="0.1" min="0" onchange="if(trimApplied)previewTrim()" <input id="trimGap" type="number" value="0.3" step="0.1" min="0" onchange="if(trimApplied)previewTrim()"
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;"> 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;">
</div> </div>
<div id="trimInfo" class="text-xs text-muted"></div> <div id="trimInfo" class="text-xs text-muted"></div>
</div> </div>
@ -138,11 +104,11 @@
</div> </div>
<style> <style>
.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 { 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:var(--surface-2); } .seg-row:hover { background:rgba(255,255,255,0.05); }
.seg-row.active { background:rgba(96,165,250,0.18); } .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-time { color:#60a5fa; font-variant-numeric:tabular-nums; flex-shrink:0; font-size:0.82rem; padding-top:1px; }
.seg-text { color:var(--text-2); } .seg-text { color:#e2e8f0; }
</style> </style>
<!-- 재작성 에디터 --> <!-- 재작성 에디터 -->
@ -154,7 +120,7 @@
</button> </button>
</div> </div>
<textarea id="reworkText" placeholder="원본을 참고해 각색/수정한 스크립트를 여기에 작성하세요. 저장하면 상태가 TARGET(작업대상)으로 바뀝니다." <textarea id="reworkText" placeholder="원본을 참고해 각색/수정한 스크립트를 여기에 작성하세요. 저장하면 상태가 TARGET(작업대상)으로 바뀝니다."
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> 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>
<div class="text-sm text-muted mt-2" id="saveInfo"></div> <div class="text-sm text-muted mt-2" id="saveInfo"></div>
</div> </div>
@ -215,8 +181,8 @@
</div> </div>
<style> <style>
.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 { 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:var(--surface); color:var(--text); } .pub-in option { background:#1e1e2d; color:white; }
</style> </style>
<script th:inline="javascript"> <script th:inline="javascript">
@ -337,7 +303,7 @@
document.getElementById('trimOn').checked = true; document.getElementById('trimOn').checked = true;
const sp = curSpeed(); const sp = curSpeed();
const kept = (p.keptDuration||0) / sp; const kept = (p.keptDuration||0) / sp;
info.style.color = 'var(--text-2)'; info.style.color = '#cbd5e1';
info.innerHTML = '제거 ' + (p.removedCount||0) + '구간 · 결과 길이 ≈ <b>' + kept.toFixed(1) + 's</b>' info.innerHTML = '제거 ' + (p.removedCount||0) + '구간 · 결과 길이 ≈ <b>' + kept.toFixed(1) + 's</b>'
+ (sp!==1 ? (' (배속 ' + sp + 'x 포함)') : '') + (sp!==1 ? (' (배속 ' + sp + 'x 포함)') : '')
+ ' · SRT/영상이 이 타임라인으로 출력됩니다'; + ' · SRT/영상이 이 타임라인으로 출력됩니다';
@ -540,10 +506,6 @@
} catch(e){ alert('발행 완료 처리 실패: ' + e.message); } } 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); })(); (async ()=>{ await load(); await loadPublish(document.getElementById('vTitle').textContent); })();
/*]]>*/ /*]]>*/
</script> </script>

View File

@ -18,14 +18,14 @@
<label class="text-sm font-bold text-muted">Keyword</label> <label class="text-sm font-bold text-muted">Keyword</label>
<input type="text" id="keyword" placeholder="Search keyword..." <input type="text" id="keyword" placeholder="Search keyword..."
class="p-2 w-full" class="p-2 w-full"
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;" 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;"
onfocus="this.style.borderColor='#3b82f6'" onblur="this.style.borderColor='var(--glass-border)'"> onfocus="this.style.borderColor='#3b82f6'" onblur="this.style.borderColor='var(--glass-border)'">
</div> </div>
<!-- Country --> <!-- Country -->
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 180px;"> <div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 180px;">
<label class="text-sm font-bold text-muted">Country</label> <label class="text-sm font-bold text-muted">Country</label>
<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;"> <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;">
<label class="flex items-center gap-1 cursor-pointer hover:text-white"> <label class="flex items-center gap-1 cursor-pointer hover:text-white">
<input type="checkbox" name="region" value="JP" checked> JP <input type="checkbox" name="region" value="JP" checked> JP
</label> </label>
@ -42,20 +42,20 @@
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 140px;"> <div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 140px;">
<label class="text-sm font-bold text-muted">Period</label> <label class="text-sm font-bold text-muted">Period</label>
<select id="periodDays" class="w-full" <select id="periodDays" class="w-full"
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;"> 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: var(--surface); color: var(--text);">Within 1 Day</option> <option value="1" style="background: #1e1e2d; color: white;">Within 1 Day</option>
<option value="7" style="background: var(--surface); color: var(--text);">Within 7 Days</option> <option value="7" style="background: #1e1e2d; color: white;">Within 7 Days</option>
<option value="10" style="background: var(--surface); color: var(--text);">Within 10 Days</option> <option value="10" style="background: #1e1e2d; color: white;">Within 10 Days</option>
<option value="15" style="background: var(--surface); color: var(--text);">Within 15 Days</option> <option value="15" style="background: #1e1e2d; color: white;">Within 15 Days</option>
<option value="30" style="background: var(--surface); color: var(--text);">Within 30 Days</option> <option value="30" style="background: #1e1e2d; color: white;">Within 30 Days</option>
<option value="0" style="background: var(--surface); color: var(--text);">전부 (All)</option> <option value="0" style="background: #1e1e2d; color: white;">전부 (All)</option>
</select> </select>
</div> </div>
<!-- Format --> <!-- Format -->
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 160px;"> <div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 160px;">
<label class="text-sm font-bold text-muted">Format</label> <label class="text-sm font-bold text-muted">Format</label>
<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;"> <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;">
<label class="flex items-center gap-1 cursor-pointer hover:text-white"> <label class="flex items-center gap-1 cursor-pointer hover:text-white">
<input type="radio" name="format" value="SHORTS" checked> Shorts <input type="radio" name="format" value="SHORTS" checked> Shorts
</label> </label>
@ -69,10 +69,10 @@
<div style="display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 120px;"> <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> <label class="text-sm font-bold text-muted">Load Size</label>
<select id="pageSize" class="w-full" <select id="pageSize" class="w-full"
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;"> 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: var(--surface); color: var(--text);">20 items</option> <option value="20" style="background: #1e1e2d; color: white;">20 items</option>
<option value="50" style="background: var(--surface); color: var(--text);" selected>50 items</option> <option value="50" style="background: #1e1e2d; color: white;" selected>50 items</option>
<option value="100" style="background: var(--surface); color: var(--text);">100 items</option> <option value="100" style="background: #1e1e2d; color: white;">100 items</option>
</select> </select>
</div> </div>
</div> </div>
@ -101,7 +101,7 @@
<!-- Video List Table --> <!-- Video List Table -->
<div class="card p-0" style="overflow-x: auto;"> <div class="card p-0" style="overflow-x: auto;">
<table class="w-full" style="border-collapse: collapse; text-align: left;"> <table class="w-full" style="border-collapse: collapse; text-align: left;">
<thead style="background: var(--surface-2); border-bottom: 1px solid var(--glass-border);"> <thead style="background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--glass-border);">
<tr> <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"><input type="checkbox" id="selectAll" onclick="toggleSelectAll(this.checked)" title="전체 선택"></th>
<th class="p-4 text-sm font-bold text-muted">Thumbnail</th> <th class="p-4 text-sm font-bold text-muted">Thumbnail</th>
@ -135,7 +135,7 @@
<!-- Video Player Modal --> <!-- 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 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, 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="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="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;"> <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> <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> <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)">' + '<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)\'">' + '<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;">' + '<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: var(--text); width: 16px; height: 16px; fill: white;"></i>' + '<i data-lucide="play" style="color: white; width: 16px; height: 16px; fill: white;"></i>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</td>' + '</td>' +
'<td class="p-4">' + '<td class="p-4">' +
'<div class="flex items-start justify-between gap-2" style="max-width: 300px;">' + '<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;">' + '<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">' + video.title + '</a>' + '<a href="https://www.youtube.com/watch?v=' + video.videoId + '" target="_blank" class="hover:underline text-white">' + video.title + '</a>' +
'</div>' + '</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;">' + '<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>' + '<i data-lucide="copy" style="width: 14px; height: 14px;"></i>' +
@ -379,7 +379,7 @@
'<td class="p-4 text-sm text-muted">' + '<td class="p-4 text-sm text-muted">' +
'<div class="flex items-center gap-2">' + '<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>' + '<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: 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>' + '<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>' +
'</div>' + '</div>' +
tagsHtml + tagsHtml +
'</td>' + '</td>' +