feat: 유튜브 업로드 스튜디오 화면 추가
This commit is contained in:
parent
0e155c852c
commit
bc416ab7bb
@ -80,6 +80,12 @@ public class WebController {
|
||||
return "shortform";
|
||||
}
|
||||
|
||||
@GetMapping("/upload-studio")
|
||||
public String uploadStudio(Model model) {
|
||||
model.addAttribute("currentPage", "upload-studio");
|
||||
return "upload-studio";
|
||||
}
|
||||
|
||||
@GetMapping("/recommend")
|
||||
public String recommend(Model model) {
|
||||
model.addAttribute("currentPage", "recommend");
|
||||
|
||||
@ -57,6 +57,9 @@
|
||||
</a>
|
||||
|
||||
<div class="nav-section">제작</div>
|
||||
<a th:href="@{/upload-studio}" class="nav-item" th:classappend="${currentPage == 'upload-studio'} ? 'active'">
|
||||
<i data-lucide="upload-cloud" class="nav-icon"></i><span class="nav-text">업로드 스튜디오</span>
|
||||
</a>
|
||||
<a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'">
|
||||
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">프로덕션</span>
|
||||
</a>
|
||||
|
||||
157
src/main/resources/templates/upload-studio.html
Normal file
157
src/main/resources/templates/upload-studio.html
Normal file
@ -0,0 +1,157 @@
|
||||
<!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:5px; margin-top:1rem; height:58px; align-items:end; }
|
||||
.us-day { height:var(--h); min-height:7px; background:var(--studio-soft); border-top:2px solid var(--studio); position:relative; }
|
||||
.us-day span { position:absolute; top:calc(100% + 6px); left:50%; transform:translateX(-50%); font:600 .58rem/1 var(--font-mono); color:var(--text-3); }
|
||||
.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-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-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; }
|
||||
@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>
|
||||
<button class="us-button us-primary" type="button" onclick="syncStudio()"><i data-lucide="refresh-cw"></i> YouTube 동기화</button>
|
||||
</header>
|
||||
|
||||
<section class="us-top" aria-label="채널과 폴더 현황">
|
||||
<div class="us-channel">
|
||||
<div class="us-channel-line"><div class="us-avatar">H</div><div><div class="us-channel-name">h-lab Shorts</div><div class="us-muted">@hlab-shorts · 마지막 동기화 방금 전</div></div><select class="us-select" aria-label="YouTube 채널"><option>h-lab Shorts</option><option>h-lab Politics</option></select></div>
|
||||
<div class="us-rhythm" aria-label="최근 7일 업로드 8개">
|
||||
<div class="us-day" style="--h:22%"><span>월</span></div><div class="us-day" style="--h:60%"><span>화</span></div><div class="us-day" style="--h:36%"><span>수</span></div><div class="us-day" style="--h:82%"><span>목</span></div><div class="us-day" style="--h:50%"><span>금</span></div><div class="us-day" style="--h:18%"><span>토</span></div><div class="us-day" style="--h:70%"><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="totalCount">4</strong><span>보관함 영상</span></div><div class="us-stat"><strong id="waitCount">2</strong><span>업로드 대기</span></div><div class="us-stat"><strong>8</strong><span>최근 7일 발행</span></div><div class="us-stat"><strong>오늘 18:30</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="review" onclick="setFilter(this)">확인 필요</button><button class="us-tab" data-filter="done" 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" value="오늘 꼭 알아야 할 이슈 3가지" maxlength="100"></div>
|
||||
<div class="us-field"><label for="uploadDescription">설명</label><textarea id="uploadDescription">핵심만 빠르게 정리했습니다.\n\n#shorts #이슈</textarea></div>
|
||||
<div class="us-field"><label for="privacy">공개 상태</label><select id="privacy"><option>공개</option><option>일부 공개</option><option>비공개</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-toast" id="studioToast" role="status"></div>
|
||||
</div>
|
||||
|
||||
<th:block layout:fragment="script">
|
||||
<script>
|
||||
let filter='all', uploadMode='now', descending=true;
|
||||
let videos=[
|
||||
{id:1,name:'오늘 꼭 알아야 할 이슈 3가지.mp4',meta:'1080×1920 · 42초 · 86.4 MB',status:'wait',label:'업로드 대기'},
|
||||
{id:2,name:'출근길 뉴스 한입_0831.mp4',meta:'1080×1920 · 57초 · 104.8 MB',status:'review',label:'확인 필요'},
|
||||
{id:3,name:'인터뷰 핵심 장면.mov',meta:'2160×3840 · 38초 · 231.2 MB',status:'wait',label:'업로드 대기'},
|
||||
{id:4,name:'지난주 베스트 클립.mp4',meta:'1080×1920 · 51초 · 97.1 MB',status:'done',label:'업로드 완료'}
|
||||
];
|
||||
let selectedId=1;
|
||||
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==='review'?'!':'✓'} ${v.label}</span><span class="us-time">오늘 · ${v.id+9}:2${v.id}</span></button>`).join(''):'<div class="us-empty">조건에 맞는 영상이 없습니다.</div>';
|
||||
document.getElementById('totalCount').textContent=videos.length;
|
||||
document.getElementById('waitCount').textContent=videos.filter(v=>v.status==='wait').length;
|
||||
}
|
||||
function selectVideo(id){selectedId=id;const v=videos.find(x=>x.id===id);document.getElementById('panelTitle').textContent='영상 1개 선택됨';document.getElementById('uploadTitle').value=v.name.replace(/\.[^.]+$/,'').replaceAll('_',' ');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))found.push({id:Date.now()+found.length,name:entry.name,meta:'로컬 영상 · 분석 대기',status:'wait',label:'업로드 대기'});}videos=found;document.getElementById('folderTitle').textContent=dir.name;document.getElementById('folderPath').textContent='권한 연결됨 · '+found.length+'개 영상 발견';selectedId=found[0]?.id||null;renderVideos();showToast(found.length+'개 영상을 불러왔습니다.');return;}catch(e){if(e.name==='AbortError')return;}}
|
||||
document.getElementById('folderFallback').click();
|
||||
}
|
||||
document.getElementById('folderFallback').addEventListener('change',e=>{const files=[...e.target.files].filter(f=>f.type.startsWith('video/'));videos=files.map((f,i)=>({id:Date.now()+i,name:f.name,meta:(f.size/1048576).toFixed(1)+' MB · 로컬 영상',status:'wait',label:'업로드 대기'}));document.getElementById('folderTitle').textContent=e.target.files[0]?.webkitRelativePath.split('/')[0]||'선택한 폴더';document.getElementById('folderPath').textContent='권한 연결됨 · '+videos.length+'개 영상 발견';renderVideos();});
|
||||
function syncStudio(){showToast('YouTube 채널 기록을 동기화했습니다.');}
|
||||
function submitUpload(e){e.preventDefault();if(!selectedId){showToast('먼저 영상을 선택해 주세요.');return;}if(uploadMode==='schedule'&&!document.getElementById('scheduleAt').value){showToast('공개 예정 시간을 선택해 주세요.');return;}const v=videos.find(x=>x.id===selectedId);v.status=uploadMode==='schedule'?'done':'done';v.label=uploadMode==='schedule'?'예약됨':'업로드 중';renderVideos();showToast(uploadMode==='schedule'?'예약 업로드를 등록했습니다.':'업로드를 시작했습니다.');}
|
||||
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);}
|
||||
renderVideos();
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user