diff --git a/frontend/src/app/admin/_panels/ChannelsPanel.tsx b/frontend/src/app/admin/_panels/ChannelsPanel.tsx new file mode 100644 index 0000000..6aac2d3 --- /dev/null +++ b/frontend/src/app/admin/_panels/ChannelsPanel.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import type { Channel } from "@/lib/api"; + +// #329 — admin/page.tsx에서 추출 (내부 로직 그대로) +export function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) { + 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 [editingChannel, setEditingChannel] = useState(null); + const [editDesc, setEditDesc] = useState(""); + const [editTags, setEditTags] = useState(""); + const [editOrder, setEditOrder] = useState(99); + + const handleSaveChannel = async (id: string) => { + try { + await api.updateChannel(id, { description: editDesc, tags: editTags, sort_order: editOrder }); + setEditingChannel(null); + load(); + } catch { + alert("채널 수정 실패"); + } + }; + + 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 ( +
+ {isAdmin &&
+

채널 추가

+
+ setNewId(e.target.value)} + className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900" + /> + setNewName(e.target.value)} + className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900" + /> + setNewFilter(e.target.value)} + className="border rounded px-3 py-2 w-40 text-sm bg-surface text-gray-900" + /> + +
+
} + +
+ + + + + + + + + + + {isAdmin && } + + + + + {channels.map((ch) => ( + + + + + + + + + {isAdmin && } + + + ))} + {channels.length === 0 && ( + + + + )} + +
채널 이름Channel ID제목 필터설명태그순서영상 수액션스캔 결과
{ch.channel_name} + {ch.channel_id} + + {ch.title_filter ? ( + + {ch.title_filter} + + ) : ( + 전체 + )} + + {editingChannel === ch.id ? ( + setEditDesc(e.target.value)} + className="border rounded px-2 py-1 text-xs w-32 bg-surface text-gray-900" placeholder="설명" /> + ) : ( + { + if (!isAdmin) return; + setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99); + }}>{ch.description || -} + )} + + {editingChannel === ch.id ? ( +
+ setEditTags(e.target.value)} + className="border rounded px-2 py-1 text-xs w-40 bg-surface text-gray-900" placeholder="태그 (쉼표 구분)" /> + + +
+ ) : ( + { + if (!isAdmin) return; + setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99); + }}>{ch.tags ? ch.tags.split(",").map(t => t.trim()).join(", ") : -} + )} +
+ {editingChannel === ch.id ? ( + setEditOrder(Number(e.target.value))} + className="border rounded px-2 py-1 text-xs w-14 text-center bg-surface text-gray-900" min={1} /> + ) : ( + {ch.sort_order ?? 99} + )} + + {ch.video_count > 0 ? ( + {ch.video_count}개 + ) : ( + 0 + )} + + + + + + {scanResult[ch.channel_id] || "-"} +
+ 등록된 채널이 없습니다 +
+
+
+ ); +} + +/* ─── 영상 관리 ─── */ +type VideoSortKey = "status" | "channel_name" | "title" | "published_at"; + diff --git a/frontend/src/app/admin/_panels/DaemonPanel.tsx b/frontend/src/app/admin/_panels/DaemonPanel.tsx new file mode 100644 index 0000000..cf190b0 --- /dev/null +++ b/frontend/src/app/admin/_panels/DaemonPanel.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import type { DaemonConfig } from "@/lib/api"; + +// #329 — admin/page.tsx에서 추출 +export function DaemonPanel({ isAdmin }: { isAdmin: boolean }) { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [running, setRunning] = useState(null); + const [result, setResult] = useState(null); + + // Editable fields + const [scanEnabled, setScanEnabled] = useState(false); + const [scanInterval, setScanInterval] = useState(60); + const [processEnabled, setProcessEnabled] = useState(false); + const [processInterval, setProcessInterval] = useState(60); + const [processLimit, setProcessLimit] = useState(10); + + const load = useCallback(() => { + setLoading(true); + api.getDaemonConfig().then((cfg) => { + setConfig(cfg); + setScanEnabled(cfg.scan_enabled); + setScanInterval(cfg.scan_interval_min); + setProcessEnabled(cfg.process_enabled); + setProcessInterval(cfg.process_interval_min); + setProcessLimit(cfg.process_limit); + }).catch(console.error).finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const handleSave = async () => { + setSaving(true); + setResult(null); + try { + await api.updateDaemonConfig({ + scan_enabled: scanEnabled, + scan_interval_min: scanInterval, + process_enabled: processEnabled, + process_interval_min: processInterval, + process_limit: processLimit, + }); + setResult("설정 저장 완료"); + load(); + } catch (e: unknown) { + setResult(e instanceof Error ? e.message : "저장 실패"); + } finally { + setSaving(false); + } + }; + + const handleRunScan = async () => { + setRunning("scan"); + setResult(null); + try { + const res = await api.runDaemonScan(); + setResult(`채널 스캔 완료: 신규 ${res.new_videos}개 영상`); + load(); + } catch (e: unknown) { + setResult(e instanceof Error ? e.message : "스캔 실패"); + } finally { + setRunning(null); + } + }; + + const handleRunProcess = async () => { + setRunning("process"); + setResult(null); + try { + const res = await api.runDaemonProcess(processLimit); + setResult(`영상 처리 완료: ${res.restaurants_extracted}개 식당 추출`); + load(); + } catch (e: unknown) { + setResult(e instanceof Error ? e.message : "처리 실패"); + } finally { + setRunning(null); + } + }; + + if (loading) return

