feat(discover): add discovery page for finding rework candidates
New /discover page surfaces collected ChannelVideos for picking rework targets, filtered by period/min-ratio/source/shorts/unprocessed and sorted by ratio/velocity/views/recency (EXCLUDED always hidden, null ratios sorted last). Reuses existing curation endpoints for row actions (status, bookmark). - ChannelVideoRepository.discover() JPQL query - ChannelVideoCurationService.discover() (defaults, limit cap, nullsLast) - GET /api/v1/channel-videos/discover endpoint - /discover page route + discover.html + sidebar link Verified by clean compileJava.
This commit is contained in:
parent
c69a02dae8
commit
f232d20f46
@ -11,7 +11,9 @@
|
|||||||
"Bash(./gradlew.bat bootRun --console=plain)",
|
"Bash(./gradlew.bat bootRun --console=plain)",
|
||||||
"Bash(echo \"bootRun started in background, PID $!\")",
|
"Bash(echo \"bootRun started in background, PID $!\")",
|
||||||
"Bash(git -c user.name=\"hehih\" -c user.email=\"hehihoho86@gmail.com\" commit -q -m \"docs: add discovery page design spec\")",
|
"Bash(git -c user.name=\"hehih\" -c user.email=\"hehihoho86@gmail.com\" commit -q -m \"docs: add discovery page design spec\")",
|
||||||
"Bash(git -c user.name=\"hehih\" -c user.email=\"hehihoho86@gmail.com\" commit -q -m \"docs: add discovery page implementation plan\")"
|
"Bash(git -c user.name=\"hehih\" -c user.email=\"hehihoho86@gmail.com\" commit -q -m \"docs: add discovery page implementation plan\")",
|
||||||
|
"PowerShell($env:JAVA_HOME=\"D:\\\\Development\\\\app\\\\JDK\\\\jdk-21.0.5\"; .\\\\gradlew.bat clean compileJava --console=plain 2>&1 | Select-Object -Last 12)",
|
||||||
|
"Bash(git -c user.name=hehih -c user.email=hehihoho86@gmail.com commit -q -m 'feat\\(discover\\): add discovery page for finding rework candidates *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,22 @@ public class ChannelVideoCurationController {
|
|||||||
return ApiResponse.ok(curationService.findOutperformers(limit, minRatio));
|
return ApiResponse.ok(curationService.findOutperformers(limit, minRatio));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/discover")
|
||||||
|
@Operation(summary = "발굴(Discovery) 조회",
|
||||||
|
description = "필터: periodDays(최근 N일), minRatio(배율 하한), shortsOnly, source(CHANNEL|SEARCH), "
|
||||||
|
+ "unprocessedOnly(NEW/REVIEWING만). 정렬 sortBy(기본 viewsPerSubRatio↓), limit(기본100). "
|
||||||
|
+ "EXCLUDED 는 항상 제외.")
|
||||||
|
public ApiResponse<List<ChannelVideo>> discover(
|
||||||
|
@RequestParam(required = false) Integer periodDays,
|
||||||
|
@RequestParam(required = false) java.math.BigDecimal minRatio,
|
||||||
|
@RequestParam(defaultValue = "false") boolean shortsOnly,
|
||||||
|
@RequestParam(required = false) String source,
|
||||||
|
@RequestParam(defaultValue = "false") boolean unprocessedOnly,
|
||||||
|
@RequestParam(required = false) String sortBy,
|
||||||
|
@RequestParam(required = false) Integer limit) {
|
||||||
|
return ApiResponse.ok(curationService.discover(periodDays, minRatio, shortsOnly, source, unprocessedOnly, sortBy, limit));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/backfill")
|
@PostMapping("/backfill")
|
||||||
@Operation(summary = "기존 수집 영상 지표 백필",
|
@Operation(summary = "기존 수집 영상 지표 백필",
|
||||||
description = "새 컬럼 추가 이전에 수집된 영상의 파생 지표/큐레이션 기본값을 재계산(외부 API 호출 없음, 재실행 안전).")
|
description = "새 컬럼 추가 이전에 수집된 영상의 파생 지표/큐레이션 기본값을 재계산(외부 API 호출 없음, 재실행 안전).")
|
||||||
|
|||||||
@ -129,6 +129,29 @@ public class ChannelVideoCurationService {
|
|||||||
org.springframework.data.domain.PageRequest.of(0, size));
|
org.springframework.data.domain.PageRequest.of(0, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발굴(Discovery) 조회 — 필터 + 정렬 + 개수 제한.
|
||||||
|
* @param periodDays 최근 N일만 (null/0이면 전체)
|
||||||
|
* @param minRatio viewsPerSubRatio 하한 (null이면 미적용)
|
||||||
|
* @param shortsOnly Shorts만
|
||||||
|
* @param source CHANNEL | SEARCH (빈값이면 전체)
|
||||||
|
* @param unprocessedOnly true면 NEW/REVIEWING 만
|
||||||
|
* @param sortBy 정렬 필드(ALLOWED_SORT, 기본 viewsPerSubRatio 내림차순, null은 항상 마지막)
|
||||||
|
* @param limit 최대 개수(기본 100, 최대 200)
|
||||||
|
*/
|
||||||
|
public List<ChannelVideo> discover(Integer periodDays, java.math.BigDecimal minRatio,
|
||||||
|
boolean shortsOnly, String source, boolean unprocessedOnly,
|
||||||
|
String sortBy, Integer limit) {
|
||||||
|
java.time.LocalDateTime publishedAfter =
|
||||||
|
(periodDays == null || periodDays <= 0) ? null : java.time.LocalDateTime.now().minusDays(periodDays);
|
||||||
|
String sourceFilter = StringUtils.hasText(source) ? source : null;
|
||||||
|
String sortField = StringUtils.hasText(sortBy) && ALLOWED_SORT.contains(sortBy) ? sortBy : "viewsPerSubRatio";
|
||||||
|
int size = (limit == null || limit <= 0) ? 100 : Math.min(limit, 200);
|
||||||
|
Sort sort = Sort.by(Sort.Order.desc(sortField).nullsLast());
|
||||||
|
return channelVideoRepository.discover(publishedAfter, minRatio, sourceFilter, shortsOnly, unprocessedOnly,
|
||||||
|
org.springframework.data.domain.PageRequest.of(0, size, sort));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기존 수집 영상의 파생 지표/큐레이션 기본값 백필.
|
* 기존 수집 영상의 파생 지표/큐레이션 기본값 백필.
|
||||||
* 새 컬럼 추가 이전에 수집된 행들은 값이 null 이라 통계/필터/떡상 발굴에 잡히지 않으므로,
|
* 새 컬럼 추가 이전에 수집된 행들은 값이 null 이라 통계/필터/떡상 발굴에 잡히지 않으므로,
|
||||||
|
|||||||
@ -41,4 +41,27 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
|||||||
@Param("shortsOnly") boolean shortsOnly,
|
@Param("shortsOnly") boolean shortsOnly,
|
||||||
@Param("bookmarkedOnly") boolean bookmarkedOnly,
|
@Param("bookmarkedOnly") boolean bookmarkedOnly,
|
||||||
org.springframework.data.domain.Sort sort);
|
org.springframework.data.domain.Sort sort);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발굴(Discovery) 조회. EXCLUDED 는 항상 제외. null/false 조건은 무시.
|
||||||
|
* 정렬·개수 제한은 호출부의 Pageable(sort + size)로 처리한다.
|
||||||
|
* @param publishedAfter 이 시각 이후 업로드분만 (null이면 전체)
|
||||||
|
* @param minRatio viewsPerSubRatio 하한 (null이면 미적용; null 배율 행은 자동 제외)
|
||||||
|
* @param shortsOnly true면 Shorts만
|
||||||
|
* @param source CHANNEL | SEARCH (null이면 전체)
|
||||||
|
* @param unprocessedOnly true면 상태가 NEW/REVIEWING 인 것만
|
||||||
|
*/
|
||||||
|
@Query("select v from ChannelVideo v where "
|
||||||
|
+ "v.interestStatus <> 'EXCLUDED' and "
|
||||||
|
+ "(:publishedAfter is null or v.publishedAt >= :publishedAfter) and "
|
||||||
|
+ "(:minRatio is null or v.viewsPerSubRatio >= :minRatio) and "
|
||||||
|
+ "(:source is null or v.source = :source) and "
|
||||||
|
+ "(:shortsOnly = false or v.isShorts = true) and "
|
||||||
|
+ "(:unprocessedOnly = false or v.interestStatus in ('NEW','REVIEWING'))")
|
||||||
|
java.util.List<ChannelVideo> discover(@Param("publishedAfter") java.time.LocalDateTime publishedAfter,
|
||||||
|
@Param("minRatio") java.math.BigDecimal minRatio,
|
||||||
|
@Param("source") String source,
|
||||||
|
@Param("shortsOnly") boolean shortsOnly,
|
||||||
|
@Param("unprocessedOnly") boolean unprocessedOnly,
|
||||||
|
org.springframework.data.domain.Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,12 @@ public class WebController {
|
|||||||
return "board";
|
return "board";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/discover")
|
||||||
|
public String discover(Model model) {
|
||||||
|
model.addAttribute("currentPage", "discover");
|
||||||
|
return "discover";
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/publish")
|
@GetMapping("/publish")
|
||||||
public String publish(Model model) {
|
public String publish(Model model) {
|
||||||
model.addAttribute("currentPage", "publish");
|
model.addAttribute("currentPage", "publish");
|
||||||
|
|||||||
225
src/main/resources/templates/discover.html
Normal file
225
src/main/resources/templates/discover.html
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<!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">
|
||||||
|
<header class="mb-4">
|
||||||
|
<h1 class="text-2xl font-bold mb-2">발굴 (Discovery)</h1>
|
||||||
|
<p class="text-muted">수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 필터 바 -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end;">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
|
||||||
|
<label class="text-sm text-muted">기간</label>
|
||||||
|
<select id="fPeriod" onchange="loadVideos()" class="filter-sel">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="7">최근 7일</option>
|
||||||
|
<option value="14">최근 14일</option>
|
||||||
|
<option value="30">최근 30일</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
|
||||||
|
<label class="text-sm text-muted">최소 배율</label>
|
||||||
|
<select id="fMinRatio" onchange="loadVideos()" class="filter-sel">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="2">≥ 2x</option>
|
||||||
|
<option value="3">≥ 3x</option>
|
||||||
|
<option value="5">≥ 5x</option>
|
||||||
|
</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="CHANNEL">채널 수집</option>
|
||||||
|
<option value="SEARCH">검색 수집</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:4px; min-width:160px;">
|
||||||
|
<label class="text-sm text-muted">정렬</label>
|
||||||
|
<select id="fSort" onchange="loadVideos()" class="filter-sel">
|
||||||
|
<option value="viewsPerSubRatio">배율(조회/구독) ↓</option>
|
||||||
|
<option value="viewsPerHour">시간당 조회수 ↓</option>
|
||||||
|
<option value="viewCount">조회수 ↓</option>
|
||||||
|
<option value="publishedAt">최신순 ↓</option>
|
||||||
|
</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 class="flex items-center gap-2 cursor-pointer" style="height:42px;">
|
||||||
|
<input type="checkbox" id="fUnprocessed" onchange="loadVideos()"> <span class="text-sm">미처리만(NEW/검토중)</span>
|
||||||
|
</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 class="card p-0" style="overflow-x:auto;">
|
||||||
|
<table class="w-full" style="border-collapse:collapse; text-align:left;">
|
||||||
|
<thead style="background:rgba(255,255,255,0.02); border-bottom:1px solid var(--glass-border);">
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<th class="p-3 text-sm font-bold text-muted">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="resultBody">
|
||||||
|
<tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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 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;">
|
||||||
|
<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;">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="iframeContainer" style="width:100%; aspect-ratio:9/16; max-height:70vh; border-radius:8px; overflow:hidden; background:#000; margin:0 auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.filter-sel { 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; }
|
||||||
|
.filter-sel option { background:#1e1e2d; color:white; }
|
||||||
|
.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:#1e1e2d; color:white; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script th:inline="javascript">
|
||||||
|
/*<![CDATA[*/
|
||||||
|
const API = '/api/v1/channel-videos';
|
||||||
|
|
||||||
|
const STATUS_LABEL = { NEW:'NEW', REVIEWING:'검토중', TARGET:'작업대상', DONE:'완료', EXCLUDED:'제외' };
|
||||||
|
const STATUS_KEYS = ['NEW','REVIEWING','TARGET','DONE','EXCLUDED'];
|
||||||
|
|
||||||
|
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
function fmt(n){ return (n==null) ? '-' : Number(n).toLocaleString(); }
|
||||||
|
function fmtDate(s){ return s ? String(s).substring(0,10) : '-'; }
|
||||||
|
|
||||||
|
async function api(url, opts){
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const json = await res.json().catch(()=>({}));
|
||||||
|
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVideos(){
|
||||||
|
const body = document.getElementById('resultBody');
|
||||||
|
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>';
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
const period = document.getElementById('fPeriod').value;
|
||||||
|
const minRatio = document.getElementById('fMinRatio').value;
|
||||||
|
const src = document.getElementById('fSource').value;
|
||||||
|
if(period) p.set('periodDays', period);
|
||||||
|
if(minRatio) p.set('minRatio', minRatio);
|
||||||
|
if(src) p.set('source', src);
|
||||||
|
if(document.getElementById('fShorts').checked) p.set('shortsOnly','true');
|
||||||
|
if(document.getElementById('fUnprocessed').checked) p.set('unprocessedOnly','true');
|
||||||
|
p.set('sortBy', document.getElementById('fSort').value);
|
||||||
|
p.set('limit', '100');
|
||||||
|
let data;
|
||||||
|
try { data = await api(API + '/discover?' + p.toString()); }
|
||||||
|
catch(e){
|
||||||
|
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderRows(data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioBadge(r){
|
||||||
|
if(r==null) return '<span class="text-muted">-</span>';
|
||||||
|
const v = Number(r);
|
||||||
|
let color = '#10b981';
|
||||||
|
if(v>=10) color = '#ef4444'; else if(v>=2) color = '#f59e0b';
|
||||||
|
return `<span style="color:${color}; font-weight:bold;">${v.toFixed(1)}x</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(list){
|
||||||
|
const body = document.getElementById('resultBody');
|
||||||
|
document.getElementById('resultCount').textContent = list.length + '건';
|
||||||
|
if(list.length===0){
|
||||||
|
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">조건에 맞는 발굴 후보가 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = list.map(v=>{
|
||||||
|
const statusOpts = STATUS_KEYS.map(k=>
|
||||||
|
`<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join('');
|
||||||
|
const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)';
|
||||||
|
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 style="border-bottom:1px solid var(--glass-border);">
|
||||||
|
<td class="p-3">
|
||||||
|
<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)}" style="width:96px; height:54px; object-fit:cover; border-radius:6px;">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-3" style="max-width:280px;">
|
||||||
|
<div class="font-bold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">
|
||||||
|
<a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline text-white">${esc(v.title)}</a>${shortsBadge}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-sm text-muted" style="max-width:130px;">
|
||||||
|
<a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:#e2e8f0;">${esc(v.channelTitle||'-')}</a>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-sm">${fmt(v.subscriberCount)}</td>
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
if(window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStatus(id, value){
|
||||||
|
try { await api(API+'/'+id+'/status', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({status:value})}); }
|
||||||
|
catch(e){ alert('상태 변경 실패: '+e.message); }
|
||||||
|
}
|
||||||
|
async function toggleBookmark(id, current){
|
||||||
|
try { await api(API+'/'+id+'/bookmark', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({bookmarked: !current})}); loadVideos(); }
|
||||||
|
catch(e){ alert('북마크 실패: '+e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVideoModal(vid, title){
|
||||||
|
document.getElementById('modalTitle').textContent = title;
|
||||||
|
document.getElementById('iframeContainer').innerHTML = '<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+vid+'?autoplay=1" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
|
||||||
|
document.getElementById('videoModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
function closeVideoModal(){
|
||||||
|
document.getElementById('iframeContainer').innerHTML = '';
|
||||||
|
document.getElementById('videoModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadVideos();
|
||||||
|
/*]]>*/
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@ -57,6 +57,13 @@
|
|||||||
<span class="nav-text">수집함</span>
|
<span class="nav-text">수집함</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<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>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a th:href="@{/board}" class="nav-item"
|
<a th:href="@{/board}" class="nav-item"
|
||||||
th:classappend="${currentPage == 'board'} ? 'active'">
|
th:classappend="${currentPage == 'board'} ? 'active'">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user