"use client"; import { getAdminToken, consumeSseStream } from "@/lib/admin-utils"; import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import type { Channel, Video, VideoDetail, VideoLink } from "@/lib/api"; // #329 — admin/page.tsx에서 추출 type VideoSortKey = "status" | "channel_name" | "title" | "published_at"; export function VideosPanel({ isAdmin }: { isAdmin: boolean }) { const [videos, setVideos] = useState([]); const [channels, setChannels] = useState([]); const [channelFilter, setChannelFilter] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [titleSearch, setTitleSearch] = useState(""); const [processing, setProcessing] = useState(false); const [processResult, setProcessResult] = useState(""); const [sortKey, setSortKey] = useState("published_at"); const [sortAsc, setSortAsc] = useState(false); const [selected, setSelected] = useState>(new Set()); const [deleting, setDeleting] = useState(false); const [page, setPage] = useState(0); const perPage = 15; const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [fetchingTranscript, setFetchingTranscript] = useState(false); const [transcriptMode, setTranscriptMode] = useState<"auto" | "manual" | "generated">("auto"); const [extracting, setExtracting] = useState(false); const [showPrompt, setShowPrompt] = useState(false); const [prompt, setPrompt] = useState(""); const [editingTitle, setEditingTitle] = useState(false); const [editTitle, setEditTitle] = useState(""); const [editingRestIdx, setEditingRestIdx] = useState(null); const [editRest, setEditRest] = useState<{ name: string; cuisine_type: string; foods_mentioned: string; evaluation: string; address: string; region: string; price_range: string; guests: string; } | null>(null); const [saving, setSaving] = useState(false); const [showManualAdd, setShowManualAdd] = useState(false); const [manualForm, setManualForm] = useState({ name: "", address: "", region: "", cuisine_type: "", price_range: "", foods_mentioned: "", evaluation: "", guests: "" }); const [manualAdding, setManualAdding] = useState(false); const [bulkExtracting, setBulkExtracting] = useState(false); const [bulkTranscripting, setBulkTranscripting] = useState(false); const [rebuildingVectors, setRebuildingVectors] = useState(false); const [vectorProgress, setVectorProgress] = useState<{ phase: string; current: number; total: number; name?: string } | null>(null); const [remappingCuisine, setRemappingCuisine] = useState(false); const [remapProgress, setRemapProgress] = useState<{ current: number; total: number; updated: number } | null>(null); const [remappingFoods, setRemappingFoods] = useState(false); const [foodsProgress, setFoodsProgress] = useState<{ current: number; total: number; updated: number } | null>(null); const [bulkProgress, setBulkProgress] = useState<{ label: string; total: number; current: number; currentTitle: string; results: { title: string; detail: string; error?: boolean }[]; waiting?: number; } | null>(null); useEffect(() => { api.getChannels().then(setChannels).catch(console.error); }, []); const load = useCallback((reset = true) => { api .getVideos({ status: statusFilter || undefined }) .then((data) => { setVideos(data); if (reset) { setSelected(new Set()); setPage(0); } }) .catch(console.error); }, [statusFilter]); useEffect(() => { load(); }, [load]); const handleSelectVideo = async (v: Video) => { if (detail?.id === v.id) { setDetail(null); return; } setDetailLoading(true); try { const d = await api.getVideoDetail(v.id); setDetail(d); } catch { alert("영상 상세 조회 실패"); } finally { setDetailLoading(false); } }; const handleDelete = async (id: string, title: string) => { if (!confirm(`"${title}" 영상을 삭제하시겠습니까?\n연결된 식당 데이터도 함께 삭제됩니다.`)) return; try { await api.deleteVideo(id); load(); } catch { alert("영상 삭제 실패"); } }; const handleSkip = async (id: string) => { try { await api.skipVideo(id); load(); } catch { alert("건너뛰기 실패"); } }; const handleBulkSkip = async () => { if (selected.size === 0) return; if (!confirm(`선택한 ${selected.size}개 영상을 건너뛰시겠습니까?`)) return; for (const id of selected) { try { await api.skipVideo(id); } catch { /* ignore */ } } setSelected(new Set()); load(); }; const handleBulkDelete = async () => { if (selected.size === 0) return; if (!confirm(`선택한 ${selected.size}개 영상을 삭제하시겠습니까?\n연결된 식당 데이터도 함께 삭제됩니다.`)) return; setDeleting(true); let failed = 0; for (const id of selected) { try { await api.deleteVideo(id); } catch { failed++; } } setSelected(new Set()); load(); setDeleting(false); if (failed > 0) alert(`${failed}개 삭제 실패`); }; const toggleSelect = (id: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const handleProcess = async () => { setProcessing(true); setProcessResult(""); try { const res = await api.triggerProcessing(10); setProcessResult(`${res.restaurants_extracted}개 식당 추출 완료`); load(); } catch { setProcessResult("처리 실패"); } finally { setProcessing(false); } }; const startBulkStream = async (mode: "transcript" | "extract", ids?: string[]) => { const isTranscript = mode === "transcript"; const setRunning = isTranscript ? setBulkTranscripting : setBulkExtracting; const hasSelection = ids && ids.length > 0; try { let count: number; if (hasSelection) { count = ids.length; } else { const pending = isTranscript ? await api.getBulkTranscriptPending() : await api.getBulkExtractPending(); if (pending.count === 0) { alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다"); return; } count = pending.count; } const msg = isTranscript ? `${hasSelection ? "선택한 " : "자막 없는 "}영상 ${count}개의 트랜스크립트를 수집하시겠습니까?` : `${hasSelection ? "선택한 " : "LLM 추출이 안된 "}영상 ${count}개를 벌크 처리하시겠습니까?`; if (!confirm(msg)) return; setRunning(true); setBulkProgress({ label: isTranscript ? "벌크 자막 수집" : "벌크 LLM 추출", total: count, current: 0, currentTitle: "", results: [], }); const apiBase = process.env.NEXT_PUBLIC_API_URL || ""; const endpoint = isTranscript ? "/api/videos/bulk-transcript" : "/api/videos/bulk-extract"; const token = getAdminToken(); const headers: Record = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; const resp = await fetch(`${apiBase}${endpoint}`, { method: "POST", headers, body: hasSelection ? JSON.stringify({ ids }) : undefined, }); if (!resp.ok) { alert(`벌크 요청 실패: ${resp.status} ${resp.statusText}`); setRunning(false); setBulkProgress(null); return; } // #351 — consumeSseStream으로 통일 await consumeSseStream(resp, (raw) => { const ev = raw as { type: string; [k: string]: unknown }; if (ev.type === "processing") { setBulkProgress((p) => p ? { ...p, current: (ev.index as number) + 1, currentTitle: ev.title as string, waiting: undefined } : p); } else if (ev.type === "wait") { setBulkProgress((p) => p ? { ...p, waiting: ev.delay as number } : p); } else if (ev.type === "done") { const detail = isTranscript ? `${ev.source} / ${(ev.length as number)?.toLocaleString()}자` : `${ev.restaurants}개 식당`; setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title as string, detail }] } : p); } else if (ev.type === "error") { setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title as string, detail: ev.message as string, error: true }] } : p); } else if (ev.type === "complete") { setRunning(false); load(); } }); setRunning(false); load(); } catch { setRunning(false); } }; const startRebuildVectors = async () => { if (!confirm("전체 식당 벡터를 재생성합니다. 진행하시겠습니까?")) return; setRebuildingVectors(true); setVectorProgress(null); try { const apiBase = process.env.NEXT_PUBLIC_API_URL || ""; const token = getAdminToken(); const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; const resp = await fetch(`${apiBase}/api/videos/rebuild-vectors`, { method: "POST", headers }); if (!resp.ok) { alert(`벡터 재생성 실패: ${resp.status}`); setRebuildingVectors(false); return; } // #351 — consumeSseStream으로 통일 await consumeSseStream(resp, (raw) => { const ev = raw as { status?: string; type?: string; [k: string]: unknown }; if (ev.status === "progress" || ev.type === "progress") { setVectorProgress({ phase: ev.phase as string, current: ev.current as number, total: ev.total as number, name: ev.name as string }); } else if (ev.status === "done" || ev.type === "done") { setVectorProgress({ phase: "done", current: ev.total as number, total: ev.total as number }); } else if (ev.type === "error") { alert(`벡터 재생성 오류: ${ev.message}`); } }); setRebuildingVectors(false); } catch { setRebuildingVectors(false); } }; const startRemapCuisine = async () => { if (!confirm("전체 식당의 음식 종류를 LLM으로 재분류합니다. 진행하시겠습니까?")) return; setRemappingCuisine(true); setRemapProgress(null); try { const apiBase = process.env.NEXT_PUBLIC_API_URL || ""; const token = getAdminToken(); const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; const resp = await fetch(`${apiBase}/api/videos/remap-cuisine`, { method: "POST", headers }); if (!resp.ok) { alert(`음식 종류 재분류 실패: ${resp.status}`); setRemappingCuisine(false); return; } // #351 — consumeSseStream으로 통일 await consumeSseStream(resp, (raw) => { const ev = raw as { type: string; [k: string]: unknown }; if (ev.type === "processing" || ev.type === "batch_done") { setRemapProgress({ current: ev.current as number, total: ev.total as number, updated: (ev.updated as number) || 0 }); } else if (ev.type === "complete") { setRemapProgress({ current: ev.total as number, total: ev.total as number, updated: ev.updated as number }); } else if (ev.type === "error") { alert(`재분류 오류: ${ev.message}`); } }); setRemappingCuisine(false); } catch { setRemappingCuisine(false); } }; const startRemapFoods = async () => { if (!confirm("전체 식당의 메뉴 태그를 LLM으로 재생성합니다 (한글, 최대 10개). 진행하시겠습니까?")) return; setRemappingFoods(true); setFoodsProgress(null); try { const apiBase = process.env.NEXT_PUBLIC_API_URL || ""; const token = getAdminToken(); const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; const resp = await fetch(`${apiBase}/api/videos/remap-foods`, { method: "POST", headers }); if (!resp.ok) { alert(`메뉴 태그 재생성 실패: ${resp.status}`); setRemappingFoods(false); return; } // #351 — consumeSseStream으로 통일 await consumeSseStream(resp, (raw) => { const ev = raw as { type: string; [k: string]: unknown }; if (ev.type === "processing" || ev.type === "batch_done") { setFoodsProgress({ current: ev.current as number, total: ev.total as number, updated: (ev.updated as number) || 0 }); } else if (ev.type === "complete") { setFoodsProgress({ current: ev.total as number, total: ev.total as number, updated: ev.updated as number }); } else if (ev.type === "error") { alert(`메뉴 태그 재생성 오류: ${ev.message}`); } }); setRemappingFoods(false); } catch { setRemappingFoods(false); } }; const handleSort = (key: VideoSortKey) => { if (sortKey === key) { setSortAsc(!sortAsc); } else { setSortKey(key); setSortAsc(true); } }; const filteredVideos = videos.filter((v) => { if (titleSearch && !v.title.toLowerCase().includes(titleSearch.toLowerCase())) return false; if (channelFilter && v.channel_name !== channelFilter) return false; return true; }); const sortedVideos = [...filteredVideos].sort((a, b) => { const av = a[sortKey] ?? ""; const bv = b[sortKey] ?? ""; const cmp = av < bv ? -1 : av > bv ? 1 : 0; return sortAsc ? cmp : -cmp; }); const totalPages = Math.max(1, Math.ceil(sortedVideos.length / perPage)); const pagedVideos = sortedVideos.slice(page * perPage, (page + 1) * perPage); const toggleSelectAll = () => { const pageIds = pagedVideos.map((v) => v.id); const allSelected = pageIds.every((id) => selected.has(id)); if (allSelected) { setSelected((prev) => { const next = new Set(prev); pageIds.forEach((id) => next.delete(id)); return next; }); } else { setSelected((prev) => new Set([...prev, ...pageIds])); } }; const sortIcon = (key: VideoSortKey) => { if (sortKey !== key) return " ↕"; return sortAsc ? " ↑" : " ↓"; }; const statusColor: Record = { pending: "bg-yellow-100 text-yellow-800", processing: "bg-brand-100 text-brand-800", done: "bg-green-100 text-green-800", error: "bg-red-100 text-red-800", skip: "bg-gray-100 text-gray-600", }; return (
{ setTitleSearch(e.target.value); setPage(0); }} onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")} className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900" /> {titleSearch ? ( ) : ( )}
{isAdmin && <> } {processResult && ( {processResult} )} {isAdmin && selected.size > 0 && ( <> )} {(titleSearch || channelFilter) ? `${filteredVideos.length} / ` : ""}총 {videos.length}개
{isAdmin && } {pagedVideos.map((v) => ( {isAdmin && } ))} {videos.length === 0 && ( )}
0 && pagedVideos.every((v) => selected.has(v.id))} onChange={toggleSelectAll} className="rounded" /> handleSort("status")} > 상태{sortIcon("status")} handleSort("channel_name")} > 채널{sortIcon("channel_name")} handleSort("title")} > 제목{sortIcon("title")} 처리 식당 handleSort("published_at")} > 게시일{sortIcon("published_at")} 액션
toggleSelect(v.id)} className="rounded" /> {v.status} {v.channel_name} {v.has_transcript ? "T" : "-"} {v.has_llm ? "L" : "-"} {v.restaurant_count > 0 ? ( 0 ? "bg-yellow-100 text-yellow-700" : "bg-red-100 text-red-600" }`} title={`매칭 ${v.matched_count}/${v.restaurant_count}`} > {v.matched_count}/{v.restaurant_count} ) : ( - )} {v.published_at?.slice(0, 10) || "-"} {v.status === "pending" && ( )}
영상이 없습니다
{totalPages > 1 && (
{page + 1} / {totalPages}
)} {/* 음식종류 재분류 진행 */} {remapProgress && (

음식종류 재분류 {remapProgress.current >= remapProgress.total ? "완료" : "진행 중"}

{remapProgress.current}/{remapProgress.total} — {remapProgress.updated}개 업데이트

)} {/* 메뉴태그 재생성 진행 */} {foodsProgress && (

메뉴태그 재생성 {foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"}

{foodsProgress.current}/{foodsProgress.total} — {foodsProgress.updated}개 업데이트

)} {/* 벡터 재생성 진행 */} {vectorProgress && (

벡터 재생성 {vectorProgress.phase === "done" ? "완료" : `(${vectorProgress.phase === "prepare" ? "데이터 준비" : "임베딩 저장"})`}

{vectorProgress.current}/{vectorProgress.total} {vectorProgress.name && ` — ${vectorProgress.name}`}

)} {/* 벌크 진행 패널 */} {bulkProgress && (

{bulkProgress.label} ({bulkProgress.current}/{bulkProgress.total})

{!bulkExtracting && !bulkTranscripting && ( )}
0 ? (bulkProgress.current / bulkProgress.total) * 100 : 0}%` }} />
{(bulkExtracting || bulkTranscripting) && bulkProgress.currentTitle && (

{bulkProgress.waiting ? `⏳ ${bulkProgress.waiting}초 대기 중...` : `처리 중: ${bulkProgress.currentTitle}`}

)} {bulkProgress.results.length > 0 && (
{bulkProgress.results.map((r, i) => (
{r.error ? "✗" : "✓"} {r.title} {r.detail}
))}
)} {!bulkExtracting && !bulkTranscripting && bulkProgress.results.length > 0 && (

완료! 성공 {bulkProgress.results.filter((r) => !r.error).length}/{bulkProgress.total}

)}
)} {/* 영상 상세 패널 */} {detailLoading && (
로딩 중...
)} {detail && !detailLoading && (
{editingTitle ? (
setEditTitle(e.target.value)} className="flex-1 border rounded px-2 py-1 text-sm font-semibold" />
) : (

{ setEditTitle(detail.title); setEditingTitle(true); } : undefined} title={isAdmin ? "클릭하여 제목 수정" : undefined} > {detail.title}

)}
{/* 왼쪽: YouTube 임베드 */}