mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Reported: the Back button fills the page width on the domain and channel edit pages. It fills it on all nine pages that have one, and this is mine from the last PR. `.wrolpi-stack` is a flex column at `align-items: stretch`, deliberately -- a panel was full-width as a block element and has to stay that way. A BARE button as a direct child of that stack is stretched too, and every page's Back button was one. On the domain, channel and playlist edit pages it also pushed the button beside it onto its own line, where the two had been inline. Wrapping each in a `.wrolpi-button-row` fixes both without touching the stack: the row is the stretched child, and it lays its buttons out at their own width. The three pages that have a second button get one row holding both, which is how they read before. Also the Videos, Archives and Documents select bars, whose Tag/Delete/Invert/ Clear were four touching buttons in a bare div. They are inline elements rather than interpolations, which is the shape the bare-siblings scan does not see. Guarded twice: the row keeps its buttons their own width inside a stack while itself stretching (both halves, since a row that refused to stretch would pass a test that only looked at the button), and every `<BackButton/>` in the app is inside a row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1396 lines
48 KiB
JavaScript
1396 lines
48 KiB
JavaScript
import React, {useState} from "react";
|
|
import {useHotkeys} from "react-hotkeys-hook";
|
|
import {
|
|
APIButton,
|
|
BackButton,
|
|
CardLink,
|
|
DirectoryLink,
|
|
encodeMediaPath,
|
|
ErrorMessage,
|
|
ExternalCardLink,
|
|
FileIcon,
|
|
findPosterPath,
|
|
humanFileSize,
|
|
humanNumber,
|
|
InfoHeader,
|
|
isoDatetimeToAgoPopup,
|
|
PageContainer,
|
|
PreviewPath,
|
|
resolveDataPath,
|
|
SearchInput,
|
|
TabLinks,
|
|
textEllipsis,
|
|
mimetypeColor,
|
|
useTitle,
|
|
} from "./Common";
|
|
import {
|
|
deleteDomain,
|
|
deleteFileGroups,
|
|
fetchArchiveBrowsers,
|
|
fetchArchiveDownloaderConfig,
|
|
generateArchiveScreenshot,
|
|
getCollectionTagInfo,
|
|
postArchiveFileFormat,
|
|
postDownload,
|
|
previewBatchReorganization,
|
|
refreshDomain,
|
|
tagDomain,
|
|
tagFileGroup,
|
|
untagFileGroup,
|
|
updateArchiveDownloaderConfig,
|
|
} from "../api";
|
|
import {useForm} from "../hooks/useForm";
|
|
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
|
|
import {CollectionTagModal} from "./collections/CollectionTagModal";
|
|
import {CollectionReorganizeModal} from "./collections/CollectionReorganizeModal";
|
|
import {BatchReorganizeModal} from "./collections/BatchReorganizeModal";
|
|
import {useReorganizationStatus} from "../contexts/FileWorkerStatusContext";
|
|
import {Link, Route, Routes, useLocation, useNavigate, useParams} from "react-router";
|
|
import {
|
|
useArchive,
|
|
useArchiveStatistics,
|
|
useDockerized,
|
|
useDomain,
|
|
useDomains,
|
|
useOneQuery,
|
|
useSearchArchives,
|
|
useSearchDomain,
|
|
useSearchOrder
|
|
} from "../hooks/customHooks";
|
|
import {DeepSearchHint, FileCards, FileRowTagIcon, FilesView, SearchControlBar} from "./Files";
|
|
import _ from "lodash";
|
|
import {
|
|
ActionInput,
|
|
Button,
|
|
Card,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
Loader,
|
|
Loading,
|
|
Message,
|
|
Panel,
|
|
Placeholder,
|
|
Select,
|
|
Statistic,
|
|
StatisticGroup,
|
|
Table,
|
|
Tabs,
|
|
Textarea,
|
|
TextInput,
|
|
Tooltip,
|
|
toast,
|
|
} from "./ui";
|
|
import {BulkTagModal} from "./BulkTagModal";
|
|
import {TagsSelector} from "../Tags";
|
|
import {AddToPlaylistButton} from "./AddToPlaylist";
|
|
import {API_ARCHIVE_UPLOAD_URI, Downloaders} from "./Vars";
|
|
import {CollectionTable} from "./collections/CollectionTable";
|
|
import {CollectionEditForm} from "./collections/CollectionEditForm";
|
|
import {RecurringDownloadsTable} from "./admin/Downloads";
|
|
import {DestinationForm} from "./Download";
|
|
|
|
function ArchivePage() {
|
|
const navigate = useNavigate();
|
|
const {fileGroupId} = useParams();
|
|
const {archiveFile, history, fetchArchive} = useArchive(fileGroupId);
|
|
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
|
|
|
|
let title = archiveFile ? archiveFile.title ? archiveFile.title : archiveFile.name : null;
|
|
useTitle(title);
|
|
|
|
if (archiveFile === null) {
|
|
return <Panel><Loading>Loading archive...</Loading></Panel>;
|
|
}
|
|
if (archiveFile === undefined) {
|
|
return <>
|
|
<Header as='h2'>Unknown archive</Header>
|
|
<Header as='h4'>This archive does not exist</Header>
|
|
</>
|
|
}
|
|
|
|
const {data, size, directory} = archiveFile;
|
|
|
|
// Resolve data paths (filename-only) to full relative paths using directory
|
|
const singlefilePath = resolveDataPath(data.singlefile_path, directory);
|
|
const readabilityPath = resolveDataPath(data.readability_path, directory);
|
|
const screenshotPath = resolveDataPath(data.screenshot_path, directory);
|
|
|
|
const singlefileUrl = singlefilePath ? `/media/${encodeMediaPath(singlefilePath)}` : null;
|
|
const readabilityUrl = readabilityPath ? `/media/${encodeMediaPath(readabilityPath)}` : null;
|
|
const screenshotUrl = screenshotPath ? `/media/${encodeMediaPath(screenshotPath)}` : null;
|
|
|
|
const singlefileButton = <ExternalCardLink to={singlefileUrl}>
|
|
<Button color='violet'>View</Button>
|
|
</ExternalCardLink>;
|
|
const readButton = <ExternalCardLink to={readabilityUrl}>
|
|
<Button color='blue' disabled={!!!readabilityUrl}>Read</Button>
|
|
</ExternalCardLink>
|
|
|
|
const screenshot = screenshotUrl ?
|
|
<img alt='Archive screenshot' src={screenshotUrl}
|
|
style={{marginTop: '1em', marginBottom: '1em', maxWidth: '100%'}}/> :
|
|
null;
|
|
|
|
const localDeleteArchive = async (force = false) => {
|
|
const result = await deleteFileGroups([archiveFile.id], force);
|
|
if (result && result.tagged) {
|
|
setTaggedFileGroups(result.file_groups);
|
|
return;
|
|
}
|
|
toast({
|
|
type: 'success',
|
|
title: 'Archive Deleted',
|
|
description: 'Archive was successfully deleted.',
|
|
time: 2000,
|
|
})
|
|
navigate(-1);
|
|
}
|
|
|
|
const localUpdateArchive = async () => {
|
|
const downloadData = {urls: [archiveFile.url], downloader: Downloaders.Archive};
|
|
const response = await postDownload(downloadData);
|
|
if (response.ok) {
|
|
toast({
|
|
type: 'success',
|
|
title: 'Archive Downloading',
|
|
description: 'Archive update has been scheduled.',
|
|
time: 2000,
|
|
});
|
|
}
|
|
}
|
|
|
|
const localGenerateScreenshot = async () => {
|
|
const success = await generateArchiveScreenshot(archiveFile.id);
|
|
if (success) {
|
|
// Refresh the archive data after a short delay to show the new screenshot
|
|
setTimeout(() => fetchArchive(), 2000);
|
|
}
|
|
}
|
|
|
|
const updateButton = <APIButton
|
|
color='green'
|
|
confirmContent='Download the latest version of this URL?'
|
|
confirmButton='Update'
|
|
onClick={localUpdateArchive}
|
|
obeyWROLMode={true}
|
|
>
|
|
Update
|
|
</APIButton>;
|
|
const deleteButton = <APIButton
|
|
role='danger'
|
|
confirmContent='Are you sure you want to delete this archive? All files will be deleted'
|
|
confirmButton='Delete Archive'
|
|
onClick={() => localDeleteArchive(false)}
|
|
obeyWROLMode={true}
|
|
>
|
|
Delete
|
|
</APIButton>;
|
|
const generateScreenshotButton = !screenshotUrl ? <APIButton
|
|
color='yellow'
|
|
confirmContent='Generate a screenshot for this archive?'
|
|
confirmButton='Generate'
|
|
onClick={localGenerateScreenshot}
|
|
obeyWROLMode={true}
|
|
>
|
|
Generate Screenshot
|
|
</APIButton> : null;
|
|
|
|
let historyList = <Loader/>;
|
|
if (history && history.length === 0) {
|
|
historyList = <p>No history available</p>;
|
|
} else if (history) {
|
|
historyList = <FileCards files={history}/>;
|
|
}
|
|
|
|
const domain = data.domain ? data.domain : null;
|
|
let domainHeader = <p>Unknown</p>;
|
|
if (domain) {
|
|
const domainUrl = `/archives?domain=${domain}`;
|
|
domainHeader = <Header as='h4'>
|
|
<a href={domainUrl}>{domain}</a>
|
|
</Header>;
|
|
}
|
|
|
|
const localAddTag = async (name) => {
|
|
await tagFileGroup(archiveFile, name);
|
|
await fetchArchive();
|
|
}
|
|
|
|
const localRemoveTag = async (name) => {
|
|
await untagFileGroup(archiveFile, name);
|
|
await fetchArchive();
|
|
}
|
|
|
|
const downloadDatetimeString = archiveFile.download_datetime ?
|
|
isoDatetimeToAgoPopup(archiveFile.download_datetime, true) : 'unknown';
|
|
const publishedDatetimeString = archiveFile.published_datetime ?
|
|
isoDatetimeToAgoPopup(archiveFile.published_datetime, true) : 'unknown';
|
|
const modifiedDatetimeString = archiveFile.published_modified_datetime
|
|
? isoDatetimeToAgoPopup(archiveFile.published_modified_datetime, true)
|
|
: 'unknown';
|
|
|
|
// Helper to find file size from the files array by resolved path
|
|
const findFileSize = (resolvedPath) => {
|
|
if (!resolvedPath || !archiveFile.files) return null;
|
|
const file = archiveFile.files.find(i => String(i.path) === String(resolvedPath));
|
|
return file && file.size
|
|
? <span style={{marginLeft: '1em', color: 'var(--muted)'}}>({humanFileSize(file.size)})</span>
|
|
: null;
|
|
};
|
|
|
|
// Helper to resolve and preview a data path
|
|
const localPreviewPath = (dataPath, mimetype) => {
|
|
const resolvedPath = resolveDataPath(dataPath, directory);
|
|
if (resolvedPath) {
|
|
return <><PreviewPath path={resolvedPath} mimetype={mimetype}
|
|
taggable={false}>{resolvedPath}</PreviewPath>{findFileSize(resolvedPath)}</>
|
|
} else {
|
|
return 'Unknown'
|
|
}
|
|
}
|
|
|
|
const tableStyle = {width: '100%', borderCollapse: 'separate', borderSpacing: '0 0.7em'};
|
|
const labelStyle = {whiteSpace: 'nowrap', paddingRight: '1.5em', verticalAlign: 'top'};
|
|
|
|
return <>
|
|
<div className='wrolpi-button-row'><BackButton/></div>
|
|
|
|
<Panel>
|
|
{screenshot}
|
|
<ExternalCardLink to={singlefileUrl}>
|
|
<Header as='h2'>{textEllipsis(archiveFile.title || data.url)}</Header>
|
|
</ExternalCardLink>
|
|
|
|
<Header as='h3'>Author: {archiveFile.author ? archiveFile.author : 'unknown'}</Header>
|
|
|
|
<div style={{display: 'flex', flexWrap: 'wrap', gap: '1em', marginBottom: '1em'}}>
|
|
<div style={{flex: '1 1 240px'}}>
|
|
<Header as='h4'>Published: {publishedDatetimeString}</Header>
|
|
</div>
|
|
<div style={{flex: '1 1 240px'}}>
|
|
<Header as='h4'>Downloaded: {downloadDatetimeString}</Header>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='wrolpi-button-row'>
|
|
{singlefileButton}
|
|
{readButton}
|
|
{updateButton}
|
|
{deleteButton}
|
|
{generateScreenshotButton}
|
|
<AddToPlaylistButton fileGroupId={archiveFile.id}/>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel>
|
|
<TagsSelector
|
|
selectedTagNames={archiveFile['tags']}
|
|
onAdd={localAddTag}
|
|
onRemove={localRemoveTag}
|
|
/>
|
|
</Panel>
|
|
|
|
<Tabs defaultValue='about'>
|
|
<Tabs.List>
|
|
<Tabs.Tab value='about'>About</Tabs.Tab>
|
|
<Tabs.Tab value='files'>Files</Tabs.Tab>
|
|
</Tabs.List>
|
|
<Tabs.Panel value='about' pt='md'>
|
|
<Header as={'h3'}>Domain</Header>
|
|
{domainHeader}
|
|
|
|
<Header as='h3'>Size</Header>
|
|
{humanFileSize(size)}
|
|
|
|
<Header as={'h3'}>URL</Header>
|
|
<p>{archiveFile.url ? <a href={archiveFile.url}>{archiveFile.url}</a> : 'N/A'}</p>
|
|
|
|
<Header as={'h3'}>Modified Date</Header>
|
|
<p>{modifiedDatetimeString}</p>
|
|
</Tabs.Panel>
|
|
<Tabs.Panel value='files' pt='md'>
|
|
<table style={tableStyle}>
|
|
<tbody>
|
|
<tr>
|
|
<td style={labelStyle}><strong>Singlefile File</strong></td>
|
|
<td>{localPreviewPath(data.singlefile_path, 'text/html')}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style={labelStyle}><strong>Readability File</strong></td>
|
|
<td>{localPreviewPath(data.readability_path, 'text/html')}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style={labelStyle}><strong>Readability Text File</strong></td>
|
|
<td>{localPreviewPath(data.readability_txt_path, 'text/plain')}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style={labelStyle}><strong>Readability JSON File</strong></td>
|
|
<td>{localPreviewPath(data.readability_json_path, 'application/json')}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style={labelStyle}><strong>Screenshot File</strong></td>
|
|
<td>
|
|
{screenshotPath
|
|
? <><PreviewPath path={screenshotPath} mimetype='image/*'
|
|
taggable={false}>{screenshotPath}</PreviewPath>{findFileSize(screenshotPath)}</>
|
|
: 'Unknown'}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style={labelStyle}><strong>Directory</strong></td>
|
|
<td><DirectoryLink path={archiveFile.directory}/></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
|
|
<Panel>
|
|
<InfoHeader
|
|
headerContent='History'
|
|
popupContent='Other archives of this URL created at different times.'
|
|
/>
|
|
{historyList}
|
|
</Panel>
|
|
|
|
<TaggedDeleteConfirmModal
|
|
open={taggedFileGroups !== null}
|
|
taggedFileGroups={taggedFileGroups}
|
|
onCancel={() => setTaggedFileGroups(null)}
|
|
onConfirm={async () => {
|
|
setTaggedFileGroups(null);
|
|
await localDeleteArchive(true);
|
|
}}
|
|
/>
|
|
</>
|
|
}
|
|
|
|
export function ArchiveCard({file}) {
|
|
const {sort} = useSearchOrder();
|
|
const sortField = sort ? sort.replace(/^-/, '') : null;
|
|
const {data, directory} = file;
|
|
|
|
// Resolve data paths (filename-only) to full relative paths using directory
|
|
const screenshotPath = resolveDataPath(data.screenshot_path, directory);
|
|
const singlefilePath = resolveDataPath(data.singlefile_path, directory);
|
|
|
|
const imageSrc = screenshotPath ? `/media/${encodeMediaPath(screenshotPath)}` : null;
|
|
const singlefileUrl = singlefilePath ? `/media/${encodeMediaPath(singlefilePath)}` : null;
|
|
|
|
// Marks a tagged file, pinned to the poster's corner (same convention as CardPoster).
|
|
const cardTagIcon = <div className='wrolpi-card-tag'><Icon name='tag' size={14} label='Tagged'/></div>;
|
|
const imageLabel = !_.isEmpty(file.tags) ? cardTagIcon : null;
|
|
|
|
let media;
|
|
if (imageSrc) {
|
|
media = <ExternalCardLink to={singlefileUrl}>
|
|
<div style={{position: 'relative'}}>
|
|
{imageLabel}
|
|
<img alt='' src={imageSrc} style={{width: '100%', display: 'block'}}/>
|
|
</div>
|
|
</ExternalCardLink>;
|
|
} else {
|
|
media = <ExternalCardLink to={singlefileUrl}>
|
|
<div className='wrolpi-card-icon'>
|
|
{imageLabel}
|
|
<FileIcon file={file}/>
|
|
</div>
|
|
</ExternalCardLink>;
|
|
}
|
|
|
|
const domain = data ? data.domain : null;
|
|
const domainUrl = `/archives?domain=${domain}`;
|
|
|
|
const title = file.title || data.url;
|
|
const titleElm = <Tooltip label={title}>
|
|
<ExternalCardLink to={singlefileUrl} className='card-title-ellipsis'>{title}</ExternalCardLink>
|
|
</Tooltip>;
|
|
const dt = file.published_datetime || file.published_modified_datetime || file.modified;
|
|
|
|
const meta = <>
|
|
{domain && <div>
|
|
<CardLink to={domainUrl}>{domain}</CardLink>
|
|
</div>}
|
|
<div>
|
|
{sortField === 'published_modified_datetime'
|
|
? isoDatetimeToAgoPopup(file.published_modified_datetime, false)
|
|
: sortField === 'download_datetime'
|
|
? isoDatetimeToAgoPopup(file.download_datetime, false)
|
|
: sortField === 'size'
|
|
? humanFileSize(file.size)
|
|
: sortField === 'viewed'
|
|
? isoDatetimeToAgoPopup(file.viewed, false)
|
|
: isoDatetimeToAgoPopup(dt, false)
|
|
}
|
|
</div>
|
|
</>;
|
|
|
|
const actions = <div style={{display: 'flex', gap: '0.5em'}}>
|
|
<Link to={`/archives/${file.id}`}>
|
|
<Button icon='file alternate'>Details</Button>
|
|
</Link>
|
|
<IconButton
|
|
icon='external'
|
|
label='Open original URL'
|
|
component='a'
|
|
href={file.url}
|
|
target='_blank'
|
|
rel='noopener noreferrer'
|
|
/>
|
|
</div>;
|
|
|
|
return <Card media={media} title={titleElm} meta={meta} actions={actions}
|
|
color={mimetypeColor(file.mimetype, file.primary_path)}/>
|
|
}
|
|
|
|
// Domain table column configuration
|
|
const DOMAIN_COLUMNS = [
|
|
{key: 'domain', label: 'Domain', sortable: true, width: 7},
|
|
{key: 'tag_name', label: 'Tag', sortable: true, width: 2},
|
|
{key: 'archive_count', label: 'Archives', sortable: true, align: 'right', width: 2},
|
|
{
|
|
key: 'min_download_frequency',
|
|
label: 'Download Frequency',
|
|
sortable: true,
|
|
format: 'frequency',
|
|
width: 2,
|
|
hideOnMobile: true
|
|
},
|
|
{key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes', width: 2, hideOnMobile: true},
|
|
{key: 'actions', label: 'Manage', sortable: false, type: 'actions', width: 1}
|
|
];
|
|
|
|
const DOMAIN_ROUTES = {
|
|
list: '/archives/domains',
|
|
edit: '/archives/domain/:id/edit',
|
|
search: '/archives',
|
|
searchParam: 'domain'
|
|
};
|
|
|
|
function DomainStatistics({statistics}) {
|
|
if (!statistics) {
|
|
return <></>
|
|
}
|
|
|
|
return <Panel>
|
|
<Header as='h1'>Statistics</Header>
|
|
<StatisticGroup>
|
|
<Statistic value={statistics.archive_count} label='Archives'/>
|
|
<Statistic value={humanFileSize(statistics.size, true)} label='Total Size'/>
|
|
<Statistic value={humanFileSize(statistics.largest_archive, true)} label='Largest Archive'/>
|
|
<Statistic value={humanNumber(statistics.archive_tags)} label='Archive Tags'/>
|
|
</StatisticGroup>
|
|
</Panel>
|
|
}
|
|
|
|
export function DomainsPage() {
|
|
useTitle('Archive Domains');
|
|
|
|
const [domains] = useDomains();
|
|
const [searchStr, setSearchStr] = useOneQuery('domain');
|
|
const searchInputRef = React.useRef();
|
|
|
|
useHotkeys('f', (e) => {
|
|
e.preventDefault();
|
|
if (searchInputRef.current) {
|
|
searchInputRef.current.focus();
|
|
}
|
|
}, {enableOnFormTags: false});
|
|
|
|
// Header section matching ChannelsPage pattern. No `marginBottom`: this is a top-level block
|
|
// of the page, and the page's stack spaces it -- its own margin was added to that gap.
|
|
const header = <div style={{display: 'flex', flexWrap: 'wrap', gap: '1em', alignItems: 'center'}}>
|
|
<div style={{flex: '1 1 240px'}}>
|
|
<SearchInput
|
|
placeholder='Domain filter...'
|
|
size='large'
|
|
searchStr={searchStr}
|
|
disabled={!Array.isArray(domains) || domains.length === 0}
|
|
onClear={() => setSearchStr('')}
|
|
onChange={setSearchStr}
|
|
onSubmit={null}
|
|
inputRef={searchInputRef}
|
|
/>
|
|
</div>
|
|
<div style={{flex: '1 1 240px', textAlign: 'right'}}>
|
|
{/* No "New Domain" button - domains are auto-created */}
|
|
</div>
|
|
</div>;
|
|
|
|
// Empty state
|
|
if (domains && domains.length === 0) {
|
|
return <>
|
|
{header}
|
|
<Message kind='info' title='No domains yet. Archive some webpages!'/>
|
|
</>;
|
|
}
|
|
|
|
// Error state
|
|
if (domains === undefined) {
|
|
return <>
|
|
{header}
|
|
<ErrorMessage>Could not fetch Domains</ErrorMessage>
|
|
</>;
|
|
}
|
|
|
|
return <>
|
|
{header}
|
|
<CollectionTable
|
|
collections={domains}
|
|
columns={DOMAIN_COLUMNS}
|
|
routes={DOMAIN_ROUTES}
|
|
searchStr={searchStr}
|
|
/>
|
|
</>;
|
|
}
|
|
|
|
export function DomainEditPage() {
|
|
const {domainId} = useParams();
|
|
const navigate = useNavigate();
|
|
const {domain, form, fetchDomain} = useDomain(parseInt(domainId));
|
|
|
|
// Modal state for tagging
|
|
const [tagEditModalOpen, setTagEditModalOpen] = useState(false);
|
|
// Modal state for reorganization
|
|
const [reorganizeModalOpen, setReorganizeModalOpen] = useState(false);
|
|
|
|
useTitle(`Edit Domain: ${domain?.domain || '...'}`);
|
|
|
|
// Wrap form.onSubmit to add toast and refresh domain data
|
|
React.useEffect(() => {
|
|
if (form && form.onSubmit) {
|
|
const originalOnSubmit = form.onSubmit;
|
|
form.onSubmit = async () => {
|
|
try {
|
|
await originalOnSubmit();
|
|
toast({
|
|
type: 'success',
|
|
title: 'Domain Updated',
|
|
description: 'Domain was successfully updated',
|
|
time: 3000,
|
|
});
|
|
// Refresh domain data to show updated values
|
|
await fetchDomain();
|
|
} catch (e) {
|
|
console.error('Failed to update domain:', e);
|
|
throw e;
|
|
}
|
|
};
|
|
}
|
|
}, [form, fetchDomain]);
|
|
|
|
// Handler for tag modal save
|
|
const handleTagSave = async (tagName, directory) => {
|
|
try {
|
|
await tagDomain(parseInt(domainId), tagName, directory);
|
|
toast({
|
|
type: 'success',
|
|
title: 'Domain Tagged',
|
|
description: `Domain "${domain?.domain}" has been tagged with "${tagName}"`,
|
|
time: 3000,
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to tag domain', e);
|
|
} finally {
|
|
setTimeout(async () => {
|
|
await fetchDomain();
|
|
}, 500);
|
|
}
|
|
};
|
|
|
|
// Handler for fetching tag info
|
|
const handleGetTagInfo = async (tagName) => {
|
|
if (domain?.id) {
|
|
return await getCollectionTagInfo(domain.id, tagName);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const handleRefreshDomain = async (e) => {
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
await refreshDomain(parseInt(domainId));
|
|
// Refresh domain data after completion
|
|
await fetchDomain();
|
|
};
|
|
|
|
if (!form.ready) {
|
|
if (form.error) {
|
|
return <>
|
|
<div className='wrolpi-button-row'><BackButton/></div>
|
|
<Message kind='error' title='Domain not found'>
|
|
<p>{form.error}</p>
|
|
</Message>
|
|
</>;
|
|
}
|
|
return <Loading>Loading domain...</Loading>;
|
|
}
|
|
|
|
// Handler for domain deletion
|
|
const handleDelete = async () => {
|
|
try {
|
|
let response = await deleteDomain(parseInt(domainId));
|
|
if (response.status === 204) {
|
|
navigate('/archives/domains');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to delete domain', e);
|
|
}
|
|
};
|
|
|
|
const deleteButton = <APIButton
|
|
role='danger'
|
|
size='small'
|
|
confirmContent='Are you sure you want to delete this domain? No archive files will be deleted, but archives will be orphaned.'
|
|
confirmButton='Delete Domain'
|
|
confirmHeader='Delete Domain?'
|
|
onClick={handleDelete}
|
|
obeyWROLMode={true}
|
|
>Delete</APIButton>;
|
|
|
|
const refreshButton = domain?.directory ? (
|
|
<APIButton
|
|
color='blue'
|
|
size='small'
|
|
onClick={handleRefreshDomain}
|
|
obeyWROLMode={true}
|
|
>Refresh</APIButton>
|
|
) : null;
|
|
|
|
const tagButton = <Button
|
|
type="button"
|
|
size='small'
|
|
onClick={() => setTagEditModalOpen(true)}
|
|
color='violet'
|
|
>Tag</Button>;
|
|
|
|
const reorganizeButton = (
|
|
<APIButton
|
|
color='orange'
|
|
size='small'
|
|
onClick={() => setReorganizeModalOpen(true)}
|
|
obeyWROLMode={true}
|
|
>Reorganize Files</APIButton>
|
|
);
|
|
|
|
const actionButtons = <>
|
|
{deleteButton}
|
|
{refreshButton}
|
|
{tagButton}
|
|
{reorganizeButton}
|
|
</>;
|
|
|
|
const [descriptionProps] = form.getCustomProps({name: 'description', path: 'description'});
|
|
|
|
return <>
|
|
<div className='wrolpi-button-row'>
|
|
<BackButton/>
|
|
<Link to={`/archives?domain=${domain?.domain}`}>
|
|
<Button>Archives</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
{domain?.needs_reorganization && (
|
|
<Message kind='warning' title='File Format Changed'>
|
|
<p>
|
|
The file name format has changed. Click "Reorganize Files" to move existing files
|
|
to match the new format.
|
|
</p>
|
|
</Message>
|
|
)}
|
|
|
|
<CollectionEditForm
|
|
form={form}
|
|
title={`Edit Domain: ${domain?.domain || '...'}`}
|
|
wrolModeContent='Domain editing is disabled while in WROL Mode.'
|
|
actionButtons={actionButtons}
|
|
appliedTagName={domain?.tag_name}
|
|
>
|
|
{/*
|
|
* CollectionEditForm still wraps its children in a `ui stackable grid` div (Semantic's
|
|
* grid CSS, not a component) because it is shared with unmigrated callers (Channels.js,
|
|
* Playlists.js). Plain "row"/"column" divs are that CSS's own class names, not a
|
|
* semantic-ui-react import.
|
|
*/}
|
|
<div className="row">
|
|
<div className="column">
|
|
<DestinationForm
|
|
form={form}
|
|
label='Directory'
|
|
name='directory'
|
|
path='directory'
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="row">
|
|
<div className="column">
|
|
<label style={{display: 'block', marginBottom: 4}}>Description</label>
|
|
<Textarea
|
|
placeholder='Optional description'
|
|
value={descriptionProps.value || ''}
|
|
onChange={(e) => descriptionProps.onChange(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CollectionEditForm>
|
|
|
|
{/* Tag Modal */}
|
|
<CollectionTagModal
|
|
open={tagEditModalOpen}
|
|
onClose={() => setTagEditModalOpen(false)}
|
|
currentTagName={domain?.tag_name}
|
|
originalDirectory={domain?.directory}
|
|
getTagInfo={handleGetTagInfo}
|
|
onSave={handleTagSave}
|
|
collectionName="Domain"
|
|
hasDirectory={!!domain?.directory}
|
|
/>
|
|
|
|
{/* Reorganize Modal */}
|
|
<CollectionReorganizeModal
|
|
open={reorganizeModalOpen}
|
|
onClose={() => setReorganizeModalOpen(false)}
|
|
collectionId={domain?.id}
|
|
collectionName={domain?.domain}
|
|
onComplete={fetchDomain}
|
|
needsReorganization={domain?.needs_reorganization}
|
|
/>
|
|
|
|
{/* Downloads Panel */}
|
|
<Panel>
|
|
<Header as='h1'>Downloads</Header>
|
|
<RecurringDownloadsTable
|
|
downloads={domain?.downloads}
|
|
fetchDownloads={fetchDomain}
|
|
/>
|
|
</Panel>
|
|
|
|
{domain && domain.statistics && <DomainStatistics statistics={domain.statistics}/>}
|
|
</>;
|
|
}
|
|
|
|
function ArchiveFileNameForm({form}) {
|
|
const [message, setMessage] = React.useState(null);
|
|
|
|
const onChange = async (value) => {
|
|
const response = await postArchiveFileFormat(value);
|
|
const {error, preview} = await response.json();
|
|
if (error) {
|
|
setMessage({kind: 'error', title: 'Invalid File Name', content: error});
|
|
} else {
|
|
setMessage({kind: 'success', title: 'File Name Preview', content: preview});
|
|
}
|
|
}
|
|
|
|
const [inputProps] = form.getInputProps({name: 'file_name_format', path: 'file_name_format', onChange});
|
|
|
|
return <div>
|
|
<InfoHeader
|
|
headerSize='h5'
|
|
headerContent='Archive File Format'
|
|
popupContent={<>
|
|
<p>Variables:</p>
|
|
<ul>
|
|
<li><code>%(title)s</code> - Page title</li>
|
|
<li><code>%(download_datetime)s</code> - Full datetime (YYYY-MM-DD-HH-MM-SS)</li>
|
|
<li><code>%(download_date)s</code> - Date only (YYYY-MM-DD)</li>
|
|
<li><code>%(download_year)s</code> - Year</li>
|
|
<li><code>%(download_month)s</code> - Month (zero-padded)</li>
|
|
<li><code>%(download_day)s</code> - Day (zero-padded)</li>
|
|
<li><code>%(domain)s</code> - Domain name</li>
|
|
<li><code>%(ext)s</code> - File extension (required, must be at end)</li>
|
|
</ul>
|
|
<p>Subdirectories supported: <code>%(download_year)s/%(title)s.%(ext)s</code></p>
|
|
</>}
|
|
/>
|
|
<TextInput
|
|
id='file_name_format_input'
|
|
name='file_name_format'
|
|
value={inputProps.value ?? ''}
|
|
onChange={inputProps.onChange}
|
|
data-path={inputProps['data-path']}
|
|
/>
|
|
{message && <Message kind={message.kind} title={message.title}>{message.content}</Message>}
|
|
</div>
|
|
}
|
|
|
|
function BrowserConfigForm({form, browsers, browsersAvailable}) {
|
|
const [useCustomPath, setUseCustomPath] = useState(false);
|
|
|
|
// Determine if custom path is being used based on current value
|
|
React.useEffect(() => {
|
|
const currentBrowser = form.formData?.browser_executable;
|
|
if (currentBrowser) {
|
|
// Check if current value matches any known browser
|
|
const isKnownBrowser = browsers.some(b => b.path === currentBrowser || b.key === currentBrowser);
|
|
setUseCustomPath(!isKnownBrowser);
|
|
}
|
|
}, [form.formData?.browser_executable, browsers]);
|
|
|
|
if (!browsersAvailable) {
|
|
return null;
|
|
}
|
|
|
|
// Build dropdown options: auto-detect + installed browsers + custom
|
|
const browserOptions = [
|
|
{value: '', label: 'Auto-detect (recommended)'},
|
|
...browsers.map(b => ({value: b.path, label: `${b.name} (${b.path})`})),
|
|
{value: '__custom__', label: 'Custom path...'},
|
|
];
|
|
|
|
const handleBrowserChange = (value) => {
|
|
if (value === '__custom__') {
|
|
setUseCustomPath(true);
|
|
form.setValue('browser_executable', '');
|
|
} else {
|
|
setUseCustomPath(false);
|
|
form.setValue('browser_executable', value || null);
|
|
}
|
|
};
|
|
|
|
const currentValue = form.formData?.browser_executable || '';
|
|
const dropdownValue = useCustomPath ? '__custom__' : currentValue;
|
|
|
|
return <>
|
|
<Header as='h4'>Browser Settings</Header>
|
|
<p>
|
|
Configure which browser SingleFile uses to create archives.
|
|
These settings only apply to native deployments (Raspberry Pi/Debian).
|
|
</p>
|
|
|
|
<div style={{marginBottom: '1em'}}>
|
|
<label style={{display: 'block', marginBottom: 4}}>Browser</label>
|
|
<Select
|
|
data={browserOptions}
|
|
value={dropdownValue}
|
|
onChange={handleBrowserChange}
|
|
placeholder='Select browser...'
|
|
/>
|
|
</div>
|
|
|
|
{useCustomPath && (
|
|
<div style={{marginBottom: '1em'}}>
|
|
<label style={{display: 'block', marginBottom: 4}}>Custom Browser Path</label>
|
|
<TextInput
|
|
placeholder='/usr/bin/chromium'
|
|
value={form.formData?.browser_executable || ''}
|
|
onChange={(e) => form.setValue('browser_executable', e.target.value)}
|
|
/>
|
|
<small style={{color: 'var(--muted)'}}>Enter the absolute path to the browser executable.</small>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{marginBottom: '1em'}}>
|
|
<label style={{display: 'block', marginBottom: 4}}>Browser Arguments</label>
|
|
<TextInput
|
|
placeholder='["--no-sandbox"]'
|
|
value={form.formData?.browser_args || '["--no-sandbox"]'}
|
|
onChange={(e) => form.setValue('browser_args', e.target.value)}
|
|
/>
|
|
<small style={{color: 'var(--muted)'}}>JSON array of arguments passed to the browser. Default:
|
|
["--no-sandbox"]</small>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{display: 'block', marginBottom: 4}}>User Agent</label>
|
|
<TextInput
|
|
placeholder='Leave empty to use system default'
|
|
value={form.formData?.user_agent || ''}
|
|
onChange={(e) => form.setValue('user_agent', e.target.value || null)}
|
|
/>
|
|
<small style={{color: 'var(--muted)'}}>Custom user agent string. Leave empty to use the system
|
|
default.</small>
|
|
</div>
|
|
</>;
|
|
}
|
|
|
|
function ArchiveSettingsPage() {
|
|
useTitle('Archive Settings');
|
|
|
|
const dockerized = useDockerized();
|
|
const [batchModalOpen, setBatchModalOpen] = useState(false);
|
|
const [domainsNeedingReorg, setDomainsNeedingReorg] = useState(0);
|
|
const [fetchingReorgCount, setFetchingReorgCount] = useState(true);
|
|
const [browsers, setBrowsers] = useState([]);
|
|
const [browsersAvailable, setBrowsersAvailable] = useState(false);
|
|
|
|
// Check if batch reorganization is currently active for domains
|
|
const {isReorganizing, taskType, collectionKind} = useReorganizationStatus();
|
|
const isBatchReorganizingDomains = isReorganizing && taskType === 'batch_reorganize' && collectionKind === 'domain';
|
|
|
|
// Check how many domains need reorganization on mount (skip if batch is in progress)
|
|
React.useEffect(() => {
|
|
if (isBatchReorganizingDomains) {
|
|
// Skip fetching preview while batch reorganization is in progress
|
|
setFetchingReorgCount(false);
|
|
return;
|
|
}
|
|
setFetchingReorgCount(true);
|
|
previewBatchReorganization('domain')
|
|
.then(data => {
|
|
setDomainsNeedingReorg(data.total_collections || 0);
|
|
})
|
|
.catch(() => {
|
|
setDomainsNeedingReorg(0);
|
|
})
|
|
.finally(() => {
|
|
setFetchingReorgCount(false);
|
|
});
|
|
}, [isBatchReorganizingDomains]);
|
|
|
|
// Fetch available browsers on mount (only if not dockerized)
|
|
React.useEffect(() => {
|
|
if (dockerized === false) {
|
|
fetchArchiveBrowsers()
|
|
.then(data => {
|
|
setBrowsers(data.browsers || []);
|
|
setBrowsersAvailable(data.available === true);
|
|
})
|
|
.catch(() => {
|
|
setBrowsers([]);
|
|
setBrowsersAvailable(false);
|
|
});
|
|
}
|
|
}, [dockerized]);
|
|
|
|
const emptyFormData = {
|
|
file_name_format: '%(download_datetime)s_%(title)s.%(ext)s',
|
|
browser_executable: null,
|
|
browser_args: '["--no-sandbox"]',
|
|
user_agent: null,
|
|
};
|
|
|
|
const configSubmitter = async () => {
|
|
return await updateArchiveDownloaderConfig(configForm.formData);
|
|
};
|
|
|
|
const configForm = useForm({
|
|
fetcher: fetchArchiveDownloaderConfig,
|
|
submitter: configSubmitter,
|
|
emptyFormData,
|
|
});
|
|
|
|
const urlClipboardButton = <APIButton
|
|
icon='copy'
|
|
onClick={() => navigator.clipboard.writeText(API_ARCHIVE_UPLOAD_URI)}
|
|
/>;
|
|
const dataFieldNameClipboardButton = <APIButton
|
|
icon='copy'
|
|
onClick={() => navigator.clipboard.writeText('singlefile_contents')}
|
|
/>;
|
|
const urlFieldNameClipboardButton = <APIButton
|
|
icon='copy'
|
|
onClick={() => navigator.clipboard.writeText('url')}
|
|
/>;
|
|
|
|
/*
|
|
* A fragment, not a `PageContainer`. `ArchiveRoute` already wraps its `<Routes>` in one, and
|
|
* a second applies its `margin-top: 1em` and `padding: 1em` again -- so this page sat 1em
|
|
* lower and 1em further in on all four sides than /archives/domains beside it. As a fragment
|
|
* these panels become blocks of the route's own page stack, which spaces them.
|
|
*/
|
|
return <>
|
|
<Panel>
|
|
<Header as='h3'>Archive Downloader Config</Header>
|
|
|
|
<div style={{opacity: configForm.loading ? 0.6 : 1, pointerEvents: configForm.loading ? 'none' : 'auto'}}>
|
|
<div style={{maxWidth: 500, marginBottom: '1em'}}>
|
|
<ArchiveFileNameForm form={configForm}/>
|
|
</div>
|
|
|
|
{/* Browser settings - only shown on native deployments */}
|
|
{!dockerized && browsersAvailable && (
|
|
<div style={{maxWidth: 500, marginBottom: '1em'}}>
|
|
<BrowserConfigForm
|
|
form={configForm}
|
|
browsers={browsers}
|
|
browsersAvailable={browsersAvailable}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{textAlign: 'right'}}>
|
|
<APIButton
|
|
disabled={configForm.disabled || !configForm.ready}
|
|
type='submit'
|
|
style={{marginTop: '0.5em'}}
|
|
onClick={configForm.onSubmit}
|
|
id='archive_settings_save_button'
|
|
>Save</APIButton>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel>
|
|
<Header as='h4'>File Organization</Header>
|
|
<p>
|
|
{fetchingReorgCount
|
|
? 'Checking for domains that need reorganization...'
|
|
: <>
|
|
<strong>{domainsNeedingReorg}</strong> domain{domainsNeedingReorg !== 1 ? 's' : ''}
|
|
{domainsNeedingReorg > 0
|
|
? ' have files that do not match the current file name format.'
|
|
: '. All domains are organized correctly.'}
|
|
</>
|
|
}
|
|
</p>
|
|
<Button
|
|
color='orange'
|
|
icon='folder open outline'
|
|
onClick={() => setBatchModalOpen(true)}
|
|
id='reorganize_all_domains_button'
|
|
disabled={fetchingReorgCount || domainsNeedingReorg === 0}
|
|
loading={fetchingReorgCount}
|
|
>
|
|
Reorganize All Domains
|
|
</Button>
|
|
</Panel>
|
|
|
|
<BatchReorganizeModal
|
|
open={batchModalOpen}
|
|
onClose={() => setBatchModalOpen(false)}
|
|
kind='domain'
|
|
onComplete={() => {
|
|
setBatchModalOpen(false);
|
|
setDomainsNeedingReorg(0);
|
|
}}
|
|
/>
|
|
|
|
<Panel>
|
|
<Header as='h3'>SingleFile Browser Extension</Header>
|
|
|
|
<p>
|
|
These are the settings necessary to configure the <a
|
|
href="https://github.com/gildas-lormeau/SingleFile?tab=readme-ov-file#install"
|
|
rel='noopener noreferrer'
|
|
target='_blank'>SingleFile Browser
|
|
Extension</a> to automatically upload to your WROLPi.
|
|
</p>
|
|
|
|
<label style={{display: 'block', marginBottom: 4}}>Upload URL</label>
|
|
<ActionInput readOnly
|
|
value={API_ARCHIVE_UPLOAD_URI}
|
|
action={urlClipboardButton}
|
|
/>
|
|
<label style={{display: 'block', marginBottom: 4, marginTop: '1em'}}>Data Field Name</label>
|
|
<ActionInput readOnly
|
|
value='singlefile_contents'
|
|
action={dataFieldNameClipboardButton}
|
|
/>
|
|
<label style={{display: 'block', marginBottom: 4, marginTop: '1em'}}>URL Field Name</label>
|
|
<ActionInput readOnly
|
|
value='url'
|
|
action={urlFieldNameClipboardButton}
|
|
/>
|
|
</Panel>
|
|
</>
|
|
}
|
|
|
|
function ArchivesPage() {
|
|
const [selectedArchives, setSelectedArchives] = useState([]);
|
|
const [bulkTagOpen, setBulkTagOpen] = useState(false);
|
|
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
|
|
const searchInputRef = React.useRef();
|
|
|
|
useHotkeys('f', (e) => {
|
|
e.preventDefault();
|
|
if (searchInputRef.current) {
|
|
searchInputRef.current.focus();
|
|
}
|
|
}, {enableOnFormTags: false});
|
|
|
|
const {domain, domains} = useSearchDomain();
|
|
|
|
// Find the domain object from the domains list to get the ID for the edit link
|
|
const domainObj = domain && domains ? domains.find(d => d.domain === domain) : null;
|
|
|
|
let title = 'Archives';
|
|
if (domainObj && domainObj.domain) {
|
|
title = `${domainObj.domain} Archives`;
|
|
}
|
|
useTitle(title);
|
|
|
|
const {
|
|
archives,
|
|
totalPages,
|
|
activePage,
|
|
setPage,
|
|
searchStr,
|
|
setSearchStr,
|
|
fetchArchives,
|
|
} = useSearchArchives();
|
|
|
|
let archiveOrders = [
|
|
{value: 'published_datetime', text: 'Published Date', short: 'P.Date'},
|
|
{value: 'published_modified_datetime', text: 'Modified Date', short: 'M.Date'},
|
|
{value: 'download_datetime', text: 'Download Date', short: 'D.Date'},
|
|
{value: 'size', text: 'Size'},
|
|
{value: 'viewed', text: 'Recently Viewed', short: 'R.Viewed'},
|
|
];
|
|
|
|
if (searchStr) {
|
|
archiveOrders = [{value: 'rank', text: 'Rank'}, ...archiveOrders];
|
|
}
|
|
|
|
const onSelect = (path, checked) => {
|
|
if (checked && path) {
|
|
setSelectedArchives([...selectedArchives, path]);
|
|
} else if (path) {
|
|
setSelectedArchives(selectedArchives.filter(i => i !== path));
|
|
}
|
|
}
|
|
|
|
const fileGroupIdsForSelection = () => archives
|
|
.filter(i => selectedArchives.indexOf(i['primary_path']) >= 0)
|
|
.map(i => i['id']);
|
|
|
|
const onDelete = async (force = false) => {
|
|
const fileGroupIds = fileGroupIdsForSelection();
|
|
const result = await deleteFileGroups(fileGroupIds, force);
|
|
if (result && result.tagged) {
|
|
setTaggedFileGroups(result.file_groups);
|
|
return;
|
|
}
|
|
await fetchArchives();
|
|
setSelectedArchives([]);
|
|
}
|
|
|
|
const invertSelection = async () => {
|
|
const newSelectedArchives = archives.map(archive => archive['key']).filter(i => selectedArchives.indexOf(i) < 0);
|
|
setSelectedArchives(newSelectedArchives);
|
|
}
|
|
|
|
const clearSelection = async (e) => {
|
|
if (e) e.preventDefault();
|
|
setSelectedArchives([]);
|
|
}
|
|
|
|
const onBulkTagComplete = async () => {
|
|
await fetchArchives();
|
|
setSelectedArchives([]);
|
|
}
|
|
|
|
const selectElm = <div className='wrolpi-button-row' style={{marginTop: '0.5em'}}>
|
|
<Button
|
|
role='primary'
|
|
disabled={_.isEmpty(selectedArchives)}
|
|
onClick={() => setBulkTagOpen(true)}
|
|
>Tag</Button>
|
|
<APIButton
|
|
role='danger'
|
|
disabled={_.isEmpty(selectedArchives)}
|
|
confirmButton='Delete'
|
|
confirmContent='Are you sure you want to delete these archives files? This cannot be undone.'
|
|
onClick={() => onDelete(false)}
|
|
>Delete</APIButton>
|
|
<Button
|
|
role='cancel'
|
|
onClick={invertSelection}
|
|
disabled={_.isEmpty(archives)}
|
|
>
|
|
Invert
|
|
</Button>
|
|
<Button
|
|
role='cancel'
|
|
onClick={clearSelection}
|
|
disabled={_.isEmpty(archives) || _.isEmpty(selectedArchives)}
|
|
>
|
|
Clear
|
|
</Button>
|
|
<BulkTagModal
|
|
open={bulkTagOpen}
|
|
onClose={() => setBulkTagOpen(false)}
|
|
paths={selectedArchives}
|
|
onComplete={onBulkTagComplete}
|
|
/>
|
|
</div>;
|
|
|
|
const {body, paginator, viewButton} = FilesView(
|
|
{
|
|
files: archives,
|
|
activePage: activePage,
|
|
totalPages: totalPages,
|
|
selectElem: selectElm,
|
|
selectedKeys: selectedArchives,
|
|
onSelect: onSelect,
|
|
setPage: setPage,
|
|
headlines: !!searchStr
|
|
},
|
|
);
|
|
|
|
|
|
// Domain header with edit link when filtering by domain
|
|
let header;
|
|
if (domainObj && domainObj.domain) {
|
|
const editLink = `/archives/domain/${domainObj.id}/edit`;
|
|
header = <>
|
|
<Header as='h1'>
|
|
{domainObj.domain}
|
|
<Link to={editLink}>
|
|
<Icon name='edit' style={{marginLeft: '0.5em'}}/>
|
|
</Link>
|
|
</Header>
|
|
</>;
|
|
} else if (domain) {
|
|
// Domain filter is set but domain object not loaded yet
|
|
header = <div style={{marginBottom: '1em'}}>
|
|
<Placeholder lines={1}/>
|
|
</div>;
|
|
}
|
|
|
|
return <>
|
|
{header}
|
|
<SearchControlBar
|
|
searchStr={searchStr}
|
|
setSearchStr={setSearchStr}
|
|
placeholder='Search Archives...'
|
|
inputRef={searchInputRef}
|
|
viewButton={viewButton}
|
|
sorts={archiveOrders}
|
|
showDeep={true}
|
|
/>
|
|
{body}
|
|
<DeepSearchHint searchStr={searchStr} files={archives}/>
|
|
{paginator}
|
|
<TaggedDeleteConfirmModal
|
|
open={taggedFileGroups !== null}
|
|
taggedFileGroups={taggedFileGroups}
|
|
onCancel={() => setTaggedFileGroups(null)}
|
|
onConfirm={async () => {
|
|
setTaggedFileGroups(null);
|
|
await onDelete(true);
|
|
}}
|
|
/>
|
|
</>
|
|
}
|
|
|
|
export function ArchiveRowCells({file}) {
|
|
const {data} = file;
|
|
let {sort} = useSearchOrder();
|
|
sort = sort ? sort.replace(/^-+/, '') : null;
|
|
|
|
const archiveUrl = `/archives/${file.id}`;
|
|
const posterPath = findPosterPath(file);
|
|
const posterUrl = posterPath ? `/media/${encodeMediaPath(posterPath)}` : null;
|
|
|
|
let poster;
|
|
if (posterUrl) {
|
|
poster = <CardLink to={archiveUrl}>
|
|
<img alt='' src={posterUrl} style={{width: '50px', height: 'auto'}}/>
|
|
</CardLink>;
|
|
} else {
|
|
poster = <FileIcon file={file} size='large'/>;
|
|
}
|
|
|
|
let dataCell = file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime) : '';
|
|
if (sort === 'published_modified_datetime') {
|
|
dataCell = file.published_modified_datetime ? isoDatetimeToAgoPopup(file.published_modified_datetime) : '';
|
|
} else if (sort === 'download_datetime') {
|
|
dataCell = file.download_datetime ? isoDatetimeToAgoPopup(file.download_datetime) : '';
|
|
} else if (sort === 'size') {
|
|
dataCell = humanFileSize(file.size);
|
|
} else if (sort === 'viewed') {
|
|
dataCell = isoDatetimeToAgoPopup(file.viewed);
|
|
}
|
|
|
|
// Fragment for SelectableRow
|
|
return <React.Fragment>
|
|
<Table.Cell>
|
|
<div style={{textAlign: 'center'}}>
|
|
{poster}
|
|
</div>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<CardLink to={archiveUrl}>
|
|
<FileRowTagIcon file={file}/>
|
|
{textEllipsis(file.title || file.stem)}
|
|
</CardLink>
|
|
</Table.Cell>
|
|
<Table.Cell>{dataCell}</Table.Cell>
|
|
</React.Fragment>
|
|
}
|
|
|
|
function ArchiveStatistics() {
|
|
useTitle('Archive Statistics');
|
|
|
|
const {statistics} = useArchiveStatistics();
|
|
|
|
if (statistics === null) {
|
|
// Request is pending.
|
|
return <Loading/>
|
|
} else if (statistics === undefined) {
|
|
return <ErrorMessage>Unable to fetch Archive Statistics</ErrorMessage>
|
|
}
|
|
|
|
const {archives, historical, domains} = statistics;
|
|
|
|
const archiveNames = [
|
|
{key: 'archives', label: 'Archives'},
|
|
{key: 'sum_size', label: 'Total Size'},
|
|
{key: 'max_size', label: 'Largest Archive'},
|
|
{key: 'week', label: 'Downloads Past Week'},
|
|
{key: 'month', label: 'Downloads Past Month'},
|
|
{key: 'year', label: 'Downloads Past Year'},
|
|
];
|
|
const historicalNames = [
|
|
{key: 'average_count', label: 'Average Monthly Downloads'},
|
|
{key: 'average_size', label: 'Average Monthly Usage'},
|
|
];
|
|
const domainNames = [
|
|
{key: 'domains', label: 'Domains'},
|
|
{key: 'tagged_domains', label: 'Tagged Domains'},
|
|
];
|
|
|
|
const buildPanel = (title, names, stats) => {
|
|
return <Panel>
|
|
<Header as='h1' style={{textAlign: 'center'}}>{title}</Header>
|
|
<StatisticGroup>
|
|
{names.map(
|
|
({key, label}) =>
|
|
<Statistic key={key} value={stats[key]} label={label}/>
|
|
)}
|
|
</StatisticGroup>
|
|
</Panel>
|
|
}
|
|
|
|
return <>
|
|
{buildPanel('Archives', archiveNames, archives)}
|
|
{buildPanel('Historical Archives', historicalNames, historical)}
|
|
{buildPanel('Domains', domainNames, domains)}
|
|
</>
|
|
}
|
|
|
|
export function ArchiveRoute() {
|
|
const location = useLocation();
|
|
const path = location.pathname;
|
|
|
|
const links = [
|
|
{
|
|
text: 'Archives',
|
|
to: '/archives',
|
|
end: true,
|
|
isActive: () => path === '/archives' || /^\/archives\/\d+$/.test(path)
|
|
},
|
|
{
|
|
text: 'Domains',
|
|
to: '/archives/domains',
|
|
isActive: () => path.startsWith('/archives/domain')
|
|
},
|
|
{text: 'Settings', to: '/archives/settings'},
|
|
{text: 'Statistics', to: '/archives/statistics'},
|
|
];
|
|
return <PageContainer>
|
|
<TabLinks links={links}/>
|
|
<Routes>
|
|
<Route path='/' element={<ArchivesPage/>}/>
|
|
<Route path='domains' element={<DomainsPage/>}/>
|
|
<Route path='domain/:domainId/edit' element={<DomainEditPage/>}/>
|
|
<Route path='settings' element={<ArchiveSettingsPage/>}/>
|
|
<Route path='statistics' element={<ArchiveStatistics/>}/>
|
|
<Route path=':fileGroupId' element={<ArchivePage/>}/>
|
|
</Routes>
|
|
</PageContainer>
|
|
}
|