로딩 중...

; + + return ( +
+ {/* Schedule Config */} +
+

스케줄 설정

+

+ 데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다. +

+ +
+ {/* Scan config */} +
+
+

채널 스캔

+ +
+
+ + setScanInterval(Number(e.target.value))} + disabled={!isAdmin} + min={1} + className="border rounded px-3 py-1.5 text-sm w-32" + /> +
+ {config?.last_scan_at && ( +

마지막 스캔: {config.last_scan_at}

+ )} +
+ + {/* Process config */} +
+
+

영상 처리

+ +
+
+
+ + setProcessInterval(Number(e.target.value))} + disabled={!isAdmin} + min={1} + className="border rounded px-3 py-1.5 text-sm w-32" + /> +
+
+ + setProcessLimit(Number(e.target.value))} + disabled={!isAdmin} + min={1} + max={50} + className="border rounded px-3 py-1.5 text-sm w-32" + /> +
+
+ {config?.last_process_at && ( +

마지막 처리: {config.last_process_at}

+ )} +
+
+ + {isAdmin && ( +
+ +
+ )} +
+ + {/* Manual Triggers */} +
+

수동 실행

+

+ 스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다. +

+ +
+ {isAdmin && ( + <> + + + + )} +
+ + {result && ( +

+ {result} +

+ )} +
+ + {/* Config updated_at */} + {config?.updated_at && ( +

설정 수정일: {config.updated_at}

+ )} +
+ ); +} diff --git a/frontend/src/app/admin/_panels/RestaurantsPanel.tsx b/frontend/src/app/admin/_panels/RestaurantsPanel.tsx new file mode 100644 index 0000000..30f45d8 --- /dev/null +++ b/frontend/src/app/admin/_panels/RestaurantsPanel.tsx @@ -0,0 +1,676 @@ +"use client"; +import { getAdminToken } from "@/lib/admin-utils"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import type { Restaurant, VideoLink } from "@/lib/api"; + +// #329 — admin/page.tsx에서 추출 +export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) { + const [restaurants, setRestaurants] = useState([]); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(0); + const [nameSearch, setNameSearch] = useState(""); + const [selected, setSelected] = useState(null); + const [editForm, setEditForm] = useState>({}); + const [saving, setSaving] = useState(false); + const [videos, setVideos] = useState([]); + const [tablingSearching, setTablingSearching] = useState(false); + const [bulkTabling, setBulkTabling] = useState(false); + const [bulkTablingProgress, setBulkTablingProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 }); + const [catchtableSearching, setCatchtableSearching] = useState(false); + const [bulkCatchtable, setBulkCatchtable] = useState(false); + const [bulkCatchtableProgress, setBulkCatchtableProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 }); + type RestSortKey = "name" | "region" | "cuisine_type" | "price_range" | "rating" | "business_status"; + const [sortKey, setSortKey] = useState("name"); + const [sortAsc, setSortAsc] = useState(true); + const perPage = 20; + + const handleSort = (key: RestSortKey) => { + if (sortKey === key) setSortAsc(!sortAsc); + else { setSortKey(key); setSortAsc(true); } + }; + const sortIcon = (key: RestSortKey) => sortKey !== key ? " ↕" : sortAsc ? " ↑" : " ↓"; + + const load = useCallback(() => { + setLoading(true); + api + .getRestaurants({ limit: 500 }) + .then(setRestaurants) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + // #322/#323 LLM 검증 UI + const [verifyPending, setVerifyPending] = useState(null); + const [verifying, setVerifying] = useState(false); + const [verifyResult, setVerifyResult] = useState(null); + + const loadVerifyPending = useCallback(() => { + api.getVerifyPending().then((r) => setVerifyPending(r.pending)).catch(() => setVerifyPending(null)); + }, []); + useEffect(() => { loadVerifyPending(); }, [loadVerifyPending]); + + const handleVerifyAll = async () => { + if (!isAdmin) return; + if (!confirm(`미검증 식당 ${verifyPending ?? "?"}건을 LLM으로 일괄 검증합니다.\n잘못 등록된 데이터/프랜차이즈를 자동으로 숨김 처리합니다.\n진행할까요?`)) return; + setVerifying(true); + setVerifyResult(null); + try { + const r = await api.verifyAll(10); + setVerifyResult(`${r.processed}건 검증 완료`); + loadVerifyPending(); + load(); + } catch (e) { + setVerifyResult(`실패: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setVerifying(false); + } + }; + + const handleToggleHidden = async (r: Restaurant) => { + if (!isAdmin) return; + const becomingHidden = !r.hidden; + const reason = becomingHidden ? (prompt("숨김 사유(선택)", "manual") ?? "manual") : ""; + try { + await api.setRestaurantHidden(r.id, becomingHidden, reason || "manual"); + load(); + } catch (e) { + alert(`실패: ${e instanceof Error ? e.message : String(e)}`); + } + }; + + const filtered = restaurants.filter((r) => { + if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false; + return true; + }); + + const sorted = [...filtered].sort((a, b) => { + let av: string | number = a[sortKey] ?? ""; + let bv: string | number = b[sortKey] ?? ""; + if (sortKey === "rating") { + av = a.rating ?? 0; + bv = b.rating ?? 0; + } + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return sortAsc ? cmp : -cmp; + }); + + const totalPages = Math.max(1, Math.ceil(sorted.length / perPage)); + const paged = sorted.slice(page * perPage, (page + 1) * perPage); + + const handleSelect = (r: Restaurant) => { + if (selected?.id === r.id) { + setSelected(null); + setVideos([]); + return; + } + setSelected(r); + api.getRestaurantVideos(r.id).then(setVideos).catch(() => setVideos([])); + setEditForm({ + name: r.name || "", + address: r.address || "", + region: r.region || "", + cuisine_type: r.cuisine_type || "", + price_range: r.price_range || "", + phone: r.phone || "", + website: r.website || "", + latitude: r.latitude != null ? String(r.latitude) : "", + longitude: r.longitude != null ? String(r.longitude) : "", + }); + }; + + const handleSave = async () => { + if (!selected) return; + setSaving(true); + try { + const data: Record = { ...editForm }; + if (editForm.latitude) data.latitude = parseFloat(editForm.latitude); + else data.latitude = null; + if (editForm.longitude) data.longitude = parseFloat(editForm.longitude); + else data.longitude = null; + await api.updateRestaurant(selected.id, data as Partial); + load(); + setSelected(null); + } catch (e) { + alert("저장 실패: " + (e instanceof Error ? e.message : String(e))); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!selected) return; + if (!confirm(`"${selected.name}" 식당을 삭제하시겠습니까?\n연결된 영상 매핑, 리뷰, 벡터도 함께 삭제됩니다.`)) return; + try { + await api.deleteRestaurant(selected.id); + setSelected(null); + load(); + } catch { + alert("삭제 실패"); + } + }; + + return ( +
+
+
+ { setNameSearch(e.target.value); setPage(0); }} + onKeyDown={(e) => e.key === "Escape" && setNameSearch("")} + className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900" + /> + {nameSearch ? ( + + ) : ( + + )} +
+ {isAdmin && (<> + {/* #322/#323 — LLM 검증 */} +
+ 미검증 {verifyPending ?? "?"}건 + + {verifyResult && {verifyResult}} +
+ + + + + )} + + {nameSearch ? `${sorted.length} / ` : ""}총 {restaurants.length}개 식당 + +
+ {bulkTabling && bulkTablingProgress.name && ( +
+
+ {bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name} + 연결: {bulkTablingProgress.linked} / 미발견: {bulkTablingProgress.notFound} +
+
+
+
+
+ )} + {bulkCatchtable && bulkCatchtableProgress.name && ( +
+
+ {bulkCatchtableProgress.current}/{bulkCatchtableProgress.total} - {bulkCatchtableProgress.name} + 연결: {bulkCatchtableProgress.linked} / 미발견: {bulkCatchtableProgress.notFound} +
+
+
+
+
+ )} + +
+ + + + + + + + + + + + + + {paged.map((r) => ( + handleSelect(r)} + className={`border-b cursor-pointer hover:bg-brand-50/50 ${selected?.id === r.id ? "bg-brand-50" : ""}`} + > + + + + + + + + + ))} + {!loading && filtered.length === 0 && ( + + + + )} + +
handleSort("name")}>이름{sortIcon("name")} handleSort("region")}>지역{sortIcon("region")} handleSort("cuisine_type")}>음식 종류{sortIcon("cuisine_type")} handleSort("price_range")}>가격대{sortIcon("price_range")} handleSort("rating")}>평점{sortIcon("rating")} handleSort("business_status")}>상태{sortIcon("business_status")}검증
{r.name}{r.region || "-"}{r.cuisine_type || "-"}{r.price_range || "-"} + {r.rating ? ( + {r.rating} + ) : "-"} + + {r.business_status === "CLOSED_PERMANENTLY" ? ( + 폐업 + ) : r.business_status === "CLOSED_TEMPORARILY" ? ( + 임시휴업 + ) : ( + - + )} + e.stopPropagation()}> + {r.hidden ? ( + + ) : r.verified_at ? ( + + ) : ( + 미검증 + )} +
+ 식당 데이터가 없습니다 +
+
+ + {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} + + {/* 식당 상세/수정 패널 */} + {selected && ( +
+
+

{selected.name}

+ +
+
+ {[ + { key: "name", label: "이름" }, + { key: "address", label: "주소" }, + { key: "region", label: "지역" }, + { key: "cuisine_type", label: "음식 종류" }, + { key: "price_range", label: "가격대" }, + { key: "phone", label: "전화" }, + { key: "website", label: "웹사이트" }, + { key: "latitude", label: "위도" }, + { key: "longitude", label: "경도" }, + ].map(({ key, label }) => ( +
+ + setEditForm((f) => ({ ...f, [key]: e.target.value }))} + className="w-full border rounded px-2 py-1.5 text-sm bg-surface text-gray-900" + disabled={!isAdmin} + /> +
+ ))} +
+ {selected.business_status && ( +

+ Google 상태: {selected.business_status} + {selected.rating && <> / ★ {selected.rating} ({selected.rating_count?.toLocaleString()})} +

+ )} + {selected.google_place_id && ( +

+ + Google Maps에서 보기 + +

+ )} + {/* 테이블링 연결 */} + {isAdmin && ( +
+
+

테이블링

+ {selected.tabling_url === "NONE" ? ( + 검색완료-없음 + ) : selected.tabling_url ? ( + {selected.tabling_url} + ) : ( + 미연결 + )} + + {selected.tabling_url && ( + + )} +
+
+ )} + {/* 캐치테이블 연결 */} + {isAdmin && ( +
+
+

캐치테이블

+ {selected.catchtable_url === "NONE" ? ( + 검색완료-없음 + ) : selected.catchtable_url ? ( + {selected.catchtable_url} + ) : ( + 미연결 + )} + + {selected.catchtable_url && ( + + )} +
+
+ )} + + {videos.length > 0 && ( +
+

연결된 영상 ({videos.length})

+
+ {videos.map((v) => ( +
+ + {v.channel_name} + + + {v.title} + + {v.published_at?.slice(0, 10)} +
+ ))} +
+
+ )} + +
+ {isAdmin && } + + {isAdmin && } +
+
+ )} +
+ ); +} + + +/* ─── 유저 관리 ─── */ +interface AdminUser { + id: string; + email: string | null; + nickname: string | null; + avatar_url: string | null; + is_admin: boolean; + provider: string | null; + created_at: string | null; + favorite_count: number; + review_count: number; + memo_count: number; +} + +interface UserFavorite { + id: string; + name: string; + address: string | null; + region: string | null; + cuisine_type: string | null; + rating: number | null; + business_status: string | null; + created_at: string | null; +} + +interface UserReview { + id: string; + restaurant_id: string; + rating: number; + review_text: string | null; + visited_at: string | null; + created_at: string | null; + restaurant_name: string | null; +} + +interface UserMemo { + id: string; + restaurant_id: string; + rating: number | null; + memo_text: string | null; + visited_at: string | null; + created_at: string; + restaurant_name: string | null; +} + diff --git a/frontend/src/app/admin/_panels/UsersPanel.tsx b/frontend/src/app/admin/_panels/UsersPanel.tsx new file mode 100644 index 0000000..32091db --- /dev/null +++ b/frontend/src/app/admin/_panels/UsersPanel.tsx @@ -0,0 +1,383 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +// #329 — admin/page.tsx에서 추출 +interface AdminUser { + id: string; + email: string | null; + nickname: string | null; + avatar_url: string | null; + is_admin: boolean; + provider: string | null; + created_at: string | null; + favorite_count: number; + review_count: number; + memo_count: number; +} + +interface UserFavorite { + id: string; + name: string; + address: string | null; + region: string | null; + cuisine_type: string | null; + rating: number | null; + business_status: string | null; + created_at: string | null; +} + +interface UserReview { + id: string; + restaurant_id: string; + rating: number; + review_text: string | null; + visited_at: string | null; + created_at: string | null; + restaurant_name: string | null; +} + +interface UserMemo { + id: string; + restaurant_id: string; + rating: number | null; + memo_text: string | null; + visited_at: string | null; + created_at: string; + restaurant_name: string | null; +} + +export function UsersPanel() { + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [selectedUser, setSelectedUser] = useState(null); + const [favorites, setFavorites] = useState([]); + const [reviews, setReviews] = useState([]); + const [memos, setMemos] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + const perPage = 20; + + const loadUsers = useCallback(async (p: number) => { + try { + const res = await api.getAdminUsers({ limit: perPage, offset: p * perPage }); + setUsers(res.users); + setTotal(res.total); + } catch (e) { + console.error("Failed to load users:", e); + } + }, []); + + useEffect(() => { + loadUsers(page); + }, [page, loadUsers]); + + const handleSelectUser = async (u: AdminUser) => { + if (selectedUser?.id === u.id) { + setSelectedUser(null); + setFavorites([]); + setReviews([]); + setMemos([]); + return; + } + setSelectedUser(u); + setDetailLoading(true); + try { + const [favs, revs, mems] = await Promise.all([ + api.getAdminUserFavorites(u.id), + api.getAdminUserReviews(u.id), + api.getAdminUserMemos(u.id), + ]); + setFavorites(favs); + setReviews(revs); + setMemos(mems); + } catch (e) { + console.error(e); + } finally { + setDetailLoading(false); + } + }; + + const totalPages = Math.ceil(total / perPage); + + return ( +
+

