h-lab/src/main/resources/templates/distribution-feed.html

68 lines
9.9 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></head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>유통 피드</h1>
<p class="sub">Facebook · Instagram · TikTok · YouTube 유통 채널의 새 영상을 모으고 텔레그램으로 알립니다.</p>
</div>
<button class="btn btn-primary" id="collectBtn" onclick="collectNow()">
<i data-lucide="refresh-cw" style="width:15px"></i> 지금 수집
</button>
</div>
<section class="card social-source-card">
<div class="social-source-head">
<div><h2>수집 채널</h2><p>채널이나 프로필 주소를 붙여넣으면 플랫폼을 자동 판별합니다.</p></div>
</div>
<div class="social-add">
<label class="sr-only" for="channelUrl">채널 URL</label>
<input id="channelUrl" type="url" placeholder="https://www.facebook.com/channel/reels/"
onkeydown="if(event.key==='Enter') addChannel()">
<label class="social-notify"><input id="notifyEnabled" type="checkbox" checked> 텔레그램 알림</label>
<button class="btn btn-secondary" id="addBtn" onclick="addChannel()">채널 등록</button>
</div>
<p class="social-help" id="addHelp">개별 영상 주소가 아닌 채널·프로필 주소를 입력하세요.</p>
<div id="channelList" class="social-channel-list"></div>
</section>
<div class="social-section-title">
<h2>수집 영상</h2><span id="videoCount" class="badge badge-muted">—</span>
</div>
<div id="videoGrid" class="social-video-grid"></div>
<div id="emptyState" class="social-empty hidden">
<i data-lucide="inbox"></i><b>아직 수집된 영상이 없습니다.</b>
<span>처음 수집한 목록은 기준점으로 저장되고, 그 다음 새 영상부터 텔레그램으로 알립니다.</span>
</div>
<div id="toast" class="social-toast" role="status"></div>
</div>
<th:block layout:fragment="styles">
<style>
.social-source-card{padding:1.1rem;margin-bottom:1.5rem}.social-source-head{display:flex;justify-content:space-between;gap:1rem;margin-bottom:.8rem}.social-source-head h2,.social-section-title h2{font-size:1rem;font-weight:700;margin:0}.social-source-head p{font-size:.8rem;color:var(--text-3);margin:.25rem 0 0}.social-add{display:flex;align-items:center;gap:.55rem;flex-wrap:wrap}.social-add input[type=url]{flex:1;min-width:280px}.social-notify{display:flex;align-items:center;gap:.4rem;font-size:.8rem;color:var(--text-2);white-space:nowrap}.social-notify input{width:16px;height:16px}.social-help{font-size:.72rem;color:var(--text-3);margin:.5rem 0 0}.social-channel-list{display:flex;flex-direction:column;gap:.45rem;margin-top:1rem}.social-channel{display:flex;align-items:center;gap:.65rem;border-top:1px solid var(--border);padding-top:.7rem}.social-platform{font-size:.65rem;font-weight:800;padding:.22rem .42rem;border-radius:99px;background:var(--inset);color:var(--text-2)}.social-platform.FACEBOOK{background:#1877f21a;color:#1877f2}.social-platform.INSTAGRAM{background:#c135841a;color:#c13584}.social-platform.TIKTOK{background:#1111;color:var(--text)}.social-platform.YOUTUBE{background:#f001;color:#e21}.social-channel-main{min-width:0;flex:1}.social-channel-main b{font-size:.84rem;display:block}.social-channel-main a,.social-channel-meta{font-size:.7rem;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}.social-channel-error{font-size:.7rem;color:var(--danger);margin-top:.15rem}.social-delete{border:1px solid var(--border);background:transparent;color:var(--text-3);border-radius:8px;width:34px;height:34px;cursor:pointer}.social-delete:hover{color:var(--danger);border-color:var(--danger)}.social-section-title{display:flex;align-items:center;gap:.55rem;margin-bottom:.75rem}.social-video-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(235px,1fr));gap:1rem}.social-video{overflow:hidden}.social-thumb{display:block;aspect-ratio:16/10;background:var(--inset);overflow:hidden}.social-thumb img{width:100%;height:100%;object-fit:cover;transition:transform .2s}.social-thumb:hover img{transform:scale(1.025)}.social-no-thumb{height:100%;display:grid;place-items:center;color:var(--text-3)}.social-video-body{padding:.8rem}.social-video-body h3{font-size:.86rem;line-height:1.4;margin:0 0 .55rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.social-video-body h3 a{color:var(--text);text-decoration:none}.social-video-meta{display:flex;align-items:center;justify-content:space-between;gap:.5rem;font-size:.7rem;color:var(--text-3)}.social-empty{padding:4rem 1rem;text-align:center;color:var(--text-3);display:flex;flex-direction:column;align-items:center;gap:.55rem}.social-empty svg{width:36px;height:36px}.social-empty b{color:var(--text-2)}.hidden{display:none!important}.social-toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,12px);opacity:0;background:#18181b;color:#fff;padding:.65rem 1rem;border-radius:9px;font-size:.8rem;pointer-events:none;transition:.2s;z-index:50}.social-toast.show{opacity:1;transform:translate(-50%,0)}.social-toast.err{background:var(--danger)}
@media(max-width:680px){.social-add{align-items:stretch}.social-add input[type=url]{min-width:100%;width:100%}.social-add .btn{margin-left:auto}}
</style>
</th:block>
<th:block layout:fragment="scripts">
<script>
const API='/api/social-feed';
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
async function api(url,options){const r=await fetch(url,options);const j=await r.json().catch(()=>({}));if(!r.ok||j.success===false)throw new Error(j.message||`HTTP ${r.status}`);return j.data}
let toastTimer;function toast(message,error){const el=document.getElementById('toast');el.textContent=message;el.classList.toggle('err',!!error);el.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),3500)}
function ago(iso){if(!iso)return '수집 대기';const ms=Date.now()-new Date(iso).getTime(),min=Math.max(0,Math.floor(ms/60000));if(min<60)return `${min}분 전`;const h=Math.floor(min/60);if(h<24)return `${h}시간 전`;return `${Math.floor(h/24)}일 전`}
function date(iso){return iso?new Date(iso).toLocaleString('ko-KR',{month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}):'게시 시각 미상'}
async function loadChannels(){const box=document.getElementById('channelList');try{const list=await api(`${API}/channels?topic=DISTRIBUTION`);box.innerHTML=list.length?list.map(c=>`<div class="social-channel"><span class="social-platform ${c.platform}">${c.platform}</span><div class="social-channel-main"><b>${esc(c.title)}</b><a href="${esc(c.sourceUrl)}" target="_blank" rel="noopener">${esc(c.sourceUrl)}</a><span class="social-channel-meta">${ago(c.lastCollectedAt)} 수집 · 신규 ${c.lastFound}건 · 알림 ${c.notificationEnabled?'켜짐':'꺼짐'}</span>${c.lastError?`<div class="social-channel-error">최근 오류: ${esc(c.lastError)}</div>`:''}</div><button class="social-delete" title="채널 삭제" onclick="removeChannel(${c.id})">×</button></div>`).join(''):'<p class="social-help">등록된 채널이 없습니다.</p>'}catch(e){box.innerHTML=`<div class="social-channel-error">채널을 불러오지 못했습니다: ${esc(e.message)}</div>`}}
async function loadVideos(){const grid=document.getElementById('videoGrid'),empty=document.getElementById('emptyState');try{const list=await api(`${API}/videos?topic=DISTRIBUTION`);document.getElementById('videoCount').textContent=`${list.length}건`;empty.classList.toggle('hidden',list.length>0);grid.innerHTML=list.map(v=>`<article class="card social-video"><a class="social-thumb" href="${esc(v.url)}" target="_blank" rel="noopener">${v.thumbnailUrl?`<img src="${esc(v.thumbnailUrl)}" alt="" loading="lazy">`:'<span class="social-no-thumb"><i data-lucide="play"></i></span>'}</a><div class="social-video-body"><h3><a href="${esc(v.url)}" target="_blank" rel="noopener">${esc(v.title)}</a></h3><div class="social-video-meta"><span>${esc(v.channelTitle)} · ${v.platform}</span><span>${date(v.publishedAt)}</span></div></div></article>`).join('');if(window.lucide)lucide.createIcons()}catch(e){toast('영상을 불러오지 못했습니다: '+e.message,true)}}
async function addChannel(){const input=document.getElementById('channelUrl'),button=document.getElementById('addBtn'),url=input.value.trim();if(!url){input.focus();return}button.disabled=true;try{const c=await api(`${API}/channels`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,topic:'DISTRIBUTION',notificationEnabled:document.getElementById('notifyEnabled').checked})});input.value='';toast(`${c.title} 채널을 등록했습니다`);await loadChannels()}catch(e){toast('등록 실패: '+e.message,true)}finally{button.disabled=false}}
async function removeChannel(id){if(!confirm('이 채널과 수집 영상을 삭제할까요?'))return;try{await api(`${API}/channels/${id}`,{method:'DELETE'});toast('채널을 삭제했습니다');await Promise.all([loadChannels(),loadVideos()])}catch(e){toast('삭제 실패: '+e.message,true)}}
async function collectNow(){const button=document.getElementById('collectBtn'),original=button.innerHTML;button.disabled=true;button.textContent='수집 중…';try{const r=await api(`${API}/collect?topic=DISTRIBUTION`,{method:'POST'});toast(`수집 완료 · 새 영상 ${r.savedVideos}건 · 알림 ${r.alerted}건${r.failed?` · 실패 ${r.failed}개`:''}`,r.failed>0);await Promise.all([loadChannels(),loadVideos()])}catch(e){toast('수집 실패: '+e.message,true)}finally{button.disabled=false;button.innerHTML=original;if(window.lucide)lucide.createIcons()}}
Promise.all([loadChannels(),loadVideos()]);
</script>
</th:block>
</body>
</html>