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 ( - <> + { onChange={(val) => onUISettingsChange('time-zone', val)} data={timeZoneOptions} /> - + ); }); 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;