Files
tasteby/frontend/src/app/admin/_panels/VideosPanel.tsx
joungmin d6ee62230e refactor(admin): #351 SSE 6곳 consumeSseStream으로 통일
VideosPanel:
- bulkTranscript/bulkExtract: 단일 SSE 핸들러 → consumeSseStream
- rebuildVectors: consumeSseStream
- remapCuisine / remapFoods: consumeSseStream

RestaurantsPanel:
- bulkTabling / bulkCatchtable: consumeSseStream

이전: 각 호출이 자체적으로 reader+decoder+buf.split+match 6곳 복제.
이제: lib/admin-utils.ts의 consumeSseStream(resp, onEvent)으로 일관 처리.

빌드 + npm test 13/13 통과. 회귀 없음.

Refs: #351
2026-06-15 17:15:35 +09:00

1231 lines
57 KiB
TypeScript
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.
"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<Video[]>([]);
const [channels, setChannels] = useState<Channel[]>([]);
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<VideoSortKey>("published_at");
const [sortAsc, setSortAsc] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [deleting, setDeleting] = useState(false);
const [page, setPage] = useState(0);
const perPage = 15;
const [detail, setDetail] = useState<VideoDetail | null>(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<number | null>(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<string, string> = { "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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {
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 (
<div>
<div className="flex flex-wrap items-center gap-3 mb-4">
<select
value={channelFilter}
onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }}
className="border rounded px-3 py-2 text-sm bg-surface text-gray-900"
>
<option value=""> </option>
{channels.map((ch) => (
<option key={ch.id} value={ch.channel_name}>{ch.channel_name}</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="border rounded px-3 py-2 text-sm bg-surface text-gray-900"
>
<option value=""> </option>
<option value="pending"></option>
<option value="processing"></option>
<option value="done"></option>
<option value="error"></option>
<option value="skip"></option>
</select>
<div className="flex">
<input
type="text"
placeholder="제목 검색..."
value={titleSearch}
onChange={(e) => { 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 ? (
<button
onClick={() => { setTitleSearch(""); setPage(0); }}
className="border rounded-r px-2 py-2 text-sm text-gray-400 hover:text-gray-600 hover:bg-gray-100"
>
</button>
) : (
<button
type="button"
onClick={() => { setEditingRestIdx(null); setEditRest(null); load(false); if (detail) { api.getVideoDetail(detail.id).then(setDetail).catch(() => {}); } }}
className="border rounded-r px-3 py-2 text-sm text-gray-400 hover:text-gray-600 hover:bg-gray-100 cursor-pointer relative z-10"
title="새로고침"
>
</button>
)}
</div>
{isAdmin && <>
<button
type="button"
onClick={handleProcess}
disabled={processing}
className="bg-green-600 text-white px-4 py-2 rounded text-sm hover:bg-green-700 disabled:opacity-50"
>
{processing ? "처리 중..." : "대기 영상 처리"}
</button>
<button
onClick={() => startBulkStream("transcript")}
disabled={bulkTranscripting || bulkExtracting}
className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
>
{bulkTranscripting ? "자막 수집 중..." : "벌크 자막 수집"}
</button>
<button
onClick={() => startBulkStream("extract")}
disabled={bulkExtracting || bulkTranscripting}
className="bg-purple-600 text-white px-4 py-2 rounded text-sm hover:bg-purple-700 disabled:opacity-50"
>
{bulkExtracting ? "벌크 추출 중..." : "벌크 LLM 추출"}
</button>
<button
onClick={startRebuildVectors}
disabled={rebuildingVectors || bulkExtracting || bulkTranscripting}
className="bg-teal-600 text-white px-4 py-2 rounded text-sm hover:bg-teal-700 disabled:opacity-50"
>
{rebuildingVectors ? "벡터 재생성 중..." : "벡터 재생성"}
</button>
<button
onClick={startRemapCuisine}
disabled={remappingCuisine || bulkExtracting || bulkTranscripting || rebuildingVectors}
className="bg-amber-600 text-white px-4 py-2 rounded text-sm hover:bg-amber-700 disabled:opacity-50"
>
{remappingCuisine ? "음식분류 중..." : "음식종류 재분류"}
</button>
<button
onClick={startRemapFoods}
disabled={remappingFoods || bulkExtracting || bulkTranscripting || rebuildingVectors || remappingCuisine}
className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
>
{remappingFoods ? "메뉴태그 재생성 중..." : "메뉴태그 재생성"}
</button>
</>}
{processResult && (
<span className="text-sm text-gray-600">{processResult}</span>
)}
{isAdmin && selected.size > 0 && (
<>
<button
onClick={() => startBulkStream("transcript", Array.from(selected))}
disabled={bulkTranscripting || bulkExtracting}
className="bg-brand-500 text-white px-4 py-2 rounded text-sm hover:bg-brand-600 disabled:opacity-50"
>
({selected.size})
</button>
<button
onClick={() => startBulkStream("extract", Array.from(selected))}
disabled={bulkExtracting || bulkTranscripting}
className="bg-purple-500 text-white px-4 py-2 rounded text-sm hover:bg-purple-600 disabled:opacity-50"
>
LLM ({selected.size})
</button>
<button
onClick={handleBulkSkip}
className="bg-gray-500 text-white px-4 py-2 rounded text-sm hover:bg-gray-600"
>
({selected.size})
</button>
<button
onClick={handleBulkDelete}
disabled={deleting}
className="bg-red-500 text-white px-4 py-2 rounded text-sm hover:bg-red-600 disabled:opacity-50"
>
{deleting ? "삭제 중..." : `선택 삭제 (${selected.size})`}
</button>
</>
)}
<span className="text-sm text-gray-400 ml-auto">
{(titleSearch || channelFilter) ? `${filteredVideos.length} / ` : ""} {videos.length}
</span>
</div>
<div className="bg-surface rounded-lg shadow overflow-auto min-w-[800px]">
<table className="w-full text-sm">
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
<tr>
<th className="px-4 py-3 w-8">
<input
type="checkbox"
checked={pagedVideos.length > 0 && pagedVideos.every((v) => selected.has(v.id))}
onChange={toggleSelectAll}
className="rounded"
/>
</th>
<th
className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100"
onClick={() => handleSort("status")}
>
{sortIcon("status")}
</th>
<th
className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100"
onClick={() => handleSort("channel_name")}
>
{sortIcon("channel_name")}
</th>
<th
className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100"
onClick={() => handleSort("title")}
>
{sortIcon("title")}
</th>
<th className="text-center px-4 py-3 whitespace-nowrap"></th>
<th className="text-center px-4 py-3 whitespace-nowrap"></th>
<th
className="text-left px-4 py-3 whitespace-nowrap cursor-pointer select-none hover:bg-gray-100"
onClick={() => handleSort("published_at")}
>
{sortIcon("published_at")}
</th>
{isAdmin && <th className="text-left px-4 py-3 whitespace-nowrap"></th>}
</tr>
</thead>
<tbody>
{pagedVideos.map((v) => (
<tr key={v.id} className={`border-b hover:bg-brand-50/50 ${selected.has(v.id) ? "bg-brand-50" : ""}`}>
<td className="px-4 py-3">
<input
type="checkbox"
checked={selected.has(v.id)}
onChange={() => toggleSelect(v.id)}
className="rounded"
/>
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded text-xs font-medium ${
statusColor[v.status] || "bg-gray-100"
}`}
>
{v.status}
</span>
</td>
<td className="px-4 py-3 text-gray-600">{v.channel_name}</td>
<td className="px-4 py-3 max-w-[300px]">
<button
onClick={() => handleSelectVideo(v)}
className={`text-left text-sm hover:underline truncate block max-w-full ${
detail?.id === v.id ? "text-blue-800 font-semibold" : "text-brand-600"
}`}
title={v.title}
>
{v.title}
</button>
</td>
<td className="px-4 py-3 text-center whitespace-nowrap">
<span title="자막" className={`inline-block w-5 text-center text-xs ${v.has_transcript ? "text-green-600" : "text-gray-300"}`}>
{v.has_transcript ? "T" : "-"}
</span>
<span title="LLM 추출" className={`inline-block w-5 text-center text-xs ${v.has_llm ? "text-brand-600" : "text-gray-300"}`}>
{v.has_llm ? "L" : "-"}
</span>
</td>
<td className="px-4 py-3 text-center">
{v.restaurant_count > 0 ? (
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
v.matched_count === v.restaurant_count
? "bg-green-100 text-green-700"
: v.matched_count > 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}
</span>
) : (
<span className="text-gray-300 text-xs">-</span>
)}
</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">
{v.published_at?.slice(0, 10) || "-"}
</td>
{isAdmin && <td className="px-4 py-3 whitespace-nowrap flex gap-3">
{v.status === "pending" && (
<button
onClick={() => handleSkip(v.id)}
className="text-gray-500 hover:underline text-sm"
>
</button>
)}
<button
onClick={() => handleDelete(v.id, v.title)}
className="text-red-500 hover:underline text-sm"
>
</button>
</td>}
</tr>
))}
{videos.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4">
<button
onClick={() => setPage(0)}
disabled={page === 0}
className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100"
>
«
</button>
<button
onClick={() => setPage(page - 1)}
disabled={page === 0}
className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100"
>
</button>
<span className="text-sm text-gray-600 px-2">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100"
>
</button>
<button
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1}
className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100"
>
»
</button>
</div>
)}
{/* 음식종류 재분류 진행 */}
{remapProgress && (
<div className="mt-4 bg-surface rounded-lg shadow p-4">
<h4 className="font-semibold text-sm mb-2">
{remapProgress.current >= remapProgress.total ? "완료" : "진행 중"}
</h4>
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
<div
className="bg-amber-500 h-2 rounded-full transition-all"
style={{ width: `${remapProgress.total ? (remapProgress.current / remapProgress.total) * 100 : 0}%` }}
/>
</div>
<p className="text-xs text-gray-500">
{remapProgress.current}/{remapProgress.total} {remapProgress.updated}
</p>
</div>
)}
{/* 메뉴태그 재생성 진행 */}
{foodsProgress && (
<div className="mt-4 bg-surface rounded-lg shadow p-4">
<h4 className="font-semibold text-sm mb-2">
{foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"}
</h4>
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
<div
className="bg-brand-500 h-2 rounded-full transition-all"
style={{ width: `${foodsProgress.total ? (foodsProgress.current / foodsProgress.total) * 100 : 0}%` }}
/>
</div>
<p className="text-xs text-gray-500">
{foodsProgress.current}/{foodsProgress.total} {foodsProgress.updated}
</p>
</div>
)}
{/* 벡터 재생성 진행 */}
{vectorProgress && (
<div className="mt-4 bg-surface rounded-lg shadow p-4">
<h4 className="font-semibold text-sm mb-2">
{vectorProgress.phase === "done" ? "완료" : `(${vectorProgress.phase === "prepare" ? "데이터 준비" : "임베딩 저장"})`}
</h4>
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
<div
className="bg-teal-500 h-2 rounded-full transition-all"
style={{ width: `${vectorProgress.total ? (vectorProgress.current / vectorProgress.total) * 100 : 0}%` }}
/>
</div>
<p className="text-xs text-gray-500">
{vectorProgress.current}/{vectorProgress.total}
{vectorProgress.name && `${vectorProgress.name}`}
</p>
</div>
)}
{/* 벌크 진행 패널 */}
{bulkProgress && (
<div className="mt-4 bg-surface rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-sm">
{bulkProgress.label} ({bulkProgress.current}/{bulkProgress.total})
</h4>
{!bulkExtracting && !bulkTranscripting && (
<button
onClick={() => setBulkProgress(null)}
className="text-gray-400 hover:text-gray-600 text-sm"
>
</button>
)}
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mb-3">
<div
className="bg-purple-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${bulkProgress.total > 0 ? (bulkProgress.current / bulkProgress.total) * 100 : 0}%` }}
/>
</div>
{(bulkExtracting || bulkTranscripting) && bulkProgress.currentTitle && (
<p className="text-xs text-gray-500 mb-2">
{bulkProgress.waiting
? `${bulkProgress.waiting}초 대기 중...`
: `처리 중: ${bulkProgress.currentTitle}`}
</p>
)}
{bulkProgress.results.length > 0 && (
<div className="max-h-40 overflow-y-auto text-xs space-y-1">
{bulkProgress.results.map((r, i) => (
<div key={i} className={`flex items-center gap-2 ${r.error ? "text-red-600" : "text-gray-600"}`}>
<span>{r.error ? "✗" : "✓"}</span>
<span className="truncate flex-1">{r.title}</span>
<span className={`shrink-0 ${r.error ? "text-red-500" : "text-purple-600"}`}>
{r.detail}
</span>
</div>
))}
</div>
)}
{!bulkExtracting && !bulkTranscripting && bulkProgress.results.length > 0 && (
<p className="mt-2 text-sm font-medium text-gray-700">
! {bulkProgress.results.filter((r) => !r.error).length}/{bulkProgress.total}
</p>
)}
</div>
)}
{/* 영상 상세 패널 */}
{detailLoading && (
<div className="mt-6 text-center text-gray-500 text-sm"> ...</div>
)}
{detail && !detailLoading && (
<div className="mt-6 bg-surface rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4 gap-2">
{editingTitle ? (
<div className="flex items-center gap-2 flex-1">
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="flex-1 border rounded px-2 py-1 text-sm font-semibold"
/>
<button
onClick={async () => {
setSaving(true);
try {
await api.updateVideo(detail.id, { title: editTitle });
setDetail({ ...detail, title: editTitle });
setEditingTitle(false);
load(false);
} catch { alert("저장 실패"); }
finally { setSaving(false); }
}}
disabled={saving}
className="px-2 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
>
</button>
<button
onClick={() => setEditingTitle(false)}
className="px-2 py-1 text-xs border rounded text-gray-600 hover:bg-gray-100"
>
</button>
</div>
) : (
<h3
className={`font-semibold text-base ${isAdmin ? "cursor-pointer hover:text-brand-600" : ""}`}
onClick={isAdmin ? () => { setEditTitle(detail.title); setEditingTitle(true); } : undefined}
title={isAdmin ? "클릭하여 제목 수정" : undefined}
>
{detail.title}
</h3>
)}
<button
onClick={() => { setDetail(null); setEditingTitle(false); setEditingRestIdx(null); }}
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
>
x
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6" style={{ maxHeight: "70vh" }}>
{/* 왼쪽: YouTube 임베드 */}
<div className="overflow-y-auto">
<div className="aspect-video rounded overflow-hidden bg-black">
<iframe
src={`https://www.youtube.com/embed/${detail.video_id}`}
title={detail.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
/>
</div>
<div className="mt-2 text-xs text-gray-500">
<span>{detail.channel_name}</span>
{detail.published_at && (
<span className="ml-3">{detail.published_at.slice(0, 10)}</span>
)}
<span className={`ml-3 px-2 py-0.5 rounded ${
detail.status === "done" ? "bg-green-100 text-green-800" :
detail.status === "error" ? "bg-red-100 text-red-800" :
"bg-yellow-100 text-yellow-800"
}`}>
{detail.status}
</span>
</div>
</div>
{/* 오른쪽: 추출 정보 + 트랜스크립트 */}
<div className="space-y-4 overflow-y-auto">
{/* 추출된 식당 정보 */}
<div className="flex items-center gap-2 mb-2">
<h4 className="font-semibold text-sm">
({detail.restaurants.length})
</h4>
{isAdmin && detail.transcript && (
<>
<button
onClick={async () => {
setExtracting(true);
try {
const res = await api.extractRestaurants(detail.id, showPrompt && prompt ? prompt : undefined);
alert(`${res.restaurants_extracted}개 식당 추출 완료`);
const d = await api.getVideoDetail(detail.id);
setDetail(d);
load(false);
} catch {
alert("식당 추출 실패");
} finally {
setExtracting(false);
}
}}
disabled={extracting}
className="px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
{extracting ? "추출 중..." : "LLM 식당 추출"}
</button>
<button
onClick={async () => {
if (!showPrompt && !prompt) {
try {
const res = await api.getExtractPrompt();
setPrompt(res.prompt);
} catch { /* ignore */ }
}
setShowPrompt(!showPrompt);
}}
className="px-2 py-1 text-xs border rounded text-gray-600 hover:bg-gray-100"
>
{showPrompt ? "프롬프트 숨기기" : "프롬프트 편집"}
</button>
</>
)}
{isAdmin && <button
onClick={() => setShowManualAdd(!showManualAdd)}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
>
{showManualAdd ? "수동 추가 닫기" : "수동 추가"}
</button>}
</div>
{showManualAdd && (
<div className="border rounded p-3 mb-3 bg-green-50 space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[10px] text-gray-500"> *</label>
<input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="식당 이름" />
</div>
<div>
<label className="text-[10px] text-gray-500"></label>
<input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="주소 (없으면 지역)" />
</div>
<div>
<label className="text-[10px] text-gray-500"></label>
<input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="서울 강남" />
</div>
<div>
<label className="text-[10px] text-gray-500"> </label>
<input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="한식, 일식..." />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[10px] text-gray-500"></label>
<input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="메뉴1, 메뉴2" />
</div>
<div>
<label className="text-[10px] text-gray-500"></label>
<input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="게스트1, 게스트2" />
</div>
</div>
<div>
<label className="text-[10px] text-gray-500">/</label>
<textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" rows={2} placeholder="맛집 평가 내용" />
</div>
<div className="flex gap-2">
<button
onClick={async () => {
if (!manualForm.name.trim()) { alert("식당명을 입력해주세요"); return; }
setManualAdding(true);
try {
await api.addManualRestaurant(detail.id, {
name: manualForm.name.trim(),
address: manualForm.address.trim() || undefined,
region: manualForm.region.trim() || undefined,
cuisine_type: manualForm.cuisine_type.trim() || undefined,
foods_mentioned: manualForm.foods_mentioned.split(",").map(s => s.trim()).filter(Boolean),
evaluation: manualForm.evaluation.trim() || undefined,
guests: manualForm.guests.split(",").map(s => s.trim()).filter(Boolean),
});
const d = await api.getVideoDetail(detail.id);
setDetail(d);
load(false);
setManualForm({ name: "", address: "", region: "", cuisine_type: "", price_range: "", foods_mentioned: "", evaluation: "", guests: "" });
setShowManualAdd(false);
} catch (e) {
alert((e as Error).message || "추가 실패");
} finally {
setManualAdding(false);
}
}}
disabled={manualAdding}
className="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
>
{manualAdding ? "추가 중..." : "식당 추가"}
</button>
<button onClick={() => setShowManualAdd(false)} className="px-3 py-1 text-xs border rounded text-gray-600 hover:bg-gray-100">
</button>
</div>
</div>
)}
{showPrompt && (
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-surface text-gray-900"
rows={12}
placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)"
/>
)}
{detail.restaurants.length > 0 && (
<div>
<div className="space-y-2">
{detail.restaurants.map((r, i) => (
<div key={i} className="border rounded p-3 text-sm">
{editingRestIdx === i && editRest ? (
<div className="space-y-2">
<div>
<label className="text-xs text-gray-500"></label>
<input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm bg-surface text-gray-900" />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-500"></label>
<input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-500"></label>
<input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
</div>
</div>
<div>
<label className="text-xs text-gray-500"> ( )</label>
<input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="메뉴1, 메뉴2, ..." />
</div>
<div>
<label className="text-xs text-gray-500">/</label>
<textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" rows={2} />
</div>
<div>
<label className="text-xs text-gray-500"> ( )</label>
<input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
</div>
<div className="flex gap-2">
<button
onClick={async () => {
setSaving(true);
try {
const foods = editRest.foods_mentioned.split(",").map(s => s.trim()).filter(Boolean);
const guests = editRest.guests.split(",").map(s => s.trim()).filter(Boolean);
let evalObj: Record<string, string> = {};
if (editRest.evaluation.trim()) {
evalObj = { text: editRest.evaluation.trim() };
}
await api.updateVideoRestaurant(detail.id, r.restaurant_id, {
name: editRest.name,
cuisine_type: editRest.cuisine_type || null,
price_range: editRest.price_range || null,
region: editRest.region || null,
address: editRest.address || null,
foods_mentioned: foods,
evaluation: evalObj,
guests: guests,
});
const d = await api.getVideoDetail(detail.id);
setDetail(d);
setEditingRestIdx(null);
setEditRest(null);
} catch (e) { alert("저장 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setSaving(false); }
}}
disabled={saving}
className="px-3 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
>
{saving ? "저장 중..." : "저장"}
</button>
<button
onClick={() => { setEditingRestIdx(null); setEditRest(null); }}
className="px-3 py-1 text-xs border rounded text-gray-600 hover:bg-gray-100"
>
</button>
</div>
</div>
) : (
<div
className={`${isAdmin ? "cursor-pointer hover:bg-brand-50/50" : ""} -m-3 p-3 rounded group`}
onClick={isAdmin ? () => {
let evalText = "";
if (typeof r.evaluation === "object" && r.evaluation) {
if (r.evaluation.text) {
evalText = r.evaluation.text;
} else if (Object.keys(r.evaluation).length > 0) {
evalText = JSON.stringify(r.evaluation);
}
} else if (r.evaluation) {
evalText = String(r.evaluation);
}
setEditingRestIdx(i);
setShowManualAdd(false);
setShowPrompt(false);
setEditRest({
name: r.name,
cuisine_type: r.cuisine_type || "",
foods_mentioned: (r.foods_mentioned || []).join(", "),
evaluation: evalText,
address: r.address || "",
region: r.region || "",
price_range: r.price_range || "",
guests: (r.guests || []).join(", "),
});
} : undefined}
title={isAdmin ? "클릭하여 수정" : undefined}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="font-medium">{r.name}</p>
{r.google_place_id ? (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 text-green-700" title={`Place ID: ${r.google_place_id}`}></span>
) : r.has_location ? (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-100 text-yellow-700" title="좌표는 있으나 Place ID 없음"></span>
) : (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-600"></span>
)}
</div>
{isAdmin && <button
onClick={async (e) => {
e.stopPropagation();
if (!confirm(`"${r.name}" 식당 매핑을 삭제하시겠습니까?`)) return;
try {
await api.deleteVideoRestaurant(detail.id, r.restaurant_id);
const d = await api.getVideoDetail(detail.id);
setDetail(d);
load(false);
} catch { alert("삭제 실패"); }
}}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 text-xs px-1.5 py-0.5 rounded hover:bg-red-50 transition-opacity"
>
</button>}
</div>
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
{r.address && <p>: {r.address}</p>}
{r.region && <p>: {r.region}</p>}
{r.cuisine_type && <p>: {r.cuisine_type}</p>}
{r.price_range && <p>: {r.price_range}</p>}
</div>
{r.foods_mentioned?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{r.foods_mentioned.map((f, j) => (
<span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
))}
</div>
)}
{r.evaluation?.text && (
<p className="mt-1 text-xs text-gray-600">{r.evaluation.text}</p>
)}
{r.guests?.length > 0 && (
<p className="mt-1 text-xs text-gray-500">: {r.guests.join(", ")}</p>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
{detail.restaurants.length === 0 && (
<p className="text-sm text-gray-400"> </p>
)}
{/* 트랜스크립트 — 식당 수정 중에는 숨김 */}
{editingRestIdx === null && <div>
<div className="flex items-center gap-2 mb-2">
<h4 className="font-semibold text-sm"></h4>
{isAdmin && <>
<select
value={transcriptMode}
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
className="border rounded px-2 py-1 text-xs bg-surface text-gray-900"
>
<option value="auto"> ()</option>
<option value="manual"> </option>
<option value="generated"> </option>
</select>
<button
onClick={async () => {
setFetchingTranscript(true);
try {
const res = await api.fetchTranscript(detail.id, transcriptMode);
const d = await api.getVideoDetail(detail.id);
setDetail(d);
alert(`트랜스크립트 가져옴 (${res.source === "manual" ? "수동 자막" : "자동생성 자막"}, ${res.length.toLocaleString()}자)`);
} catch {
alert("트랜스크립트를 가져올 수 없습니다");
} finally {
setFetchingTranscript(false);
}
}}
disabled={fetchingTranscript}
className="px-2 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
>
{fetchingTranscript ? "가져오는 중..." : detail.transcript ? "다시 가져오기" : "트랜스크립트 가져오기"}
</button>
</>}
</div>
{detail.transcript ? (
<pre className="text-xs text-gray-700 bg-gray-50 rounded p-3 whitespace-pre-wrap leading-relaxed max-h-[200px] overflow-y-auto">
{detail.transcript}
</pre>
) : (
<p className="text-sm text-gray-400"> </p>
)}
</div>}
</div>
</div>
</div>
)}
</div>
);
}
/* ─── 식당 관리 ─── */