diff --git a/src/frontend/src/components/FormatProfileForm.tsx b/src/frontend/src/components/FormatProfileForm.tsx index 052c8a8..5fc6048 100644 --- a/src/frontend/src/components/FormatProfileForm.tsx +++ b/src/frontend/src/components/FormatProfileForm.tsx @@ -20,6 +20,9 @@ export interface FormatProfileFormValues { isDefault: boolean; subtitleLanguages: string | null; embedSubtitles: boolean; + embedChapters: boolean; + embedThumbnail: boolean; + sponsorBlockRemove: string | null; } interface FormatProfileFormProps { @@ -92,6 +95,9 @@ export function FormatProfileForm({ const [isDefault, setIsDefault] = useState(profile?.isDefault ?? false); const [subtitleLanguages, setSubtitleLanguages] = useState(profile?.subtitleLanguages ?? ''); 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( (e: FormEvent) => { @@ -106,9 +112,12 @@ export function FormatProfileForm({ isDefault, subtitleLanguages: subtitleLanguages.trim() || null, 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 ( @@ -247,6 +256,62 @@ export function FormatProfileForm({ + {/* Embed Chapters checkbox */} +
+ setEmbedChapters(e.target.checked)} + style={{ + width: 16, + height: 16, + accentColor: 'var(--accent)', + cursor: 'pointer', + }} + /> + +
+ + {/* Embed Thumbnail checkbox */} +
+ setEmbedThumbnail(e.target.checked)} + style={{ + width: 16, + height: 16, + accentColor: 'var(--accent)', + cursor: 'pointer', + }} + /> + +
+ + {/* SponsorBlock Remove */} +
+ + setSponsorBlockRemove(e.target.value)} + placeholder="e.g. sponsor,selfpromo,intro,outro" + style={inputStyle} + /> + + Comma-separated categories: sponsor, selfpromo, interaction, intro, outro, preview, music_offtopic, filler + +
+ {/* Is Default checkbox */}
>(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 }; +} diff --git a/src/frontend/src/hooks/usePersistedState.ts b/src/frontend/src/hooks/usePersistedState.ts new file mode 100644 index 0000000..83dd3b0 --- /dev/null +++ b/src/frontend/src/hooks/usePersistedState.ts @@ -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(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] { + const [state, setState] = useState(() => { + 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]; +} diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index d0bf812..c15ccea 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; +import { usePersistedState } from '../hooks/usePersistedState'; +import { useBulkSelection } from '../hooks/useBulkSelection'; import { ArrowLeft, Bookmark, @@ -80,31 +82,10 @@ export function ChannelDetail() { const [contentSearch, setContentSearch] = useState(''); const [contentStatusFilter, setContentStatusFilter] = useState(''); const [contentTypeFilter, setContentTypeFilter] = useState(''); - const [sortKey, setSortKey] = useState(() => { - try { return localStorage.getItem('tubearr-sort-key') || null; } catch { return null; } - }); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(() => { - try { - const stored = localStorage.getItem('tubearr-sort-dir'); - if (stored === 'asc' || stored === 'desc') return stored; - return 'asc'; - } catch { return 'asc'; } - }); - const [groupBy, setGroupBy] = useState(() => { - 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 [sortKey, setSortKey] = usePersistedState('tubearr-sort-key', null); + const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc'); + const [groupBy, setGroupBy] = usePersistedState('tubearr-group-by', 'none'); + const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table'); const contentFilters: ChannelContentFilters = useMemo(() => ({ page: contentPage, @@ -120,6 +101,10 @@ export function ChannelDetail() { const content = contentResponse?.data ?? []; const contentPagination = contentResponse?.pagination; + const { selectedIds, toggleSelect, clearSelection, toggleSelectAll, isAllSelected } = useBulkSelection( + content?.map((item) => item.id) ?? [], + ); + // ── Mutation hooks ── const updateChannel = useUpdateChannel(channelId); const deleteChannel = useDeleteChannel(); @@ -139,7 +124,6 @@ export function ChannelDetail() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showFullDescription, setShowFullDescription] = useState(false); const [expandedGroups, setExpandedGroups] = useState>(new Set()); - const [selectedIds, setSelectedIds] = useState>(new Set()); const [localCheckInterval, setLocalCheckInterval] = useState(''); const [checkIntervalSaved, setCheckIntervalSaved] = useState(false); const { toast } = useToast(); @@ -280,14 +264,11 @@ export function ChannelDetail() { setSortKey(key); setSortDirection(direction); setContentPage(1); - try { localStorage.setItem('tubearr-sort-key', key); } catch { /* ignore */ } - try { localStorage.setItem('tubearr-sort-dir', direction); } catch { /* ignore */ } - }, []); + }, [setSortKey, setSortDirection]); const handleGroupByChange = useCallback((value: GroupByKey) => { setGroupBy(value); - try { localStorage.setItem('tubearr-group-by', value); } catch { /* ignore */ } - }, []); + }, [setGroupBy]); // Reset expanded groups when groupBy changes useEffect(() => { @@ -296,8 +277,7 @@ export function ChannelDetail() { const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => { setViewMode(mode); - try { localStorage.setItem('tubearr-content-view', mode); } catch { /* ignore */ } - }, []); + }, [setViewMode]); const toggleGroup = useCallback((id: string | number) => { 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( (monitored: boolean) => { bulkMonitored.mutate(