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:
parent
05045828d8
commit
9e7d98c7c7
3 changed files with 205 additions and 2 deletions
|
|
@ -59,7 +59,7 @@ export function useUpdateChannel(id: number) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
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),
|
apiClient.put<Channel>(`/api/v1/channel/${id}`, data),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
queryClient.setQueryData(channelKeys.detail(id), updated);
|
queryClient.setQueryData(channelKeys.detail(id), updated);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Filter,
|
||||||
Film,
|
Film,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
|
|
@ -126,6 +127,10 @@ export function ChannelDetail() {
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
|
||||||
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
|
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
|
||||||
const [checkIntervalSaved, setCheckIntervalSaved] = useState(false);
|
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();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// ── Collapsible header ──
|
// ── Collapsible header ──
|
||||||
|
|
@ -153,6 +158,18 @@ export function ChannelDetail() {
|
||||||
}
|
}
|
||||||
}, [channel?.checkInterval]);
|
}, [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
|
// Surface download errors via toast
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (downloadContent.isError) {
|
if (downloadContent.isError) {
|
||||||
|
|
@ -188,6 +205,32 @@ export function ChannelDetail() {
|
||||||
);
|
);
|
||||||
}, [localCheckInterval, updateChannel]);
|
}, [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(
|
const handleMonitoringModeChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
setMonitoringMode.mutate({ monitoringMode: e.target.value });
|
setMonitoringMode.mutate({ monitoringMode: e.target.value });
|
||||||
|
|
@ -1263,6 +1306,164 @@ export function ChannelDetail() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ const updateChannelBodySchema = {
|
||||||
checkInterval: { type: 'number' as const, minimum: 1 },
|
checkInterval: { type: 'number' as const, minimum: 1 },
|
||||||
monitoringEnabled: { type: 'boolean' as const },
|
monitoringEnabled: { type: 'boolean' as const },
|
||||||
formatProfileId: { type: 'number' as const, nullable: true },
|
formatProfileId: { type: 'number' as const, nullable: true },
|
||||||
|
includeKeywords: { type: 'string' as const, nullable: true },
|
||||||
|
excludeKeywords: { type: 'string' as const, nullable: true },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
@ -253,7 +255,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
fastify.put<{
|
fastify.put<{
|
||||||
Params: { id: string };
|
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',
|
'/api/v1/channel/:id',
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue