mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Phase 2, part 4. Baseline 50 -> 15.
Migrated: all eight calculators, the rest of inventory and collections, the
Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage,
DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings
and Status, Download, and App.
Also the three shared modules everything imports: api.js and customHooks.js
swap the Semantic toast library for the library's shim (same call shape), and
useForm.js renders the library's Message. useForm accepts both the old
`{content, header, positive|negative}` shape and the new `{kind, title}`,
because the pages building those objects migrate separately.
Two library gaps closed, both found by call sites needing them:
ActionInput is a forwardRef now. Its ref reaches the input rather than the
wrapper, because callers use it to focus or select the field -- a ref to the
surrounding div would do neither, which is why one call site had resorted to
hand-copying the component's markup.
Header takes a `color` token name. Three separate call sites had reached for
an inline style to colour a heading that carries meaning ("3 items to add"
against "2 to remove"), which is how a hex ends up in markup.
Two files needed repair after being interrupted mid-edit: MapViewer had a
Segment left in its error branch, and VideoPlayer had lost the useContext
import its own code still used. Neither jest nor tsc noticed; only
`npm run build` did, which is the whole reason that check exists.
Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser,
FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim,
ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the
unmigrated files still import its Semantic-backed wrappers, so it can only go
once they have.
261 lines
9.5 KiB
JavaScript
261 lines
9.5 KiB
JavaScript
import React, {useContext} from 'react';
|
|
import {Link} from 'react-router';
|
|
import {Button, Message, Placeholder, Table} from '../ui';
|
|
import _ from 'lodash';
|
|
import {SortableTable} from '../SortableTable';
|
|
import {formatFrequency, humanFileSize} from '../Common';
|
|
import {Media} from '../../contexts/contexts';
|
|
import {TagsContext} from '../../Tags';
|
|
|
|
/**
|
|
* Get the ID field from routes configuration.
|
|
* @param {Object} routes - Routes configuration
|
|
* @returns {string} The ID field name
|
|
*/
|
|
function getIdField(routes) {
|
|
return routes?.id_field || 'id';
|
|
}
|
|
|
|
/**
|
|
* Generate a search link for a collection based on routes configuration.
|
|
* Supports both query parameter-based (e.g., ?domain=...) and route-based (e.g., /channel/:id/video) linking.
|
|
*
|
|
* @param {Object} collection - The collection object
|
|
* @param {Object} routes - Routes configuration
|
|
* @param {string} primaryKey - The primary column key to use for the value
|
|
* @returns {string|null} The generated link, or null if no link can be generated
|
|
*/
|
|
function getCollectionSearchLink(collection, routes, primaryKey) {
|
|
const searchRoute = routes?.search;
|
|
if (!searchRoute) {
|
|
return null;
|
|
}
|
|
|
|
// Check if routes specifies a query parameter to use
|
|
if (routes.searchParam) {
|
|
// Query parameter-based linking (e.g., /archive?domain=example.com)
|
|
return `${searchRoute}?${routes.searchParam}=${collection[primaryKey]}`;
|
|
} else if (searchRoute.includes(':id')) {
|
|
// Route parameter-based linking (e.g., /videos/channel/123/video)
|
|
const idField = getIdField(routes);
|
|
return searchRoute.replace(':id', collection[idField]);
|
|
}
|
|
|
|
// No linking strategy available
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Convert a Semantic-style relative column width (1-16) into a CSS percentage.
|
|
* @param {number} width - Relative width, 1-16
|
|
* @returns {Object|undefined} A style object, or undefined when no width is configured
|
|
*/
|
|
function widthStyle(width) {
|
|
return width ? {width: `${(width / 16) * 100}%`} : undefined;
|
|
}
|
|
|
|
/**
|
|
* Render a single table row for desktop view.
|
|
*
|
|
* @param {Object} collection - The collection data
|
|
* @param {Array} columns - Column configurations
|
|
* @param {Object} routes - Routes configuration
|
|
* @param {Function} SingleTag - Tag component from context
|
|
* @param {Function} onRowClick - Optional click handler
|
|
*/
|
|
function renderRow(collection, columns, routes, SingleTag, onRowClick) {
|
|
const cells = columns.map((col) => {
|
|
let value = collection[col.key];
|
|
|
|
// Handle actions column - render action buttons
|
|
if (col.type === 'actions') {
|
|
const idField = getIdField(routes);
|
|
const editRoute = routes?.edit?.replace(':id', collection[idField]);
|
|
return <Table.Cell key={col.key} style={{textAlign: col.align || 'right', ...widthStyle(col.width)}}>
|
|
{editRoute && <Button role='cancel' component={Link} to={editRoute} size='sm'>Edit</Button>}
|
|
</Table.Cell>;
|
|
}
|
|
|
|
// Format the value based on column configuration
|
|
if (col.format === 'bytes') {
|
|
value = humanFileSize(value);
|
|
} else if (col.format === 'frequency') {
|
|
value = formatFrequency(value);
|
|
}
|
|
|
|
// Special handling for tag_name column - render SingleTag component
|
|
if (col.key === 'tag_name' && value) {
|
|
value = <SingleTag name={value}/>;
|
|
}
|
|
|
|
// Special handling for the primary column (usually domain/name)
|
|
if (col.key === columns[0].key) {
|
|
const searchLink = getCollectionSearchLink(collection, routes, col.key);
|
|
if (searchLink) {
|
|
value = <Link to={searchLink}>{value}</Link>;
|
|
}
|
|
}
|
|
|
|
return <Table.Cell key={col.key} style={{textAlign: col.align || 'left', ...widthStyle(col.width)}}>
|
|
{value || '-'}
|
|
</Table.Cell>;
|
|
});
|
|
|
|
return <Table.Row
|
|
key={collection.id}
|
|
onClick={() => onRowClick && onRowClick(collection)}
|
|
className={onRowClick ? 'clickable' : ''}
|
|
>
|
|
{cells}
|
|
</Table.Row>;
|
|
}
|
|
|
|
/**
|
|
* Mobile row component for collections - stacked layout for better wrapping of long names
|
|
*/
|
|
function MobileCollectionRow({collection, mobileColumns, routes}) {
|
|
const {SingleTag} = useContext(TagsContext);
|
|
const primaryColumn = mobileColumns[0];
|
|
const idField = getIdField(routes);
|
|
const editRoute = routes?.edit?.replace(':id', collection[idField]);
|
|
const searchLink = getCollectionSearchLink(collection, routes, primaryColumn.key);
|
|
|
|
return <Table.Row style={{verticalAlign: 'top'}}>
|
|
<Table.Cell>
|
|
{searchLink ? (
|
|
<Link to={searchLink}>
|
|
<strong>{collection[primaryColumn.key]}</strong>
|
|
</Link>
|
|
) : (
|
|
<strong>{collection[primaryColumn.key]}</strong>
|
|
)}
|
|
{collection.tag_name && <> <SingleTag name={collection.tag_name}/></>}
|
|
{mobileColumns
|
|
.filter(col => col.type !== 'actions' && col.key !== primaryColumn.key && col.key !== 'tag_name')
|
|
.map(col => {
|
|
let value = collection[col.key];
|
|
if (col.format === 'bytes') {
|
|
value = humanFileSize(value);
|
|
} else if (col.format === 'frequency') {
|
|
value = formatFrequency(value);
|
|
}
|
|
return (
|
|
<div key={col.key}>
|
|
{col.label}: {value || '-'}
|
|
</div>
|
|
);
|
|
})
|
|
}
|
|
</Table.Cell>
|
|
<Table.Cell style={{textAlign: 'right'}}>
|
|
{editRoute && <Button role='cancel' component={Link} to={editRoute} size='sm'>Edit</Button>}
|
|
</Table.Cell>
|
|
</Table.Row>;
|
|
}
|
|
|
|
/**
|
|
* Reusable table component for displaying collections (Domains, Channels, etc).
|
|
*
|
|
* @param {Array} collections - Array of collection objects
|
|
* @param {Array} columns - Column configurations for the table
|
|
* @param {Object} routes - Routes configuration for navigation (edit, search, etc.)
|
|
* @param {String} searchStr - Search filter string (managed by parent)
|
|
* @param {Function} onRowClick - Optional callback when a row is clicked
|
|
* @param {String} emptyMessage - Message to display when there are no collections
|
|
*/
|
|
export function CollectionTable({
|
|
collections,
|
|
columns,
|
|
routes = {},
|
|
searchStr = '',
|
|
onRowClick,
|
|
emptyMessage = 'No items yet'
|
|
}) {
|
|
const {SingleTag} = useContext(TagsContext);
|
|
|
|
// Loading state
|
|
if (collections === null) {
|
|
return <Placeholder lines={2}/>;
|
|
}
|
|
|
|
// Error state
|
|
if (collections === undefined) {
|
|
return <Message kind='error' title='Could not fetch collections'/>;
|
|
}
|
|
|
|
// Empty state
|
|
if (collections && collections.length === 0) {
|
|
return <Message title={emptyMessage}/>;
|
|
}
|
|
|
|
// No columns configured
|
|
if (!columns || columns.length === 0) {
|
|
return <Message kind='warning' title='No columns configured'/>;
|
|
}
|
|
|
|
// Filter collections by search string
|
|
let filteredCollections = collections;
|
|
if (searchStr) {
|
|
const re = new RegExp(_.escapeRegExp(searchStr), 'i');
|
|
filteredCollections = collections.filter(collection => {
|
|
// Search across all string fields
|
|
return Object.values(collection).some(value => {
|
|
if (typeof value === 'string') {
|
|
return re.test(value);
|
|
}
|
|
return false;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Build table headers from columns (desktop)
|
|
const headers = columns.map((col) => ({
|
|
key: col.key,
|
|
text: col.label,
|
|
sortBy: col.sortable ? col.key : null,
|
|
width: col.width,
|
|
}));
|
|
|
|
// Build mobile columns (exclude hideOnMobile) and simplified 2-column headers
|
|
const mobileColumns = columns.filter(col => !col.hideOnMobile);
|
|
const primaryColumn = columns[0];
|
|
const mobileHeaders = [
|
|
{
|
|
key: primaryColumn.key,
|
|
text: primaryColumn.label,
|
|
sortBy: primaryColumn.sortable ? primaryColumn.key : null
|
|
},
|
|
{
|
|
key: 'manage',
|
|
text: 'Manage'
|
|
}
|
|
];
|
|
|
|
// Get default sort column (first column key)
|
|
const defaultSortColumn = columns[0]?.key || 'id';
|
|
|
|
|
|
return <>
|
|
<Media at='mobile'>
|
|
<SortableTable
|
|
tableProps={{striped: true, size: 'sm'}}
|
|
data={filteredCollections}
|
|
rowFunc={(collection) => <MobileCollectionRow key={collection.id} collection={collection}
|
|
mobileColumns={mobileColumns} routes={routes}/>}
|
|
rowKey='id'
|
|
tableHeaders={mobileHeaders}
|
|
defaultSortColumn={defaultSortColumn}
|
|
/>
|
|
</Media>
|
|
<Media greaterThanOrEqual='tablet'>
|
|
<SortableTable
|
|
tableProps={{striped: true, size: 'lg', verticalSpacing: 'xs'}}
|
|
data={filteredCollections}
|
|
rowFunc={(collection) => renderRow(collection, columns, routes, SingleTag, onRowClick)}
|
|
rowKey='id'
|
|
tableHeaders={headers}
|
|
defaultSortColumn={defaultSortColumn}
|
|
/>
|
|
</Media>
|
|
</>;
|
|
}
|