297 lines
31 KiB
HTML
297 lines
31 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|