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(