1 Commits

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:10:21 +09:00
9 changed files with 211 additions and 42 deletions

View File

@@ -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);
}
}

View File

@@ -76,6 +76,14 @@ public class ChannelController {
return result; 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}") @DeleteMapping("/{channelId}")
public Map<String, Object> delete(@PathVariable String channelId) { public Map<String, Object> delete(@PathVariable String channelId) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();

View File

@@ -14,6 +14,8 @@ public class Channel {
private String channelId; private String channelId;
private String channelName; private String channelName;
private String titleFilter; private String titleFilter;
private String description;
private String tags;
private int videoCount; private int videoCount;
private String lastVideoAt; private String lastVideoAt;
} }

View File

@@ -21,4 +21,8 @@ public interface ChannelMapper {
int deactivateById(@Param("id") String id); int deactivateById(@Param("id") String id);
Channel findByChannelId(@Param("channelId") String channelId); Channel findByChannelId(@Param("channelId") String channelId);
void updateDescriptionTags(@Param("id") String id,
@Param("description") String description,
@Param("tags") String tags);
} }

View File

@@ -38,4 +38,8 @@ public class ChannelService {
public Channel findByChannelId(String channelId) { public Channel findByChannelId(String channelId) {
return mapper.findByChannelId(channelId); return mapper.findByChannelId(channelId);
} }
public void update(String id, String description, String tags) {
mapper.updateDescriptionTags(id, description, tags);
}
} }

View File

@@ -7,12 +7,14 @@
<result property="channelId" column="channel_id"/> <result property="channelId" column="channel_id"/>
<result property="channelName" column="channel_name"/> <result property="channelName" column="channel_name"/>
<result property="titleFilter" column="title_filter"/> <result property="titleFilter" column="title_filter"/>
<result property="description" column="description"/>
<result property="tags" column="tags"/>
<result property="videoCount" column="video_count"/> <result property="videoCount" column="video_count"/>
<result property="lastVideoAt" column="last_video_at"/> <result property="lastVideoAt" column="last_video_at"/>
</resultMap> </resultMap>
<select id="findAllActive" resultMap="channelResultMap"> <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 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 (SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
FROM channels c FROM channels c
@@ -35,6 +37,11 @@
WHERE id = #{id} AND is_active = 1 WHERE id = #{id} AND is_active = 1
</update> </update>
<update id="updateDescriptionTags">
UPDATE channels SET description = #{description}, tags = #{tags}
WHERE id = #{id}
</update>
<select id="findByChannelId" resultMap="channelResultMap"> <select id="findByChannelId" resultMap="channelResultMap">
SELECT id, channel_id, channel_name, title_filter SELECT id, channel_id, channel_name, title_filter
FROM channels FROM channels

View File

@@ -7,6 +7,33 @@ import { useAuth } from "@/lib/auth-context";
type Tab = "channels" | "videos" | "restaurants" | "users" | "daemon"; 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() { export default function AdminPage() {
const [tab, setTab] = useState<Tab>("channels"); const [tab, setTab] = useState<Tab>("channels");
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
@@ -38,10 +65,13 @@ export default function AdminPage() {
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium"> </span> <span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium"> </span>
)} )}
</div> </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-blue-600 hover:underline">
&larr; &larr;
</a> </a>
</div> </div>
</div>
<nav className="mt-3 flex gap-1"> <nav className="mt-3 flex gap-1">
{(["channels", "videos", "restaurants", "users", "daemon"] as Tab[]).map((t) => ( {(["channels", "videos", "restaurants", "users", "daemon"] as Tab[]).map((t) => (
<button <button
@@ -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) => { const handleDelete = async (channelId: string, channelName: string) => {
if (!confirm(`"${channelName}" 채널을 삭제하시겠습니까?`)) return; if (!confirm(`"${channelName}" 채널을 삭제하시겠습니까?`)) return;
try { 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"> </th>
<th className="text-left px-4 py-3">Channel ID</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-left px-4 py-3"></th>
<th className="text-right 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>} {isAdmin && <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>
</tr> </tr>
@@ -186,6 +231,32 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
<span className="text-gray-400 text-xs"></span> <span className="text-gray-400 text-xs"></span>
)} )}
</td> </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"> <td className="px-4 py-3 text-right font-medium">
{ch.video_count > 0 ? ( {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="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> <span className="text-gray-400 text-xs">0</span>
)} )}
</td> </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"> {isAdmin && <td className="px-4 py-3 flex gap-3">
<button <button
onClick={() => handleScan(ch.channel_id)} onClick={() => handleScan(ch.channel_id)}

View File

@@ -68,7 +68,7 @@ function buildRegionTree(restaurants: Restaurant[]) {
const tree = new Map<string, Map<string, Set<string>>>(); const tree = new Map<string, Map<string, Set<string>>>();
for (const r of restaurants) { for (const r of restaurants) {
const p = parseRegion(r.region); 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()); if (!tree.has(p.country)) tree.set(p.country, new Map());
const cityMap = tree.get(p.country)!; const cityMap = tree.get(p.country)!;
if (p.city) { if (p.city) {
@@ -109,7 +109,7 @@ function findRegionFromCoords(
const groups = new Map<string, { country: string; city: string; lats: number[]; lngs: number[] }>(); const groups = new Map<string, { country: string; city: string; lats: number[]; lngs: number[] }>();
for (const r of restaurants) { for (const r of restaurants) {
const p = parseRegion(r.region); 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}`; const key = `${p.country}|${p.city}`;
if (!groups.has(key)) groups.set(key, { country: p.country, city: p.city, lats: [], lngs: [] }); if (!groups.has(key)) groups.set(key, { country: p.country, city: p.city, lats: [], lngs: [] });
const g = groups.get(key)!; 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> <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> </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 <select
value={cuisineFilter} value={cuisineFilter}
onChange={(e) => setCuisineFilter(e.target.value)} onChange={(e) => setCuisineFilter(e.target.value)}
@@ -705,6 +689,37 @@ export default function Home() {
{filteredRestaurants.length} {filteredRestaurants.length}
</span> </span>
</div> </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> </div>
{/* User area */} {/* 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" : ""}`}> <div className={`md:hidden px-4 pb-3 space-y-2 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}>
{/* Row 1: Search */} {/* Row 1: Search */}
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} /> <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 */} {/* Row 2: Toolbar */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <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"> <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 */} {/* Dropdown filters */}
<div className="flex items-center gap-2 flex-wrap"> <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 <select
value={cuisineFilter} value={cuisineFilter}
onChange={(e) => setCuisineFilter(e.target.value)} onChange={(e) => setCuisineFilter(e.target.value)}

View File

@@ -70,6 +70,8 @@ export interface Channel {
channel_id: string; channel_id: string;
channel_name: string; channel_name: string;
title_filter: string | null; title_filter: string | null;
description: string | null;
tags: string | null;
video_count: number; video_count: number;
last_scanned_at: string | null; 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) { deleteChannel(channelId: string) {
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, { return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
method: "DELETE", method: "DELETE",
@@ -497,6 +506,12 @@ export const api = {
}); });
}, },
flushCache() {
return fetchApi<{ ok: boolean }>("/api/admin/cache-flush", {
method: "POST",
});
},
runDaemonProcess(limit: number = 10) { runDaemonProcess(limit: number = 10) {
return fetchApi<{ ok: boolean; restaurants_extracted: number }>( return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
`/api/daemon/run/process?limit=${limit}`, `/api/daemon/run/process?limit=${limit}`,