"use client"; import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import type { Channel, Video, VideoDetail, VideoLink, Restaurant } from "@/lib/api"; type Tab = "channels" | "videos" | "restaurants" | "users"; export default function AdminPage() { const [tab, setTab] = useState("channels"); return (

Tasteby Admin

← 메인으로
{tab === "channels" && } {tab === "videos" && } {tab === "restaurants" && } {tab === "users" && }
); } /* ─── 채널 관리 ─── */ function ChannelsPanel() { const [channels, setChannels] = useState([]); const [newId, setNewId] = useState(""); const [newName, setNewName] = useState(""); const [newFilter, setNewFilter] = useState(""); const [loading, setLoading] = useState(false); const [scanResult, setScanResult] = useState>({}); const load = useCallback(() => { api.getChannels().then(setChannels).catch(console.error); }, []); useEffect(() => { load(); }, [load]); const handleAdd = async () => { if (!newId.trim() || !newName.trim()) return; setLoading(true); try { await api.addChannel(newId.trim(), newName.trim(), newFilter.trim() || undefined); setNewId(""); setNewName(""); setNewFilter(""); load(); } catch (e: unknown) { alert(e instanceof Error ? e.message : "채널 추가 실패"); } finally { setLoading(false); } }; const handleDelete = async (channelId: string, channelName: string) => { if (!confirm(`"${channelName}" 채널을 삭제하시겠습니까?`)) return; try { await api.deleteChannel(channelId); load(); } catch { alert("채널 삭제 실패"); } }; const handleScan = async (channelId: string, full: boolean = false) => { setScanResult((prev) => ({ ...prev, [channelId]: full ? "전체 스캔 중..." : "스캔 중..." })); try { const res = await api.scanChannel(channelId, full); setScanResult((prev) => ({ ...prev, [channelId]: `${res.total_fetched}개 조회, ${res.new_videos}개 신규${(res as Record).filtered ? `, ${(res as Record).filtered}개 필터링` : ""}`, })); } catch { setScanResult((prev) => ({ ...prev, [channelId]: "스캔 실패" })); } }; return (

채널 추가

setNewId(e.target.value)} className="border rounded px-3 py-2 flex-1 text-sm" /> setNewName(e.target.value)} className="border rounded px-3 py-2 flex-1 text-sm" /> setNewFilter(e.target.value)} className="border rounded px-3 py-2 w-40 text-sm" />
{channels.map((ch) => ( ))} {channels.length === 0 && ( )}
채널 이름 Channel ID 제목 필터 액션 스캔 결과
{ch.channel_name} {ch.channel_id} {ch.title_filter ? ( {ch.title_filter} ) : ( 전체 )} {scanResult[ch.channel_id] || "-"}
등록된 채널이 없습니다
); } /* ─── 영상 관리 ─── */ type VideoSortKey = "status" | "channel_name" | "title" | "published_at"; function VideosPanel() { 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 [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, limit: 500 }) .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") => { const isTranscript = mode === "transcript"; const setRunning = isTranscript ? setBulkTranscripting : setBulkExtracting; try { const pending = isTranscript ? await api.getBulkTranscriptPending() : await api.getBulkExtractPending(); if (pending.count === 0) { alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다"); return; } const msg = isTranscript ? `자막 없는 영상 ${pending.count}개의 트랜스크립트를 수집하시겠습니까?\n(영상 당 5~15초 랜덤 딜레이)` : `LLM 추출이 안된 영상 ${pending.count}개를 벌크 처리하시겠습니까?\n(영상 당 3~8초 랜덤 딜레이)`; if (!confirm(msg)) return; setRunning(true); setBulkProgress({ label: isTranscript ? "벌크 자막 수집" : "벌크 LLM 추출", total: pending.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 resp = await fetch(`${apiBase}${endpoint}`, { method: "POST" }); const reader = resp.body?.getReader(); const decoder = new TextDecoder(); if (!reader) { setRunning(false); return; } let buf = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split("\n"); buf = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; try { const ev = JSON.parse(line.slice(6)); if (ev.type === "processing") { setBulkProgress((p) => p ? { ...p, current: ev.index + 1, currentTitle: ev.title, waiting: undefined } : p); } else if (ev.type === "wait") { setBulkProgress((p) => p ? { ...p, waiting: ev.delay } : p); } else if (ev.type === "done") { const detail = isTranscript ? `${ev.source} / ${ev.length?.toLocaleString()}자` : `${ev.restaurants}개 식당`; setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title, detail }] } : p); } else if (ev.type === "error") { setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title, detail: ev.message, error: true }] } : p); } else if (ev.type === "complete") { setRunning(false); load(); } } catch { /* ignore */ } } } setRunning(false); load(); } catch { setRunning(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-blue-100 text-blue-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" /> {titleSearch ? ( ) : ( )}
{processResult && ( {processResult} )} {selected.size > 0 && ( <> )} {(titleSearch || channelFilter) ? `${filteredVideos.length} / ` : ""}총 {videos.length}개
{pagedVideos.map((v) => ( ))} {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}
)} {/* 벌크 진행 패널 */} {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); }} title="클릭하여 제목 수정" > {detail.title}

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