채널 카드 필터 UI, 캐시 초기화, 나라 필터 수정
- 채널 설명/태그 DB 컬럼 추가 및 백오피스 편집 기능 - 채널 드롭다운을 유튜브 아이콘 토글 카드로 변경 (데스크톱 최대 4개 표시, 스크롤) - 모바일 홈탭 채널 카드 가로 스크롤 - region "나라" 값 필터 옵션에서 제외 - 관리자 캐시 초기화 버튼 및 API 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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">
|
||||
← 메인으로
|
||||
</a>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && <CacheFlushButton />}
|
||||
<a href="/" className="text-sm text-blue-600 hover:underline">
|
||||
← 메인으로
|
||||
</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)}
|
||||
|
||||
Reference in New Issue
Block a user