wrolpi/app/src/components/admin/Configs.js
Roland 973a61af28 ui: migrate the extension pages and Configs; five components they needed
Phase 2, part 1.  Three files leave Semantic UI (baseline 75 -> 72), and the
components they needed join the library and the gallery:

  Header       Semantic's shape (as/icon/subheader/dividing).  47 files use
               <Header>, and the level now picks the size, so the type scale
               stays a scale instead of each call site choosing a font size.
  Message      gains an icon and a dismiss button.
  ActionInput  input and button sharing one outline, for <Input action={...}/>.
  Loading      centered loader with a caption, replacing the
               <Loader active inline='centered'> + wrapper div in 19 places.
  Modal        becomes compound - Modal.Header/.Content/.Actions, plus
               Semantic's open, closeIcon, and size names - because 34 files
               are written against that shape.

test-utils mounts MantineProvider with ThemeProvider's exact configuration:
migrated components render Mantine internals and every one of their test files
would otherwise need its own provider.

One Semantic bleed found and fixed: semantic.min.css sets
`h1,h2,h3,h4,h5 { font-family: Lato }` and loads after Mantine's element rule,
so headings ignored the theme's body face.  Only visible in amber, where the
body is mono and the headings were not.  A class-level restatement outranks it;
the rule comes out when Semantic does.

Verified: 925 jest tests, clean production build, and a night-mode audit of
every painted pixel in the gallery (red-only, one known false positive).  The
Configs backup and restore flow was exercised against the real API.
2026-07-30 09:15:04 -06:00

436 lines
16 KiB
JavaScript

