diff --git a/CHANGELOG.md b/CHANGELOG.md index 388f2156..97952632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.1] - 2026-01-27 + +### Fixed + +- Series Rules API Swagger Documentation: Fixed drf_yasg validation error where TYPE_ARRAY schemas were missing required items parameter, causing module import failure + +## [0.18.0] - 2026-01-27 + ### Security - Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities: - **High**: Open Redirect XSS vulnerability in Action/Server Action Request Processing ([GHSA-h5cw-625j-3rxh](https://github.com/advisories/GHSA-h5cw-625j-3rxh), [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx)) - **Moderate**: SSR XSS vulnerability in ScrollRestoration component ([GHSA-8v8x-cx79-35w7](https://github.com/advisories/GHSA-8v8x-cx79-35w7)) - Updated react-router-dom from 7.11.0 to 7.12.0 (dependency of react-router) +- Fixed moderate severity Prototype Pollution vulnerability in Lodash (`_.unset` and `_.omit` functions) See [GHSA-xxjr-mmjv-4gpg](https://github.com/advisories/GHSA-xxjr-mmjv-4gpg) for details. ### Added +- Series Rules API Swagger Documentation: Added comprehensive Swagger/OpenAPI documentation for all series-rules endpoints (`GET /series-rules/`, `POST /series-rules/`, `DELETE /series-rules/{tvg_id}/`, `POST /series-rules/evaluate/`, `POST /series-rules/bulk-remove/`), including detailed descriptions, request/response schemas, and error handling information for improved API discoverability - Editable Channel Table Mode: - Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal. - EPG and logo columns support searchable dropdowns with instant filtering and keyboard navigation for fast assignment. @@ -49,6 +59,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Data loading and initialization refactor: Major performance improvement reducing initial page load time by eliminating duplicate API requests caused by race conditions between authentication flow and route rendering: + - Fixed authentication race condition where `isAuthenticated` was set before data loading completed, causing routes to render and tables to mount prematurely + - Added `isInitialized` flag to delay route rendering until after all initialization data is loaded via `initData()` + - Consolidated version and environment settings fetching into centralized settings store with caching to prevent redundant calls + - Implemented stale fetch prevention in ChannelsTable and StreamsTable using fetch version tracking to ignore outdated responses + - Fixed filter handling in tables to use `debouncedFilters` consistently, preventing unnecessary refetches + - Added initialization guards using refs to prevent double-execution of auth and superuser checks during React StrictMode's intentional double-rendering in development + - Removed duplicate version/environment fetch calls from Sidebar, LoginForm, and SuperuserForm by using centralized store - Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence. - Streams table button labels: Renamed "Remove" to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and consistency with other UI terminology. - 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. diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 5d5dc4b6..11e7525f 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -2406,9 +2406,72 @@ class SeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] + @swagger_auto_schema( + operation_summary="List all series rules", + operation_description="Retrieve all configured DVR series recording rules.", + responses={ + 200: openapi.Response( + description="List of series rules", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'rules': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'), + 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], description='Recording mode: all episodes or new only'), + 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'), + }, + ), + description='List of series recording rules' + ), + }, + ), + ), + }, + ) def get(self, request): return Response({"rules": CoreSettings.get_dvr_series_rules()}) + @swagger_auto_schema( + operation_summary="Create or update a series rule", + operation_description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['tvg_id'], + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'), + 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], default='all', description='all: record all episodes, new: record only new episodes'), + 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'), + }, + ), + responses={ + 200: openapi.Response( + description="Series rule created/updated successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), + 'rules': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING), + 'mode': openapi.Schema(type=openapi.TYPE_STRING), + 'title': openapi.Schema(type=openapi.TYPE_STRING), + }, + ), + description='Updated list of all rules' + ), + }, + ), + ), + 400: openapi.Response(description="Bad request (missing tvg_id or invalid mode)"), + }, + ) def post(self, request): data = request.data or {} tvg_id = str(data.get("tvg_id") or "").strip() @@ -2441,6 +2504,36 @@ class DeleteSeriesRuleAPIView(APIView): except KeyError: return [Authenticated()] + @swagger_auto_schema( + operation_summary="Delete a series rule", + operation_description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.", + manual_parameters=[ + openapi.Parameter('tvg_id', openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description='Channel TVG ID'), + ], + responses={ + 200: openapi.Response( + description="Series rule deleted successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), + 'rules': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING), + 'mode': openapi.Schema(type=openapi.TYPE_STRING), + 'title': openapi.Schema(type=openapi.TYPE_STRING), + }, + ), + description='Updated list of all rules' + ), + }, + ), + ), + }, + ) def delete(self, request, tvg_id): tvg_id = unquote(str(tvg_id or "")) rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id] @@ -2455,6 +2548,27 @@ class EvaluateSeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] + @swagger_auto_schema( + operation_summary="Evaluate series rules", + operation_description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated.'), + }, + ), + responses={ + 200: openapi.Response( + description="Evaluation completed successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), + }, + ), + ), + }, + ) def post(self, request): tvg_id = request.data.get("tvg_id") # Run synchronously so UI sees results immediately @@ -2476,6 +2590,32 @@ class BulkRemoveSeriesRecordingsAPIView(APIView): except KeyError: return [Authenticated()] + @swagger_auto_schema( + operation_summary="Bulk remove scheduled recordings for a series", + operation_description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['tvg_id'], + properties={ + 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID (required)'), + 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title - when scope=title, only recordings matching this title are removed'), + 'scope': openapi.Schema(type=openapi.TYPE_STRING, enum=['title', 'channel'], default='title', description='title: remove only matching title on channel, channel: remove all future recordings on channel'), + }, + ), + responses={ + 200: openapi.Response( + description="Recordings removed successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), + 'removed': openapi.Schema(type=openapi.TYPE_INTEGER, description='Number of recordings deleted'), + }, + ), + ), + 400: openapi.Response(description="Bad request (missing tvg_id)"), + }, + ) def post(self, request): from django.utils import timezone tvg_id = str(request.data.get("tvg_id") or "").strip() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 476b764b..0a349a3d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3720,9 +3720,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.clamp": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f22d408f..3869740e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,4 @@ -// frontend/src/App.js -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { BrowserRouter as Router, Route, @@ -40,18 +39,25 @@ const defaultRoute = '/channels'; const App = () => { const [open, setOpen] = useState(true); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isInitialized = useAuthStore((s) => s.isInitialized); const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated); const logout = useAuthStore((s) => s.logout); const initData = useAuthStore((s) => s.initData); const initializeAuth = useAuthStore((s) => s.initializeAuth); const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists); + const authCheckStarted = useRef(false); + const superuserCheckStarted = useRef(false); + const toggleDrawer = () => { setOpen(!open); }; // Check if a superuser exists on first load. useEffect(() => { + if (superuserCheckStarted.current) return; + superuserCheckStarted.current = true; + async function checkSuperuser() { try { const response = await API.fetchSuperUser(); @@ -69,10 +75,13 @@ const App = () => { } } checkSuperuser(); - }, []); + }, [setSuperuserExists]); // Authentication check useEffect(() => { + if (authCheckStarted.current) return; + authCheckStarted.current = true; + const checkAuth = async () => { try { const loggedIn = await initializeAuth(); @@ -105,14 +114,15 @@ const App = () => { height: 0, }} navbar={{ - width: isAuthenticated - ? open - ? drawerWidth - : miniDrawerWidth - : 0, + width: + isAuthenticated && isInitialized + ? open + ? drawerWidth + : miniDrawerWidth + : 0, }} > - {isAuthenticated && ( + {isAuthenticated && isInitialized && ( { > - {isAuthenticated ? ( + {isAuthenticated && isInitialized ? ( <> } /> } /> @@ -154,7 +164,11 @@ const App = () => { path="*" element={ } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index d8c3fae8..a25aa301 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { copyToClipboard } from '../utils'; import { @@ -33,8 +33,7 @@ import logo from '../images/logo.png'; import useChannelsStore from '../store/channels'; import './sidebar.css'; import useSettingsStore from '../store/settings'; -import useAuthStore from '../store/auth'; // Add this import -import API from '../api'; +import useAuthStore from '../store/auth'; import { USER_LEVELS } from '../constants'; import UserForm from './forms/User'; @@ -75,16 +74,13 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const channels = useChannelsStore((s) => s.channels); const environment = useSettingsStore((s) => s.environment); + const appVersion = useSettingsStore((s) => s.version); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const authUser = useAuthStore((s) => s.user); const logout = useAuthStore((s) => s.logout); const publicIPRef = useRef(null); - const [appVersion, setAppVersion] = useState({ - version: '', - timestamp: null, - }); const [userFormOpen, setUserFormOpen] = useState(false); const closeUserForm = () => setUserFormOpen(false); @@ -144,36 +140,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { }, ]; - // Fetch environment settings including version on component mount - useEffect(() => { - if (!isAuthenticated) { - return; - } - - const fetchEnvironment = async () => { - API.getEnvironmentSettings(); - }; - - fetchEnvironment(); - }, [isAuthenticated]); - - // Fetch version information on component mount (regardless of authentication) - useEffect(() => { - const fetchVersion = async () => { - try { - const versionData = await API.getVersion(); - setAppVersion({ - version: versionData.version || '', - timestamp: versionData.timestamp || null, - }); - } catch (error) { - console.error('Failed to fetch version information:', error); - // Keep using default values from useState initialization - } - }; - - fetchVersion(); - }, []); + // Environment settings and version are loaded by the settings store during initData() + // No need to fetch them again here - just use the store values const copyPublicIP = async () => { const success = await copyToClipboard(environment.public_ip); diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx index 353cd50e..4e973891 100644 --- a/frontend/src/components/forms/LoginForm.jsx +++ b/frontend/src/components/forms/LoginForm.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import useAuthStore from '../../store/auth'; -import API from '../../api'; +import useSettingsStore from '../../store/settings'; import { Paper, Title, @@ -25,13 +25,14 @@ const LoginForm = () => { const logout = useAuthStore((s) => s.logout); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const initData = useAuthStore((s) => s.initData); + const fetchVersion = useSettingsStore((s) => s.fetchVersion); + const storedVersion = useSettingsStore((s) => s.version); const navigate = useNavigate(); // Hook to navigate to other routes const [formData, setFormData] = useState({ username: '', password: '' }); const [rememberMe, setRememberMe] = useState(false); const [savePassword, setSavePassword] = useState(false); const [forgotPasswordOpened, setForgotPasswordOpened] = useState(false); - const [version, setVersion] = useState(null); const [isLoading, setIsLoading] = useState(false); // Simple base64 encoding/decoding for localStorage @@ -55,11 +56,9 @@ const LoginForm = () => { }; useEffect(() => { - // Fetch version info - API.getVersion().then((data) => { - setVersion(data?.version); - }); - }, []); + // Fetch version info using the settings store (will skip if already loaded) + fetchVersion(); + }, [fetchVersion]); useEffect(() => { // Load saved username if it exists @@ -234,8 +233,8 @@ const LoginForm = () => { lineHeight: '1.2', }} > - ⚠ Password will be stored locally without encryption. Only - use on trusted devices. + ⚠ Password will be stored locally without encryption. Only use + on trusted devices. )} @@ -252,7 +251,7 @@ const LoginForm = () => { - {version && ( + {storedVersion.version && ( { right: 30, }} > - v{version} + v{storedVersion.version} )} diff --git a/frontend/src/components/forms/SuperuserForm.jsx b/frontend/src/components/forms/SuperuserForm.jsx index ca8c81fc..b5094993 100644 --- a/frontend/src/components/forms/SuperuserForm.jsx +++ b/frontend/src/components/forms/SuperuserForm.jsx @@ -13,6 +13,7 @@ import { } from '@mantine/core'; import API from '../../api'; import useAuthStore from '../../store/auth'; +import useSettingsStore from '../../store/settings'; import logo from '../../assets/logo.png'; function SuperuserForm() { @@ -22,15 +23,14 @@ function SuperuserForm() { email: '', }); const [error, setError] = useState(''); - const [version, setVersion] = useState(null); const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists); + const fetchVersion = useSettingsStore((s) => s.fetchVersion); + const storedVersion = useSettingsStore((s) => s.version); useEffect(() => { - // Fetch version info - API.getVersion().then((data) => { - setVersion(data?.version); - }); - }, []); + // Fetch version info using the settings store (will skip if already loaded) + fetchVersion(); + }, [fetchVersion]); const handleChange = (e) => { setFormData((prev) => ({ @@ -120,7 +120,7 @@ function SuperuserForm() { - {version && ( + {storedVersion.version && ( - v{version} + v{storedVersion.version} )} diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index f9780139..f41ca72d 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -253,7 +253,7 @@ const ChannelsTable = ({ onReady }) => { const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded); // Get channel logos for logo selection - const { logos: channelLogos, ensureLogosLoaded } = useChannelLogoSelection(); + const { ensureLogosLoaded } = useChannelLogoSelection(); const theme = useMantineTheme(); const channelGroups = useChannelsStore((s) => s.channelGroups); @@ -344,6 +344,9 @@ const ChannelsTable = ({ onReady }) => { const [deleting, setDeleting] = useState(false); const hasFetchedData = useRef(false); + const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates + const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests + const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress // Drag-and-drop sensors const sensors = useSensors( @@ -423,8 +426,7 @@ const ChannelsTable = ({ onReady }) => { * Functions */ const fetchData = useCallback(async () => { - setIsLoading(true); - + // Build params first to check for duplicates const params = new URLSearchParams(); params.append('page', pagination.pageIndex + 1); params.append('page_size', pagination.pageSize); @@ -447,7 +449,7 @@ const ChannelsTable = ({ onReady }) => { } // Apply debounced filters - Object.entries(filters).forEach(([key, value]) => { + Object.entries(debouncedFilters).forEach(([key, value]) => { if (value) { if (Array.isArray(value)) { // Convert null values to "null" string for URL parameter @@ -461,12 +463,36 @@ const ChannelsTable = ({ onReady }) => { } }); + const paramsString = params.toString(); + + // Skip if same fetch is already in progress (prevents StrictMode double-fetch) + if ( + fetchInProgressRef.current && + lastFetchParamsRef.current === paramsString + ) { + return; + } + + // Increment fetch version to track this specific fetch request + const currentFetchVersion = ++fetchVersionRef.current; + lastFetchParamsRef.current = paramsString; + fetchInProgressRef.current = true; + + setIsLoading(true); + try { const [results, ids] = await Promise.all([ - await API.queryChannels(params), - await API.getAllChannelIds(params), + API.queryChannels(params), + API.getAllChannelIds(params), ]); + fetchInProgressRef.current = false; + + // Skip state updates if a newer fetch has been initiated + if (currentFetchVersion !== fetchVersionRef.current) { + return; + } + setIsLoading(false); hasFetchedData.current = true; @@ -483,6 +509,12 @@ const ChannelsTable = ({ onReady }) => { onReady(); } } catch (error) { + fetchInProgressRef.current = false; + + // Skip state updates if a newer fetch has been initiated + if (currentFetchVersion !== fetchVersionRef.current) { + return; + } setIsLoading(false); // API layer handles "Invalid page" errors by resetting and retrying // Just re-throw to show notification for actual errors @@ -492,11 +524,9 @@ const ChannelsTable = ({ onReady }) => { pagination, sorting, debouncedFilters, - onReady, showDisabled, selectedProfileId, showOnlyStreamlessChannels, - tvgsLoaded, ]); const stopPropagation = useCallback((e) => { @@ -947,19 +977,11 @@ const ChannelsTable = ({ onReady }) => { enableResizing: false, header: '', cell: (props) => ( - { - // Ensure logos are loaded when user tries to edit - ensureLogosLoaded(); - }} - style={{ width: '100%', height: '100%' }} - > - - + ), }, { @@ -987,8 +1009,9 @@ const ChannelsTable = ({ onReady }) => { // the actual sizes through its own state after initialization. // Note: logos is intentionally excluded - LazyLogo components handle their own logo data // from the store, so we don't need to recreate columns when logos load. + // Note: tvgsLoaded is intentionally excluded - EditableEPGCell handles loading state internally // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedProfileId, channelGroups, theme, tvgsById, epgs, tvgsLoaded] + [selectedProfileId, channelGroups, theme, tvgsById, epgs] ); const renderHeaderCell = (header) => { diff --git a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx index 065e4631..42d99e73 100644 --- a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx +++ b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useMemo, + memo, } from 'react'; import { Box, @@ -16,33 +17,88 @@ import { } from '@mantine/core'; import API from '../../../api'; import useChannelsTableStore from '../../../store/channelsTable'; +import useLogosStore from '../../../store/logos'; + +// Lightweight wrapper that only renders full editable cell when unlocked +// This prevents 250+ heavy component instances when table is locked +const EditableCellWrapper = memo( + ({ children, getValue, isUnlocked, renderLocked }) => { + if (!isUnlocked) { + // Render lightweight locked view + return renderLocked ? ( + renderLocked(getValue()) + ) : ( + + {getValue() ?? ''} + + ); + } + // Only render heavy component when unlocked + return children; + } +); // Editable text cell export const EditableTextCell = ({ row, column, getValue }) => { const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const [isFocused, setIsFocused] = useState(false); + + // When locked or not focused, show simple display + if (!isUnlocked || !isFocused) { + return ( + isUnlocked && setIsFocused(true)} + style={{ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + cursor: isUnlocked ? 'text' : 'default', + padding: '0 4px', + }} + > + {getValue() ?? ''} + + ); + } + + // Only mount heavy component when actually editing + return ( + setIsFocused(false)} + /> + ); +}; + +// Inner component with all the editing logic - only rendered when focused +const EditableTextCellInner = ({ row, column, getValue, onBlur }) => { const initialValue = getValue() || ''; const [value, setValue] = useState(initialValue); - const [isFocused, setIsFocused] = useState(false); const previousValue = useRef(initialValue); const isMounted = useRef(false); const debounceTimer = useRef(null); useEffect(() => { const currentValue = getValue() || ''; - if (!isFocused && currentValue !== previousValue.current) { + if (currentValue !== previousValue.current) { setValue(currentValue); previousValue.current = currentValue; } - }, [getValue, isFocused]); + }, [getValue]); const saveValue = useCallback( async (newValue) => { - // Don't save if not mounted, not unlocked, or value hasn't changed - if ( - !isMounted.current || - !isUnlocked || - newValue === previousValue.current - ) { + // Don't save if not mounted or value hasn't changed + if (!isMounted.current || newValue === previousValue.current) { return; } @@ -62,7 +118,7 @@ export const EditableTextCell = ({ row, column, getValue }) => { setValue(previousValue.current || ''); } }, - [row.original.id, column.id, isUnlocked] + [row.original.id, column.id] ); useEffect(() => { @@ -77,7 +133,6 @@ export const EditableTextCell = ({ row, column, getValue }) => { }, []); const handleChange = (e) => { - if (!isUnlocked) return; const newValue = e.currentTarget.value; setValue(newValue); @@ -93,40 +148,10 @@ export const EditableTextCell = ({ row, column, getValue }) => { }; const handleBlur = () => { - setIsFocused(false); - if (isUnlocked) { - saveValue(value); - } + saveValue(value); + onBlur(); }; - const handleClick = () => { - if (isUnlocked) { - setIsFocused(true); - } - }; - - if (!isUnlocked || !isFocused) { - return ( - - {value} - - ); - } - return ( { // Editable number cell export const EditableNumberCell = ({ row, column, getValue }) => { const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const [isFocused, setIsFocused] = useState(false); + + const value = getValue(); + const formattedValue = + value !== null && value !== undefined + ? value === Math.floor(value) + ? Math.floor(value) + : value + : ''; + + // When locked or not focused, show simple display + if (!isUnlocked || !isFocused) { + return ( + isUnlocked && setIsFocused(true)} + style={{ + textAlign: 'right', + width: '100%', + cursor: isUnlocked ? 'text' : 'default', + padding: '0 4px', + }} + > + {formattedValue} + + ); + } + + return ( + setIsFocused(false)} + /> + ); +}; + +// Inner component with all the editing logic - only rendered when focused +const EditableNumberCellInner = ({ row, column, getValue, onBlur }) => { const initialValue = getValue(); const [value, setValue] = useState(initialValue); - const [isFocused, setIsFocused] = useState(false); const previousValue = useRef(initialValue); const isMounted = useRef(false); useEffect(() => { const currentValue = getValue(); - if (!isFocused && currentValue !== previousValue.current) { + if (currentValue !== previousValue.current) { setValue(currentValue); previousValue.current = currentValue; } - }, [getValue, isFocused]); + }, [getValue]); const saveValue = useCallback( async (newValue) => { - // Don't save if not mounted, not unlocked, or value hasn't changed - if ( - !isMounted.current || - !isUnlocked || - newValue === previousValue.current - ) { + // Don't save if not mounted or value hasn't changed + if (!isMounted.current || newValue === previousValue.current) { return; } @@ -203,8 +262,7 @@ export const EditableNumberCell = ({ row, column, getValue }) => { // If channel_number was changed, refetch to reorder the table if (column.id === 'channel_number') { await API.requeryChannels(); - // Exit edit mode after resorting to avoid confusion - setIsFocused(false); + onBlur(); } } } catch (error) { @@ -212,7 +270,7 @@ export const EditableNumberCell = ({ row, column, getValue }) => { setValue(previousValue.current); } }, - [row.original.id, column.id, isUnlocked] + [row.original.id, column.id, onBlur] ); useEffect(() => { @@ -223,51 +281,14 @@ export const EditableNumberCell = ({ row, column, getValue }) => { }, []); const handleChange = (newValue) => { - if (!isUnlocked) return; setValue(newValue); }; const handleBlur = () => { - setIsFocused(false); - if (isUnlocked) { - saveValue(value); - } + saveValue(value); + onBlur(); }; - const handleClick = () => { - if (isUnlocked) { - setIsFocused(true); - } - }; - - const formattedValue = - value !== null && value !== undefined - ? value === Math.floor(value) - ? Math.floor(value) - : value - : ''; - - if (!isUnlocked || !isFocused) { - return ( - - {formattedValue} - - ); - } - return ( { }; // Editable select cell for groups -export const EditableGroupCell = ({ row, getValue, channelGroups }) => { +export const EditableGroupCell = ({ row, channelGroups }) => { const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const [isFocused, setIsFocused] = useState(false); const groupId = row.original.channel_group_id; const groupName = channelGroups[groupId]?.name || ''; + + // Show simple display when locked OR when unlocked but not focused + if (!isUnlocked || !isFocused) { + return ( + isUnlocked && setIsFocused(true)} + style={{ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + padding: '0 4px', + cursor: isUnlocked ? 'pointer' : 'default', + }} + > + {groupName} + + ); + } + + return ( + setIsFocused(false)} + /> + ); +}; + +// Inner component with all the editing logic - only rendered when focused +const EditableGroupCellInner = ({ + row, + channelGroups, + groupName, + groupId, + onBlur, +}) => { const previousGroupId = useRef(groupId); - const [isFocused, setIsFocused] = useState(false); const [searchValue, setSearchValue] = useState(''); const saveValue = useCallback( async (newGroupId) => { - // Don't save if not unlocked or value hasn't changed - if ( - !isUnlocked || - String(newGroupId) === String(previousGroupId.current) - ) { + // Don't save if value hasn't changed + if (String(newGroupId) === String(previousGroupId.current)) { return; } @@ -324,18 +380,12 @@ export const EditableGroupCell = ({ row, getValue, channelGroups }) => { console.error('Failed to update channel group:', error); } }, - [row.original.id, isUnlocked] + [row.original.id] ); - const handleClick = () => { - if (isUnlocked) { - setIsFocused(true); - } - }; - const handleChange = (newGroupId) => { saveValue(newGroupId); - setIsFocused(false); + onBlur(); setSearchValue(''); }; @@ -344,33 +394,11 @@ export const EditableGroupCell = ({ row, getValue, channelGroups }) => { label: group.name, })); - if (!isUnlocked || !isFocused) { - return ( - - {groupName} - - ); - } - return ( setIsFocused(false)} + onBlur={onBlur} data={epgOptions} size="xs" variant="unstyled" @@ -599,17 +637,69 @@ export const EditableEPGCell = ({ }; // Editable cell for Logo selection -export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => { +export const EditableLogoCell = ({ + row, + getValue, + LazyLogo, + ensureLogosLoaded, +}) => { const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); - const logoId = getValue(); - const previousLogoId = useRef(logoId); const [isFocused, setIsFocused] = useState(false); + const logoId = getValue(); + + const handleClick = () => { + if (isUnlocked) { + // Ensure logos are loaded when user tries to edit + ensureLogosLoaded?.(); + setIsFocused(true); + } + }; + + // Show simple display when locked OR when unlocked but not focused + if (!isUnlocked || !isFocused) { + return ( + + {LazyLogo && ( + + )} + + ); + } + + return ( + setIsFocused(false)} + /> + ); +}; + +// Inner component with all the editing logic - only rendered when focused +const EditableLogoCellInner = ({ row, logoId, onBlur }) => { + // Subscribe directly to the logos store so we get updates when logos load + const channelLogos = useLogosStore((s) => s.channelLogos); + const previousLogoId = useRef(logoId); const [searchValue, setSearchValue] = useState(''); const saveValue = useCallback( async (newLogoId) => { - // Don't save if not unlocked or value hasn't changed - if (!isUnlocked || String(newLogoId) === String(previousLogoId.current)) { + // Don't save if value hasn't changed + if (String(newLogoId) === String(previousLogoId.current)) { return; } @@ -628,20 +718,13 @@ export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => { console.error('Failed to update logo:', error); } }, - [row.original.id, isUnlocked] + [row.original.id] ); - const handleClick = () => { - if (isUnlocked) { - setSearchValue(''); - setIsFocused(true); - } - }; - const handleChange = (newLogoId) => { saveValue(newLogoId); setSearchValue(''); - setIsFocused(false); + onBlur(); }; // Build logo options with logo data @@ -706,36 +789,6 @@ export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => { ); }; - if (!isUnlocked || !isFocused) { - // When not editing, show the logo image - return ( - - {LazyLogo && ( - - )} - - ); - } - return ( {