mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Replace semantic-ui-react and ./Theme imports with ./ui across both files: Grid -> Group/flexbox (two-up rows), Loader -> Loading, Segment -> Panel, Statistic/StatisticLabel/StatisticValue -> Statistic + StatisticGroup, Tab -> Tabs, Accordion -> Mantine Accordion.Item with controlled string value, Dropdown -> Select, Message/Label/Placeholder -> ui equivalents. Buttons take role (primary/save/retry/danger/cancel) instead of Semantic color names where a role fits. Zim unsubscribe now goes through a destructive Confirm (via APIButton's confirmContent) instead of firing immediately, matching the Confirm+destructive rule for irreversible actions; the outdated-Zims delete flow keeps its rich custom Modal (dynamic file lists) but its trigger and confirm buttons are styled destructive and labeled "Delete". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
521 lines
16 KiB
JavaScript
521 lines
16 KiB
JavaScript
import {useOutdatedZims, useSearchZim, useWROLMode} from "../hooks/customHooks";
|
|
import React, {useState} from "react";
|
|
import {
|
|
APIButton,
|
|
encodeMediaPath,
|
|
ErrorMessage,
|
|
HandPointMessage,
|
|
humanFileSize,
|
|
IframeViewer,
|
|
InfoMessage,
|
|
normalizeEstimate,
|
|
PageContainer,
|
|
Paginator,
|
|
TabLinks,
|
|
TagIcon,
|
|
Toggle,
|
|
useTitle,
|
|
WarningMessage
|
|
} from "./Common";
|
|
import {
|
|
Accordion,
|
|
Button,
|
|
Divider,
|
|
Group,
|
|
Header,
|
|
Label,
|
|
Loading,
|
|
Message,
|
|
Modal,
|
|
Panel,
|
|
Placeholder,
|
|
Select,
|
|
Table,
|
|
toast,
|
|
} from "./ui";
|
|
import {TextPlaceholder} from "./Placeholder";
|
|
import {TagsSelector} from "../Tags";
|
|
import {AddToPlaylistButton} from "./AddToPlaylist";
|
|
import {
|
|
deleteOutdatedZims,
|
|
fetchZims,
|
|
fetchZimSubscriptions,
|
|
refreshFiles,
|
|
saveSettings,
|
|
setZimAutoSearch,
|
|
tagZimEntry,
|
|
untagZimEntry,
|
|
zimSubscribe,
|
|
zimUnsubscribe
|
|
} from "../api";
|
|
import {useSearch} from "./Search";
|
|
import {TagsQuerySelector} from "./Files";
|
|
import {Link, Route, Routes} from "react-router";
|
|
import {SortableTable} from "./SortableTable";
|
|
import _ from "lodash";
|
|
import {ZIM_VIEWER_URI} from "./Vars";
|
|
import {HeadlineText} from "./Headline";
|
|
|
|
export const OutdatedZimsMessage = ({onClick}) => {
|
|
const [open, setOpen] = React.useState(false);
|
|
|
|
const {outdated, current} = useOutdatedZims();
|
|
|
|
const onClose = () => setOpen(false);
|
|
|
|
const handleDelete = async () => {
|
|
try {
|
|
const success = await deleteOutdatedZims();
|
|
if (success) {
|
|
toast({
|
|
type: 'info',
|
|
title: 'Zims deleted',
|
|
description: 'Outdated Zims have been deleted.',
|
|
time: 5000,
|
|
});
|
|
} else {
|
|
toast({
|
|
type: 'error',
|
|
title: 'Failed to delete Zims',
|
|
description: 'Outdated Zims have NOT been deleted. See server logs.',
|
|
time: 5000,
|
|
});
|
|
}
|
|
} finally {
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
const handleIgnore = async () => {
|
|
const config = {ignore_outdated_zims: true};
|
|
await saveSettings(config);
|
|
if (onClick) {
|
|
await onClick();
|
|
}
|
|
}
|
|
|
|
let modalContent = <Placeholder lines={1}/>;
|
|
if (outdated && outdated.length > 0) {
|
|
modalContent = <>
|
|
<Header as='h3'>To Delete</Header>
|
|
{outdated.map(i => <pre key={i}>{i}</pre>)}
|
|
|
|
<Header as='h3'>To Keep</Header>
|
|
{current.map(i => <pre key={i}>{i}</pre>)}
|
|
</>
|
|
}
|
|
|
|
return <Message kind='info' icon='question' title='Outdated Zim Files'>
|
|
New Zim files have been downloaded. Outdated Zim files can be removed.
|
|
<p></p>
|
|
|
|
<Button role='danger' onClick={() => setOpen(true)}>Delete</Button>
|
|
<Modal open={open}
|
|
onClose={onClose}
|
|
title='Delete'
|
|
>
|
|
{modalContent}
|
|
<Modal.Actions>
|
|
<APIButton
|
|
role='danger'
|
|
onClick={handleDelete}
|
|
>Delete</APIButton>
|
|
<Button role='cancel' onClick={onClose}>Close</Button>
|
|
</Modal.Actions>
|
|
</Modal>
|
|
|
|
<Link to='/files?folders=zims'><Button role='cancel'>Delete Manually</Button></Link>
|
|
<APIButton role='cancel'
|
|
onClick={handleIgnore}
|
|
>Ignore Forever</APIButton>
|
|
</Message>
|
|
}
|
|
|
|
const ZimSearchEntry = ({zimId, onTag, onUntag, entry}) => {
|
|
const {path, title, headline, tag_names} = entry;
|
|
const [open, setOpen] = React.useState(false);
|
|
const url = `/api/zim/${zimId}/entry/${encodeMediaPath(path)}`;
|
|
|
|
const handleClick = async (e) => {
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
setOpen(true);
|
|
}
|
|
|
|
const localAddTag = (name) => {
|
|
onTag(zimId, path, name);
|
|
}
|
|
|
|
const localUntag = (name) => {
|
|
onUntag(zimId, path, name);
|
|
}
|
|
|
|
const tagIcon = tag_names && tag_names.length > 0 ? <TagIcon/> : null;
|
|
|
|
return <div style={{marginLeft: '0.5em'}}>
|
|
<Header
|
|
as='h3'
|
|
className='clickable'
|
|
onClick={handleClick}
|
|
>
|
|
<u>
|
|
<HeadlineText headline={title || path} openTag={'<i>'} closeTag={'</i>'}/>
|
|
</u>
|
|
{tagIcon}
|
|
</Header>
|
|
<HeadlineText headline={headline}/>
|
|
<Modal open={open}
|
|
onClose={() => setOpen(false)}>
|
|
<div className='preview-fit'>
|
|
<ZimViewer src={url} style={{
|
|
// Override IframeViewer's `fixed` so the iframe stays inside the Modal.
|
|
position: 'static',
|
|
height: '100%', width: '100%', border: 'none',
|
|
// Use white to avoid iframe displaying with dark-theme.
|
|
backgroundColor: '#ffffff',
|
|
}}/>
|
|
</div>
|
|
<Modal.Actions>
|
|
<Group justify='space-between' align='flex-start' wrap='wrap' style={{width: '100%'}}>
|
|
<TagsSelector selectedTagNames={tag_names} onAdd={localAddTag} onRemove={localUntag}/>
|
|
<Group gap='xs'>
|
|
<AddToPlaylistButton
|
|
zim={{zimId, entry: path, title: (title || path).replace(/<[^>]*>/g, '')}}
|
|
content={null} title='Add to Playlist'/>
|
|
<Button role='primary' component='a' href={url} target='_blank' rel='noopener noreferrer'>
|
|
Open
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
</Modal.Actions>
|
|
</Modal>
|
|
</div>
|
|
}
|
|
|
|
const ZimAccordionItem = ({value, active, data, searchStr, activeTags}) => {
|
|
const {id, estimate, metadata} = data;
|
|
const {title, date} = metadata;
|
|
const {zim, fetchSearch, pages, loading} = useSearchZim(searchStr, id, active, activeTags);
|
|
|
|
const localAddTag = async (zimId, path, name) => {
|
|
await tagZimEntry(zimId, path, name);
|
|
await fetchSearch();
|
|
}
|
|
|
|
const localUntag = async (zimId, path, name) => {
|
|
await untagZimEntry(zimId, path, name);
|
|
await fetchSearch();
|
|
}
|
|
|
|
let body = <TextPlaceholder/>;
|
|
if (zim && !loading) {
|
|
const {search} = zim;
|
|
if (search && search.length > 0) {
|
|
body = search.map(i => <div
|
|
key={i['path']}
|
|
style={{borderBottom: '1px solid var(--border)', paddingBottom: '1em', marginBottom: '1em'}}
|
|
>
|
|
<ZimSearchEntry zimId={id} onTag={localAddTag} onUntag={localUntag} entry={i}/>
|
|
</div>);
|
|
} else {
|
|
body = <p>No results</p>;
|
|
}
|
|
}
|
|
|
|
const paginator = <center style={{marginTop: '2em'}}>
|
|
<Paginator activePage={pages.activePage} totalPages={pages.totalPages} onPageChange={pages.setPage}/>
|
|
</center>;
|
|
|
|
const label = <Label color={estimate > 0 ? 'violet' : undefined}>{normalizeEstimate(estimate)}</Label>;
|
|
|
|
return <Accordion.Item value={value}>
|
|
<Accordion.Control>
|
|
<Header as='h3' style={{marginBottom: 0}}>{title} {label}</Header>
|
|
</Accordion.Control>
|
|
<Accordion.Panel>
|
|
<Header as='h4'>{date}</Header>
|
|
{body}
|
|
{paginator}
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
}
|
|
|
|
const ZimsRefreshWarning = () => {
|
|
return <div onClick={refreshFiles} style={{cursor: 'pointer'}}>
|
|
<Message kind='warning' icon='hand point right' title='No Zims have been indexed.'>
|
|
<a href='#'>Click here</a> to refresh all your files.
|
|
</Message>
|
|
</div>;
|
|
}
|
|
|
|
export const ZimSearchView = ({suggestions, loading}) => {
|
|
const [activeValue, setActiveValue] = React.useState(null);
|
|
const {searchStr, activeTags, setTags} = useSearch();
|
|
const {zimsEstimates} = suggestions;
|
|
|
|
let body;
|
|
if (!_.isEmpty(zimsEstimates)) {
|
|
body = <Accordion value={activeValue} onChange={setActiveValue}>
|
|
{zimsEstimates.map(i => <ZimAccordionItem
|
|
key={i['path']}
|
|
value={String(i['id'])}
|
|
active={activeValue === String(i['id'])}
|
|
data={i}
|
|
searchStr={searchStr}
|
|
activeTags={activeTags}
|
|
/>)}
|
|
</Accordion>;
|
|
} else if (loading) {
|
|
body = <Panel><Loading/></Panel>;
|
|
} else if (_.isEmpty(zimsEstimates)) {
|
|
body = <ZimsRefreshWarning/>;
|
|
}
|
|
|
|
return <>
|
|
<TagsQuerySelector onChange={(i, j) => setTags(i)}/>
|
|
{body}
|
|
</>
|
|
}
|
|
|
|
const DownloadMessage = () => {
|
|
return <InfoMessage>
|
|
<p>More Zim files are available from the full Kiwix library
|
|
<a href='https://download.kiwix.org/' rel='noopener noreferrer' target='_blank'>https://download.kiwix.org/</a>
|
|
</p>
|
|
</InfoMessage>
|
|
}
|
|
|
|
const ViewerMessage = () => {
|
|
return <HandPointMessage>
|
|
<p>You can view your Zim files using the Kiwix app, or at <a href={ZIM_VIEWER_URI}>{ZIM_VIEWER_URI}</a></p>
|
|
</HandPointMessage>
|
|
}
|
|
|
|
const ZimCatalogItemRow = ({item, subscriptions, iso_639_codes, fetchSubscriptions}) => {
|
|
const {name, languages, size} = item;
|
|
const subscription = name in subscriptions ? subscriptions[name] : null;
|
|
const subscriptionLanguage = subscription ? subscription['language'] : 'en';
|
|
|
|
const [language, setLanguage] = useState(subscriptionLanguage);
|
|
const [pending, setPending] = useState(false);
|
|
const languageChange = subscription ? language !== subscription['language'] : false;
|
|
|
|
const wrolModeEnabled = useWROLMode();
|
|
|
|
const handleButton = async () => {
|
|
let success = false;
|
|
try {
|
|
setPending(true);
|
|
if (subscription && !languageChange) {
|
|
success = await zimUnsubscribe(subscription['id']);
|
|
} else {
|
|
success = await zimSubscribe(name, language);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
setPending(false);
|
|
if (!success) {
|
|
toast({
|
|
type: 'error',
|
|
title: 'Error!',
|
|
description: subscription ? 'Failed to unsubscribe' : 'Failed to subscribe',
|
|
})
|
|
}
|
|
if (fetchSubscriptions) {
|
|
await fetchSubscriptions();
|
|
}
|
|
}
|
|
|
|
const languageOptions = languages.map(i => {
|
|
return {value: i, label: iso_639_codes[i]}
|
|
});
|
|
const languageDropdown = <Select
|
|
searchable
|
|
placeholder='Language'
|
|
data={languageOptions}
|
|
value={language}
|
|
disabled={wrolModeEnabled}
|
|
onChange={(value) => setLanguage(value)}
|
|
/>;
|
|
|
|
const isDestructive = subscription && !languageChange;
|
|
const subscribeButton = <APIButton
|
|
role={isDestructive ? 'danger' : 'primary'}
|
|
confirmContent={isDestructive ? 'Are you sure you want to unsubscribe from this Zim?' : undefined}
|
|
confirmButton={isDestructive ? 'Unsubscribe' : undefined}
|
|
disabled={pending}
|
|
onClick={handleButton}
|
|
obeyWROLMode={true}
|
|
>
|
|
{isDestructive ? 'Unsubscribe' : 'Subscribe'}
|
|
</APIButton>
|
|
|
|
return <Table.Row key={name}>
|
|
<Table.Cell>{name}</Table.Cell>
|
|
<Table.Cell>{languageDropdown}</Table.Cell>
|
|
<Table.Cell>{subscribeButton}</Table.Cell>
|
|
<Table.Cell>{humanFileSize(size)}</Table.Cell>
|
|
</Table.Row>
|
|
}
|
|
|
|
class ManageZim extends React.Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
catalog: null,
|
|
iso_639_codes: null,
|
|
subscriptions: null,
|
|
zims: null,
|
|
}
|
|
}
|
|
|
|
async componentDidMount() {
|
|
await this.fetchZims();
|
|
await this.fetchSubscriptions();
|
|
}
|
|
|
|
async fetchZims() {
|
|
try {
|
|
const {zims} = await fetchZims();
|
|
this.setState({zims});
|
|
} catch (e) {
|
|
console.error(e);
|
|
this.setState({zims: undefined}); // Display error.
|
|
}
|
|
}
|
|
|
|
async fetchSubscriptions() {
|
|
try {
|
|
const {subscriptions, catalog, iso_639_codes} = await fetchZimSubscriptions();
|
|
this.setState({subscriptions, catalog, iso_639_codes});
|
|
} catch (e) {
|
|
console.error(e);
|
|
this.setState({subscriptions: undefined, catalog: undefined}); // Display error.
|
|
}
|
|
}
|
|
|
|
zimFileTableRow = (zim, sortData, localFetchZims) => {
|
|
const {id, path, size, auto_search} = zim;
|
|
|
|
const toggleZimAutoSearch = async () => {
|
|
try {
|
|
await setZimAutoSearch(id, !auto_search);
|
|
} catch (e) {
|
|
throw e;
|
|
} finally {
|
|
await localFetchZims();
|
|
}
|
|
}
|
|
const toggle = <Toggle
|
|
checked={auto_search}
|
|
onChange={toggleZimAutoSearch}
|
|
popupContent='Enable/Disable searching this Zim file in the Global Search.'
|
|
/>
|
|
|
|
return <Table.Row key={path}>
|
|
<Table.Cell>{path}</Table.Cell>
|
|
<Table.Cell>{humanFileSize(size)}</Table.Cell>
|
|
<Table.Cell>{toggle}</Table.Cell>
|
|
</Table.Row>
|
|
}
|
|
|
|
render() {
|
|
const {zims, catalog, iso_639_codes, subscriptions} = this.state;
|
|
|
|
const zimFilesHeaders = [
|
|
{key: 'path', text: 'Path', sortBy: 'path', width: 14},
|
|
{key: 'size', text: 'Size', sortBy: 'size', width: 2},
|
|
{key: 'search', text: 'Search', sortBy: 'auto_search', width: 2},
|
|
];
|
|
let zimFilesBody = <Placeholder lines={2}/>;
|
|
if (zims && zims.length >= 1) {
|
|
zimFilesBody = <SortableTable
|
|
data={zims}
|
|
rowFunc={(i, sortData) => this.zimFileTableRow(i, sortData, this.fetchZims.bind(this))}
|
|
rowKey='path'
|
|
tableHeaders={zimFilesHeaders}
|
|
/>;
|
|
} else if (zims && zims.length === 0) {
|
|
zimFilesBody = <WarningMessage>
|
|
You have not subscribed to any Kiwix projects, or your files have not been refreshed.
|
|
</WarningMessage>;
|
|
} else if (zims === undefined) {
|
|
zimFilesBody = <ErrorMessage>Could not fetch Zim files</ErrorMessage>;
|
|
}
|
|
|
|
const kiwixCatalogHeaders = [
|
|
{
|
|
key: 'name', text: 'Name', 'sortBy': [i => {
|
|
return i['name'].toLowerCase()
|
|
}], width: 8
|
|
},
|
|
{key: 'language', text: 'Language', 'sortBy': null, width: 4},
|
|
{key: 'subscription', text: 'Subscription', 'sortBy': null, width: 2},
|
|
{key: 'size', text: 'Maximum Size', sortBy: 'size', width: 2},
|
|
];
|
|
let kiwixCatalog = <Placeholder lines={2}/>;
|
|
if (catalog && catalog.length > 0) {
|
|
kiwixCatalog = <SortableTable
|
|
defaultSortColumn='name'
|
|
data={catalog}
|
|
rowFunc={i => <ZimCatalogItemRow
|
|
iso_639_codes={iso_639_codes}
|
|
subscriptions={subscriptions}
|
|
item={i}
|
|
fetchSubscriptions={async () => await this.fetchSubscriptions()}
|
|
/>}
|
|
rowKey='name'
|
|
tableHeaders={kiwixCatalogHeaders}
|
|
/>
|
|
} else if (catalog === undefined) {
|
|
kiwixCatalog = <ErrorMessage>Could not fetch catalog</ErrorMessage>;
|
|
}
|
|
|
|
return <PageContainer>
|
|
<Header as='h2'>Zim Files</Header>
|
|
{zimFilesBody}
|
|
|
|
<Header as='h2'>Kiwix Catalog</Header>
|
|
{kiwixCatalog}
|
|
|
|
<Divider/>
|
|
|
|
<DownloadMessage/>
|
|
<ViewerMessage/>
|
|
</PageContainer>
|
|
}
|
|
}
|
|
|
|
function ZimViewer({src = ZIM_VIEWER_URI, style = null}) {
|
|
const fallback = <Panel>
|
|
<Header as='h3'>Failed to fetch Zim service.</Header>
|
|
<p>You may need to give permission to access the page: <a href={src}>{src}</a></p>
|
|
|
|
<p>If the above does not work, try starting the service:</p>
|
|
<pre>sudo systemctl start wrolpi-kiwix</pre>
|
|
|
|
<p>Check the logs</p>
|
|
<pre>journalctl -u wrolpi-kiwix</pre>
|
|
</Panel>;
|
|
|
|
return <IframeViewer title='zim' src={src} fallback={fallback} style={style}/>
|
|
}
|
|
|
|
export function ZimRoute() {
|
|
useTitle('Zim');
|
|
|
|
const links = [
|
|
{text: 'Viewer', to: '/zim', key: 'zim', end: true},
|
|
{text: 'Manage', to: '/zim/manage', key: 'manage'},
|
|
]
|
|
|
|
return <div style={{marginTop: '2em'}}>
|
|
<TabLinks links={links}/>
|
|
<Routes>
|
|
<Route path='/' exact element={<ZimViewer/>}/>
|
|
<Route path='manage' exact element={<ManageZim/>}/>
|
|
</Routes>
|
|
</div>
|
|
}
|