import {APIButton} from "../Common";
import {Button, Header, Icon, IconButton, Loader, Loading, Modal, Placeholder, Table, Tooltip} from "../ui";
import React, {useEffect, useState} from "react";
import {getConfigText, getConfigBackups, postConfigBackupImport, postConfigBackupPreview} from "../../api";
function WarningIcon({ok}) {
// null is empty, true is valid, false is invalid.
const content = ok === true ? 'Valid and can be imported.' : ok === false ? 'Invalid, can be overwritten.' : 'Empty config, can be overwritten.';
return <Tooltip label={content}>
<span style={{color: ok === true ? 'var(--violet)' : ok === false ? 'var(--red)' : 'var(--muted)'}}>
<Icon name={ok === false ? 'close' : 'check'} label={content}/>
</span>
</Tooltip>
}
const TYPE_LABELS = {
tag: 'Tags',
tag_file: 'Tag Files',
tag_zim: 'Tag Zims',
channel: 'Channels',
domain: 'Domains',
download: 'Downloads',
inventory: 'Inventories',
playlist: 'Playlists',
};
const TYPE_COLUMNS = {
tag: [{key: 'name', header: 'Name'}],
tag_file: [{key: 'tag', header: 'Tag'}, {key: 'path', header: 'Path'}],
tag_zim: [{key: 'tag', header: 'Tag'}, {key: 'zim', header: 'Zim'}, {key: 'entry', header: 'Entry'}],
channel: [{key: 'name', header: 'Name'}, {key: 'directory', header: 'Directory'}],
domain: [{key: 'name', header: 'Name'}, {key: 'directory', header: 'Directory'}],
download: [{key: 'url', header: 'URL'}, {key: 'downloader', header: 'Downloader'}],
inventory: [{key: 'name', header: 'Name'}],
playlist: [{key: 'name', header: 'Name'}, {key: 'items', header: 'Items'}],
};
function groupItemsByType(items) {
const groups = {};
for (const item of items) {
if (!groups[item.type]) {
groups[item.type] = [];
}
groups[item.type].push(item);
}
return Object.entries(groups).map(([type, items]) => ({
type, label: TYPE_LABELS[type] || type, items,
}));
}
function PreviewItemTable({items, negative}) {
if (!items.length) return null;
const columns = TYPE_COLUMNS[items[0].type] || [{key: 'type', header: 'Item'}];
return <Table style={{marginBottom: '0.5em'}}>
<Table.Header>
<Table.Row>
{columns.map(col => <Table.HeaderCell key={col.key}>{col.header}</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{/* Items being removed take the failed treatment: dashed, in every theme. */}
{items.map((item, i) => <Table.Row key={i} failed={negative}>
{columns.map(col =>
<Table.Cell key={col.key}>{item[col.key] || ''}</Table.Cell>
)}
</Table.Row>)}
</Table.Body>
</Table>;
}
const formatDate = (d) => d ? `${d.substring(0, 4)}-${d.substring(4, 6)}-${d.substring(6, 8)}` : '';
function BackupDateRow({date, previews, previewsLoaded, onSelect}) {
const mergeLoaded = previewsLoaded?.merge;
const overwriteLoaded = previewsLoaded?.overwrite;
const mergePreview = previews?.merge;
const overwritePreview = previews?.overwrite;
const hasChanges = (p) => p && (p.add.length > 0 || p.remove.length > 0);
const mergeHasChanges = hasChanges(mergePreview);
const overwriteHasChanges = hasChanges(overwritePreview);
const noChanges = <span style={{color: 'var(--muted)', fontStyle: 'italic'}}>No changes</span>;
const previewFailed = <Tooltip label='Failed to load preview'>
<span style={{color: 'var(--red)'}}><Icon name='warning sign' label='Failed to load preview'/></span>
</Tooltip>;
let mergeCell;
if (!mergeLoaded) {
mergeCell = <Loader size='xs'/>;
} else if (mergePreview === null) {
mergeCell = previewFailed;
} else if (mergeHasChanges) {
mergeCell = <Button size='xs' role='save' icon='plus'
onClick={() => onSelect(date, 'merge', mergePreview)}>Merge</Button>;
} else {
mergeCell = noChanges;
}
let overwriteCell;
if (!overwriteLoaded) {
overwriteCell = <Loader size='xs'/>;
} else if (overwritePreview === null) {
overwriteCell = previewFailed;
} else if (overwriteHasChanges) {
// Overwrite discards whatever is in the database, so it warns rather than saves.
overwriteCell = <Button size='xs' role='retry' icon='refresh'
onClick={() => onSelect(date, 'overwrite', overwritePreview)}>Overwrite</Button>;
} else {
overwriteCell = noChanges;
}
return <Table.Row>
<Table.Cell>{formatDate(date)}</Table.Cell>
<Table.Cell>{mergeCell}</Table.Cell>
<Table.Cell>{overwriteCell}</Table.Cell>
</Table.Row>;
}
function BackupsModal({open, onClose, fileName, fetchConfigs}) {
const [dates, setDates] = useState([]);
const [datesLoading, setDatesLoading] = useState(true);
const [previews, setPreviews] = useState({});
const [previewsLoaded, setPreviewsLoaded] = useState({});
const [selectedAction, setSelectedAction] = useState(null); // {date, mode, preview}
const [applying, setApplying] = useState(false);
// Fetch backup dates on open.
useEffect(() => {
if (!open) return;
let cancelled = false;
setDatesLoading(true);
setDates([]);
setPreviews({});
setPreviewsLoaded({});
setSelectedAction(null);
setApplying(false);
getConfigBackups(fileName).then(result => {
if (cancelled) return;
setDates(result?.dates || []);
setDatesLoading(false);
});
return () => { cancelled = true; };
}, [open, fileName]);
// Progressively fetch previews for each date, newest first.
useEffect(() => {
if (!open || dates.length === 0) return;
let cancelled = false;
const loadPreviews = async () => {
for (const date of dates) {
if (cancelled) break;
const [mergeResult, overwriteResult] = await Promise.all([
postConfigBackupPreview(fileName, date, 'merge'),
postConfigBackupPreview(fileName, date, 'overwrite'),
]);
if (cancelled) break;
setPreviews(prev => ({
...prev,
[date]: {merge: mergeResult?.preview || null, overwrite: overwriteResult?.preview || null}
}));
setPreviewsLoaded(prev => ({...prev, [date]: {merge: true, overwrite: true}}));
}
};
loadPreviews();
return () => { cancelled = true; };
}, [open, dates, fileName]);
const handleSelect = (date, mode, preview) => {
setSelectedAction({date, mode, preview});
};
const handleApply = async () => {
if (!selectedAction) return;
setApplying(true);
await postConfigBackupImport(fileName, selectedAction.date, selectedAction.mode);
await fetchConfigs();
setApplying(false);
handleClose();
};
const handleBack = () => {
setSelectedAction(null);
};
const handleClose = () => {
onClose();
};
// Confirmation view: show the diff for the selected action.
if (selectedAction) {
const {date, mode, preview} = selectedAction;
const modeLabel = mode === 'merge' ? 'Merge' : 'Overwrite';
return <Modal open={open} onClose={handleClose} closeIcon size='large'>
<Modal.Header>
{modeLabel}: {fileName} ({formatDate(date)})
</Modal.Header>
<Modal.Content>
{applying
? <Loading>Applying backup...</Loading>
: <>
{preview.add.length > 0 && <>
<Header as='h4' icon='plus' style={{color: 'var(--green)'}}>
{preview.add.length} item{preview.add.length !== 1 ? 's' : ''} to add
</Header>
<div style={{maxHeight: '300px', overflowY: 'auto', marginBottom: '1em'}}>
{groupItemsByType(preview.add).map(group =>
<React.Fragment key={group.type}>
<Header as='h5' style={{marginBottom: '0.3em'}}>{group.label}</Header>
<PreviewItemTable items={group.items}/>
</React.Fragment>
)}
</div>
</>}
{preview.remove.length > 0 && <>
<Header as='h4' icon='minus' style={{color: 'var(--red)'}}>
{preview.remove.length} item{preview.remove.length !== 1 ? 's' : ''} to remove
</Header>
<div style={{maxHeight: '300px', overflowY: 'auto', marginBottom: '1em'}}>
{groupItemsByType(preview.remove).map(group =>
<React.Fragment key={group.type}>
<Header as='h5' style={{marginBottom: '0.3em'}}>{group.label}</Header>
<PreviewItemTable items={group.items} negative/>
</React.Fragment>
)}
</div>
</>}
<p style={{color: 'var(--muted)'}}>{preview.unchanged} unchanged
item{preview.unchanged !== 1 ? 's' : ''}</p>
</>
}
</Modal.Content>
<Modal.Actions>
{!applying && <>
<Button role='cancel' onClick={handleBack}>Back</Button>
<Button role='save' icon='check' onClick={handleApply}>Apply</Button>
</>}
</Modal.Actions>
</Modal>;
}
// Backups list view.
return <Modal open={open} onClose={handleClose} closeIcon size='small'>
<Modal.Header>Restore Backup: {fileName}</Modal.Header>
<Modal.Content>
{datesLoading
? <Loading>Loading backups...</Loading>
: dates.length === 0
? <p>No backups available for this config.</p>
: <div style={{maxHeight: '400px', overflowY: 'auto'}}>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Date</Table.HeaderCell>
<Table.HeaderCell>Merge</Table.HeaderCell>
<Table.HeaderCell>Overwrite</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{dates.map(date =>
<BackupDateRow
key={date}
date={date}
previews={previews[date]}
previewsLoaded={previewsLoaded[date]}
onSelect={handleSelect}
/>
)}
</Table.Body>
</Table>
</div>
}
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={handleClose}>Close</Button>
</Modal.Actions>
</Modal>;
}
function ConfigContentModal({open, onClose, fileName}) {
const [content, setContent] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setContent(null);
getConfigText(fileName).then(result => {
if (cancelled) return;
setContent(result);
setLoading(false);
}).catch(() => {
if (cancelled) return;
setContent(null);
setLoading(false);
});
return () => { cancelled = true; };
}, [open, fileName]);
return <Modal open={open} onClose={onClose} closeIcon size='large'>
<Modal.Header>{fileName}</Modal.Header>
<Modal.Content scrolling>
{loading
? <Loading/>
: content
? <pre style={{whiteSpace: 'pre-wrap', wordBreak: 'break-word'}}>{content}</pre>
: <p>Failed to load config contents.</p>
}
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>;
}
function ConfigTableRow({config, importConfig, saveConfig, fetchConfigs}) {
const {file_name, valid, successful_import, has_backup_import} = config;
const [backupsModalOpen, setBackupsModalOpen] = useState(false);
const [contentModalOpen, setContentModalOpen] = useState(false);
const localImportConfig = async () => {
await importConfig(file_name);
await fetchConfigs();
}
const localSaveConfig = async () => {
await saveConfig(file_name);
await fetchConfigs();
}
const importButton = <APIButton
icon='upload'
disabled={!valid}
onClick={localImportConfig}
/>;
let saveButton;
if (successful_import) {
saveButton = <APIButton
color='grey'
icon='download'
onClick={localSaveConfig}
/>;
} else {
// Warn the user they may overwrite because import was not successful.
saveButton = <APIButton
color='grey'
icon='download'
onClick={localSaveConfig}
confirmButton='Overwrite'
confirmContent='Config was not imported successfully, overwrite the config? Data may be lost!'
/>;
}
return <Table.Row>
<Table.Cell>
<span style={{cursor: 'pointer'}} onClick={() => setContentModalOpen(true)}>{file_name}</span>
</Table.Cell>
<Table.Cell><WarningIcon ok={successful_import}/></Table.Cell>
<Table.Cell><WarningIcon ok={valid}/></Table.Cell>
<Table.Cell className="hide-on-mobile">
{has_backup_import
? <IconButton icon='undo' label='Restore a backup'
onClick={() => setBackupsModalOpen(true)}/>
: null
}
</Table.Cell>
<Table.Cell>{importButton}</Table.Cell>
<Table.Cell>{saveButton}</Table.Cell>
{backupsModalOpen && <BackupsModal
open={backupsModalOpen}
onClose={() => setBackupsModalOpen(false)}
fileName={file_name}
fetchConfigs={fetchConfigs}
/>}
{contentModalOpen && <ConfigContentModal
open={contentModalOpen}
onClose={() => setContentModalOpen(false)}
fileName={file_name}
/>}
</Table.Row>
}
export function ConfigsTable({configs, loading, importConfig, saveConfig, fetchConfigs}) {
let body = <Table.Row>
<Table.Cell colSpan={6}><Placeholder lines={2}/></Table.Cell>
</Table.Row>
if (loading) {
// Configs are being fetched again.
} else if (configs === undefined) {
body = <Table.Row>
<Table.Cell colSpan={6}>Failed to fetch configs</Table.Cell>
</Table.Row>;
} else if (configs && Object.keys(configs).length === 0) {
body = <Table.Row>
<Table.Cell colSpan={6}>No configs exist. Try tagging some files.</Table.Cell>
</Table.Row>;
} else if (configs) {
body = Object.entries(configs).map(([key, value]) =>
<ConfigTableRow
key={key}
config={value}
importConfig={importConfig}
saveConfig={saveConfig}
fetchConfigs={fetchConfigs}
/>
);
}
return <Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File Name</Table.HeaderCell>
<Table.HeaderCell>Imported</Table.HeaderCell>
<Table.HeaderCell>Valid</Table.HeaderCell>
<Table.HeaderCell className="hide-on-mobile">Restore</Table.HeaderCell>
<Table.HeaderCell>Import</Table.HeaderCell>
<Table.HeaderCell>Save</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{body}
</Table.Body>
</Table>
}