diff --git a/src/webui/modern/package-lock.json b/src/webui/modern/package-lock.json
index c63c599de..3f3c762a2 100644
--- a/src/webui/modern/package-lock.json
+++ b/src/webui/modern/package-lock.json
@@ -17,10 +17,12 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
+ "@types/marked": "^5.0.2",
"@types/node": "^16.18.126",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@types/react-router-dom": "^5.3.3",
+ "marked": "^16.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.2",
@@ -4182,6 +4184,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"license": "MIT"
},
+ "node_modules/@types/marked": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz",
+ "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==",
+ "license": "MIT"
+ },
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -11801,6 +11809,18 @@
"tmpl": "1.0.5"
}
},
+ "node_modules/marked": {
+ "version": "16.3.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz",
+ "integrity": "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
diff --git a/src/webui/modern/package.json b/src/webui/modern/package.json
index e1f0201e2..d51f0f513 100644
--- a/src/webui/modern/package.json
+++ b/src/webui/modern/package.json
@@ -13,10 +13,12 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
+ "@types/marked": "^5.0.2",
"@types/node": "^16.18.126",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@types/react-router-dom": "^5.3.3",
+ "marked": "^16.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.2",
diff --git a/src/webui/modern/src/App.tsx b/src/webui/modern/src/App.tsx
index 2917bbea3..1351aa3c7 100644
--- a/src/webui/modern/src/App.tsx
+++ b/src/webui/modern/src/App.tsx
@@ -33,6 +33,10 @@ import DVR from './components/DVR';
import Configuration from './components/Configuration';
import Status from './components/Status';
import About from './components/About';
+import HelpSystem from './components/common/HelpSystem';
+
+// Contexts
+import { HelpProvider, useHelp } from './contexts/HelpContext';
// Theme matching Tvheadend's blue color scheme
const theme = createTheme({
@@ -62,6 +66,7 @@ function AppContent() {
const navigate = useNavigate();
const location = useLocation();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
+ const { isHelpOpen, helpPageName, closeHelp } = useHelp();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
@@ -179,6 +184,13 @@ function AppContent() {
} />
} />
+
+ {/* Global Help System */}
+
);
@@ -187,9 +199,11 @@ function AppContent() {
function App() {
return (
-
-
-
+
+
+
+
+
);
}
diff --git a/src/webui/modern/src/components/Configuration.tsx b/src/webui/modern/src/components/Configuration.tsx
index 06ef434cf..39e84cf51 100644
--- a/src/webui/modern/src/components/Configuration.tsx
+++ b/src/webui/modern/src/components/Configuration.tsx
@@ -44,6 +44,7 @@ import {
loadLanguages,
LanguageOption
} from '../utils/api';
+import { useHelp } from '../contexts/HelpContext';
import AccessEntriesSection from './sections/AccessEntriesSection';
import PasswordsSection from './sections/PasswordsSection';
import IPBlockingSection from './sections/IPBlockingSection';
@@ -89,6 +90,7 @@ function Configuration() {
const [selectedSection, setSelectedSection] = useState('general');
const [selectedSubsection, setSelectedSubsection] = useState('base');
const [expandedSections, setExpandedSections] = useState(['general']);
+ const { showHelp } = useHelp();
// Server data
const [serverInfo, setServerInfo] = useState(null);
@@ -520,6 +522,7 @@ function Configuration() {
variant="outlined"
size="small"
sx={{ mb: 1 }}
+ onClick={() => showHelp('introduction')}
>
Help
@@ -689,7 +692,12 @@ function Configuration() {
- }>Help
+ }
+ onClick={() => showHelp('wizard/hello')}
+ >
+ Help
+
diff --git a/src/webui/modern/src/components/common/ConfigDataGrid.tsx b/src/webui/modern/src/components/common/ConfigDataGrid.tsx
index 5a267cad6..5e111f4fb 100644
--- a/src/webui/modern/src/components/common/ConfigDataGrid.tsx
+++ b/src/webui/modern/src/components/common/ConfigDataGrid.tsx
@@ -24,6 +24,13 @@ import {
Checkbox,
FormControlLabel,
CircularProgress,
+ TablePagination,
+ TableSortLabel,
+ Menu,
+ Switch,
+ Chip,
+ Tooltip,
+ Collapse,
} from '@mui/material';
import {
Add as AddIcon,
@@ -32,7 +39,13 @@ import {
ArrowUpward as MoveUpIcon,
ArrowDownward as MoveDownIcon,
Refresh as RefreshIcon,
+ Help as HelpIcon,
+ FilterList as FilterIcon,
+ ViewColumn as ColumnIcon,
+ ExpandMore as ExpandMoreIcon,
+ ExpandLess as ExpandLessIcon,
} from '@mui/icons-material';
+import { useHelp } from '../../contexts/HelpContext';
export interface GridColumn {
key: string;
@@ -40,6 +53,9 @@ export interface GridColumn {
width?: number;
type?: 'text' | 'number' | 'boolean' | 'select';
options?: Array<{key: string | number, val: string}>;
+ sortable?: boolean;
+ filterable?: boolean;
+ hidden?: boolean;
}
export interface ConfigDataGridProps {
@@ -51,7 +67,13 @@ export interface ConfigDataGridProps {
canEdit?: boolean;
canDelete?: boolean;
canMove?: boolean;
+ canGroup?: boolean;
+ autoRefresh?: boolean;
+ autoRefreshInterval?: number; // seconds
fields?: string[];
+ helpPage?: string; // Help page to show when help button is clicked
+ pageSize?: number;
+ supportsPagination?: boolean;
}
const ConfigDataGrid: React.FC = ({
@@ -63,7 +85,13 @@ const ConfigDataGrid: React.FC = ({
canEdit = true,
canDelete = true,
canMove = false,
+ canGroup = false,
+ autoRefresh = false,
+ autoRefreshInterval = 30,
fields,
+ helpPage,
+ pageSize = 50,
+ supportsPagination = true,
}) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
@@ -73,31 +101,118 @@ const ConfigDataGrid: React.FC = ({
isNew: false
});
const [editData, setEditData] = useState({});
+
+ // Advanced grid state
+ const [page, setPage] = useState(0);
+ const [rowsPerPage, setRowsPerPage] = useState(pageSize);
+ const [sortBy, setSortBy] = useState('');
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
+ const [filters, setFilters] = useState>({});
+ const [visibleColumns, setVisibleColumns] = useState>(
+ columns.reduce((acc, col) => ({...acc, [col.key]: !col.hidden}), {})
+ );
+ const [totalCount, setTotalCount] = useState(0);
+ const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(autoRefresh);
+
+ // UI state for menus and dialogs
+ const [columnMenuAnchor, setColumnMenuAnchor] = useState(null);
+ const [filterMenuAnchor, setFilterMenuAnchor] = useState(null);
+ const [filtersExpanded, setFiltersExpanded] = useState(false);
+
+ const { showHelp } = useHelp();
const loadData = useCallback(async () => {
setLoading(true);
try {
- // Use POST request with grid parameters like the old UI
- const response = await fetch(`/api/${url}`, {
+ // Build parameters like ExtJS interface
+ const params = new URLSearchParams({
+ sort: sortBy || 'prefix',
+ dir: sortDirection.toUpperCase(),
+ groupBy: 'false',
+ groupDir: 'ASC',
+ start: supportsPagination ? (page * rowsPerPage).toString() : '0',
+ limit: supportsPagination ? rowsPerPage.toString() : '999999'
+ });
+
+ // Add filters
+ Object.entries(filters).forEach(([key, value]) => {
+ if (value) {
+ params.append(`filter[${key}]`, value);
+ }
+ });
+
+ // Use POST request with grid parameters like the old UI
+ const response = await fetch(`api/${url}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: new URLSearchParams({
- sort: 'prefix',
- dir: 'ASC',
- groupBy: 'false',
- groupDir: 'ASC',
- start: '0',
- limit: '999999'
- })
+ body: params
});
const result = await response.json();
setData(result.entries || []);
+ setTotalCount(result.totalCount || result.entries?.length || 0);
} catch (error) {
console.error(`Failed to load ${title}:`, error);
} finally {
setLoading(false);
}
- }, [url, title]);
+ }, [url, title, page, rowsPerPage, sortBy, sortDirection, filters, supportsPagination]);
+
+ // Auto-refresh functionality
+ useEffect(() => {
+ let interval: NodeJS.Timeout;
+ if (autoRefreshEnabled && autoRefreshInterval > 0) {
+ interval = setInterval(() => {
+ loadData();
+ }, autoRefreshInterval * 1000);
+ }
+ return () => {
+ if (interval) clearInterval(interval);
+ };
+ }, [autoRefreshEnabled, autoRefreshInterval, loadData]);
+
+ const handleSort = (columnKey: string) => {
+ const column = columns.find(col => col.key === columnKey);
+ if (!column?.sortable && column?.sortable !== undefined) return;
+
+ if (sortBy === columnKey) {
+ setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
+ } else {
+ setSortBy(columnKey);
+ setSortDirection('asc');
+ }
+ setPage(0); // Reset to first page when sorting
+ };
+
+ const handleFilter = (columnKey: string, value: string) => {
+ setFilters(prev => ({
+ ...prev,
+ [columnKey]: value
+ }));
+ setPage(0); // Reset to first page when filtering
+ };
+
+ const clearFilters = () => {
+ setFilters({});
+ setPage(0);
+ };
+
+ const handleChangePage = (event: unknown, newPage: number) => {
+ setPage(newPage);
+ };
+
+ const handleChangeRowsPerPage = (event: React.ChangeEvent) => {
+ setRowsPerPage(parseInt(event.target.value, 10));
+ setPage(0);
+ };
+
+ const toggleColumn = (columnKey: string) => {
+ setVisibleColumns(prev => ({
+ ...prev,
+ [columnKey]: !prev[columnKey]
+ }));
+ };
+
+ const activeFiltersCount = Object.values(filters).filter(v => v).length;
useEffect(() => {
loadData();
@@ -118,7 +233,7 @@ const ConfigDataGrid: React.FC = ({
try {
for (const uuid of selectedRows) {
- await fetch('/api/idnode/delete', {
+ await fetch('api/idnode/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid }),
@@ -133,7 +248,7 @@ const ConfigDataGrid: React.FC = ({
const handleSave = async () => {
try {
- const apiUrl = editDialog.isNew ? `/api/${url}` : '/api/idnode/save';
+ const apiUrl = editDialog.isNew ? `api/${url}` : 'api/idnode/save';
const method = 'POST';
const body = editDialog.isNew
? { ...editData }
@@ -154,7 +269,7 @@ const ConfigDataGrid: React.FC = ({
const handleMove = async (uuid: string, direction: 'up' | 'down') => {
try {
- await fetch('/api/idnode/move', {
+ await fetch('api/idnode/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid, dir: direction === 'up' ? -1 : 1 }),
@@ -232,9 +347,54 @@ const ConfigDataGrid: React.FC = ({
{title}
+
+ {/* Auto-refresh toggle */}
+ {autoRefresh && (
+ setAutoRefreshEnabled(e.target.checked)}
+ size="small"
+ />
+ }
+ label="Auto-refresh"
+ sx={{ mr: 2 }}
+ />
+ )}
+
+ {/* Filters */}
+ 0 ? `(${activeFiltersCount})` : ''}`}>
+ setFilterMenuAnchor(e.currentTarget)}
+ color={activeFiltersCount > 0 ? 'primary' : 'default'}
+ >
+
+ {activeFiltersCount > 0 && (
+
+ )}
+
+
+
+ {/* Column visibility */}
+
+ setColumnMenuAnchor(e.currentTarget)}>
+
+
+
+
+ {helpPage && (
+ showHelp(helpPage)}>
+
+
+ )}
{canAdd && (
} onClick={handleAdd}>
Add
@@ -242,11 +402,35 @@ const ConfigDataGrid: React.FC = ({
)}
{canDelete && selectedRows.length > 0 && (
} onClick={handleDelete} color="error">
- Delete
+ Delete ({selectedRows.length})
)}
+ {/* Filter Panel */}
+
+
+
+ Filters:
+
+
+
+ {columns.filter(col => col.filterable !== false && visibleColumns[col.key]).map((column) => (
+ handleFilter(column.key, e.target.value)}
+ sx={{ minWidth: 200 }}
+ />
+ ))}
+
+
+
+
@@ -260,9 +444,19 @@ const ConfigDataGrid: React.FC = ({
}}
/>
- {columns.map((column) => (
+ {columns.filter(col => visibleColumns[col.key]).map((column) => (
- {column.label}
+ {column.sortable !== false ? (
+ handleSort(column.key)}
+ >
+ {column.label}
+
+ ) : (
+ column.label
+ )}
))}
Actions
@@ -271,7 +465,7 @@ const ConfigDataGrid: React.FC = ({
{loading ? (
-
+ visibleColumns[col.key]).length + 2} align="center">
@@ -289,7 +483,7 @@ const ConfigDataGrid: React.FC = ({
}}
/>
- {columns.map((column) => (
+ {columns.filter(col => visibleColumns[col.key]).map((column) => (
{column.type === 'boolean'
? (row[column.key] ? 'Yes' : 'No')
@@ -329,8 +523,87 @@ const ConfigDataGrid: React.FC = ({
+
+ {/* Pagination */}
+ {supportsPagination && (
+
+ `${from}–${to} of ${count !== -1 ? count : `more than ${to}`}`
+ }
+ />
+ )}
+ {/* Column Visibility Menu */}
+
+
+ {/* Filter Menu */}
+
+
{/* Edit Dialog */}