유저 관리 ({total}명)

+ + {/* Users Table */} +
+ + + + + + + + + + + + + + {users.map((u) => ( + handleSelectUser(u)} + className={`border-t cursor-pointer transition-colors ${ + selectedUser?.id === u.id + ? "bg-brand-50" + : "hover:bg-brand-50/50" + }`} + > + + + + + + + + + ))} + +
사용자이메일관리자리뷰메모가입일
+
+ {u.avatar_url ? ( + + ) : ( +
+ {(u.nickname || u.email || "?").charAt(0).toUpperCase()} +
+ )} + + {u.nickname || "-"} + +
+
{u.email || "-"} + + + {u.favorite_count > 0 ? ( + + {u.favorite_count} + + ) : ( + 0 + )} + + {u.review_count > 0 ? ( + + {u.review_count} + + ) : ( + 0 + )} + + {u.memo_count > 0 ? ( + + {u.memo_count} + + ) : ( + 0 + )} + + {u.created_at?.slice(0, 10) || "-"} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} + + {/* Selected User Detail */} + {selectedUser && ( +
+
+ {selectedUser.avatar_url ? ( + + ) : ( +
+ {(selectedUser.nickname || selectedUser.email || "?").charAt(0).toUpperCase()} +
+ )} +
+
{selectedUser.nickname || "-"}
+
{selectedUser.email || "-"}
+
+ {selectedUser.provider} · 가입: {selectedUser.created_at?.slice(0, 10)} +
+
+
+ + {detailLoading ? ( +

로딩 중...

+ ) : ( +
+ {/* Favorites */} +
+

+ 찜한 식당 ({favorites.length}) +

+ {favorites.length === 0 ? ( +

찜한 식당이 없습니다.

+ ) : ( +
+ {favorites.map((f) => ( +
+
+ {f.name} + {f.region && ( + {f.region} + )} + {f.cuisine_type && ( + · {f.cuisine_type} + )} + {f.business_status === "CLOSED_PERMANENTLY" && ( + + 폐업 + + )} +
+ {f.rating && ( + + ★ {f.rating} + + )} +
+ ))} +
+ )} +
+ + {/* Reviews */} +
+

+ 작성한 리뷰 ({reviews.length}) +

+ {reviews.length === 0 ? ( +

작성한 리뷰가 없습니다.

+ ) : ( +
+ {reviews.map((r) => ( +
+
+ + {r.restaurant_name || "알 수 없음"} + + + {"★".repeat(Math.round(r.rating))} {r.rating} + +
+ {r.review_text && ( +

+ {r.review_text} +

+ )} +
+ {r.visited_at && `방문: ${r.visited_at} · `} + {r.created_at?.slice(0, 10)} +
+
+ ))} +
+ )} +
+ + {/* Memos */} +
+

+ 작성한 메모 ({memos.length}) +

+ {memos.length === 0 ? ( +

작성한 메모가 없습니다.

+ ) : ( +
+ {memos.map((m) => ( +
+
+ + {m.restaurant_name || "알 수 없음"} + + {m.rating && ( + + {"★".repeat(Math.round(m.rating))} {m.rating} + + )} +
+ {m.memo_text && ( +

+ {m.memo_text} +

+ )} +
+ {m.visited_at && `방문: ${m.visited_at} · `} + {m.created_at?.slice(0, 10)} + 비공개 +
+
+ ))} +
+ )} +
+
+ )} +
+ )} +
+ ); +} + +/* ─── 데몬 설정 ─── */ diff --git a/frontend/src/app/admin/_panels/VideosPanel.tsx b/frontend/src/app/admin/_panels/VideosPanel.tsx new file mode 100644 index 0000000..1988f65 --- /dev/null +++ b/frontend/src/app/admin/_panels/VideosPanel.tsx @@ -0,0 +1,1283 @@ +"use client"; +import { getAdminToken } 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; + } + 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 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; + } + const reader = resp.body?.getReader(); + const decoder = new TextDecoder(); + if (!reader) { setRebuildingVectors(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.status === "progress" || ev.type === "progress") { + setVectorProgress({ phase: ev.phase, current: ev.current, total: ev.total, name: ev.name }); + } else if (ev.status === "done" || ev.type === "done") { + setVectorProgress({ phase: "done", current: ev.total, total: ev.total }); + } else if (ev.type === "error") { + alert(`벡터 재생성 오류: ${ev.message}`); + } + } catch { /* ignore */ } + } + } + 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; + } + const reader = resp.body?.getReader(); + const decoder = new TextDecoder(); + if (!reader) { setRemappingCuisine(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" || ev.type === "batch_done") { + setRemapProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 }); + } else if (ev.type === "complete") { + setRemapProgress({ current: ev.total, total: ev.total, updated: ev.updated }); + } else if (ev.type === "error") { + alert(`재분류 오류: ${ev.message}`); + } + } catch { /* ignore */ } + } + } + 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; + } + const reader = resp.body?.getReader(); + const decoder = new TextDecoder(); + if (!reader) { setRemappingFoods(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" || ev.type === "batch_done") { + setFoodsProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 }); + } else if (ev.type === "complete") { + setFoodsProgress({ current: ev.total, total: ev.total, updated: ev.updated }); + } else if (ev.type === "error") { + alert(`메뉴 태그 재생성 오류: ${ev.message}`); + } + } catch { /* ignore */ } + } + } + 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 임베드 */} +
+
+