mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule. - A Repo is a Collection (kind='repo') plus a `repository` row. The new `git` downloader clones it, then updates it (weekly by default, or Never: the clone is kept as it is and only updated on request). - Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses. An existing clone can be imported where it is. repos.yaml is the source of truth. - A GitHub-like page shows each repo's README, files, and history, with a ZIP download. READMEs are searchable under the global search's Other tab. - git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are updated. - Tagging a Repo moves its clone as one directory. Repo changes wait for repos.yaml to be imported at startup. - The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the destination. A failed tag move restores the Collection's tag. A Collection's directory cannot leave the media directory. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
9.8 KiB
JavaScript
266 lines
9.8 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 relative column width (1-16, sixteenths) 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.render) {
|
|
value = col.render(collection);
|
|
} else 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.render) {
|
|
value = col.render(collection);
|
|
} else 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. A column's optional `render(collection)` displays
|
|
* the cell; the column still sorts by `collection[key]`.
|
|
* @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>
|
|
</>;
|
|
}
|