feat: Add collapsible keyword filter UI to channel detail with include/…

- "src/frontend/src/pages/ChannelDetail.tsx"
- "src/server/routes/channel.ts"
- "src/frontend/src/api/hooks/useChannels.ts"

GSD-Task: S03/T04
This commit is contained in:
jlightner 2026-04-04 05:46:46 +00:00
parent 05045828d8
commit 9e7d98c7c7
3 changed files with 205 additions and 2 deletions

View file

@ -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<Channel>(`/api/v1/channel/${id}`, data),
onSuccess: (updated) => {
queryClient.setQueryData(channelKeys.detail(id), updated);

View file

@ -12,6 +12,7 @@ import {
ChevronUp,
Download,
ExternalLink,
Filter,
Film,
Grid3X3,
LayoutList,
@ -126,6 +127,10 @@ export function ChannelDetail() {
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
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<HTMLSelectElement>) => {
setMonitoringMode.mutate({ monitoringMode: e.target.value });
@ -1263,6 +1306,164 @@ export function ChannelDetail() {
</button>
</div>
</div>
{/* Keyword Filter Section — collapsible */}
<div
style={{
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
marginTop: 'var(--space-4)',
}}
>
<button
onClick={() => setShowKeywordFilters((prev) => !prev)}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
background: 'none',
border: 'none',
cursor: 'pointer',
color: (channel.includeKeywords || channel.excludeKeywords) ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
padding: 0,
}}
aria-expanded={showKeywordFilters}
>
<Filter size={14} />
Keyword Filters
{(channel.includeKeywords || channel.excludeKeywords) && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 18,
height: 18,
borderRadius: '50%',
backgroundColor: 'var(--accent)',
color: '#fff',
fontSize: '10px',
fontWeight: 700,
lineHeight: 1,
}}
>
{(channel.includeKeywords ? 1 : 0) + (channel.excludeKeywords ? 1 : 0)}
</span>
)}
{showKeywordFilters ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{showKeywordFilters && (
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 'var(--space-4)',
marginTop: 'var(--space-3)',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<label
htmlFor="include-keywords"
style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
Include Patterns
</label>
<textarea
id="include-keywords"
value={localIncludeKeywords}
onChange={(e) => setLocalIncludeKeywords(e.target.value)}
placeholder={'One pattern per line. Supports:\n• plain text match\n• glob: *livestream*\n• regex: /^ep\\d+/'}
rows={3}
style={{
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-main)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
fontFamily: 'var(--font-mono, monospace)',
resize: 'vertical',
minHeight: 60,
}}
/>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
Only titles matching at least one pattern will be auto-enqueued
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<label
htmlFor="exclude-keywords"
style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
Exclude Patterns
</label>
<textarea
id="exclude-keywords"
value={localExcludeKeywords}
onChange={(e) => setLocalExcludeKeywords(e.target.value)}
placeholder={'One pattern per line. Supports:\n• plain text: shorts\n• glob: *#shorts*\n• regex: /\\bshorts?\\b/'}
rows={3}
style={{
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-main)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
fontFamily: 'var(--font-mono, monospace)',
resize: 'vertical',
minHeight: 60,
}}
/>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
Titles matching any pattern will be skipped during scan
</span>
</div>
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<button
onClick={handleKeywordsSave}
disabled={updateChannel.isPending}
className="btn btn-ghost"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
opacity: updateChannel.isPending ? 0.6 : 1,
}}
>
{updateChannel.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : keywordsSaved ? (
<CheckCircle size={14} style={{ color: 'var(--success)' }} />
) : (
<Save size={14} />
)}
{keywordsSaved ? 'Saved!' : 'Save Filters'}
</button>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
Use pipe <code style={{ backgroundColor: 'var(--bg-hover)', padding: '1px 4px', borderRadius: 'var(--radius-sm)' }}>|</code> to separate multiple patterns on one line, or put each on its own line
</span>
</div>
</div>
)}
</div>
</div>
</div>

View file

@ -54,6 +54,8 @@ const updateChannelBodySchema = {
checkInterval: { type: 'number' as const, minimum: 1 },
monitoringEnabled: { type: 'boolean' as const },
formatProfileId: { type: 'number' as const, nullable: true },
includeKeywords: { type: 'string' as const, nullable: true },
excludeKeywords: { type: 'string' as const, nullable: true },
},
additionalProperties: false,
};
@ -253,7 +255,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
fastify.put<{
Params: { id: string };
Body: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null };
Body: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null };
}>(
'/api/v1/channel/:id',
{