From 9e7d98c7c7e541ec2f83c433381f10cc0e3f7410 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 05:46:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20collapsible=20keyword=20filter=20?= =?UTF-8?q?UI=20to=20channel=20detail=20with=20include/=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/pages/ChannelDetail.tsx" - "src/server/routes/channel.ts" - "src/frontend/src/api/hooks/useChannels.ts" GSD-Task: S03/T04 --- src/frontend/src/api/hooks/useChannels.ts | 2 +- src/frontend/src/pages/ChannelDetail.tsx | 201 ++++++++++++++++++++++ src/server/routes/channel.ts | 4 +- 3 files changed, 205 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/api/hooks/useChannels.ts b/src/frontend/src/api/hooks/useChannels.ts index 82d260a..abedf90 100644 --- a/src/frontend/src/api/hooks/useChannels.ts +++ b/src/frontend/src/api/hooks/useChannels.ts @@ -59,7 +59,7 @@ export function useUpdateChannel(id: number) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null }) => + mutationFn: (data: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null }) => apiClient.put(`/api/v1/channel/${id}`, data), onSuccess: (updated) => { queryClient.setQueryData(channelKeys.detail(id), updated); diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index c15ccea..171c9ac 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -12,6 +12,7 @@ import { ChevronUp, Download, ExternalLink, + Filter, Film, Grid3X3, LayoutList, @@ -126,6 +127,10 @@ export function ChannelDetail() { const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [localCheckInterval, setLocalCheckInterval] = useState(''); const [checkIntervalSaved, setCheckIntervalSaved] = useState(false); + const [localIncludeKeywords, setLocalIncludeKeywords] = useState(''); + const [localExcludeKeywords, setLocalExcludeKeywords] = useState(''); + const [keywordsSaved, setKeywordsSaved] = useState(false); + const [showKeywordFilters, setShowKeywordFilters] = useState(false); const { toast } = useToast(); // ── Collapsible header ── @@ -153,6 +158,18 @@ export function ChannelDetail() { } }, [channel?.checkInterval]); + // Sync local keyword fields from channel data + useEffect(() => { + if (channel) { + setLocalIncludeKeywords(channel.includeKeywords ?? ''); + setLocalExcludeKeywords(channel.excludeKeywords ?? ''); + // Auto-expand if filters are already set + if (channel.includeKeywords || channel.excludeKeywords) { + setShowKeywordFilters(true); + } + } + }, [channel?.includeKeywords, channel?.excludeKeywords]); // eslint-disable-line react-hooks/exhaustive-deps + // Surface download errors via toast useEffect(() => { if (downloadContent.isError) { @@ -188,6 +205,32 @@ export function ChannelDetail() { ); }, [localCheckInterval, updateChannel]); + const handleKeywordsSave = useCallback(() => { + // Convert newlines to pipes for storage, preserving pipes inside /regex/ patterns + const toStored = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed) return null; + // Replace newlines with pipe separators + return trimmed + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('|'); + }; + const include = toStored(localIncludeKeywords); + const exclude = toStored(localExcludeKeywords); + updateChannel.mutate( + { includeKeywords: include, excludeKeywords: exclude }, + { + onSuccess: () => { + setKeywordsSaved(true); + setTimeout(() => setKeywordsSaved(false), 2500); + toast('Keyword filters saved', 'success'); + }, + }, + ); + }, [localIncludeKeywords, localExcludeKeywords, updateChannel, toast]); + const handleMonitoringModeChange = useCallback( (e: React.ChangeEvent) => { setMonitoringMode.mutate({ monitoringMode: e.target.value }); @@ -1263,6 +1306,164 @@ export function ChannelDetail() { + + {/* Keyword Filter Section — collapsible */} +
+ + + {showKeywordFilters && ( +
+
+ +