- page.tsx 2817 LOC → 107 LOC (탭 라우팅 + CacheFlushButton만)
- _panels/ChannelsPanel.tsx (222), VideosPanel.tsx (1282),
RestaurantsPanel.tsx (675), UsersPanel.tsx (383), DaemonPanel.tsx (231)
- localStorage.getItem("tasteby_token") 10곳 → getAdminToken() (lib/admin-utils)
- 패널 내부 로직/state 그대로 (점진적 접근, 회귀 위험 최소화)
후속 분리:
- (신규) SSE 파싱 6곳을 consumeSseStream으로 통일
설계서: docs/design/329-admin-split/README.md
Refs: #329 (Developer)
223 lines
9.3 KiB
TypeScript
223 lines
9.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { api } from "@/lib/api";
|
|
import type { Channel } from "@/lib/api";
|
|
|
|
// #329 — admin/page.tsx에서 추출 (내부 로직 그대로)
|
|
export function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|
const [channels, setChannels] = useState<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 [editingChannel, setEditingChannel] = useState<string | null>(null);
|
|
const [editDesc, setEditDesc] = useState("");
|
|
const [editTags, setEditTags] = useState("");
|
|
const [editOrder, setEditOrder] = useState<number>(99);
|
|
|
|
const handleSaveChannel = async (id: string) => {
|
|
try {
|
|
await api.updateChannel(id, { description: editDesc, tags: editTags, sort_order: editOrder });
|
|
setEditingChannel(null);
|
|
load();
|
|
} catch {
|
|
alert("채널 수정 실패");
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (channelId: string, channelName: string) => {
|
|
if (!confirm(`"${channelName}" 채널을 삭제하시겠습니까?`)) return;
|
|
try {
|
|
await api.deleteChannel(channelId);
|
|
load();
|
|
} catch {
|
|
alert("채널 삭제 실패");
|
|
}
|
|
};
|
|
|
|
const handleScan = async (channelId: string, full: boolean = false) => {
|
|
setScanResult((prev) => ({ ...prev, [channelId]: full ? "전체 스캔 중..." : "스캔 중..." }));
|
|
try {
|
|
const res = await api.scanChannel(channelId, full);
|
|
setScanResult((prev) => ({
|
|
...prev,
|
|
[channelId]: `${res.total_fetched}개 조회, ${res.new_videos}개 신규${(res as Record<string, unknown>).filtered ? `, ${(res as Record<string, unknown>).filtered}개 필터링` : ""}`,
|
|
}));
|
|
} catch {
|
|
setScanResult((prev) => ({ ...prev, [channelId]: "스캔 실패" }));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{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-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-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-surface text-gray-900"
|
|
/>
|
|
<button
|
|
onClick={handleAdd}
|
|
disabled={loading}
|
|
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-surface rounded-lg shadow">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
|
<tr>
|
|
<th className="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>
|
|
<th className="text-center px-4 py-3">순서</th>
|
|
<th className="text-right 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>
|
|
<tbody>
|
|
{channels.map((ch) => (
|
|
<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-brand-50 text-brand-700 rounded text-xs">
|
|
{ch.title_filter}
|
|
</span>
|
|
) : (
|
|
<span className="text-gray-400 text-xs">전체</span>
|
|
)}
|
|
</td>
|
|
<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-surface text-gray-900" placeholder="설명" />
|
|
) : (
|
|
<span className="text-gray-600 cursor-pointer" onClick={() => {
|
|
if (!isAdmin) return;
|
|
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
|
}}>{ch.description || <span className="text-gray-400">-</span>}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs">
|
|
{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-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>
|
|
) : (
|
|
<span className="text-gray-500 cursor-pointer" onClick={() => {
|
|
if (!isAdmin) return;
|
|
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
|
}}>{ch.tags ? ch.tags.split(",").map(t => t.trim()).join(", ") : <span className="text-gray-400">-</span>}</span>
|
|
)}
|
|
</td>
|
|
<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-surface text-gray-900" min={1} />
|
|
) : (
|
|
<span className="text-gray-500">{ch.sort_order ?? 99}</span>
|
|
)}
|
|
</td>
|
|
<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>
|
|
{isAdmin && <td className="px-4 py-3 flex gap-3">
|
|
<button
|
|
onClick={() => handleScan(ch.channel_id)}
|
|
className="text-brand-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={7} className="px-4 py-8 text-center text-gray-400">
|
|
등록된 채널이 없습니다
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── 영상 관리 ─── */
|
|
type VideoSortKey = "status" | "channel_name" | "title" | "published_at";
|
|
|