diff --git a/CHANGELOG.md b/CHANGELOG.md
index 610c2ee5..5282ff54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663)
+
### Changed
+- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence.
+
- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage.
+- Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `table.headerPinned`) instead of calling hooks directly, improving maintainability and providing consistent API across all tables.
### Fixed
diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx
index dc130254..0723dcf7 100644
--- a/frontend/src/components/backups/BackupManager.jsx
+++ b/frontend/src/components/backups/BackupManager.jsx
@@ -236,7 +236,6 @@ export default function BackupManager() {
// Read user's preferences from settings
const [timeFormat] = useLocalStorage('time-format', '12h');
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- const [tableSize] = useLocalStorage('table-size', 'default');
const [userTimezone] = useLocalStorage('time-zone', getDefaultTimeZone());
const is12Hour = timeFormat === '12h';
@@ -309,10 +308,10 @@ export default function BackupManager() {
{
id: 'actions',
header: 'Actions',
- size: tableSize === 'compact' ? 75 : 100,
+ size: 100,
},
],
- [tableSize]
+ []
);
const renderHeaderCell = (header) => {
diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx
index 62d6e62f..a00f3664 100644
--- a/frontend/src/components/cards/StreamConnectionCard.jsx
+++ b/frontend/src/components/cards/StreamConnectionCard.jsx
@@ -71,7 +71,6 @@ const StreamConnectionCard = ({
// Get Date-format from localStorage
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
- const [tableSize] = useLocalStorage('table-size', 'default');
// Create a map of M3U account IDs to names for quick lookup
const m3uAccountsMap = useMemo(() => {
@@ -296,7 +295,7 @@ const StreamConnectionCard = ({
{
id: 'actions',
header: 'Actions',
- size: tableSize == 'compact' ? 75 : 100,
+ size: 100,
},
],
[]
diff --git a/frontend/src/components/forms/settings/UiSettingsForm.jsx b/frontend/src/components/forms/settings/UiSettingsForm.jsx
index dc123916..68977b5b 100644
--- a/frontend/src/components/forms/settings/UiSettingsForm.jsx
+++ b/frontend/src/components/forms/settings/UiSettingsForm.jsx
@@ -1,18 +1,18 @@
import useSettingsStore from '../../../store/settings.jsx';
import useLocalStorage from '../../../hooks/useLocalStorage.jsx';
+import useTablePreferences from '../../../hooks/useTablePreferences.jsx';
import {
buildTimeZoneOptions,
getDefaultTimeZone,
} from '../../../utils/dateTimeUtils.js';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { showNotification } from '../../../utils/notificationUtils.js';
-import { Select } from '@mantine/core';
+import { Select, Switch, Stack } from '@mantine/core';
import { saveTimeZoneSetting } from '../../../utils/forms/settings/UiSettingsFormUtils.js';
const UiSettingsForm = React.memo(() => {
const settings = useSettingsStore((s) => s.settings);
- const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy');
const [timeZone, setTimeZone] = useLocalStorage(
@@ -20,6 +20,10 @@ const UiSettingsForm = React.memo(() => {
getDefaultTimeZone()
);
+ // Use shared table preferences hook
+ const { headerPinned, setHeaderPinned, tableSize, setTableSize } =
+ useTablePreferences();
+
const timeZoneOptions = useMemo(
() => buildTimeZoneOptions(timeZone),
[timeZone]
@@ -74,11 +78,14 @@ const UiSettingsForm = React.memo(() => {
persistTimeZoneSetting(value);
}
break;
+ case 'header-pinned':
+ setHeaderPinned(value);
+ break;
}
};
return (
- <>
+
);
});
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index dc82c131..b986e70e 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -114,6 +114,7 @@ const ChannelRowActions = React.memo(
({
theme,
row,
+ table,
editChannel,
deleteChannel,
handleWatchStream,
@@ -123,7 +124,6 @@ const ChannelRowActions = React.memo(
// Extract the channel ID once to ensure consistency
const channelId = row.original.id;
const channelUuid = row.original.uuid;
- const [tableSize, _] = useLocalStorage('table-size', 'default');
const authUser = useAuthStore((s) => s.user);
@@ -149,6 +149,7 @@ const ChannelRowActions = React.memo(
createRecording(row.original);
}, [channelId]);
+ const tableSize = table?.tableSize ?? 'default';
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
@@ -272,7 +273,6 @@ const ChannelsTable = ({ onReady }) => {
// store/settings
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
- const [tableSize, _] = useLocalStorage('table-size', 'default');
// store/warnings
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
@@ -429,9 +429,10 @@ const ChannelsTable = ({ onReady }) => {
setIsLoading(false);
hasFetchedData.current = true;
- setTablePrefs({
+ setTablePrefs((prev) => ({
+ ...prev,
pageSize: pagination.pageSize,
- });
+ }));
setAllRowIds(ids);
// Signal ready after first successful data fetch AND EPG data is loaded
@@ -949,13 +950,14 @@ const ChannelsTable = ({ onReady }) => {
},
{
id: 'actions',
- size: tableSize == 'compact' ? 75 : 100,
+ size: 100,
enableResizing: false,
header: '',
- cell: ({ row }) => (
+ cell: ({ row, table }) => (
s.user);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
+
+ const headerPinned = table?.headerPinned ?? false;
+ const setHeaderPinned = table?.setHeaderPinned || (() => {});
const closeAssignChannelNumbersModal = () => {
setAssignNumbersModalOpen(false);
};
@@ -229,6 +235,10 @@ const ChannelTableHeader = ({
setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels);
};
+ const toggleHeaderPinned = () => {
+ setHeaderPinned(!headerPinned);
+ };
+
return (
@@ -346,6 +356,19 @@ const ChannelTableHeader = ({
+ :
+ }
+ onClick={toggleHeaderPinned}
+ >
+
+ {headerPinned ? 'Unpin Headers' : 'Pin Headers'}
+
+
+
+
+
}
disabled={
diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx
index 90407d49..d6315818 100644
--- a/frontend/src/components/tables/CustomTable/CustomTable.jsx
+++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx
@@ -4,10 +4,9 @@ import { useCallback, useState, useRef, useMemo } from 'react';
import { flexRender } from '@tanstack/react-table';
import table from '../../../helpers/table';
import CustomTableBody from './CustomTableBody';
-import useLocalStorage from '../../../hooks/useLocalStorage';
const CustomTable = ({ table }) => {
- const [tableSize, _] = useLocalStorage('table-size', 'default');
+ const tableSize = table?.tableSize ?? 'default';
// Get column sizing state for dependency tracking
const columnSizing = table.getState().columnSizing;
@@ -34,7 +33,6 @@ const CustomTable = ({ table }) => {
minWidth: `${minTableWidth}px`,
display: 'flex',
flexDirection: 'column',
- overflow: 'hidden',
}}
>
{
}
selectedTableIds={table.selectedTableIds}
tableCellProps={table.tableCellProps}
+ headerPinned={table.headerPinned}
/>
{
const renderHeaderCell = (header) => {
if (headerCellRenderFns[header.id]) {
@@ -59,15 +60,22 @@ const CustomTableHeader = ({
return width;
}, [headerGroups]);
+ // Memoize the style object to ensure it updates when headerPinned changes
+ const headerStyle = useMemo(
+ () => ({
+ position: headerPinned ? 'sticky' : 'relative',
+ top: headerPinned ? 0 : 'auto',
+ backgroundColor: '#3E3E45',
+ zIndex: headerPinned ? 10 : 1,
+ }),
+ [headerPinned]
+ );
+
return (
{getHeaderGroups().map((headerGroup) => (
{
if (e.key === 'Shift') {
@@ -244,8 +249,22 @@ const useTable = ({
expandedRowRenderer,
setSelectedTableIds,
isShiftKeyDown, // Include shift key state in the table instance
+ headerPinned,
+ setHeaderPinned,
+ tableSize,
+ setTableSize,
}),
- [selectedTableIdsSet, expandedRowIds, allRowIds, isShiftKeyDown]
+ [
+ selectedTableIdsSet,
+ expandedRowIds,
+ allRowIds,
+ isShiftKeyDown,
+ options,
+ headerPinned,
+ setHeaderPinned,
+ tableSize,
+ setTableSize,
+ ]
);
return {
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 21b13baf..72d50d49 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -68,8 +68,9 @@ const StreamRowActions = ({
handleWatchStream,
selectedChannelIds,
createChannelFromStream,
+ table,
}) => {
- const [tableSize, _] = useLocalStorage('table-size', 'default');
+ const tableSize = table?.tableSize ?? 'default';
const channelSelectionStreams = useChannelsTableStore(
(state) =>
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
@@ -268,7 +269,6 @@ const StreamsTable = ({ onReady }) => {
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
- const [tableSize, _] = useLocalStorage('table-size', 'default');
// Warnings store for "remember choice" functionality
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@@ -286,7 +286,7 @@ const StreamsTable = ({ onReady }) => {
() => [
{
id: 'actions',
- size: columnSizing.actions || (tableSize == 'compact' ? 60 : 80),
+ size: columnSizing.actions || 75,
},
{
id: 'select',
@@ -354,7 +354,7 @@ const StreamsTable = ({ onReady }) => {
),
},
],
- [channelGroups, playlists, columnSizing, tableSize]
+ [channelGroups, playlists, columnSizing]
);
/**
diff --git a/frontend/src/hooks/useTablePreferences.jsx b/frontend/src/hooks/useTablePreferences.jsx
new file mode 100644
index 00000000..218a2e33
--- /dev/null
+++ b/frontend/src/hooks/useTablePreferences.jsx
@@ -0,0 +1,117 @@
+import { useState, useEffect, useCallback } from 'react';
+
+const useTablePreferences = () => {
+ // Initialize all preferences from localStorage
+ const [headerPinned, setHeaderPinnedState] = useState(() => {
+ try {
+ const prefs = localStorage.getItem('table-preferences');
+ if (prefs) {
+ const parsed = JSON.parse(prefs);
+ return parsed.headerPinned ?? false;
+ }
+ } catch (e) {
+ console.error('Error reading headerPinned from localStorage:', e);
+ }
+ return false;
+ });
+
+ const [tableSize, setTableSizeState] = useState(() => {
+ try {
+ // Check new location first
+ const prefs = localStorage.getItem('table-preferences');
+ if (prefs) {
+ const parsed = JSON.parse(prefs);
+ if (parsed.tableSize) {
+ return parsed.tableSize;
+ }
+ }
+ // Fallback to old location for migration
+ const oldSize = localStorage.getItem('table-size');
+ if (oldSize) {
+ return JSON.parse(oldSize);
+ }
+ } catch (e) {
+ console.error('Error reading tableSize from localStorage:', e);
+ }
+ return 'default';
+ });
+
+ // Listen for changes from other components
+ useEffect(() => {
+ const handleCustomEvent = (e) => {
+ if (
+ e.detail.headerPinned !== undefined &&
+ e.detail.headerPinned !== headerPinned
+ ) {
+ setHeaderPinnedState(e.detail.headerPinned);
+ }
+ if (
+ e.detail.tableSize !== undefined &&
+ e.detail.tableSize !== tableSize
+ ) {
+ setTableSizeState(e.detail.tableSize);
+ }
+ };
+
+ window.addEventListener('table-preferences-changed', handleCustomEvent);
+ return () =>
+ window.removeEventListener(
+ 'table-preferences-changed',
+ handleCustomEvent
+ );
+ }, [headerPinned, tableSize]);
+
+ // Function to update headerPinned and persist to localStorage
+ const setHeaderPinned = useCallback((value) => {
+ setHeaderPinnedState(value);
+
+ try {
+ // Read current prefs, update headerPinned, and save back
+ let prefs = {};
+ const stored = localStorage.getItem('table-preferences');
+ if (stored) {
+ prefs = JSON.parse(stored);
+ }
+ prefs.headerPinned = value;
+ localStorage.setItem('table-preferences', JSON.stringify(prefs));
+
+ // Dispatch custom event for same-page sync
+ window.dispatchEvent(
+ new CustomEvent('table-preferences-changed', {
+ detail: { headerPinned: value },
+ })
+ );
+ } catch (e) {
+ console.error('Error saving headerPinned to localStorage:', e);
+ }
+ }, []);
+
+ // Function to update tableSize and persist to localStorage
+ const setTableSize = useCallback((value) => {
+ setTableSizeState(value);
+
+ try {
+ // Read current prefs, update tableSize, and save back
+ let prefs = {};
+ const stored = localStorage.getItem('table-preferences');
+ if (stored) {
+ prefs = JSON.parse(stored);
+ }
+ prefs.tableSize = value;
+ localStorage.setItem('table-preferences', JSON.stringify(prefs));
+
+ // Dispatch custom event for same-page sync
+ window.dispatchEvent(
+ new CustomEvent('table-preferences-changed', {
+ detail: { tableSize: value },
+ })
+ );
+ } catch (e) {
+ console.error('Error saving tableSize to localStorage:', e);
+ }
+ }, []);
+
+ return { headerPinned, setHeaderPinned, tableSize, setTableSize };
+};
+
+export default useTablePreferences;