feat: FormatProfile settings UI + extract ChannelDetail hooks
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:
jlightner 2026-04-04 02:53:50 +00:00
parent aa09bc089c
commit 21a458f500
4 changed files with 139 additions and 63 deletions

View file

@ -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

View 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 };
}

View 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];
}

View file

@ -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(