feat: FormatProfile settings UI + extract ChannelDetail hooks
Some checks failed
CI / test (push) Failing after 19s
Some checks failed
CI / test (push) Failing after 19s
- FormatProfile editor now includes embedChapters, embedThumbnail, sponsorBlockRemove fields with proper labels and help text - Extract usePersistedState hook (replaces 7 localStorage try/catch blocks) - Extract useBulkSelection hook (replaces inline selection state management) - ChannelDetail.tsx: 1721 → 1672 lines
This commit is contained in:
parent
aa09bc089c
commit
21a458f500
4 changed files with 139 additions and 63 deletions
|
|
@ -20,6 +20,9 @@ export interface FormatProfileFormValues {
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
subtitleLanguages: string | null;
|
subtitleLanguages: string | null;
|
||||||
embedSubtitles: boolean;
|
embedSubtitles: boolean;
|
||||||
|
embedChapters: boolean;
|
||||||
|
embedThumbnail: boolean;
|
||||||
|
sponsorBlockRemove: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormatProfileFormProps {
|
interface FormatProfileFormProps {
|
||||||
|
|
@ -92,6 +95,9 @@ export function FormatProfileForm({
|
||||||
const [isDefault, setIsDefault] = useState(profile?.isDefault ?? false);
|
const [isDefault, setIsDefault] = useState(profile?.isDefault ?? false);
|
||||||
const [subtitleLanguages, setSubtitleLanguages] = useState(profile?.subtitleLanguages ?? '');
|
const [subtitleLanguages, setSubtitleLanguages] = useState(profile?.subtitleLanguages ?? '');
|
||||||
const [embedSubtitles, setEmbedSubtitles] = useState(profile?.embedSubtitles ?? false);
|
const [embedSubtitles, setEmbedSubtitles] = useState(profile?.embedSubtitles ?? false);
|
||||||
|
const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false);
|
||||||
|
const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false);
|
||||||
|
const [sponsorBlockRemove, setSponsorBlockRemove] = useState(profile?.sponsorBlockRemove ?? '');
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(e: FormEvent) => {
|
(e: FormEvent) => {
|
||||||
|
|
@ -106,9 +112,12 @@ export function FormatProfileForm({
|
||||||
isDefault,
|
isDefault,
|
||||||
subtitleLanguages: subtitleLanguages.trim() || null,
|
subtitleLanguages: subtitleLanguages.trim() || null,
|
||||||
embedSubtitles,
|
embedSubtitles,
|
||||||
|
embedChapters,
|
||||||
|
embedThumbnail,
|
||||||
|
sponsorBlockRemove: sponsorBlockRemove.trim() || null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, onSubmit],
|
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockRemove, onSubmit],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -247,6 +256,62 @@ export function FormatProfileForm({
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Embed Chapters checkbox */}
|
||||||
|
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
|
<input
|
||||||
|
id="fp-embed-chapters"
|
||||||
|
type="checkbox"
|
||||||
|
checked={embedChapters}
|
||||||
|
onChange={(e) => setEmbedChapters(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
accentColor: 'var(--accent)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="fp-embed-chapters" style={{ fontSize: 'var(--font-size-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
||||||
|
Embed chapter markers in downloaded files
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Embed Thumbnail checkbox */}
|
||||||
|
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
|
<input
|
||||||
|
id="fp-embed-thumbnail"
|
||||||
|
type="checkbox"
|
||||||
|
checked={embedThumbnail}
|
||||||
|
onChange={(e) => setEmbedThumbnail(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
accentColor: 'var(--accent)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="fp-embed-thumbnail" style={{ fontSize: 'var(--font-size-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
||||||
|
Embed thumbnail as cover art
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SponsorBlock Remove */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label htmlFor="fp-sponsorblock" style={labelStyle}>
|
||||||
|
SponsorBlock — Remove Segments
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fp-sponsorblock"
|
||||||
|
type="text"
|
||||||
|
value={sponsorBlockRemove}
|
||||||
|
onChange={(e) => setSponsorBlockRemove(e.target.value)}
|
||||||
|
placeholder="e.g. sponsor,selfpromo,intro,outro"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
|
||||||
|
Comma-separated categories: sponsor, selfpromo, interaction, intro, outro, preview, music_offtopic, filler
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Is Default checkbox */}
|
{/* Is Default checkbox */}
|
||||||
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
28
src/frontend/src/hooks/useBulkSelection.ts
Normal file
28
src/frontend/src/hooks/useBulkSelection.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages a set of selected IDs with toggle, select-all, and clear operations.
|
||||||
|
*/
|
||||||
|
export function useBulkSelection(allIds: number[]) {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id: number) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
|
||||||
|
|
||||||
|
const toggleSelectAll = useCallback(() => {
|
||||||
|
if (allIds.length === 0) return;
|
||||||
|
setSelectedIds((prev) => prev.size === allIds.length ? new Set() : new Set(allIds));
|
||||||
|
}, [allIds]);
|
||||||
|
|
||||||
|
const isAllSelected = allIds.length > 0 && selectedIds.size === allIds.length;
|
||||||
|
|
||||||
|
return { selectedIds, toggleSelect, clearSelection, toggleSelectAll, isAllSelected };
|
||||||
|
}
|
||||||
32
src/frontend/src/hooks/usePersistedState.ts
Normal file
32
src/frontend/src/hooks/usePersistedState.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useState backed by localStorage. Reads initial value from storage,
|
||||||
|
* writes on every update. Swallows storage errors silently.
|
||||||
|
*/
|
||||||
|
export function usePersistedState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||||
|
const [state, setState] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
if (stored === null) return defaultValue;
|
||||||
|
return JSON.parse(stored) as T;
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setPersistedState = useCallback(
|
||||||
|
(value: T | ((prev: T) => T)) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = typeof value === 'function' ? (value as (prev: T) => T)(prev) : value;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(next));
|
||||||
|
} catch { /* storage full or unavailable */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [state, setPersistedState];
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { usePersistedState } from '../hooks/usePersistedState';
|
||||||
|
import { useBulkSelection } from '../hooks/useBulkSelection';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
|
@ -80,31 +82,10 @@ export function ChannelDetail() {
|
||||||
const [contentSearch, setContentSearch] = useState('');
|
const [contentSearch, setContentSearch] = useState('');
|
||||||
const [contentStatusFilter, setContentStatusFilter] = useState('');
|
const [contentStatusFilter, setContentStatusFilter] = useState('');
|
||||||
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
||||||
const [sortKey, setSortKey] = useState<string | null>(() => {
|
const [sortKey, setSortKey] = usePersistedState<string | null>('tubearr-sort-key', null);
|
||||||
try { return localStorage.getItem('tubearr-sort-key') || null; } catch { return null; }
|
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc');
|
||||||
});
|
const [groupBy, setGroupBy] = usePersistedState<GroupByKey>('tubearr-group-by', 'none');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(() => {
|
const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table');
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('tubearr-sort-dir');
|
|
||||||
if (stored === 'asc' || stored === 'desc') return stored;
|
|
||||||
return 'asc';
|
|
||||||
} catch { return 'asc'; }
|
|
||||||
});
|
|
||||||
const [groupBy, setGroupBy] = useState<GroupByKey>(() => {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('tubearr-group-by');
|
|
||||||
if (stored === 'none' || stored === 'playlist' || stored === 'year' || stored === 'type') return stored;
|
|
||||||
return 'none';
|
|
||||||
} catch { return 'none'; }
|
|
||||||
});
|
|
||||||
const [viewMode, setViewMode] = useState<'table' | 'card' | 'list'>(() => {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('tubearr-content-view');
|
|
||||||
if (stored === 'table' || stored === 'card' || stored === 'list') return stored;
|
|
||||||
return 'table';
|
|
||||||
}
|
|
||||||
catch { return 'table'; }
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentFilters: ChannelContentFilters = useMemo(() => ({
|
const contentFilters: ChannelContentFilters = useMemo(() => ({
|
||||||
page: contentPage,
|
page: contentPage,
|
||||||
|
|
@ -120,6 +101,10 @@ export function ChannelDetail() {
|
||||||
const content = contentResponse?.data ?? [];
|
const content = contentResponse?.data ?? [];
|
||||||
const contentPagination = contentResponse?.pagination;
|
const contentPagination = contentResponse?.pagination;
|
||||||
|
|
||||||
|
const { selectedIds, toggleSelect, clearSelection, toggleSelectAll, isAllSelected } = useBulkSelection(
|
||||||
|
content?.map((item) => item.id) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Mutation hooks ──
|
// ── Mutation hooks ──
|
||||||
const updateChannel = useUpdateChannel(channelId);
|
const updateChannel = useUpdateChannel(channelId);
|
||||||
const deleteChannel = useDeleteChannel();
|
const deleteChannel = useDeleteChannel();
|
||||||
|
|
@ -139,7 +124,6 @@ export function ChannelDetail() {
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showFullDescription, setShowFullDescription] = useState(false);
|
const [showFullDescription, setShowFullDescription] = useState(false);
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
||||||
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
|
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
|
||||||
const [checkIntervalSaved, setCheckIntervalSaved] = useState(false);
|
const [checkIntervalSaved, setCheckIntervalSaved] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -280,14 +264,11 @@ export function ChannelDetail() {
|
||||||
setSortKey(key);
|
setSortKey(key);
|
||||||
setSortDirection(direction);
|
setSortDirection(direction);
|
||||||
setContentPage(1);
|
setContentPage(1);
|
||||||
try { localStorage.setItem('tubearr-sort-key', key); } catch { /* ignore */ }
|
}, [setSortKey, setSortDirection]);
|
||||||
try { localStorage.setItem('tubearr-sort-dir', direction); } catch { /* ignore */ }
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleGroupByChange = useCallback((value: GroupByKey) => {
|
const handleGroupByChange = useCallback((value: GroupByKey) => {
|
||||||
setGroupBy(value);
|
setGroupBy(value);
|
||||||
try { localStorage.setItem('tubearr-group-by', value); } catch { /* ignore */ }
|
}, [setGroupBy]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset expanded groups when groupBy changes
|
// Reset expanded groups when groupBy changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -296,8 +277,7 @@ export function ChannelDetail() {
|
||||||
|
|
||||||
const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => {
|
const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => {
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
try { localStorage.setItem('tubearr-content-view', mode); } catch { /* ignore */ }
|
}, [setViewMode]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleGroup = useCallback((id: string | number) => {
|
const toggleGroup = useCallback((id: string | number) => {
|
||||||
setExpandedGroups((prev) => {
|
setExpandedGroups((prev) => {
|
||||||
|
|
@ -311,35 +291,6 @@ export function ChannelDetail() {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Bulk selection handlers ──
|
|
||||||
|
|
||||||
const toggleSelect = useCallback((id: number) => {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) {
|
|
||||||
next.delete(id);
|
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleSelectAll = useCallback(() => {
|
|
||||||
const items = content ?? [];
|
|
||||||
if (items.length === 0) return;
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
if (prev.size === items.length) return new Set();
|
|
||||||
return new Set(items.map((item) => item.id));
|
|
||||||
});
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
const isAllSelected = content != null && content.length > 0 && selectedIds.size === content.length;
|
|
||||||
|
|
||||||
const handleBulkMonitor = useCallback(
|
const handleBulkMonitor = useCallback(
|
||||||
(monitored: boolean) => {
|
(monitored: boolean) => {
|
||||||
bulkMonitored.mutate(
|
bulkMonitored.mutate(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue