Files
tasteby/frontend/src/app/admin/page.tsx
joungmin 3694730501 Add admin features, responsive UI, user reviews, visit stats, and channel-colored markers
- Admin: video management with Google Maps match status, manual restaurant mapping, restaurant remap on name change
- Admin: user management tab with favorites/reviews detail
- Admin: channel deletion fix for IDs with slashes
- Frontend: responsive mobile layout (map top, list bottom, 2-row header)
- Frontend: channel-colored map markers with legend
- Frontend: my reviews list, favorites toggle, visit counter overlay
- Frontend: force light mode for dark theme devices
- Backend: visit tracking (site_visits table), user reviews endpoint
- Backend: bulk transcript/extract streaming, geocode key fixes
- Nginx config for production deployment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 14:52:20 +09:00

1799 lines
76 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 { 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<Tab>("channels");
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">Tasteby Admin</h1>
<a href="/" className="text-sm text-blue-600 hover:underline">
&larr;
</a>
</div>
<nav className="mt-3 flex gap-1">
{(["channels", "videos", "restaurants", "users"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 text-sm rounded-t font-medium ${
tab === t
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : "유저 관리"}
</button>
))}
</nav>
</header>
<main className="max-w-6xl mx-auto p-6">
{tab === "channels" && <ChannelsPanel />}
{tab === "videos" && <VideosPanel />}
{tab === "restaurants" && <RestaurantsPanel />}
{tab === "users" && <UsersPanel />}
</main>
</div>
);
}
/* ─── 채널 관리 ─── */
function ChannelsPanel() {
const [channels, setChannels] = useState<Channel[]>([]);
const [newId, setNewId] = useState("");
const [newName, setNewName] = useState("");
const [newFilter, setNewFilter] = useState("");
const [loading, setLoading] = useState(false);
const [scanResult, setScanResult] = useState<Record<string, string>>({});
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<string, unknown>).filtered ? `, ${(res as Record<string, unknown>).filtered}개 필터링` : ""}`,
}));
} catch {
setScanResult((prev) => ({ ...prev, [channelId]: "스캔 실패" }));
}
};
return (
<div>
<div className="bg-white rounded-lg shadow p-4 mb-6">
<h2 className="font-semibold mb-3"> </h2>
<div className="flex gap-2">
<input
placeholder="YouTube Channel ID"
value={newId}
onChange={(e) => setNewId(e.target.value)}
className="border rounded px-3 py-2 flex-1 text-sm"
/>
<input
placeholder="채널 이름"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="border rounded px-3 py-2 flex-1 text-sm"
/>
<input
placeholder="제목 필터 (선택)"
value={newFilter}
onChange={(e) => setNewFilter(e.target.value)}
className="border rounded px-3 py-2 w-40 text-sm"
/>
<button
onClick={handleAdd}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700 disabled:opacity-50"
>
</button>
</div>
</div>
<div className="bg-white rounded-lg shadow">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="text-left px-4 py-3"> </th>
<th className="text-left px-4 py-3">Channel ID</th>
<th className="text-left px-4 py-3"> </th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"> </th>
</tr>
</thead>
<tbody>
{channels.map((ch) => (
<tr key={ch.id} className="border-b hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{ch.channel_name}</td>
<td className="px-4 py-3 text-gray-500 font-mono text-xs">
{ch.channel_id}
</td>
<td className="px-4 py-3 text-sm">
{ch.title_filter ? (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">
{ch.title_filter}
</span>
) : (
<span className="text-gray-400 text-xs"></span>
)}
</td>
<td className="px-4 py-3 flex gap-3">
<button
onClick={() => handleScan(ch.channel_id)}
className="text-blue-600 hover:underline text-sm"
>
</button>
<button
onClick={() => handleScan(ch.channel_id, true)}
className="text-purple-600 hover:underline text-sm"
>
</button>
<button
onClick={() => handleDelete(ch.id, ch.channel_name)}
className="text-red-500 hover:underline text-sm"
>
</button>
</td>
<td className="px-4 py-3 text-gray-600">
{scanResult[ch.channel_id] || "-"}
</td>
</tr>
))}
{channels.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
/* ─── 영상 관리 ─── */
type VideoSortKey = "status" | "channel_name" | "title" | "published_at";
function VideosPanel() {
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 [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<string, string> = {
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 (
<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"
>
<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"
>
<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"
/>
{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={() => { setDetail(null); setEditingRestIdx(null); setEditRest(null); load(); }}
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>
<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-orange-600 text-white px-4 py-2 rounded text-sm hover:bg-orange-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>
{processResult && (
<span className="text-sm text-gray-600">{processResult}</span>
)}
{selected.size > 0 && (
<>
<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-white rounded-lg shadow overflow-auto min-w-[800px]">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<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>
<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-gray-50 ${selected.has(v.id) ? "bg-blue-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">
<button
onClick={() => handleSelectVideo(v)}
className={`text-left text-sm hover:underline ${
detail?.id === v.id ? "text-blue-800 font-semibold" : "text-blue-600"
}`}
>
{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-blue-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>
<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>
)}
{/* 벌크 진행 패널 */}
{bulkProgress && (
<div className="mt-4 bg-white 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-white 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-blue-600 text-white rounded hover:bg-blue-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 cursor-pointer hover:text-blue-600"
onClick={() => { setEditTitle(detail.title); setEditingTitle(true); }}
title="클릭하여 제목 수정"
>
{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>
{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>
</>
)}
<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" 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" 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" 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" 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" 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" 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" 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-gray-50"
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" />
</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" />
</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" />
</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" />
</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" />
</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" 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" 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" />
</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 { alert("저장 실패"); }
finally { setSaving(false); }
}}
disabled={saving}
className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-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="cursor-pointer hover:bg-gray-50 -m-3 p-3 rounded group"
onClick={() => {
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(", "),
});
}}
title="클릭하여 수정"
>
<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>
<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-orange-50 text-orange-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>
<select
value={transcriptMode}
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
className="border rounded px-2 py-1 text-xs"
>
<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-blue-600 text-white rounded hover:bg-blue-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>
);
}
/* ─── 식당 관리 ─── */
function RestaurantsPanel() {
const [restaurants, setRestaurants] = useState<Restaurant[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(0);
const [nameSearch, setNameSearch] = useState("");
const [selected, setSelected] = useState<Restaurant | null>(null);
const [editForm, setEditForm] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [videos, setVideos] = useState<VideoLink[]>([]);
type RestSortKey = "name" | "region" | "cuisine_type" | "price_range" | "rating" | "business_status";
const [sortKey, setSortKey] = useState<RestSortKey>("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]);
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<string, unknown> = { ...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<Restaurant>);
load();
setSelected(null);
} catch {
alert("저장 실패");
} 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 (
<div>
<div className="flex items-center gap-3 mb-4">
<div className="flex">
<input
type="text"
placeholder="식당 이름 검색..."
value={nameSearch}
onChange={(e) => { 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"
/>
{nameSearch ? (
<button
onClick={() => { setNameSearch(""); 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
onClick={() => load()}
className="border rounded-r px-2 py-2 text-sm text-gray-400 hover:text-gray-600 hover:bg-gray-100"
title="새로고침"
>
</button>
)}
</div>
<span className="text-sm text-gray-400 ml-auto">
{nameSearch ? `${sorted.length} / ` : ""} {restaurants.length}
</span>
</div>
<div className="bg-white rounded-lg shadow overflow-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>{sortIcon("name")}</th>
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>{sortIcon("region")}</th>
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("cuisine_type")}> {sortIcon("cuisine_type")}</th>
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>{sortIcon("price_range")}</th>
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>{sortIcon("rating")}</th>
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>{sortIcon("business_status")}</th>
</tr>
</thead>
<tbody>
{paged.map((r) => (
<tr
key={r.id}
onClick={() => handleSelect(r)}
className={`border-b cursor-pointer hover:bg-gray-50 ${selected?.id === r.id ? "bg-blue-50" : ""}`}
>
<td className="px-4 py-3 font-medium">{r.name}</td>
<td className="px-4 py-3 text-gray-600 text-xs">{r.region || "-"}</td>
<td className="px-4 py-3 text-gray-600 text-xs">{r.cuisine_type || "-"}</td>
<td className="px-4 py-3 text-gray-600 text-xs">{r.price_range || "-"}</td>
<td className="px-4 py-3 text-center text-xs">
{r.rating ? (
<span><span className="text-yellow-500"></span> {r.rating}</span>
) : "-"}
</td>
<td className="px-4 py-3 text-center">
{r.business_status === "CLOSED_PERMANENTLY" ? (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold"></span>
) : r.business_status === "CLOSED_TEMPORARILY" ? (
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold"></span>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</td>
</tr>
))}
{!loading && filtered.length === 0 && (
<tr>
<td colSpan={6} 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>
)}
{/* 식당 상세/수정 패널 */}
{selected && (
<div className="mt-6 bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-base">{selected.name}</h3>
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">x</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[
{ 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 }) => (
<div key={key}>
<label className="text-xs text-gray-500">{label}</label>
<input
value={editForm[key] || ""}
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
className="w-full border rounded px-2 py-1.5 text-sm"
/>
</div>
))}
</div>
{selected.business_status && (
<p className="mt-3 text-xs text-gray-500">
Google : <span className="font-medium">{selected.business_status}</span>
{selected.rating && <> / {selected.rating} ({selected.rating_count?.toLocaleString()})</>}
</p>
)}
{selected.google_place_id && (
<p className="mt-1">
<a
href={`https://www.google.com/maps/place/?q=place_id:${selected.google_place_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs"
>
Google Maps에서
</a>
</p>
)}
{videos.length > 0 && (
<div className="mt-4 border-t pt-3">
<h4 className="text-xs font-semibold text-gray-500 mb-2"> ({videos.length})</h4>
<div className="space-y-1.5 max-h-32 overflow-y-auto">
{videos.map((v) => (
<div key={v.video_id} className="flex items-center gap-2 text-xs">
<span className="px-1.5 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-medium shrink-0">
{v.channel_name}
</span>
<a href={v.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline truncate">
{v.title}
</a>
<span className="text-gray-400 shrink-0">{v.published_at?.slice(0, 10)}</span>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2 mt-4">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "저장 중..." : "저장"}
</button>
<button
onClick={() => setSelected(null)}
className="px-4 py-2 text-sm border rounded text-gray-600 hover:bg-gray-100"
>
</button>
<button
onClick={handleDelete}
className="px-4 py-2 text-sm text-red-500 border border-red-200 rounded hover:bg-red-50 ml-auto"
>
</button>
</div>
</div>
)}
</div>
);
}
/* ─── 유저 관리 ─── */
interface AdminUser {
id: string;
email: string | null;
nickname: string | null;
avatar_url: string | null;
provider: string | null;
created_at: string | null;
favorite_count: number;
review_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;
}
function UsersPanel() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
const [reviews, setReviews] = useState<UserReview[]>([]);
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([]);
return;
}
setSelectedUser(u);
setDetailLoading(true);
try {
const [favs, revs] = await Promise.all([
api.getAdminUserFavorites(u.id),
api.getAdminUserReviews(u.id),
]);
setFavorites(favs);
setReviews(revs);
} catch (e) {
console.error(e);
} finally {
setDetailLoading(false);
}
};
const totalPages = Math.ceil(total / perPage);
return (
<div className="space-y-4">
<h2 className="text-lg font-bold"> ({total})</h2>
{/* Users Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="text-left px-4 py-2"></th>
<th className="text-left px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-left px-4 py-2"></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr
key={u.id}
onClick={() => handleSelectUser(u)}
className={`border-t cursor-pointer transition-colors ${
selectedUser?.id === u.id
? "bg-blue-50"
: "hover:bg-gray-50"
}`}
>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{u.avatar_url ? (
<img
src={u.avatar_url}
alt=""
className="w-7 h-7 rounded-full"
/>
) : (
<div className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-xs font-semibold text-gray-500">
{(u.nickname || u.email || "?").charAt(0).toUpperCase()}
</div>
)}
<span className="font-medium">
{u.nickname || "-"}
</span>
</div>
</td>
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
<td className="px-4 py-2 text-center">
{u.favorite_count > 0 ? (
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">
{u.favorite_count}
</span>
) : (
<span className="text-gray-300">0</span>
)}
</td>
<td className="px-4 py-2 text-center">
{u.review_count > 0 ? (
<span className="inline-block px-2 py-0.5 bg-blue-50 text-blue-600 rounded-full text-xs font-medium">
{u.review_count}
</span>
) : (
<span className="text-gray-300">0</span>
)}
</td>
<td className="px-4 py-2 text-gray-400 text-xs">
{u.created_at?.slice(0, 10) || "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 text-sm border rounded disabled:opacity-30"
>
</button>
<span className="text-sm text-gray-600">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1 text-sm border rounded disabled:opacity-30"
>
</button>
</div>
)}
{/* Selected User Detail */}
{selectedUser && (
<div className="bg-white rounded-lg shadow p-5 space-y-4">
<div className="flex items-center gap-3 pb-3 border-b">
{selectedUser.avatar_url ? (
<img
src={selectedUser.avatar_url}
alt=""
className="w-12 h-12 rounded-full"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-lg font-semibold text-gray-500">
{(selectedUser.nickname || selectedUser.email || "?").charAt(0).toUpperCase()}
</div>
)}
<div>
<div className="font-bold">{selectedUser.nickname || "-"}</div>
<div className="text-sm text-gray-500">{selectedUser.email || "-"}</div>
<div className="text-xs text-gray-400">
{selectedUser.provider} · : {selectedUser.created_at?.slice(0, 10)}
</div>
</div>
</div>
{detailLoading ? (
<p className="text-sm text-gray-500"> ...</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Favorites */}
<div>
<h3 className="font-semibold text-sm mb-2 text-red-600">
({favorites.length})
</h3>
{favorites.length === 0 ? (
<p className="text-xs text-gray-400"> .</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{favorites.map((f) => (
<div
key={f.id}
className="flex items-center justify-between border rounded px-3 py-2 text-xs"
>
<div>
<span className="font-medium">{f.name}</span>
{f.region && (
<span className="ml-1.5 text-gray-400">{f.region}</span>
)}
{f.cuisine_type && (
<span className="ml-1.5 text-gray-400">· {f.cuisine_type}</span>
)}
{f.business_status === "CLOSED_PERMANENTLY" && (
<span className="ml-1.5 px-1 bg-red-100 text-red-600 rounded text-[10px]">
</span>
)}
</div>
{f.rating && (
<span className="text-yellow-500 shrink-0">
{f.rating}
</span>
)}
</div>
))}
</div>
)}
</div>
{/* Reviews */}
<div>
<h3 className="font-semibold text-sm mb-2 text-blue-600">
({reviews.length})
</h3>
{reviews.length === 0 ? (
<p className="text-xs text-gray-400"> .</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{reviews.map((r) => (
<div
key={r.id}
className="border rounded px-3 py-2 text-xs space-y-0.5"
>
<div className="flex items-center justify-between">
<span className="font-medium">
{r.restaurant_name || "알 수 없음"}
</span>
<span className="text-yellow-500 shrink-0">
{"★".repeat(Math.round(r.rating))} {r.rating}
</span>
</div>
{r.review_text && (
<p className="text-gray-600 line-clamp-2">
{r.review_text}
</p>
)}
<div className="text-gray-400 text-[10px]">
{r.visited_at && `방문: ${r.visited_at} · `}
{r.created_at?.slice(0, 10)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
);
}