feat(discover): 제외 목록 보기 + 복원 기능
- 목록 API status 파라미터(NEW|EXCLUDED|REGISTERED), POST /{id}/restore(→NEW)
- recommend.html: 헤더 '제외 목록' 토글로 EXCLUDED 채널 보기, 카드에 '복원' 버튼
(제외 보기에선 등록/제외 대신 복원+채널이동만), 지역 탭은 그대로 적용
검증: 제외→EXCLUDED 목록 노출→복원→NEW 복귀 end-to-end 확인.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1d7d50f50b
commit
fd18a8981a
@ -21,9 +21,9 @@ public class RecommendedChannelController {
|
||||
private final ChannelService channelService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "추천 채널 목록", description = "status=NEW 를 배율 내림차순으로 반환")
|
||||
public ApiResponse<List<RecommendedChannel>> list() {
|
||||
return ApiResponse.ok(repository.findByStatusOrderByRatioDesc("NEW"));
|
||||
@Operation(summary = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환")
|
||||
public ApiResponse<List<RecommendedChannel>> list(@RequestParam(defaultValue = "NEW") String status) {
|
||||
return ApiResponse.ok(repository.findByStatusOrderByRatioDesc(status));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/register")
|
||||
@ -49,6 +49,17 @@ public class RecommendedChannelController {
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
@Operation(summary = "제외 복원", description = "EXCLUDED 를 다시 NEW(추천 목록)로 되돌린다")
|
||||
@Transactional
|
||||
public ApiResponse<Void> restore(@PathVariable Long id) {
|
||||
RecommendedChannel rc = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
|
||||
rc.setStatus("NEW");
|
||||
repository.save(rc);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
@PostMapping("/run")
|
||||
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행")
|
||||
public ApiResponse<Map<String, Object>> run() {
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
<p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" id="viewToggle" onclick="toggleView()"><i data-lucide="archive" style="width:15px;"></i> 제외 목록</button>
|
||||
<button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -54,12 +55,21 @@
|
||||
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
|
||||
|
||||
let ALL_RECS = [];
|
||||
let CUR_REGION = ''; // ''=전체
|
||||
let CUR_REGION = ''; // ''=전체
|
||||
let VIEW_STATUS = 'NEW'; // 'NEW'(추천) | 'EXCLUDED'(제외 목록)
|
||||
|
||||
async function load(){
|
||||
try { ALL_RECS = await api(API); renderGrid(); }
|
||||
try { ALL_RECS = await api(API + '?status=' + VIEW_STATUS); renderGrid(); }
|
||||
catch(e){ document.getElementById('grid').innerHTML='<div style="color:#f87171;">불러오기 실패: '+esc(e.message)+'</div>'; }
|
||||
}
|
||||
function toggleView(){
|
||||
VIEW_STATUS = (VIEW_STATUS === 'NEW') ? 'EXCLUDED' : 'NEW';
|
||||
const btn = document.getElementById('viewToggle');
|
||||
btn.innerHTML = (VIEW_STATUS === 'EXCLUDED')
|
||||
? '<i data-lucide="list" style="width:15px;"></i> 추천 목록'
|
||||
: '<i data-lucide="archive" style="width:15px;"></i> 제외 목록';
|
||||
load();
|
||||
}
|
||||
function setTab(btn){
|
||||
CUR_REGION = btn.dataset.region || '';
|
||||
document.querySelectorAll('.rc-tab').forEach(b=>{ const on=(b===btn); b.classList.toggle('btn-primary',on); b.classList.toggle('btn-secondary',!on); });
|
||||
@ -67,26 +77,36 @@
|
||||
}
|
||||
function renderGrid(){
|
||||
const grid=document.getElementById('grid');
|
||||
const excludedView = (VIEW_STATUS === 'EXCLUDED');
|
||||
const list = CUR_REGION ? ALL_RECS.filter(c=>c.region===CUR_REGION) : ALL_RECS;
|
||||
if(!list.length){ grid.innerHTML='<div class="text-muted">'+(CUR_REGION ? (CUR_REGION+' 지역 추천 채널이 없습니다.') : '아직 발굴된 추천 채널이 없습니다. ‘지금 발굴’을 눌러보세요.')+'</div>'; return; }
|
||||
grid.innerHTML=list.map(c=>
|
||||
'<div class="rc-card" id="rc-'+c.id+'">'
|
||||
+ '<img class="rc-thumb" style="cursor:pointer;" src="'+esc(c.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(c.topVideoId)+'" data-title="'+esc(c.topVideoTitle||c.channelTitle||'').replace(/"/g,'"')+'" onclick="openVideoModal(this)">'
|
||||
+ '<div class="rc-body">'
|
||||
+ '<div class="font-bold" style="line-height:1.4;">'+esc(c.channelTitle)+'</div>'
|
||||
+ '<div class="text-sm text-muted">👤 '+fmt(c.subscriberCount)+'명 · '+esc(c.region||'')+'</div>'
|
||||
+ '<div class="mt-2"><span class="rc-ratio">🔥 배율 '+(c.ratio?Number(c.ratio).toFixed(1):'-')+'x</span> <span class="text-sm text-muted">👁 '+fmt(c.topVideoViewCount)+'</span></div>'
|
||||
+ '<div class="text-sm text-muted mt-1" style="line-height:1.4;">'+esc(c.topVideoTitle||'')+'</div>'
|
||||
+ '<div class="rc-actions">'
|
||||
+ '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>'
|
||||
+ '<a class="btn btn-secondary px-3 py-2" href="https://www.youtube.com/channel/'+esc(c.channelId)+'" target="_blank" title="유튜브 채널로 이동"><i data-lucide="external-link" style="width:14px;"></i> 채널 이동</a>'
|
||||
+ '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>'
|
||||
+ '</div>'
|
||||
+ '</div></div>').join('');
|
||||
if(!list.length){
|
||||
grid.innerHTML='<div class="text-muted">'
|
||||
+ (excludedView ? '제외한 채널이 없습니다.'
|
||||
: (CUR_REGION ? (CUR_REGION+' 지역 추천 채널이 없습니다.') : '아직 발굴된 추천 채널이 없습니다. ‘지금 발굴’을 눌러보세요.'))
|
||||
+ '</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML=list.map(c=>{
|
||||
const moveBtn = '<a class="btn btn-secondary px-3 py-2" href="https://www.youtube.com/channel/'+esc(c.channelId)+'" target="_blank" title="유튜브 채널로 이동"><i data-lucide="external-link" style="width:14px;"></i> 채널 이동</a>';
|
||||
const actions = excludedView
|
||||
? '<button class="btn btn-primary px-3 py-2" onclick="restore('+c.id+')"><i data-lucide="rotate-ccw" style="width:14px;"></i> 복원</button>' + moveBtn
|
||||
: '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>' + moveBtn
|
||||
+ '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>';
|
||||
return '<div class="rc-card" id="rc-'+c.id+'">'
|
||||
+ '<img class="rc-thumb" style="cursor:pointer;" src="'+esc(c.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(c.topVideoId)+'" data-title="'+esc(c.topVideoTitle||c.channelTitle||'').replace(/"/g,'"')+'" onclick="openVideoModal(this)">'
|
||||
+ '<div class="rc-body">'
|
||||
+ '<div class="font-bold" style="line-height:1.4;">'+esc(c.channelTitle)+'</div>'
|
||||
+ '<div class="text-sm text-muted">👤 '+fmt(c.subscriberCount)+'명 · '+esc(c.region||'')+'</div>'
|
||||
+ '<div class="mt-2"><span class="rc-ratio">🔥 배율 '+(c.ratio?Number(c.ratio).toFixed(1):'-')+'x</span> <span class="text-sm text-muted">👁 '+fmt(c.topVideoViewCount)+'</span></div>'
|
||||
+ '<div class="text-sm text-muted mt-1" style="line-height:1.4;">'+esc(c.topVideoTitle||'')+'</div>'
|
||||
+ '<div class="rc-actions">'+actions+'</div>'
|
||||
+ '</div></div>';
|
||||
}).join('');
|
||||
if(window.lucide) lucide.createIcons();
|
||||
}
|
||||
async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('등록 실패: '+e.message); } }
|
||||
async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('제외 실패: '+e.message); } }
|
||||
async function restore(id){ try{ await api(API+'/'+id+'/restore',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('복원 실패: '+e.message); } }
|
||||
function openVideoModal(el){
|
||||
const vid = el.dataset.vid || '';
|
||||
document.getElementById('modalTitle').textContent = el.dataset.title || '';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user