UX improvements: mobile bottom sheet, cuisine taxonomy, search enhancements

- Add BottomSheet component for Google Maps-style restaurant detail on mobile
  (3-snap drag: 40%/55%/92%, velocity-based close, backdrop overlay)
- Mobile map mode now full-screen with bottom sheet overlay for details
- Collapsible filter panel on mobile with active filter badge count
- Standardized cuisine taxonomy (46 categories: 한식|국밥, 일식|스시 etc.)
  with LLM remap endpoint and admin UI button
- Enhanced search: keyword search now includes foods_mentioned + video title
- Search results include channels array for frontend filtering
- Channel filter moved to frontend filteredRestaurants (not API-level)
- LLM extraction prompt updated for pipe-delimited region + cuisine taxonomy
- Vector rebuild endpoint with rich JSON chunks per restaurant
- Geolocation-based auto region selection on page load
- Desktop filters split into two clean rows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-09 10:54:28 +09:00
parent 3694730501
commit 2bddb0f764
16 changed files with 2277 additions and 308 deletions

View File

@@ -2,24 +2,48 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { Channel, Video, VideoDetail, VideoLink, Restaurant } from "@/lib/api";
import type { Channel, Video, VideoDetail, VideoLink, Restaurant, DaemonConfig } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
type Tab = "channels" | "videos" | "restaurants" | "users";
type Tab = "channels" | "videos" | "restaurants" | "users" | "daemon";
export default function AdminPage() {
const [tab, setTab] = useState<Tab>("channels");
const { user, isLoading } = useAuth();
const isAdmin = user?.is_admin === true;
if (isLoading) {
return <div className="min-h-screen bg-gray-50 flex items-center justify-center text-gray-500"> ...</div>;
}
if (!user) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<p className="text-gray-600 mb-4"> </p>
<a href="/" className="text-blue-600 hover:underline"> </a>
</div>
</div>
);
}
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>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">Tasteby Admin</h1>
{!isAdmin && (
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium"> </span>
)}
</div>
<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) => (
{(["channels", "videos", "restaurants", "users", "daemon"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
@@ -29,24 +53,25 @@ export default function AdminPage() {
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : "유저 관리"}
{t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : t === "users" ? "유저 관리" : "데몬 설정"}
</button>
))}
</nav>
</header>
<main className="max-w-6xl mx-auto p-6">
{tab === "channels" && <ChannelsPanel />}
{tab === "videos" && <VideosPanel />}
{tab === "restaurants" && <RestaurantsPanel />}
{tab === "channels" && <ChannelsPanel isAdmin={isAdmin} />}
{tab === "videos" && <VideosPanel isAdmin={isAdmin} />}
{tab === "restaurants" && <RestaurantsPanel isAdmin={isAdmin} />}
{tab === "users" && <UsersPanel />}
{tab === "daemon" && <DaemonPanel isAdmin={isAdmin} />}
</main>
</div>
);
}
/* ─── 채널 관리 ─── */
function ChannelsPanel() {
function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
const [channels, setChannels] = useState<Channel[]>([]);
const [newId, setNewId] = useState("");
const [newName, setNewName] = useState("");
@@ -101,7 +126,7 @@ function ChannelsPanel() {
return (
<div>
<div className="bg-white rounded-lg shadow p-4 mb-6">
{isAdmin && <div className="bg-white rounded-lg shadow p-4 mb-6">
<h2 className="font-semibold mb-3"> </h2>
<div className="flex gap-2">
<input
@@ -130,7 +155,7 @@ function ChannelsPanel() {
</button>
</div>
</div>
</div>}
<div className="bg-white rounded-lg shadow">
<table className="w-full text-sm">
@@ -139,7 +164,9 @@ function ChannelsPanel() {
<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-right px-4 py-3"> </th>
<th className="text-left px-4 py-3"> </th>
{isAdmin && <th className="text-left px-4 py-3"></th>}
<th className="text-left px-4 py-3"> </th>
</tr>
</thead>
@@ -159,7 +186,17 @@ function ChannelsPanel() {
<span className="text-gray-400 text-xs"></span>
)}
</td>
<td className="px-4 py-3 flex gap-3">
<td className="px-4 py-3 text-right font-medium">
{ch.video_count > 0 ? (
<span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">{ch.video_count}</span>
) : (
<span className="text-gray-400 text-xs">0</span>
)}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{ch.last_scanned_at ? ch.last_scanned_at.slice(0, 16).replace("T", " ") : "-"}
</td>
{isAdmin && <td className="px-4 py-3 flex gap-3">
<button
onClick={() => handleScan(ch.channel_id)}
className="text-blue-600 hover:underline text-sm"
@@ -178,7 +215,7 @@ function ChannelsPanel() {
>
</button>
</td>
</td>}
<td className="px-4 py-3 text-gray-600">
{scanResult[ch.channel_id] || "-"}
</td>
@@ -186,7 +223,7 @@ function ChannelsPanel() {
))}
{channels.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
@@ -201,7 +238,7 @@ function ChannelsPanel() {
/* ─── 영상 관리 ─── */
type VideoSortKey = "status" | "channel_name" | "title" | "published_at";
function VideosPanel() {
function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
const [videos, setVideos] = useState<Video[]>([]);
const [channels, setChannels] = useState<Channel[]>([]);
const [channelFilter, setChannelFilter] = useState("");
@@ -241,6 +278,10 @@ function VideosPanel() {
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 [bulkProgress, setBulkProgress] = useState<{
label: string;
total: number;
@@ -256,7 +297,7 @@ function VideosPanel() {
const load = useCallback((reset = true) => {
api
.getVideos({ status: statusFilter || undefined, limit: 500 })
.getVideos({ status: statusFilter || undefined })
.then((data) => {
setVideos(data);
if (reset) {
@@ -375,7 +416,16 @@ function VideosPanel() {
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 token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const resp = await fetch(`${apiBase}${endpoint}`, { method: "POST", headers });
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; }
@@ -416,6 +466,96 @@ function VideosPanel() {
}
};
const startRebuildVectors = async () => {
if (!confirm("전체 식당 벡터를 재생성합니다. 진행하시겠습니까?")) return;
setRebuildingVectors(true);
setVectorProgress(null);
try {
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const resp = await fetch(`${apiBase}/api/videos/rebuild-vectors`, { method: "POST", headers });
if (!resp.ok) {
alert(`벡터 재생성 실패: ${resp.status}`);
setRebuildingVectors(false);
return;
}
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 = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const resp = await fetch(`${apiBase}/api/videos/remap-cuisine`, { method: "POST", headers });
if (!resp.ok) {
alert(`음식 종류 재분류 실패: ${resp.status}`);
setRemappingCuisine(false);
return;
}
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 handleSort = (key: VideoSortKey) => {
if (sortKey === key) {
setSortAsc(!sortAsc);
@@ -512,7 +652,7 @@ function VideosPanel() {
) : (
<button
type="button"
onClick={() => { setDetail(null); setEditingRestIdx(null); setEditRest(null); load(); }}
onClick={() => { setEditingRestIdx(null); setEditRest(null); load(false); if (detail) { api.getVideoDetail(detail.id).then(setDetail).catch(() => {}); } }}
className="border rounded-r px-3 py-2 text-sm text-gray-400 hover:text-gray-600 hover:bg-gray-100 cursor-pointer relative z-10"
title="새로고침"
>
@@ -520,6 +660,7 @@ function VideosPanel() {
</button>
)}
</div>
{isAdmin && <>
<button
type="button"
onClick={handleProcess}
@@ -542,10 +683,25 @@ function VideosPanel() {
>
{bulkExtracting ? "벌크 추출 중..." : "벌크 LLM 추출"}
</button>
<button
onClick={startRebuildVectors}
disabled={rebuildingVectors || bulkExtracting || bulkTranscripting}
className="bg-teal-600 text-white px-4 py-2 rounded text-sm hover:bg-teal-700 disabled:opacity-50"
>
{rebuildingVectors ? "벡터 재생성 중..." : "벡터 재생성"}
</button>
<button
onClick={startRemapCuisine}
disabled={remappingCuisine || bulkExtracting || bulkTranscripting || rebuildingVectors}
className="bg-amber-600 text-white px-4 py-2 rounded text-sm hover:bg-amber-700 disabled:opacity-50"
>
{remappingCuisine ? "음식분류 중..." : "음식종류 재분류"}
</button>
</>}
{processResult && (
<span className="text-sm text-gray-600">{processResult}</span>
)}
{selected.size > 0 && (
{isAdmin && selected.size > 0 && (
<>
<button
onClick={handleBulkSkip}
@@ -605,7 +761,7 @@ function VideosPanel() {
>
{sortIcon("published_at")}
</th>
<th className="text-left px-4 py-3 whitespace-nowrap"></th>
{isAdmin && <th className="text-left px-4 py-3 whitespace-nowrap"></th>}
</tr>
</thead>
<tbody>
@@ -629,12 +785,13 @@ function VideosPanel() {
</span>
</td>
<td className="px-4 py-3 text-gray-600">{v.channel_name}</td>
<td className="px-4 py-3">
<td className="px-4 py-3 max-w-[300px]">
<button
onClick={() => handleSelectVideo(v)}
className={`text-left text-sm hover:underline ${
className={`text-left text-sm hover:underline truncate block max-w-full ${
detail?.id === v.id ? "text-blue-800 font-semibold" : "text-blue-600"
}`}
title={v.title}
>
{v.title}
</button>
@@ -668,7 +825,7 @@ function VideosPanel() {
<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">
{isAdmin && <td className="px-4 py-3 whitespace-nowrap flex gap-3">
{v.status === "pending" && (
<button
onClick={() => handleSkip(v.id)}
@@ -683,7 +840,7 @@ function VideosPanel() {
>
</button>
</td>
</td>}
</tr>
))}
{videos.length === 0 && (
@@ -733,6 +890,43 @@ function VideosPanel() {
</div>
)}
{/* 음식종류 재분류 진행 */}
{remapProgress && (
<div className="mt-4 bg-white rounded-lg shadow p-4">
<h4 className="font-semibold text-sm mb-2">
{remapProgress.current >= remapProgress.total ? "완료" : "진행 중"}
</h4>
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
<div
className="bg-amber-500 h-2 rounded-full transition-all"
style={{ width: `${remapProgress.total ? (remapProgress.current / remapProgress.total) * 100 : 0}%` }}
/>
</div>
<p className="text-xs text-gray-500">
{remapProgress.current}/{remapProgress.total} {remapProgress.updated}
</p>
</div>
)}
{/* 벡터 재생성 진행 */}
{vectorProgress && (
<div className="mt-4 bg-white rounded-lg shadow p-4">
<h4 className="font-semibold text-sm mb-2">
{vectorProgress.phase === "done" ? "완료" : `(${vectorProgress.phase === "prepare" ? "데이터 준비" : "임베딩 저장"})`}
</h4>
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
<div
className="bg-teal-500 h-2 rounded-full transition-all"
style={{ width: `${vectorProgress.total ? (vectorProgress.current / vectorProgress.total) * 100 : 0}%` }}
/>
</div>
<p className="text-xs text-gray-500">
{vectorProgress.current}/{vectorProgress.total}
{vectorProgress.name && `${vectorProgress.name}`}
</p>
</div>
)}
{/* 벌크 진행 패널 */}
{bulkProgress && (
<div className="mt-4 bg-white rounded-lg shadow p-4">
@@ -822,9 +1016,9 @@ function VideosPanel() {
</div>
) : (
<h3
className="font-semibold text-base cursor-pointer hover:text-blue-600"
onClick={() => { setEditTitle(detail.title); setEditingTitle(true); }}
title="클릭하여 제목 수정"
className={`font-semibold text-base ${isAdmin ? "cursor-pointer hover:text-blue-600" : ""}`}
onClick={isAdmin ? () => { setEditTitle(detail.title); setEditingTitle(true); } : undefined}
title={isAdmin ? "클릭하여 제목 수정" : undefined}
>
{detail.title}
</h3>
@@ -871,7 +1065,7 @@ function VideosPanel() {
<h4 className="font-semibold text-sm">
({detail.restaurants.length})
</h4>
{detail.transcript && (
{isAdmin && detail.transcript && (
<>
<button
onClick={async () => {
@@ -909,12 +1103,12 @@ function VideosPanel() {
</button>
</>
)}
<button
{isAdmin && <button
onClick={() => setShowManualAdd(!showManualAdd)}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
>
{showManualAdd ? "수동 추가 닫기" : "수동 추가"}
</button>
</button>}
</div>
{showManualAdd && (
<div className="border rounded p-3 mb-3 bg-green-50 space-y-2">
@@ -1064,7 +1258,7 @@ function VideosPanel() {
setDetail(d);
setEditingRestIdx(null);
setEditRest(null);
} catch { alert("저장 실패"); }
} catch (e) { alert("저장 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setSaving(false); }
}}
disabled={saving}
@@ -1082,8 +1276,8 @@ function VideosPanel() {
</div>
) : (
<div
className="cursor-pointer hover:bg-gray-50 -m-3 p-3 rounded group"
onClick={() => {
className={`${isAdmin ? "cursor-pointer hover:bg-gray-50" : ""} -m-3 p-3 rounded group`}
onClick={isAdmin ? () => {
let evalText = "";
if (typeof r.evaluation === "object" && r.evaluation) {
if (r.evaluation.text) {
@@ -1107,8 +1301,8 @@ function VideosPanel() {
price_range: r.price_range || "",
guests: r.guests.join(", "),
});
}}
title="클릭하여 수정"
} : undefined}
title={isAdmin ? "클릭하여 수정" : undefined}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -1121,7 +1315,7 @@ function VideosPanel() {
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-600"></span>
)}
</div>
<button
{isAdmin && <button
onClick={async (e) => {
e.stopPropagation();
if (!confirm(`"${r.name}" 식당 매핑을 삭제하시겠습니까?`)) return;
@@ -1135,7 +1329,7 @@ function VideosPanel() {
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>
</button>}
</div>
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
{r.address && <p>: {r.address}</p>}
@@ -1172,6 +1366,7 @@ function VideosPanel() {
{editingRestIdx === null && <div>
<div className="flex items-center gap-2 mb-2">
<h4 className="font-semibold text-sm"></h4>
{isAdmin && <>
<select
value={transcriptMode}
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
@@ -1200,6 +1395,7 @@ function VideosPanel() {
>
{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">
@@ -1218,7 +1414,7 @@ function VideosPanel() {
}
/* ─── 식당 관리 ─── */
function RestaurantsPanel() {
function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
const [restaurants, setRestaurants] = useState<Restaurant[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(0);
@@ -1301,8 +1497,8 @@ function RestaurantsPanel() {
await api.updateRestaurant(selected.id, data as Partial<Restaurant>);
load();
setSelected(null);
} catch {
alert("저장 실패");
} catch (e) {
alert("저장 실패: " + (e instanceof Error ? e.message : String(e)));
} finally {
setSaving(false);
}
@@ -1439,6 +1635,7 @@ function RestaurantsPanel() {
value={editForm[key] || ""}
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
className="w-full border rounded px-2 py-1.5 text-sm"
disabled={!isAdmin}
/>
</div>
))}
@@ -1481,25 +1678,25 @@ function RestaurantsPanel() {
)}
<div className="flex gap-2 mt-4">
<button
{isAdmin && <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>}
<button
onClick={() => setSelected(null)}
className="px-4 py-2 text-sm border rounded text-gray-600 hover:bg-gray-100"
>
{isAdmin ? "취소" : "닫기"}
</button>
<button
{isAdmin && <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>
</button>}
</div>
</div>
)}
@@ -1796,3 +1993,229 @@ function UsersPanel() {
</div>
);
}
/* ─── 데몬 설정 ─── */
function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
const [config, setConfig] = useState<DaemonConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(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 <p className="text-gray-500"> ...</p>;
return (
<div className="space-y-6">
{/* Schedule Config */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4"> </h2>
<p className="text-xs text-gray-500 mb-4">
, .
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Scan config */}
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium"> </h3>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={scanEnabled}
onChange={(e) => setScanEnabled(e.target.checked)}
disabled={!isAdmin}
className="w-4 h-4"
/>
<span className={`text-sm ${scanEnabled ? "text-green-600 font-medium" : "text-gray-500"}`}>
{scanEnabled ? "활성" : "비활성"}
</span>
</label>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"> ()</label>
<input
type="number"
value={scanInterval}
onChange={(e) => setScanInterval(Number(e.target.value))}
disabled={!isAdmin}
min={1}
className="border rounded px-3 py-1.5 text-sm w-32"
/>
</div>
{config?.last_scan_at && (
<p className="text-xs text-gray-400"> : {config.last_scan_at}</p>
)}
</div>
{/* Process config */}
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium"> </h3>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={processEnabled}
onChange={(e) => setProcessEnabled(e.target.checked)}
disabled={!isAdmin}
className="w-4 h-4"
/>
<span className={`text-sm ${processEnabled ? "text-green-600 font-medium" : "text-gray-500"}`}>
{processEnabled ? "활성" : "비활성"}
</span>
</label>
</div>
<div className="flex gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1"> ()</label>
<input
type="number"
value={processInterval}
onChange={(e) => setProcessInterval(Number(e.target.value))}
disabled={!isAdmin}
min={1}
className="border rounded px-3 py-1.5 text-sm w-32"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"> </label>
<input
type="number"
value={processLimit}
onChange={(e) => setProcessLimit(Number(e.target.value))}
disabled={!isAdmin}
min={1}
max={50}
className="border rounded px-3 py-1.5 text-sm w-32"
/>
</div>
</div>
{config?.last_process_at && (
<p className="text-xs text-gray-400"> : {config.last_process_at}</p>
)}
</div>
</div>
{isAdmin && (
<div className="mt-4">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "저장 중..." : "설정 저장"}
</button>
</div>
)}
</div>
{/* Manual Triggers */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4"> </h2>
<p className="text-xs text-gray-500 mb-4">
. .
</p>
<div className="flex gap-3">
{isAdmin && (
<>
<button
onClick={handleRunScan}
disabled={running !== null}
className="px-4 py-2 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
>
{running === "scan" ? "스캔 중..." : "채널 스캔 실행"}
</button>
<button
onClick={handleRunProcess}
disabled={running !== null}
className="px-4 py-2 bg-purple-600 text-white text-sm rounded hover:bg-purple-700 disabled:opacity-50"
>
{running === "process" ? "처리 중..." : "영상 처리 실행"}
</button>
</>
)}
</div>
{result && (
<p className={`mt-3 text-sm ${result.includes("실패") || result.includes("API") ? "text-red-600" : "text-green-600"}`}>
{result}
</p>
)}
</div>
{/* Config updated_at */}
{config?.updated_at && (
<p className="text-xs text-gray-400 text-right"> : {config.updated_at}</p>
)}
</div>
);
}