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();
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue