채널 카드 필터 UI, 캐시 초기화, 나라 필터 수정
- 채널 설명/태그 DB 컬럼 추가 및 백오피스 편집 기능 - 채널 드롭다운을 유튜브 아이콘 토글 카드로 변경 (데스크톱 최대 4개 표시, 스크롤) - 모바일 홈탭 채널 카드 가로 스크롤 - region "나라" 값 필터 옵션에서 제외 - 관리자 캐시 초기화 버튼 및 API 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class AdminCacheController {
|
||||
|
||||
private final CacheService cacheService;
|
||||
|
||||
public AdminCacheController(CacheService cacheService) {
|
||||
this.cacheService = cacheService;
|
||||
}
|
||||
|
||||
@PostMapping("/cache-flush")
|
||||
public Map<String, Object> flushCache() {
|
||||
AuthUtil.requireAdmin();
|
||||
cacheService.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,14 @@ public class ChannelController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
channelService.update(id, body.get("description"), body.get("tags"));
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{channelId}")
|
||||
public Map<String, Object> delete(@PathVariable String channelId) {
|
||||
AuthUtil.requireAdmin();
|
||||
|
||||
@@ -14,6 +14,8 @@ public class Channel {
|
||||
private String channelId;
|
||||
private String channelName;
|
||||
private String titleFilter;
|
||||
private String description;
|
||||
private String tags;
|
||||
private int videoCount;
|
||||
private String lastVideoAt;
|
||||
}
|
||||
|
||||
@@ -21,4 +21,8 @@ public interface ChannelMapper {
|
||||
int deactivateById(@Param("id") String id);
|
||||
|
||||
Channel findByChannelId(@Param("channelId") String channelId);
|
||||
|
||||
void updateDescriptionTags(@Param("id") String id,
|
||||
@Param("description") String description,
|
||||
@Param("tags") String tags);
|
||||
}
|
||||
|
||||
@@ -38,4 +38,8 @@ public class ChannelService {
|
||||
public Channel findByChannelId(String channelId) {
|
||||
return mapper.findByChannelId(channelId);
|
||||
}
|
||||
|
||||
public void update(String id, String description, String tags) {
|
||||
mapper.updateDescriptionTags(id, description, tags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
<result property="channelId" column="channel_id"/>
|
||||
<result property="channelName" column="channel_name"/>
|
||||
<result property="titleFilter" column="title_filter"/>
|
||||
<result property="description" column="description"/>
|
||||
<result property="tags" column="tags"/>
|
||||
<result property="videoCount" column="video_count"/>
|
||||
<result property="lastVideoAt" column="last_video_at"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findAllActive" resultMap="channelResultMap">
|
||||
SELECT c.id, c.channel_id, c.channel_name, c.title_filter,
|
||||
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags,
|
||||
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
|
||||
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
|
||||
FROM channels c
|
||||
@@ -35,6 +37,11 @@
|
||||
WHERE id = #{id} AND is_active = 1
|
||||
</update>
|
||||
|
||||
<update id="updateDescriptionTags">
|
||||
UPDATE channels SET description = #{description}, tags = #{tags}
|
||||
WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<select id="findByChannelId" resultMap="channelResultMap">
|
||||
SELECT id, channel_id, channel_name, title_filter
|
||||
FROM channels
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -68,7 +68,7 @@ function buildRegionTree(restaurants: Restaurant[]) {
|
||||
const tree = new Map<string, Map<string, Set<string>>>();
|
||||
for (const r of restaurants) {
|
||||
const p = parseRegion(r.region);
|
||||
if (!p || !p.country) continue;
|
||||
if (!p || !p.country || p.country === "나라") continue;
|
||||
if (!tree.has(p.country)) tree.set(p.country, new Map());
|
||||
const cityMap = tree.get(p.country)!;
|
||||
if (p.city) {
|
||||
@@ -109,7 +109,7 @@ function findRegionFromCoords(
|
||||
const groups = new Map<string, { country: string; city: string; lats: number[]; lngs: number[] }>();
|
||||
for (const r of restaurants) {
|
||||
const p = parseRegion(r.region);
|
||||
if (!p || !p.country || !p.city) continue;
|
||||
if (!p || !p.country || p.country === "나라" || !p.city) continue;
|
||||
const key = `${p.country}|${p.city}`;
|
||||
if (!groups.has(key)) groups.set(key, { country: p.country, city: p.city, lats: [], lngs: [] });
|
||||
const g = groups.get(key)!;
|
||||
@@ -564,22 +564,6 @@ export default function Home() {
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
</button>
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
setChannelFilter(e.target.value);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">📺 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
📺 {ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
@@ -705,6 +689,37 @@ export default function Home() {
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
</div>
|
||||
{/* Row 3: Channel cards (toggle filter) - max 4 visible, scroll for rest */}
|
||||
{!isSearchResult && channels.length > 0 && (
|
||||
<div className="overflow-x-auto scrollbar-hide" style={{ maxWidth: `${4 * 200 + 3 * 8}px` }}>
|
||||
<div className="flex gap-2">
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => {
|
||||
setChannelFilter(channelFilter === ch.channel_name ? "" : ch.channel_name);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className={`shrink-0 flex items-center gap-2 rounded-lg px-3 py-1.5 border transition-all text-left ${
|
||||
channelFilter === ch.channel_name
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-orange-200 dark:hover:border-orange-800"
|
||||
}`}
|
||||
style={{ width: "200px" }}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-xs font-semibold truncate ${
|
||||
channelFilter === ch.channel_name ? "text-orange-600 dark:text-orange-400" : "dark:text-gray-200"
|
||||
}`}>{ch.channel_name}</p>
|
||||
{ch.description && <p className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{ch.description}</p>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User area */}
|
||||
@@ -742,6 +757,43 @@ export default function Home() {
|
||||
<div className={`md:hidden px-4 pb-3 space-y-2 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}>
|
||||
{/* Row 1: Search */}
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
{/* Channel cards - toggle filter */}
|
||||
{mobileTab === "home" && !isSearchResult && channels.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide">
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => {
|
||||
setChannelFilter(channelFilter === ch.channel_name ? "" : ch.channel_name);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className={`shrink-0 rounded-xl px-3 py-2 text-left border transition-all ${
|
||||
channelFilter === ch.channel_name
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
style={{ minWidth: "140px", maxWidth: "170px" }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 shrink-0 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
<p className={`text-xs font-semibold truncate ${
|
||||
channelFilter === ch.channel_name ? "text-orange-600 dark:text-orange-400" : "dark:text-gray-200"
|
||||
}`}>{ch.channel_name}</p>
|
||||
</div>
|
||||
{ch.description && <p className="text-[10px] text-gray-500 dark:text-gray-400 truncate mt-0.5">{ch.description}</p>}
|
||||
{ch.tags && (
|
||||
<div className="flex gap-1 mt-1 overflow-hidden">
|
||||
{ch.tags.split(",").slice(0, 2).map((t) => (
|
||||
<span key={t} className="text-[9px] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full whitespace-nowrap">{t.trim()}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -769,22 +821,6 @@ export default function Home() {
|
||||
<div className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-md rounded-xl p-3.5 space-y-3 border border-white/50 dark:border-gray-700/50 shadow-sm">
|
||||
{/* Dropdown filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
setChannelFilter(e.target.value);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">📺 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
📺 {ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface Channel {
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
title_filter: string | null;
|
||||
description: string | null;
|
||||
tags: string | null;
|
||||
video_count: number;
|
||||
last_scanned_at: string | null;
|
||||
}
|
||||
@@ -350,6 +352,13 @@ export const api = {
|
||||
);
|
||||
},
|
||||
|
||||
updateChannel(id: string, data: { description?: string; tags?: string }) {
|
||||
return fetchApi<{ ok: boolean }>(`/api/channels/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
deleteChannel(channelId: string) {
|
||||
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
|
||||
method: "DELETE",
|
||||
@@ -497,6 +506,12 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
flushCache() {
|
||||
return fetchApi<{ ok: boolean }>("/api/admin/cache-flush", {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
runDaemonProcess(limit: number = 10) {
|
||||
return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
|
||||
`/api/daemon/run/process?limit=${limit}`,
|
||||
|
||||
Reference in New Issue
Block a user