mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Four defects on /videos, /docs and /archives. Titles were invisible outside light. `.card-link` carried a literal `color: black` from before the themes existed -- about 1.1:1 on the dark, night and amber panels. It applies to every card title and to the channel/author/domain line beside it. The date was fine, since it takes `--muted` from the card body, which is what made this look like a card bug rather than a link bug. Titles now take `--text`: they are links, but they are the card's text, not a wall of blue. Posters were cropped, not mis-proportioned. The image carried a fixed 290x163 box; a card in the default grid is about 233px wide and Mantine's Card sets `overflow: hidden`, so the browser painted the middle 233px of a 290px image. The ratio was right in the layout tree and wrong on screen. Replaced with caps on both axes -- the card's width and `--card-poster-max-height: 200px` -- with `width`/`height` left `auto` so the pair behaves as "contain". Bounding the width alone was tried first and is not enough: a square thumbnail then stands as tall as the card is wide, and a portrait book cover taller still. The meta line was foot-anchored, so on /docs a PDF with no author had its date shoved up to 55px below the title, at the bottom of a card that was tall only because of its neighbour in the row. It now sits directly under the title. Archive cards read buttons-then-domain, because the buttons were children and the meta was what got pushed to the foot. Card gains an `actions` slot, rendered last and anchored to the foot, so a row lines its buttons up however many lines each title took. Gallery gains a poster row covering all five ratios and a row showing meta/actions against unequal titles. 1076 jest tests, 145 in ui-layout.cy.js, clean build. Every new guard was checked against a planted regression: dropping the height cap reddens the square and portrait cases and leaves 16:9 green; `width: 100%` reddens containment; restoring `color: black` reddens dark/night/amber and leaves light green; re-anchoring the meta and swapping the slot order redden both the jest order test and the cypress geometry tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1391 lines
48 KiB
JavaScript
1391 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}
|
|
style={{marginTop: '0.5em'}}
|
|
>
|
|
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}
|
|
style={{marginTop: '0.5em'}}
|
|
>
|
|
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 <>
|
|
<BackButton/>
|
|
|
|
<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>
|
|
|
|
{singlefileButton}
|
|
{readButton}
|
|
{updateButton}
|
|
{deleteButton}
|
|
{generateScreenshotButton}
|
|
<AddToPlaylistButton fileGroupId={archiveFile.id}/>
|
|
</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
|
|
const header = <div style={{marginBottom: '1em', 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 <>
|
|
<BackButton/>
|
|
<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}
|
|
style={{marginTop: '1em'}}
|
|
>Delete</APIButton>;
|
|
|
|
const refreshButton = domain?.directory ? (
|
|
<APIButton
|
|
color='blue'
|
|
size='small'
|
|
onClick={handleRefreshDomain}
|
|
obeyWROLMode={true}
|
|
style={{marginTop: '1em'}}
|
|
>Refresh</APIButton>
|
|
) : null;
|
|
|
|
const tagButton = <Button
|
|
type="button"
|
|
size='small'
|
|
onClick={() => setTagEditModalOpen(true)}
|
|
color='violet'
|
|
style={{marginTop: '1em'}}
|
|
>Tag</Button>;
|
|
|
|
const reorganizeButton = (
|
|
<APIButton
|
|
color='orange'
|
|
size='small'
|
|
onClick={() => setReorganizeModalOpen(true)}
|
|
obeyWROLMode={true}
|
|
style={{marginTop: '1em'}}
|
|
>Reorganize Files</APIButton>
|
|
);
|
|
|
|
const actionButtons = <>
|
|
{deleteButton}
|
|
{refreshButton}
|
|
{tagButton}
|
|
{reorganizeButton}
|
|
</>;
|
|
|
|
const [descriptionProps] = form.getCustomProps({name: 'description', path: 'description'});
|
|
|
|
return <>
|
|
<BackButton/>
|
|
<Link to={`/archives?domain=${domain?.domain}`}>
|
|
<Button>Archives</Button>
|
|
</Link>
|
|
|
|
{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')}
|
|
/>;
|
|
|
|
return <PageContainer>
|
|
<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>
|
|
</PageContainer>
|
|
}
|
|
|
|
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 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>
|
|
}
|