h-lab/src/main/resources/templates/upload-studio.html

297 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 업로드 스튜디오</title>
<style>
:root { --studio:#6d5dfc; --studio-soft:rgba(109,93,252,.11); }
.us-shell { max-width:1480px; margin:0 auto; }
.us-kicker { font:700 .68rem/1 var(--font-mono); color:var(--studio); letter-spacing:.12em; text-transform:uppercase; margin-bottom:.55rem; }
.us-head { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem; margin-bottom:1.4rem; }
.us-head h1 { font-size:clamp(2rem,4vw,3.5rem); line-height:.92; margin:0; letter-spacing:-.055em; }
.us-head p { color:var(--text-2); margin:.7rem 0 0; max-width:620px; }
.us-primary { background:var(--studio); color:#fff; border:1px solid var(--studio); }
.us-primary:hover { background:#5848e8; }
.us-primary:focus-visible, .us-button:focus-visible, .us-tab:focus-visible, .us-card:focus-visible { outline:3px solid rgba(109,93,252,.28); outline-offset:2px; }
.us-top { display:grid; grid-template-columns:minmax(0,1.45fr) minmax(290px,.55fr); border:1px solid var(--border); background:var(--surface); margin-bottom:1rem; }
.us-channel { padding:1.15rem 1.25rem; min-width:0; }
.us-channel-line { display:flex; align-items:center; gap:.8rem; }
.us-avatar { width:42px; height:42px; border-radius:50%; background:linear-gradient(145deg,#201c4c,#806ff9); color:#fff; display:grid; place-items:center; font-weight:800; }
.us-channel-name { font-weight:750; font-size:1.05rem; }
.us-muted { color:var(--text-2); font-size:.82rem; }
.us-select { margin-left:auto; border:1px solid var(--border); background:var(--surface-2); color:var(--text); padding:.55rem 2rem .55rem .7rem; border-radius:var(--r-sm); }
.us-rhythm { display:grid; grid-template-columns:repeat(7,1fr); gap:7px; margin-top:1rem; height:92px; align-items:end; }
.us-day { height:var(--h); min-height:9px; background:linear-gradient(180deg,rgba(109,93,252,.28),var(--studio-soft)); border-top:2px solid var(--studio); position:relative; }
.us-day-label { position:absolute; top:calc(100% + 6px); left:50%; transform:translateX(-50%); font:600 .58rem/1 var(--font-mono); color:var(--text-3); }
.us-day-views { position:absolute; left:5px; top:5px; font:750 .62rem/1 var(--font-mono); color:var(--text); white-space:nowrap; }
.us-day-count { position:absolute; right:4px; top:-20px; color:var(--studio); font:700 .58rem/1 var(--font-mono); white-space:nowrap; }
.us-folder { border-left:1px solid var(--border); padding:1.15rem 1.25rem; display:flex; flex-direction:column; justify-content:center; }
.us-folder-title { display:flex; align-items:center; gap:.45rem; font-weight:700; }
.us-folder-path { color:var(--text-2); font:500 .7rem/1.5 var(--font-mono); margin:.55rem 0 .8rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.us-button { min-height:42px; border:1px solid var(--border-strong); background:var(--surface); color:var(--text); padding:.6rem .85rem; border-radius:var(--r-sm); font-weight:700; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; gap:.45rem; }
.us-button svg { width:16px; height:16px; }
.us-button:disabled { cursor:not-allowed; opacity:.45; }
.us-danger { color:var(--danger); border-color:rgba(var(--danger-rgb),.35); }
.us-summary { display:grid; grid-template-columns:repeat(4,1fr); border:1px solid var(--border); background:var(--surface); margin-bottom:1rem; }
.us-stat { padding:.85rem 1rem; border-right:1px solid var(--border); }
.us-stat:last-child { border:0; }
.us-stat strong { display:block; font:750 1.55rem/1 var(--font-display); }
.us-stat span { color:var(--text-2); font-size:.74rem; }
.us-work { display:grid; grid-template-columns:minmax(0,1fr) 360px; gap:1rem; align-items:start; }
.us-library { border:1px solid var(--border); background:var(--surface); min-width:0; }
.us-toolbar { padding:.85rem; border-bottom:1px solid var(--border); display:flex; gap:.65rem; align-items:center; flex-wrap:wrap; }
.us-search { flex:1; min-width:190px; position:relative; }
.us-search input { width:100%; box-sizing:border-box; min-height:42px; padding:.6rem .8rem .6rem 2.2rem; background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r-sm); color:var(--text); }
.us-search svg { position:absolute; width:15px; left:.75rem; top:13px; color:var(--text-3); }
.us-tabs { display:flex; gap:.25rem; overflow:auto; padding:.7rem .85rem 0; }
.us-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--text-2); padding:.55rem .65rem; font-weight:700; white-space:nowrap; cursor:pointer; }
.us-tab.active { color:var(--studio); border-color:var(--studio); }
.us-list { padding:.35rem .85rem .85rem; }
.us-card { width:100%; display:grid; grid-template-columns:28px 116px minmax(160px,1fr) 120px 100px; gap:.8rem; align-items:center; text-align:left; padding:.75rem .35rem; border:0; border-bottom:1px solid var(--border); background:transparent; color:var(--text); cursor:pointer; }
.us-card:last-child { border-bottom:0; }
.us-card:hover, .us-card.selected { background:var(--hover); }
.us-card.selected { box-shadow:inset 3px 0 var(--studio); }
.us-check { width:18px; height:18px; border:1px solid var(--border-strong); border-radius:3px; display:grid; place-items:center; }
.selected .us-check { background:var(--studio); border-color:var(--studio); color:#fff; }
.us-thumb { aspect-ratio:16/9; border-radius:4px; background:linear-gradient(135deg,#211f31,#6f638b); overflow:hidden; position:relative; }
.us-thumb::after { content:'▶'; position:absolute; inset:0; display:grid; place-items:center; color:rgba(255,255,255,.85); font-size:.75rem; }
.us-title { min-width:0; }
.us-title strong { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.us-title span { color:var(--text-3); font:500 .66rem/1.5 var(--font-mono); }
.us-badge { justify-self:start; display:inline-flex; align-items:center; gap:.3rem; border-radius:99px; padding:.3rem .55rem; font-size:.7rem; font-weight:750; }
.us-badge.wait { color:#6655e8; background:var(--studio-soft); }
.us-badge.done { color:var(--success); background:rgba(var(--success-rgb),.1); }
.us-badge.review { color:var(--warning); background:rgba(196,122,18,.11); }
.us-badge.scheduled { color:#6655e8; background:var(--studio-soft); }
.us-badge.uploading { color:var(--warning); background:rgba(196,122,18,.11); }
.us-badge.failed { color:var(--danger); background:rgba(var(--danger-rgb),.1); }
.us-time { color:var(--text-2); font-size:.75rem; }
.us-panel { position:sticky; top:1rem; border:1px solid var(--border); background:var(--surface); }
.us-panel-head { padding:1rem; border-bottom:1px solid var(--border); }
.us-panel-head h2 { font-size:1.15rem; margin:0 0 .25rem; }
.us-form { padding:1rem; display:grid; gap:.85rem; }
.us-field label { display:block; font-size:.72rem; font-weight:750; margin-bottom:.35rem; color:var(--text-2); }
.us-field input, .us-field textarea, .us-field select { box-sizing:border-box; width:100%; border:1px solid var(--border); background:var(--surface-2); color:var(--text); border-radius:var(--r-sm); padding:.65rem .7rem; font:inherit; }
.us-field textarea { min-height:82px; resize:vertical; }
.us-mode { display:grid; grid-template-columns:1fr 1fr; gap:.4rem; }
.us-mode button { min-height:40px; border:1px solid var(--border); background:var(--surface-2); color:var(--text-2); font-weight:700; cursor:pointer; }
.us-mode button.active { color:var(--studio); background:var(--studio-soft); border-color:var(--studio); }
.us-submit { width:100%; min-height:46px; border:0; border-radius:var(--r-sm); font-weight:800; cursor:pointer; }
.us-empty { padding:3rem 1rem; text-align:center; color:var(--text-2); }
.us-toast { position:fixed; right:1.25rem; bottom:1.25rem; background:#201d35; color:white; padding:.85rem 1rem; border-radius:6px; box-shadow:var(--shadow-lg); z-index:20; display:none; }
.us-modal { position:fixed; inset:0; z-index:30; background:rgba(15,14,22,.58); display:none; place-items:center; padding:1rem; }
.us-modal.open { display:grid; }
.us-dialog { width:min(520px,100%); max-height:calc(100vh - 2rem); overflow:auto; background:var(--surface); border:1px solid var(--border-strong); box-shadow:var(--shadow-lg); }
.us-dialog-head { padding:1.1rem 1.2rem; border-bottom:1px solid var(--border); display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; }
.us-dialog-head h2 { margin:0 0 .3rem; font-size:1.3rem; }
.us-dialog-body { padding:1.2rem; display:grid; gap:1rem; }
.us-help { background:var(--studio-soft); border-left:3px solid var(--studio); padding:.75rem .85rem; color:var(--text-2); font-size:.78rem; line-height:1.55; }
.us-help code { color:var(--text); overflow-wrap:anywhere; }
.us-dialog-actions { display:flex; justify-content:flex-end; gap:.5rem; }
@media(max-width:1100px){ .us-work{grid-template-columns:1fr}.us-panel{position:static}.us-card{grid-template-columns:28px 100px minmax(150px,1fr) 110px}.us-time{display:none} }
@media(max-width:760px){ .us-head{align-items:flex-start;flex-direction:column}.us-top{grid-template-columns:1fr}.us-folder{border-left:0;border-top:1px solid var(--border)}.us-summary{grid-template-columns:1fr 1fr}.us-stat:nth-child(2){border-right:0}.us-stat:nth-child(-n+2){border-bottom:1px solid var(--border)}.us-card{grid-template-columns:24px 84px minmax(120px,1fr)}.us-badge{grid-column:3}.us-head .us-button{width:100%} }
@media(prefers-reduced-motion:reduce){ *{scroll-behavior:auto!important;transition:none!important} }
</style>
</head>
<body>
<div layout:fragment="content" class="us-shell">
<header class="us-head">
<div><div class="us-kicker">YouTube operations desk</div><h1>업로드 스튜디오</h1><p>로컬 내보내기 영상과 채널 발행 기록을 맞춰 보고, 업로드와 예약을 한 흐름에서 관리합니다.</p></div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap"><button class="us-button" type="button" onclick="openOAuthSettings()"><i data-lucide="settings"></i> OAuth 설정</button><button class="us-button" type="button" onclick="connectYoutube()"><i data-lucide="link"></i> 채널 연결</button><button class="us-button us-danger" id="disconnectButton" type="button" onclick="disconnectYoutube()" disabled><i data-lucide="unlink"></i> 연결 해제</button><button class="us-button us-primary" type="button" onclick="syncStudio()"><i data-lucide="refresh-cw"></i> YouTube 동기화</button></div>
</header>
<section class="us-top" aria-label="채널과 폴더 현황">
<div class="us-channel">
<div class="us-channel-line"><div class="us-avatar" id="channelAvatar">Y</div><div><div class="us-channel-name" id="channelName">YouTube 채널을 연결하세요</div><div class="us-muted" id="channelMeta">Google OAuth 연결이 필요합니다.</div></div><select class="us-select" id="channelSelect" aria-label="YouTube 채널" onchange="selectChannel(this.value)"><option value="">연결된 채널 없음</option></select></div>
<div class="us-rhythm" id="uploadRhythm" aria-label="최근 7일 업로드"><div class="us-day" style="--h:8%"><span>불러오는 중</span></div></div>
</div>
<div class="us-folder"><div class="us-folder-title"><i data-lucide="folder-check" style="width:17px;color:var(--success)"></i> <span id="folderTitle">CapCut 내보내기 폴더</span></div><div class="us-folder-path" id="folderPath">폴더를 연결하면 영상 파일을 바로 비교합니다.</div><button class="us-button" type="button" onclick="chooseFolder()"><i data-lucide="folder-open"></i> 폴더 선택</button><input id="folderFallback" type="file" accept="video/*" webkitdirectory multiple hidden></div>
</section>
<section class="us-summary" aria-label="업로드 요약">
<div class="us-stat"><strong id="sevenDayViews">—</strong><span>7일 조회수 합계</span></div><div class="us-stat"><strong id="recentCount">—</strong><span>7일 업로드</span></div><div class="us-stat"><strong id="viewsPerUpload">—</strong><span>업로드 1개당 조회수</span></div><div class="us-stat"><strong id="recentVideoViews">—</strong><span>최근 영상 누적 조회수</span></div>
</section>
<div class="us-work">
<section class="us-library">
<div class="us-toolbar"><div class="us-search"><i data-lucide="search"></i><input id="videoSearch" type="search" placeholder="파일명으로 검색" oninput="renderVideos()"></div><button class="us-button" type="button" onclick="sortVideos()"><i data-lucide="arrow-up-down"></i> 최신순</button></div>
<div class="us-tabs" role="tablist"><button class="us-tab active" data-filter="all" onclick="setFilter(this)">전체</button><button class="us-tab" data-filter="wait" onclick="setFilter(this)">업로드 대기</button><button class="us-tab" data-filter="scheduled" onclick="setFilter(this)">예약</button><button class="us-tab" data-filter="done" onclick="setFilter(this)">업로드 완료</button><button class="us-tab" data-filter="failed" onclick="setFilter(this)">실패</button></div>
<div class="us-list" id="videoList"></div>
</section>
<aside class="us-panel" aria-label="업로드 설정">
<div class="us-panel-head"><div class="us-kicker">Selected video</div><h2 id="panelTitle">영상 1개 선택됨</h2><div class="us-muted">업로드 전 정보를 확인하세요.</div></div>
<form class="us-form" onsubmit="submitUpload(event)">
<div class="us-field"><label for="uploadTitle">제목</label><input id="uploadTitle" maxlength="100"></div>
<div class="us-field"><label for="uploadDescription">설명</label><textarea id="uploadDescription"></textarea></div>
<div class="us-field"><label for="privacy">공개 상태</label><select id="privacy"><option value="public">공개</option><option value="unlisted">일부 공개</option><option value="private">비공개</option></select></div>
<div class="us-field"><label>업로드 시점</label><div class="us-mode"><button class="active" type="button" onclick="setMode(this,'now')">즉시 업로드</button><button type="button" onclick="setMode(this,'schedule')">예약</button></div></div>
<div class="us-field" id="scheduleField" hidden><label for="scheduleAt">공개 예정 시간 · Asia/Seoul</label><input id="scheduleAt" type="datetime-local"></div>
<button class="us-submit us-primary" id="submitButton" type="submit">YouTube에 업로드</button>
</form>
</aside>
</div>
<div class="us-modal" id="oauthModal" role="dialog" aria-modal="true" aria-labelledby="oauthTitle" onclick="if(event.target===this)closeOAuthSettings()">
<div class="us-dialog">
<div class="us-dialog-head"><div><div class="us-kicker">One-time setup</div><h2 id="oauthTitle">YouTube API 키 연결</h2><div class="us-muted">Google Cloud의 웹 OAuth 클라이언트 정보를 한 번만 입력하세요.</div></div><button class="us-button" type="button" aria-label="닫기" onclick="closeOAuthSettings()">×</button></div>
<form class="us-dialog-body" onsubmit="saveOAuthSettings(event)">
<div class="us-help">Google Cloud Console에서 <b>YouTube Data API v3</b>를 사용 설정하고 OAuth 클라이언트 유형을 <b>웹 애플리케이션</b>으로 만드세요. 승인된 리디렉션 URI에는 아래 콜백 주소를 그대로 등록해야 합니다.</div>
<div class="us-field"><label for="oauthClientId">Client ID</label><input id="oauthClientId" autocomplete="off" placeholder="000000000000-….apps.googleusercontent.com" required></div>
<div class="us-field"><label for="oauthClientSecret">Client Secret</label><input id="oauthClientSecret" type="password" autocomplete="new-password" placeholder="GOCSPX-…" required></div>
<div class="us-field"><label for="oauthRedirectUri">승인된 리디렉션 URI</label><input id="oauthRedirectUri" type="url" value="http://localhost:8088/api/youtube/oauth/callback" required></div>
<div class="us-help">키는 브라우저나 Git에 보관하지 않습니다. 채널 연결 전에는 리디렉션 URI 오타를 수정할 수 있으며, 저장 직후 Google 로그인으로 이동합니다.</div>
<div class="us-dialog-actions"><button class="us-button" type="button" onclick="closeOAuthSettings()">취소</button><button class="us-button us-primary" id="oauthSaveButton" type="submit">저장하고 채널 연결</button></div>
</form>
</div>
</div>
<div class="us-toast" id="studioToast" role="status"></div>
</div>
<th:block layout:fragment="script">
<script>
let filter='all', uploadMode='now', descending=true;
let localVideos=[], uploadJobs=[], recentUploads=[], videos=[];
let selectedId=null;
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function renderVideos(){
const q=document.getElementById('videoSearch').value.toLowerCase();
const list=videos.filter(v=>(filter==='all'||v.status===filter)&&v.name.toLowerCase().includes(q));
document.getElementById('videoList').innerHTML=list.length?list.map(v=>`<button type="button" class="us-card ${v.id===selectedId?'selected':''}" onclick="selectVideo('${v.id}')"><span class="us-check">${v.id===selectedId?'✓':''}</span><span class="us-thumb"></span><span class="us-title"><strong>${esc(v.name)}</strong><span>${esc(v.meta)}</span></span><span class="us-badge ${v.status}">${v.status==='wait'?'↑':v.status==='failed'?'!':v.status==='uploading'?'…':'✓'} ${v.label}</span><span class="us-time">${esc(v.time||'')}</span></button>`).join(''):'<div class="us-empty">폴더를 선택하거나 YouTube 동기화를 실행해 주세요.</div>';
}
function rebuildVideos(){
const jobVideoIds=new Set(uploadJobs.map(v=>v.youtubeVideoId).filter(Boolean));
videos=[...localVideos,...uploadJobs,...recentUploads.filter(v=>!jobVideoIds.has(v.youtubeVideoId))];
if(selectedId&&!videos.some(v=>v.id===selectedId))selectedId=null;
renderVideos();
}
function selectVideo(id){
selectedId=id;const v=videos.find(x=>x.id===id);if(!v)return;
document.getElementById('panelTitle').textContent=v.file?'영상 1개 선택됨':'업로드 기록';
document.getElementById('uploadTitle').value=v.title||v.name.replace(/\.[^.]+$/,'').replaceAll('_',' ');
document.getElementById('submitButton').disabled=!v.file;
renderVideos();
}
function setFilter(el){filter=el.dataset.filter;document.querySelectorAll('.us-tab').forEach(x=>x.classList.toggle('active',x===el));renderVideos();}
function sortVideos(){descending=!descending;videos.reverse();renderVideos();}
function setMode(el,mode){uploadMode=mode;document.querySelectorAll('.us-mode button').forEach(x=>x.classList.toggle('active',x===el));document.getElementById('scheduleField').hidden=mode!=='schedule';document.getElementById('submitButton').textContent=mode==='schedule'?'예약 업로드 등록':'YouTube에 업로드';}
async function chooseFolder(){
if(window.showDirectoryPicker){try{const dir=await window.showDirectoryPicker();const found=[];for await(const entry of dir.values()){if(entry.kind==='file'&&/\.(mp4|mov|m4v|webm)$/i.test(entry.name)){const file=await entry.getFile();found.push(localVideo(file,found.length));}}localVideos=found;document.getElementById('folderTitle').textContent=dir.name;document.getElementById('folderPath').textContent='권한 연결됨 · '+found.length+'개 영상 발견';selectedId=found[0]?.id||null;rebuildVideos();if(selectedId)selectVideo(selectedId);showToast(found.length+'개 영상을 불러왔습니다.');return;}catch(e){if(e.name==='AbortError')return;}}
document.getElementById('folderFallback').click();
}
function localVideo(file,index){return{id:'local-'+Date.now()+'-'+index,name:file.name,title:file.name.replace(/\.[^.]+$/,'').replaceAll('_',' '),meta:(file.size/1048576).toFixed(1)+' MB · 로컬 영상',status:'wait',label:'업로드 대기',file,time:'로컬'};}
document.getElementById('folderFallback').addEventListener('change',e=>{const files=[...e.target.files].filter(f=>f.type.startsWith('video/'));localVideos=files.map(localVideo);document.getElementById('folderTitle').textContent=e.target.files[0]?.webkitRelativePath.split('/')[0]||'선택한 폴더';document.getElementById('folderPath').textContent='권한 연결됨 · '+localVideos.length+'개 영상 발견';selectedId=localVideos[0]?.id||null;rebuildVideos();if(selectedId)selectVideo(selectedId);});
let connections=[];
let oauthConfigured=false;
async function loadOAuthStatus(){
try{const response=await fetch('/api/youtube/settings/status');const body=await response.json();oauthConfigured=!!body.data.configured;if(body.data.redirectUri)document.getElementById('oauthRedirectUri').value=body.data.redirectUri;}catch(e){oauthConfigured=false;}
}
async function connectYoutube(){await loadOAuthStatus();if(!oauthConfigured){openOAuthSettings();return;}location.href='/api/youtube/oauth/start';}
function openOAuthSettings(){document.getElementById('oauthModal').classList.add('open');setTimeout(()=>document.getElementById('oauthClientId').focus(),0);}
function closeOAuthSettings(){document.getElementById('oauthModal').classList.remove('open');}
async function saveOAuthSettings(event){
event.preventDefault();
const button=document.getElementById('oauthSaveButton');button.disabled=true;button.textContent='안전하게 저장 중…';
try{
const response=await fetch('/api/youtube/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientId:document.getElementById('oauthClientId').value.trim(),clientSecret:document.getElementById('oauthClientSecret').value.trim(),redirectUri:document.getElementById('oauthRedirectUri').value.trim()})});
const body=await response.json();if(!response.ok||!body.success)throw new Error(body.message||'설정 저장 실패');
document.getElementById('oauthClientSecret').value='';oauthConfigured=true;location.href='/api/youtube/oauth/start';
}catch(e){showToast(e.message||'키 설정을 저장하지 못했습니다.');button.disabled=false;button.textContent='저장하고 채널 연결';}
}
async function loadConnections(){
try{
const response=await fetch('/api/youtube/connections');
const body=await response.json();
if(!body.success) throw new Error(body.message);
connections=body.data||[];
const select=document.getElementById('channelSelect');
select.innerHTML=connections.length?connections.map(c=>`<option value="${c.id}">${esc(c.title)}</option>`).join(''):'<option value="">연결된 채널 없음</option>';
selectChannel(select.value);
}catch(e){showToast('채널 정보를 불러오지 못했습니다.');}
}
function selectChannel(id){
const c=connections.find(x=>String(x.id)===String(id));
document.getElementById('channelName').textContent=c?c.title:'YouTube 채널을 연결하세요';
document.getElementById('channelMeta').textContent=c?(c.handle||c.channelId)+' · 토큰 '+(c.tokenStatus==='ACTIVE'?'정상':'확인 필요'):'Google OAuth 연결이 필요합니다.';
document.getElementById('channelAvatar').textContent=c?c.title.slice(0,1).toUpperCase():'Y';
document.getElementById('disconnectButton').disabled=!c;
if(c)loadUploadJobs(c.id);else{uploadJobs=[];recentUploads=[];rebuildVideos();}
}
async function loadUploadJobs(connectionId){
try{
const response=await fetch('/api/youtube/connections/'+connectionId+'/uploads');const body=await response.json();
if(!response.ok||!body.success)throw new Error(body.message||'업로드 기록 조회 실패');
uploadJobs=(body.data||[]).map(j=>({id:'job-'+j.id,name:j.fileName,title:j.title,
meta:(j.fileSize/1048576).toFixed(1)+' MB · '+(j.youtubeVideoId||'YouTube 처리 중'),
status:j.status==='SCHEDULED'?'scheduled':j.status==='PUBLISHED'?'done':j.status==='FAILED'?'failed':'uploading',
label:j.status==='SCHEDULED'?'예약됨':j.status==='PUBLISHED'?'업로드 완료':j.status==='FAILED'?'업로드 실패':'업로드 중',
youtubeVideoId:j.youtubeVideoId,publishAt:j.publishAt,time:j.publishAt?new Date(j.publishAt).toLocaleString('ko-KR'):'기록'}));
rebuildVideos();
}catch(e){showToast(e.message||'업로드 기록을 불러오지 못했습니다.');}
}
async function disconnectYoutube(){
const id=document.getElementById('channelSelect').value;
const connection=connections.find(c=>String(c.id)===String(id));
if(!connection)return;
if(!confirm(`'${connection.title}' 채널 연결을 해제할까요?\n저장된 OAuth 토큰만 삭제되며 영상 파일과 작업 데이터는 유지됩니다.`))return;
const button=document.getElementById('disconnectButton');button.disabled=true;
try{
const response=await fetch('/api/youtube/connections/'+id,{method:'DELETE'});
const body=await response.json();if(!response.ok||!body.success)throw new Error(body.message||'연결 해제 실패');
document.getElementById('recentCount').textContent='—';
await loadConnections();
showToast('YouTube 채널 연결을 해제했습니다.');
}catch(e){showToast(e.message||'채널 연결을 해제하지 못했습니다.');selectChannel(id);}
}
async function syncStudio(silent=false){
if(!connections.length){if(!silent)connectYoutube();return;}
const id=document.getElementById('channelSelect').value;if(!id)return;
try{
const response=await fetch('/api/youtube/connections/'+id+'/sync',{method:'POST'});const body=await response.json();
if(!response.ok||!body.success)throw new Error(body.message||'동기화 실패');
document.getElementById('recentCount').textContent=body.data.recentSevenDayCount;
document.getElementById('sevenDayViews').textContent=compactNumber(body.data.sevenDayViews);
document.getElementById('viewsPerUpload').textContent=compactNumber(body.data.viewsPerUpload);
document.getElementById('recentVideoViews').textContent=compactNumber(body.data.recentVideoViews);
recentUploads=(body.data.recentUploads||[]).map(v=>({id:'youtube-'+v.videoId,name:v.title,title:v.title,
meta:'조회수 '+Number(v.viewCount||0).toLocaleString('ko-KR')+' · '+v.videoId,status:'done',label:'게시됨',youtubeVideoId:v.videoId,
time:new Date(v.publishedAt).toLocaleDateString('ko-KR')}));
renderRhythm(body.data.dailyCounts);await loadConnections();rebuildVideos();if(!silent)showToast('최근 7일 업로드 '+body.data.recentSevenDayCount+'개를 동기화했습니다.');
}catch(e){showToast(e.message||'YouTube 동기화에 실패했습니다.');}
}
function renderRhythm(days){
const max=Math.max(1,...days.map(d=>d.views));const labels=['일','월','화','수','목','금','토'];
document.getElementById('uploadRhythm').innerHTML=days.map((d,i)=>{const date=new Date(d.date+'T00:00:00');const label=i===days.length-1?'오늘':labels[date.getDay()];const height=Math.max(10,Math.round(Math.sqrt(d.views/max)*88));return `<div class="us-day" style="--h:${height}%" title="${d.date} · 조회수 ${Number(d.views).toLocaleString('ko-KR')} · 업로드 ${d.count}개"><span class="us-day-views">${compactNumber(d.views)}</span><span class="us-day-count">↑ ${d.count}</span><span class="us-day-label">${label}</span></div>`;}).join('');
}
function compactNumber(value){return new Intl.NumberFormat('ko-KR',{notation:'compact',maximumFractionDigits:1}).format(Number(value||0));}
async function submitUpload(e){
e.preventDefault();
const connectionId=document.getElementById('channelSelect').value;
const v=videos.find(x=>x.id===selectedId);
if(!connectionId){showToast('먼저 YouTube 채널을 연결해 주세요.');return;}
if(!v||!v.file){showToast('폴더에서 업로드할 로컬 영상을 선택해 주세요.');return;}
const scheduleValue=document.getElementById('scheduleAt').value;
if(uploadMode==='schedule'&&!scheduleValue){showToast('공개 예정 시간을 선택해 주세요.');return;}
const form=new FormData();form.append('file',v.file);form.append('title',document.getElementById('uploadTitle').value.trim());
form.append('description',document.getElementById('uploadDescription').value);form.append('privacyStatus',document.getElementById('privacy').value);
if(uploadMode==='schedule')form.append('publishAt',new Date(scheduleValue).toISOString());
const button=document.getElementById('submitButton');button.disabled=true;button.textContent='YouTube로 업로드 중…';
v.status='uploading';v.label='업로드 중';renderVideos();
try{
const response=await fetch('/api/youtube/connections/'+connectionId+'/uploads',{method:'POST',body:form});
const body=await response.json();if(!response.ok||!body.success)throw new Error(body.message||'업로드 실패');
localVideos=localVideos.filter(x=>x.id!==v.id);selectedId=null;await loadUploadJobs(connectionId);
showToast(uploadMode==='schedule'?'YouTube 예약 공개를 등록했습니다.':'YouTube 업로드를 완료했습니다.');
}catch(err){v.status='failed';v.label='업로드 실패';renderVideos();showToast(err.message||'YouTube 업로드에 실패했습니다.');}
finally{button.disabled=false;button.textContent=uploadMode==='schedule'?'예약 업로드 등록':'YouTube에 업로드';}
}
function showToast(message){const t=document.getElementById('studioToast');t.textContent=message;t.style.display='block';clearTimeout(t.timer);t.timer=setTimeout(()=>t.style.display='none',2400);}
const oauthResult=new URLSearchParams(location.search).get('youtube');
if(oauthResult){const messages={connected:'YouTube 채널을 연결했습니다.',denied:'YouTube 권한 요청이 취소되었습니다.','invalid-state':'안전하지 않은 OAuth 응답을 차단했습니다.','missing-code':'Google 인증 코드가 없습니다.',failed:'YouTube 채널 연결에 실패했습니다.'};setTimeout(()=>showToast(messages[oauthResult]||'YouTube 연결을 확인해 주세요.'),100);history.replaceState(null,'',location.pathname);}
rebuildVideos();
loadOAuthStatus();
loadConnections().then(()=>{if(connections.length)syncStudio(true);});
</script>
</th:block>
</body>
</html>