Material Symbols 아이콘 전환 + 로고 이미지 적용 + 테이블링 이름 유사도 체크
- 전체 인라인 SVG를 Google Material Symbols Rounded로 교체 - Icon 컴포넌트 추가, cuisine-icons 매핑 리팩토링 - Tasteby 핀 로고 이미지 적용 (라이트/다크 버전) - 테이블링/캐치테이블 이름 유사도 체크 및 리셋 API 추가 - 어드민 페이지 리셋 버튼 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,33 +41,37 @@ export default function AdminPage() {
|
||||
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>;
|
||||
return <div className="min-h-screen bg-background 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="min-h-screen bg-background 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>
|
||||
<a href="/" className="text-brand-600 hover:underline">메인으로 돌아가기</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<header className="bg-white border-b px-6 py-4">
|
||||
<div className="min-h-screen bg-background text-gray-900">
|
||||
<header className="bg-surface border-b border-brand-100 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold">Tasteby Admin</h1>
|
||||
<picture>
|
||||
<source srcSet="/logo-dark-80h.png" media="(prefers-color-scheme: dark)" />
|
||||
<img src="/logo-80h.png" alt="Tasteby" className="h-7" />
|
||||
</picture>
|
||||
<span className="text-xl font-bold text-gray-500">Admin</span>
|
||||
{!isAdmin && (
|
||||
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">읽기 전용</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && <CacheFlushButton />}
|
||||
<a href="/" className="text-sm text-blue-600 hover:underline">
|
||||
<a href="/" className="text-sm text-brand-600 hover:underline">
|
||||
← 메인으로
|
||||
</a>
|
||||
</div>
|
||||
@@ -79,8 +83,8 @@ export default function AdminPage() {
|
||||
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"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-brand-50 text-brand-700 hover:bg-brand-100"
|
||||
}`}
|
||||
>
|
||||
{t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : t === "users" ? "유저 관리" : "데몬 설정"}
|
||||
@@ -171,40 +175,40 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isAdmin && <div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
{isAdmin && <div className="bg-surface 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 bg-white text-gray-900"
|
||||
className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900"
|
||||
/>
|
||||
<input
|
||||
placeholder="채널 이름"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900"
|
||||
className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900"
|
||||
/>
|
||||
<input
|
||||
placeholder="제목 필터 (선택)"
|
||||
value={newFilter}
|
||||
onChange={(e) => setNewFilter(e.target.value)}
|
||||
className="border rounded px-3 py-2 w-40 text-sm bg-white text-gray-900"
|
||||
className="border rounded px-3 py-2 w-40 text-sm bg-surface text-gray-900"
|
||||
/>
|
||||
<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"
|
||||
className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="bg-surface rounded-lg shadow">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">채널 이름</th>
|
||||
<th className="text-left px-4 py-3">Channel ID</th>
|
||||
@@ -219,14 +223,14 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{channels.map((ch) => (
|
||||
<tr key={ch.id} className="border-b hover:bg-gray-50">
|
||||
<tr key={ch.id} className="border-b hover:bg-brand-50/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">
|
||||
<span className="px-2 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">
|
||||
{ch.title_filter}
|
||||
</span>
|
||||
) : (
|
||||
@@ -236,7 +240,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{editingChannel === ch.id ? (
|
||||
<input value={editDesc} onChange={(e) => setEditDesc(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs w-32 bg-white text-gray-900" placeholder="설명" />
|
||||
className="border rounded px-2 py-1 text-xs w-32 bg-surface text-gray-900" placeholder="설명" />
|
||||
) : (
|
||||
<span className="text-gray-600 cursor-pointer" onClick={() => {
|
||||
if (!isAdmin) return;
|
||||
@@ -248,8 +252,8 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{editingChannel === ch.id ? (
|
||||
<div className="flex gap-1">
|
||||
<input value={editTags} onChange={(e) => setEditTags(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs w-40 bg-white text-gray-900" placeholder="태그 (쉼표 구분)" />
|
||||
<button onClick={() => handleSaveChannel(ch.id)} className="text-blue-600 text-xs hover:underline">저장</button>
|
||||
className="border rounded px-2 py-1 text-xs w-40 bg-surface text-gray-900" placeholder="태그 (쉼표 구분)" />
|
||||
<button onClick={() => handleSaveChannel(ch.id)} className="text-brand-600 text-xs hover:underline">저장</button>
|
||||
<button onClick={() => setEditingChannel(null)} className="text-gray-400 text-xs hover:underline">취소</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -262,7 +266,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<td className="px-4 py-3 text-center text-xs">
|
||||
{editingChannel === ch.id ? (
|
||||
<input type="number" value={editOrder} onChange={(e) => setEditOrder(Number(e.target.value))}
|
||||
className="border rounded px-2 py-1 text-xs w-14 text-center bg-white text-gray-900" min={1} />
|
||||
className="border rounded px-2 py-1 text-xs w-14 text-center bg-surface text-gray-900" min={1} />
|
||||
) : (
|
||||
<span className="text-gray-500">{ch.sort_order ?? 99}</span>
|
||||
)}
|
||||
@@ -277,7 +281,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{isAdmin && <td className="px-4 py-3 flex gap-3">
|
||||
<button
|
||||
onClick={() => handleScan(ch.channel_id)}
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
className="text-brand-600 hover:underline text-sm"
|
||||
>
|
||||
스캔
|
||||
</button>
|
||||
@@ -738,7 +742,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
pending: "bg-yellow-100 text-yellow-800",
|
||||
processing: "bg-blue-100 text-blue-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",
|
||||
@@ -750,7 +754,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }}
|
||||
className="border rounded px-3 py-2 text-sm bg-white text-gray-900"
|
||||
className="border rounded px-3 py-2 text-sm bg-surface text-gray-900"
|
||||
>
|
||||
<option value="">전체 채널</option>
|
||||
{channels.map((ch) => (
|
||||
@@ -760,7 +764,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="border rounded px-3 py-2 text-sm bg-white text-gray-900"
|
||||
className="border rounded px-3 py-2 text-sm bg-surface text-gray-900"
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="pending">대기중</option>
|
||||
@@ -776,7 +780,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
value={titleSearch}
|
||||
onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }}
|
||||
onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")}
|
||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900"
|
||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900"
|
||||
/>
|
||||
{titleSearch ? (
|
||||
<button
|
||||
@@ -880,9 +884,9 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-auto min-w-[800px]">
|
||||
<div className="bg-surface rounded-lg shadow overflow-auto min-w-[800px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="px-4 py-3 w-8">
|
||||
<input
|
||||
@@ -923,7 +927,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedVideos.map((v) => (
|
||||
<tr key={v.id} className={`border-b hover:bg-gray-50 ${selected.has(v.id) ? "bg-blue-50" : ""}`}>
|
||||
<tr key={v.id} className={`border-b hover:bg-brand-50/50 ${selected.has(v.id) ? "bg-brand-50" : ""}`}>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -946,7 +950,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<button
|
||||
onClick={() => handleSelectVideo(v)}
|
||||
className={`text-left text-sm hover:underline truncate block max-w-full ${
|
||||
detail?.id === v.id ? "text-blue-800 font-semibold" : "text-blue-600"
|
||||
detail?.id === v.id ? "text-blue-800 font-semibold" : "text-brand-600"
|
||||
}`}
|
||||
title={v.title}
|
||||
>
|
||||
@@ -957,7 +961,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<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"}`}>
|
||||
<span title="LLM 추출" className={`inline-block w-5 text-center text-xs ${v.has_llm ? "text-brand-600" : "text-gray-300"}`}>
|
||||
{v.has_llm ? "L" : "-"}
|
||||
</span>
|
||||
</td>
|
||||
@@ -1049,7 +1053,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
{/* 음식종류 재분류 진행 */}
|
||||
{remapProgress && (
|
||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
||||
<div className="mt-4 bg-surface rounded-lg shadow p-4">
|
||||
<h4 className="font-semibold text-sm mb-2">
|
||||
음식종류 재분류 {remapProgress.current >= remapProgress.total ? "완료" : "진행 중"}
|
||||
</h4>
|
||||
@@ -1067,7 +1071,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
{/* 메뉴태그 재생성 진행 */}
|
||||
{foodsProgress && (
|
||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
||||
<div className="mt-4 bg-surface rounded-lg shadow p-4">
|
||||
<h4 className="font-semibold text-sm mb-2">
|
||||
메뉴태그 재생성 {foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"}
|
||||
</h4>
|
||||
@@ -1085,7 +1089,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
{/* 벡터 재생성 진행 */}
|
||||
{vectorProgress && (
|
||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
||||
<div className="mt-4 bg-surface rounded-lg shadow p-4">
|
||||
<h4 className="font-semibold text-sm mb-2">
|
||||
벡터 재생성 {vectorProgress.phase === "done" ? "완료" : `(${vectorProgress.phase === "prepare" ? "데이터 준비" : "임베딩 저장"})`}
|
||||
</h4>
|
||||
@@ -1104,7 +1108,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
{/* 벌크 진행 패널 */}
|
||||
{bulkProgress && (
|
||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
||||
<div className="mt-4 bg-surface rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-semibold text-sm">
|
||||
{bulkProgress.label} ({bulkProgress.current}/{bulkProgress.total})
|
||||
@@ -1157,7 +1161,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<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="mt-6 bg-surface rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4 gap-2">
|
||||
{editingTitle ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
@@ -1178,7 +1182,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
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"
|
||||
className="px-2 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
@@ -1191,7 +1195,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
) : (
|
||||
<h3
|
||||
className={`font-semibold text-base ${isAdmin ? "cursor-pointer hover:text-blue-600" : ""}`}
|
||||
className={`font-semibold text-base ${isAdmin ? "cursor-pointer hover:text-brand-600" : ""}`}
|
||||
onClick={isAdmin ? () => { setEditTitle(detail.title); setEditingTitle(true); } : undefined}
|
||||
title={isAdmin ? "클릭하여 제목 수정" : undefined}
|
||||
>
|
||||
@@ -1290,34 +1294,34 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">식당명 *</label>
|
||||
<input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="식당 이름" />
|
||||
<input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="식당 이름" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">주소</label>
|
||||
<input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="주소 (없으면 지역)" />
|
||||
<input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="주소 (없으면 지역)" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">지역</label>
|
||||
<input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="서울 강남" />
|
||||
<input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="서울 강남" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">음식 종류</label>
|
||||
<input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="한식, 일식..." />
|
||||
<input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="한식, 일식..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">메뉴</label>
|
||||
<input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="메뉴1, 메뉴2" />
|
||||
<input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="메뉴1, 메뉴2" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">게스트</label>
|
||||
<input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="게스트1, 게스트2" />
|
||||
<input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="게스트1, 게스트2" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">평가/요약</label>
|
||||
<textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" rows={2} placeholder="맛집 평가 내용" />
|
||||
<textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" rows={2} placeholder="맛집 평가 내용" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -1360,7 +1364,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-white text-gray-900"
|
||||
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-surface text-gray-900"
|
||||
rows={12}
|
||||
placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)"
|
||||
/>
|
||||
@@ -1374,39 +1378,39 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">이름</label>
|
||||
<input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm bg-white text-gray-900" />
|
||||
<input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm bg-surface text-gray-900" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">종류</label>
|
||||
<input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
||||
<input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">가격대</label>
|
||||
<input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
||||
<input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">지역</label>
|
||||
<input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
||||
<input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">주소</label>
|
||||
<input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
||||
<input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">메뉴 (쉼표 구분)</label>
|
||||
<input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="메뉴1, 메뉴2, ..." />
|
||||
<input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="메뉴1, 메뉴2, ..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">평가/요약</label>
|
||||
<textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" rows={2} />
|
||||
<textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" rows={2} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">게스트 (쉼표 구분)</label>
|
||||
<input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
||||
<input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -1437,7 +1441,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
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"
|
||||
className="px-3 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</button>
|
||||
@@ -1451,7 +1455,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`${isAdmin ? "cursor-pointer hover:bg-gray-50" : ""} -m-3 p-3 rounded group`}
|
||||
className={`${isAdmin ? "cursor-pointer hover:bg-brand-50/50" : ""} -m-3 p-3 rounded group`}
|
||||
onClick={isAdmin ? () => {
|
||||
let evalText = "";
|
||||
if (typeof r.evaluation === "object" && r.evaluation) {
|
||||
@@ -1545,7 +1549,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<select
|
||||
value={transcriptMode}
|
||||
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
|
||||
className="border rounded px-2 py-1 text-xs bg-white text-gray-900"
|
||||
className="border rounded px-2 py-1 text-xs bg-surface text-gray-900"
|
||||
>
|
||||
<option value="auto">자동 (수동→자동생성)</option>
|
||||
<option value="manual">수동 자막만</option>
|
||||
@@ -1566,7 +1570,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
}}
|
||||
disabled={fetchingTranscript}
|
||||
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-2 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{fetchingTranscript ? "가져오는 중..." : detail.transcript ? "다시 가져오기" : "트랜스크립트 가져오기"}
|
||||
</button>
|
||||
@@ -1707,7 +1711,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
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 bg-white text-gray-900"
|
||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900"
|
||||
/>
|
||||
{nameSearch ? (
|
||||
<button
|
||||
@@ -1773,6 +1777,38 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
>
|
||||
{bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("테이블링 매핑을 전부 초기화하시겠습니까?")) return;
|
||||
try {
|
||||
await fetch("/api/restaurants/reset-tabling", {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||
});
|
||||
alert("테이블링 매핑 초기화 완료");
|
||||
load();
|
||||
} catch (e) { alert("실패: " + e); }
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100"
|
||||
>
|
||||
테이블링 초기화
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("캐치테이블 매핑을 전부 초기화하시겠습니까?")) return;
|
||||
try {
|
||||
await fetch("/api/restaurants/reset-catchtable", {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||
});
|
||||
alert("캐치테이블 매핑 초기화 완료");
|
||||
load();
|
||||
} catch (e) { alert("실패: " + e); }
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100"
|
||||
>
|
||||
캐치테이블 초기화
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const pending = await fetch(`/api/restaurants/catchtable-pending`, {
|
||||
@@ -1847,9 +1883,9 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-auto">
|
||||
<div className="bg-surface rounded-lg shadow overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||
<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>
|
||||
@@ -1864,7 +1900,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<tr
|
||||
key={r.id}
|
||||
onClick={() => handleSelect(r)}
|
||||
className={`border-b cursor-pointer hover:bg-gray-50 ${selected?.id === r.id ? "bg-blue-50" : ""}`}
|
||||
className={`border-b cursor-pointer hover:bg-brand-50/50 ${selected?.id === r.id ? "bg-brand-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>
|
||||
@@ -1909,7 +1945,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
{/* 식당 상세/수정 패널 */}
|
||||
{selected && (
|
||||
<div className="mt-6 bg-white rounded-lg shadow p-4">
|
||||
<div className="mt-6 bg-surface 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>
|
||||
@@ -1931,7 +1967,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<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 bg-white text-gray-900"
|
||||
className="w-full border rounded px-2 py-1.5 text-sm bg-surface text-gray-900"
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
@@ -1949,7 +1985,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
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"
|
||||
className="text-brand-600 hover:underline text-xs"
|
||||
>
|
||||
Google Maps에서 보기
|
||||
</a>
|
||||
@@ -1964,7 +2000,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||
) : selected.tabling_url ? (
|
||||
<a href={selected.tabling_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs">{selected.tabling_url}</a>
|
||||
className="text-brand-600 hover:underline text-xs">{selected.tabling_url}</a>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미연결</span>
|
||||
)}
|
||||
@@ -2015,7 +2051,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||
) : selected.catchtable_url ? (
|
||||
<a href={selected.catchtable_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs">{selected.catchtable_url}</a>
|
||||
className="text-brand-600 hover:underline text-xs">{selected.catchtable_url}</a>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미연결</span>
|
||||
)}
|
||||
@@ -2067,7 +2103,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<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">
|
||||
<a href={v.url} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline truncate">
|
||||
{v.title}
|
||||
</a>
|
||||
<span className="text-gray-400 shrink-0">{v.published_at?.slice(0, 10)}</span>
|
||||
@@ -2081,7 +2117,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{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"
|
||||
className="px-4 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</button>}
|
||||
@@ -2192,9 +2228,9 @@ function UsersPanel() {
|
||||
<h2 className="text-lg font-bold">유저 관리 ({total}명)</h2>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="bg-surface rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">사용자</th>
|
||||
<th className="text-left px-4 py-2">이메일</th>
|
||||
@@ -2210,8 +2246,8 @@ function UsersPanel() {
|
||||
onClick={() => handleSelectUser(u)}
|
||||
className={`border-t cursor-pointer transition-colors ${
|
||||
selectedUser?.id === u.id
|
||||
? "bg-blue-50"
|
||||
: "hover:bg-gray-50"
|
||||
? "bg-brand-50"
|
||||
: "hover:bg-brand-50/50"
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
@@ -2244,7 +2280,7 @@ function UsersPanel() {
|
||||
</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">
|
||||
<span className="inline-block px-2 py-0.5 bg-brand-50 text-brand-600 rounded-full text-xs font-medium">
|
||||
{u.review_count}
|
||||
</span>
|
||||
) : (
|
||||
@@ -2285,7 +2321,7 @@ function UsersPanel() {
|
||||
|
||||
{/* Selected User Detail */}
|
||||
{selectedUser && (
|
||||
<div className="bg-white rounded-lg shadow p-5 space-y-4">
|
||||
<div className="bg-surface rounded-lg shadow p-5 space-y-4">
|
||||
<div className="flex items-center gap-3 pb-3 border-b">
|
||||
{selectedUser.avatar_url ? (
|
||||
<img
|
||||
@@ -2352,7 +2388,7 @@ function UsersPanel() {
|
||||
|
||||
{/* Reviews */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 text-blue-600">
|
||||
<h3 className="font-semibold text-sm mb-2 text-brand-600">
|
||||
작성한 리뷰 ({reviews.length})
|
||||
</h3>
|
||||
{reviews.length === 0 ? (
|
||||
@@ -2476,7 +2512,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Schedule Config */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-surface rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">스케줄 설정</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다.
|
||||
@@ -2569,7 +2605,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<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"
|
||||
className="px-4 py-2 bg-brand-600 text-white text-sm rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "저장 중..." : "설정 저장"}
|
||||
</button>
|
||||
@@ -2578,7 +2614,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
|
||||
{/* Manual Triggers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-surface rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">수동 실행</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다.
|
||||
|
||||
Reference in New Issue
Block a user