채널 카드 필터 UI, 캐시 초기화, 나라 필터 수정

- 채널 설명/태그 DB 컬럼 추가 및 백오피스 편집 기능
- 채널 드롭다운을 유튜브 아이콘 토글 카드로 변경 (데스크톱 최대 4개 표시, 스크롤)
- 모바일 홈탭 채널 카드 가로 스크롤
- region "나라" 값 필터 옵션에서 제외
- 관리자 캐시 초기화 버튼 및 API 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-11 16:10:21 +09:00
parent 0f985d52a9
commit 2a0ee1d2cc
9 changed files with 211 additions and 42 deletions

View File

@@ -7,6 +7,33 @@ import { useAuth } from "@/lib/auth-context";
type Tab = "channels" | "videos" | "restaurants" | "users" | "daemon";
function CacheFlushButton() {
const [flushing, setFlushing] = useState(false);
const handleFlush = async () => {
if (!confirm("Redis 캐시를 초기화하시겠습니까?")) return;
setFlushing(true);
try {
await api.flushCache();
alert("캐시가 초기화되었습니다.");
} catch (e) {
alert("캐시 초기화 실패: " + (e instanceof Error ? e.message : e));
} finally {
setFlushing(false);
}
};
return (
<button
onClick={handleFlush}
disabled={flushing}
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded-lg hover:bg-red-100 disabled:opacity-50 transition-colors"
>
{flushing ? "초기화 중..." : "🗑 캐시 초기화"}
</button>
);
}
export default function AdminPage() {
const [tab, setTab] = useState<Tab>("channels");
const { user, isLoading } = useAuth();
@@ -38,9 +65,12 @@ export default function AdminPage() {
<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 className="flex items-center gap-3">
{isAdmin && <CacheFlushButton />}
<a href="/" className="text-sm text-blue-600 hover:underline">
&larr;
</a>
</div>
</div>
<nav className="mt-3 flex gap-1">
{(["channels", "videos", "restaurants", "users", "daemon"] as Tab[]).map((t) => (
@@ -101,6 +131,20 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
}
};
const [editingChannel, setEditingChannel] = useState<string | null>(null);
const [editDesc, setEditDesc] = useState("");
const [editTags, setEditTags] = useState("");
const handleSaveChannel = async (id: string) => {
try {
await api.updateChannel(id, { description: editDesc, tags: editTags });
setEditingChannel(null);
load();
} catch {
alert("채널 수정 실패");
}
};
const handleDelete = async (channelId: string, channelName: string) => {
if (!confirm(`"${channelName}" 채널을 삭제하시겠습니까?`)) return;
try {
@@ -164,8 +208,9 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
<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-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>
@@ -186,6 +231,32 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
<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-white text-gray-900" placeholder="설명" />
) : (
<span className="text-gray-600 cursor-pointer" onClick={() => {
if (!isAdmin) return;
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
}}>{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-white text-gray-900" placeholder="태그 (쉼표 구분)" />
<button onClick={() => handleSaveChannel(ch.id)} className="text-blue-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 || "");
}}>{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-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>
@@ -193,9 +264,6 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
<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)}