mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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.
436 lines
16 KiB
JavaScript
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>
|
|
}
|