Compare commits
1 Commits
v0.1.3
...
2a0ee1d2cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0ee1d2cc |
@@ -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;
|
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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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>
|
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">읽기 전용</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<a href="/" className="text-sm text-blue-600 hover:underline">
|
<div className="flex items-center gap-3">
|
||||||
← 메인으로
|
{isAdmin && <CacheFlushButton />}
|
||||||
</a>
|
<a href="/" className="text-sm text-blue-600 hover:underline">
|
||||||
|
← 메인으로
|
||||||
|
</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) => (
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user