mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
824 lines
27 KiB
JavaScript
824 lines
27 KiB
JavaScript
import React, {useContext, useState} from "react";
|
|
import {
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardMeta,
|
|
Checkbox,
|
|
Container,
|
|
Dropdown,
|
|
Form,
|
|
Image,
|
|
PlaceholderLine,
|
|
Step,
|
|
TableCell,
|
|
TableHeaderCell,
|
|
} from "semantic-ui-react";
|
|
import {
|
|
CardGroupCentered,
|
|
CardPoster,
|
|
encodeMediaPath,
|
|
ErrorMessage,
|
|
ExternalCardLink,
|
|
FileIcon,
|
|
findPosterPath,
|
|
HandPointMessage,
|
|
humanFileSize,
|
|
isoDatetimeToAgoPopup,
|
|
mimetypeColor,
|
|
PageContainer,
|
|
Paginator,
|
|
PreviewLink,
|
|
TagIcon,
|
|
textEllipsis,
|
|
useTitle
|
|
} from "./Common";
|
|
import {
|
|
usePages,
|
|
useSearchFiles,
|
|
useSearchFilter,
|
|
useSearchOrder,
|
|
useSearchView,
|
|
useStatusFlag,
|
|
useWROLMode
|
|
} from "../hooks/customHooks";
|
|
import {useFileWorkerStatus} from "../contexts/FileWorkerStatusContext";
|
|
import {Link, Route, Routes, useSearchParams} from "react-router";
|
|
import {CardPlaceholder} from "./Placeholder";
|
|
import {ArchiveCard, ArchiveRowCells} from "./Archive";
|
|
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
|
|
import {Media, ThemeContext} from "../contexts/contexts";
|
|
import {
|
|
Button,
|
|
Card,
|
|
Icon,
|
|
Modal,
|
|
Placeholder,
|
|
Popup,
|
|
Progress,
|
|
Segment
|
|
} from "./Theme";
|
|
import {SelectableTable} from "./Tables";
|
|
import {VideoCard, VideoRowCells} from "./Videos";
|
|
import {FileBrowser} from "./FileBrowser";
|
|
import {refreshFiles} from "../api";
|
|
import {TagsSelector} from "../Tags";
|
|
import {Headlines} from "./Headline";
|
|
import {useSearch} from "./Search";
|
|
import {FILES_MEDIA_URI} from "./Vars";
|
|
|
|
// Split a ts_headline snippet on our sentinel markers and wrap each match in <b>.
|
|
// We use sentinels instead of ts_headline's default <b> tags so the snippet can be
|
|
// safely rendered as React children without dangerouslySetInnerHTML.
|
|
function renderHighlightedSnippet(snippet, s) {
|
|
const parts = snippet.split(/\[\[WROLPI_HL\]\]|\[\[\/WROLPI_HL\]\]/);
|
|
// Even indices are plain text, odd indices are highlighted matches.
|
|
return parts.map((part, i) => i % 2 === 1 ? <b key={i} {...s}>{part}</b> : <span key={i} {...s}>{part}</span>);
|
|
}
|
|
|
|
function EbookCard({file, sortField}) {
|
|
const {s, t} = useContext(ThemeContext);
|
|
const [searchParams] = useSearchParams();
|
|
const query = searchParams.get('q') || '';
|
|
|
|
const downloadUrl = `/download/${encodeMediaPath(file.primary_path)}`;
|
|
const isEpub = file['mimetype'] && file['mimetype'].startsWith('application/epub');
|
|
const viewerUrl = isEpub ? `/epub/epub.html?url=${downloadUrl}` : null;
|
|
|
|
// Link to doc detail page if this is a modeled doc, augmenting with
|
|
// the section hint from search so the viewer opens at the right chapter/page.
|
|
const hint = file.section_hint;
|
|
let detailUrl = file.model === 'doc' ? `/docs/${file.id}` : null;
|
|
if (detailUrl && hint) {
|
|
const params = new URLSearchParams();
|
|
if (hint.kind === 'epub_spine') params.set('loc', String(hint.ordinal));
|
|
if (hint.kind === 'pdf_page') params.set('page', String(hint.ordinal));
|
|
if (query) params.set('q', query);
|
|
detailUrl = `${detailUrl}?${params.toString()}`;
|
|
}
|
|
|
|
const color = mimetypeColor(file.mimetype, file.primary_path);
|
|
const title = file.title || file.stem || file.name;
|
|
const header = detailUrl
|
|
? <Link to={detailUrl} className='no-link-underscore card-link card-title-ellipsis' {...t}>{title}</Link>
|
|
: <ExternalCardLink to={viewerUrl || downloadUrl} className='card-title-ellipsis'>{title}</ExternalCardLink>;
|
|
return <Card color={color}>
|
|
<CardPoster file={file} to={detailUrl}/>
|
|
<CardContent {...s}>
|
|
<CardHeader>
|
|
<Container textAlign='left'>
|
|
<Popup on='hover'
|
|
trigger={header}
|
|
content={title}/>
|
|
</Container>
|
|
</CardHeader>
|
|
<CardMeta>
|
|
{file.author ? <b {...s}>{file.author}</b> : null}
|
|
{sortField === 'published_datetime'
|
|
? <p {...s}>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</p>
|
|
: file.size && <p {...s}>{humanFileSize(file.size)}</p>
|
|
}
|
|
{hint && <p {...s} style={{fontSize: '0.85em', marginTop: '0.3em'}}>
|
|
<b {...s}>{hint.label}</b>
|
|
{hint.snippet && <span style={{...s, marginLeft: '0.4em', opacity: 0.8}}>
|
|
{renderHighlightedSnippet(hint.snippet, s)}
|
|
</span>}
|
|
</p>}
|
|
</CardMeta>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
}
|
|
|
|
function ImageCard({file}) {
|
|
const {s} = useContext(ThemeContext);
|
|
const url = `/media/${encodeMediaPath(file.primary_path)}`;
|
|
|
|
const title = file.title || file.stem || file.primary_path;
|
|
const header = <ExternalCardLink to={url} className='no-link-underscore card-link'>
|
|
<p>{textEllipsis(title)}</p>
|
|
</ExternalCardLink>;
|
|
const dt = file.published_datetime || file.published_modified_datetime || file.modified;
|
|
return <Card color={mimetypeColor(file.mimetype)}>
|
|
<PreviewLink file={file}>
|
|
<CardPoster file={file}/>
|
|
</PreviewLink>
|
|
<CardContent {...s}>
|
|
<CardHeader>
|
|
<Popup on='hover'
|
|
trigger={header}
|
|
content={title}/>
|
|
</CardHeader>
|
|
<CardMeta {...s}>
|
|
<p>{isoDatetimeToAgoPopup(dt, false)}</p>
|
|
</CardMeta>
|
|
<CardDescription {...s}>
|
|
<p>{humanFileSize(file.size)}</p>
|
|
</CardDescription>
|
|
</CardContent>
|
|
</Card>
|
|
}
|
|
|
|
function FileCard({file}) {
|
|
const {s} = useContext(ThemeContext);
|
|
const {sort} = useSearchOrder();
|
|
const sortField = sort ? sort.replace(/^-/, '') : null;
|
|
|
|
const isDocType = file.model === 'doc' || (file.mimetype && (
|
|
file.mimetype.startsWith('application/epub') || file.mimetype.startsWith('application/x-mobipocket-ebook')
|
|
));
|
|
// Default doc sort is published_datetime when no order query param is set.
|
|
const docSortField = sortField || 'published_datetime';
|
|
|
|
if (file.model === 'video' && 'video' in file) {
|
|
return <VideoCard key={file['primary_path']} file={file}/>;
|
|
} else if (file.model === 'archive') {
|
|
return <ArchiveCard key={file['primary_path']} file={file}/>;
|
|
} else if (file.mimetype && file.mimetype.startsWith('image/')) {
|
|
return <ImageCard key={file['primary_path']} file={file}/>;
|
|
} else if (isDocType) {
|
|
return <EbookCard key={file['primary_path']} file={file} sortField={docSortField}/>;
|
|
}
|
|
|
|
const author = file.author;
|
|
const color = mimetypeColor(file.mimetype, file.primary_path);
|
|
const size = file.size !== null && file.size !== undefined ? humanFileSize(file.size) : null;
|
|
|
|
const title = file.title || file.name || file.primary_path;
|
|
const dt = file.published_datetime || file.published_modified_datetime || file.modified;
|
|
return <Card color={color}>
|
|
<CardPoster file={file}/>
|
|
<CardContent {...s}>
|
|
<CardHeader>
|
|
<PreviewLink file={file}>
|
|
<Popup on='hover'
|
|
trigger={<span className='card-title-ellipsis'>{title}</span>}
|
|
content={title}/>
|
|
</PreviewLink>
|
|
</CardHeader>
|
|
{author && <b {...s}>{author}</b>}
|
|
{sortField === 'published_datetime'
|
|
? <p>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</p>
|
|
: <>
|
|
<p>{isoDatetimeToAgoPopup(dt, false)}</p>
|
|
<p>{size}</p>
|
|
</>
|
|
}
|
|
</CardContent>
|
|
</Card>
|
|
}
|
|
|
|
export function FileCards({files}) {
|
|
if (files && files.length >= 1) {
|
|
return <CardGroupCentered>
|
|
{files.map(i => <FileCard key={i['primary_path']} file={i}/>)}
|
|
</CardGroupCentered>
|
|
} else if (files && files.length === 0) {
|
|
return <Segment>No results!</Segment>
|
|
} else if (files === undefined) {
|
|
return <ErrorMessage>Could not search!</ErrorMessage>
|
|
}
|
|
// Response is pending.
|
|
return <CardGroupCentered><CardPlaceholder/></CardGroupCentered>
|
|
}
|
|
|
|
function ImageRowCells({file}) {
|
|
const url = `/media/${encodeMediaPath(file.primary_path)}`;
|
|
|
|
let poster = <FileIcon file={file} size='large'/>;
|
|
if (file.size && file.size < 50000000) {
|
|
// Image is less than 5mb, use it.
|
|
poster = <Image wrapped src={url} width='50px'/>;
|
|
}
|
|
|
|
// Fragment for SelectableRow
|
|
return <React.Fragment>
|
|
<TableCell>
|
|
<center>
|
|
{poster}
|
|
</center>
|
|
</TableCell>
|
|
<TableCell>
|
|
<PreviewLink file={file}>
|
|
<p>{textEllipsis(file.title || file.stem || file.primary_path)}</p>
|
|
</PreviewLink>
|
|
</TableCell>
|
|
<TableCell>{humanFileSize(file.size)}</TableCell>
|
|
</React.Fragment>
|
|
}
|
|
|
|
export function EbookRowCells({file}) {
|
|
let cover = <FileIcon file={file} size='large'/>;
|
|
const posterPath = findPosterPath(file);
|
|
if (posterPath) {
|
|
const coverSrc = `/media/${encodeMediaPath(posterPath)}`;
|
|
cover = <Image wrapped src={coverSrc} width='50px'/>;
|
|
}
|
|
|
|
// Fragment for SelectableRow
|
|
return <React.Fragment>
|
|
<TableCell>
|
|
<center>{cover}</center>
|
|
</TableCell>
|
|
<TableCell>
|
|
<PreviewLink file={file}>
|
|
<FileRowTagIcon file={file}/>
|
|
{textEllipsis(file.title || file.stem)}
|
|
</PreviewLink>
|
|
</TableCell>
|
|
<TableCell>{humanFileSize(file.size)}</TableCell>
|
|
</React.Fragment>
|
|
}
|
|
|
|
function FileRow({file}) {
|
|
const isDocType = file.model === 'doc' || (file.mimetype && (
|
|
file.mimetype.startsWith('application/epub') || file.mimetype.startsWith('application/x-mobipocket-ebook')
|
|
));
|
|
|
|
if (file.model === 'video' && 'video' in file) {
|
|
return <VideoRowCells file={file}/>;
|
|
} else if (file.model === 'archive') {
|
|
return <ArchiveRowCells file={file}/>;
|
|
} else if (file.mimetype && file.mimetype.startsWith('image/')) {
|
|
return <ImageRowCells file={file}/>;
|
|
} else if (isDocType) {
|
|
return <EbookRowCells key={file['primary_path']} file={file}/>;
|
|
}
|
|
|
|
// Fragment for SelectableRow
|
|
return <React.Fragment>
|
|
<TableCell>
|
|
<center><FileIcon file={file} size='large'/></center>
|
|
</TableCell>
|
|
<TableCell>
|
|
<FileRowTagIcon file={file}/>
|
|
<PreviewLink file={file}>{textEllipsis(file.title || file.name || file.stem)}</PreviewLink>
|
|
</TableCell>
|
|
<TableCell/>
|
|
</React.Fragment>
|
|
}
|
|
|
|
export function FileTable({files, selectOn, onSelect, footer, selectedKeys}) {
|
|
if (files && files.length > 0) {
|
|
const headerContents = ['Poster', 'Title', 'Data'];
|
|
const rows = files.map(i => <FileRow key={i['key']} file={i}/>);
|
|
return <SelectableTable
|
|
headerContents={headerContents}
|
|
selectOn={selectOn}
|
|
onSelect={onSelect}
|
|
selectedKeys={selectedKeys}
|
|
footer={footer}
|
|
rows={rows}
|
|
/>;
|
|
} else if (files) {
|
|
return <Segment>No results!</Segment>
|
|
} else if (files === undefined) {
|
|
return <ErrorMessage>Could not search!</ErrorMessage>
|
|
}
|
|
|
|
return <Placeholder>
|
|
<PlaceholderLine/>
|
|
<PlaceholderLine/>
|
|
<PlaceholderLine/>
|
|
</Placeholder>
|
|
}
|
|
|
|
export function FileRowTagIcon({file}) {
|
|
if (file.tags && file.tags.length) {
|
|
return <TagIcon/>;
|
|
}
|
|
}
|
|
|
|
export function SearchViewButton({headlines}) {
|
|
const {view, setView} = useSearchView();
|
|
|
|
if (headlines) {
|
|
// Cycle: cards -> headline -> list
|
|
if (view === 'headline') {
|
|
return <Button icon='list' onClick={() => setView('list')}/>;
|
|
} else if (view === 'list') {
|
|
return <Button icon='th' onClick={() => setView('cards')}/>;
|
|
}
|
|
return <Button icon='searchengin' onClick={() => setView('headline')}/>;
|
|
}
|
|
|
|
// Cycle: cards -> list
|
|
if (view === 'list') {
|
|
return <Button icon='th' onClick={() => setView('cards')}/>;
|
|
}
|
|
return <Button icon='browser' onClick={() => setView('list')}/>;
|
|
}
|
|
|
|
export function SearchLimitDropdown({limits = []}) {
|
|
const {limit, setLimit} = usePages();
|
|
const limitOptions = limits.map(i => {
|
|
return {key: i, value: i, text: i.toString()}
|
|
});
|
|
|
|
return <Dropdown fluid selection
|
|
placeholder='Limit'
|
|
options={limitOptions}
|
|
value={limit || limitOptions[0]['value']}
|
|
onChange={(e, {value}) => setLimit(value)}
|
|
/>
|
|
}
|
|
|
|
export function FilesView(
|
|
{
|
|
files,
|
|
activePage,
|
|
totalPages,
|
|
selectElem,
|
|
selectedKeys,
|
|
onSelect,
|
|
setPage,
|
|
headlines = false,
|
|
limitOptions = [12, 24, 48, 96],
|
|
showAnyTag = false,
|
|
},
|
|
) {
|
|
const {view} = useSearchView();
|
|
|
|
const paginator = <center style={{marginTop: '2em'}}>
|
|
<Paginator activePage={activePage} totalPages={totalPages} onPageChange={setPage}/>
|
|
</center>;
|
|
|
|
let body;
|
|
if (view === 'list') {
|
|
const footer = selectElem ?
|
|
<>
|
|
<TableHeaderCell colSpan='3'>{selectElem}</TableHeaderCell>
|
|
</> : null;
|
|
body = <FileTable
|
|
files={files}
|
|
selectOn={true}
|
|
onSelect={onSelect}
|
|
footer={footer}
|
|
selectedKeys={selectedKeys || []}
|
|
/>;
|
|
} else if (view === 'headline') {
|
|
body = <Headlines results={files}/>;
|
|
} else {
|
|
body = <FileCards files={files}/>;
|
|
}
|
|
|
|
const viewButton = <SearchViewButton headlines={headlines}/>
|
|
const limitDropdown = <SearchLimitDropdown limits={limitOptions}/>;
|
|
const tagQuerySelector = <TagsQuerySelector showAny={showAnyTag}/>;
|
|
|
|
return {
|
|
body,
|
|
paginator,
|
|
viewButton,
|
|
limitDropdown,
|
|
tagQuerySelector,
|
|
}
|
|
}
|
|
|
|
export function SearchFilter({filters = [], modalHeader, size = 'medium'}) {
|
|
const {filter, setFilter} = useSearchFilter();
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const handleOpen = (e) => {
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
setOpen(true);
|
|
}
|
|
|
|
const handleClear = (e) => {
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
setFilter(null);
|
|
setOpen(false);
|
|
}
|
|
|
|
const filterFields = filters.map(
|
|
i => <Form.Field key={i['text']}>
|
|
<Checkbox radio
|
|
label={i['text']}
|
|
name='searchFilterRadioGroup'
|
|
checked={filter === i['value']}
|
|
value={i['value']}
|
|
onChange={() => setFilter(i['value'])}
|
|
/>
|
|
</Form.Field>
|
|
);
|
|
|
|
// Use violet color when filter has been applied.
|
|
const buttonColor = filter ? 'violet' : 'grey';
|
|
|
|
if (filters && filters.length > 0) {
|
|
return <>
|
|
<Modal open={open} onOpen={() => handleOpen()} onClose={() => setOpen(false)} closeIcon>
|
|
{modalHeader || <Modal.Header>Filter</Modal.Header>}
|
|
<Modal.Content>
|
|
<Form>
|
|
{filterFields}
|
|
</Form>
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button onClick={handleClear} secondary>Clear</Button>
|
|
<Button onClick={() => setOpen(false)}>Close</Button>
|
|
</Modal.Actions>
|
|
</Modal>
|
|
<Button
|
|
icon='filter'
|
|
onClick={handleOpen}
|
|
color={buttonColor}
|
|
size={size}
|
|
/>
|
|
</>
|
|
}
|
|
|
|
return <></>
|
|
}
|
|
|
|
export function TagsQuerySelector({onChange, showAny = true}) {
|
|
// Creates modal that the User can use to manipulate the tag query params.
|
|
const {activeTags, anyTag, setTags, setAnyTag} = useSearch();
|
|
|
|
const localOnChange = (tagNames, newAnyTag) => {
|
|
if (newAnyTag) {
|
|
setAnyTag(true);
|
|
} else {
|
|
setTags(tagNames);
|
|
}
|
|
if (onChange) {
|
|
onChange(tagNames, newAnyTag);
|
|
}
|
|
}
|
|
|
|
return <TagsSelector hideGroup={true}
|
|
hideEdit={true}
|
|
showAny={showAny}
|
|
active={anyTag || activeTags && activeTags.length > 0}
|
|
selectedTagNames={activeTags}
|
|
anyTag={anyTag}
|
|
onChange={localOnChange}
|
|
filterByOverlap={true}
|
|
style={{marginLeft: '0.3em', marginTop: '0.3em'}}
|
|
/>
|
|
}
|
|
|
|
export function FileSearchFilterButton({size = 'medium'}) {
|
|
// see `filterToMimetypes`
|
|
const filterOptions = [
|
|
{key: 'video', text: 'Video', value: 'video'},
|
|
{key: 'archive', text: 'Archive', value: 'archive'},
|
|
{key: 'pdf', text: 'PDF', value: 'pdf'},
|
|
{key: 'ebook', text: 'eBook', value: 'ebook'},
|
|
{key: 'doc', text: 'Document', value: 'doc'},
|
|
{key: 'audio', text: 'Audio', value: 'audio'},
|
|
{key: 'image', text: 'Image', value: 'image'},
|
|
{key: 'zip', text: 'ZIP', value: 'zip'},
|
|
{key: 'model', text: '3D Model', value: 'model'},
|
|
{key: 'software', text: 'Software', value: 'software'},
|
|
];
|
|
|
|
return <SearchFilter filters={filterOptions} size={size}/>
|
|
}
|
|
|
|
export function DocSearchFilterButton({size = 'medium'}) {
|
|
const filterOptions = [
|
|
{key: 'pdf', text: 'PDF', value: 'pdf'},
|
|
{key: 'epub', text: 'EPUB', value: 'epub'},
|
|
{key: 'comic', text: 'Comic', value: 'comic'},
|
|
{key: 'mobi', text: 'MOBI', value: 'mobi'},
|
|
];
|
|
|
|
return <SearchFilter filters={filterOptions} size={size}/>
|
|
}
|
|
|
|
export function FilesSearchView({
|
|
showView = true,
|
|
emptySearch = false,
|
|
model,
|
|
}) {
|
|
|
|
const {searchFiles, pages} = useSearchFiles(24, emptySearch, model);
|
|
|
|
const {body, paginator, viewButton, limitDropdown, tagQuerySelector} = FilesView(
|
|
{
|
|
files: searchFiles,
|
|
activePage: pages.activePage,
|
|
totalPages: pages.totalPages,
|
|
selectElem: null,
|
|
selectedKeys: null,
|
|
onSelect: null,
|
|
setPage: pages.setPage,
|
|
headlines: true,
|
|
showAnyTag: true,
|
|
},
|
|
);
|
|
|
|
return <>
|
|
<Media at='mobile'>
|
|
<Grid>
|
|
<Grid.Row>
|
|
{showView &&
|
|
<Grid.Column width={2}>{viewButton}</Grid.Column>}
|
|
<Grid.Column width={4}>{limitDropdown}</Grid.Column>
|
|
<Grid.Column width={2}>{tagQuerySelector}</Grid.Column>
|
|
</Grid.Row>
|
|
</Grid>
|
|
</Media>
|
|
<Media greaterThanOrEqual='tablet'>
|
|
<Grid>
|
|
<Grid.Row>
|
|
{showView &&
|
|
<Grid.Column width={1}>{viewButton}</Grid.Column>}
|
|
<Grid.Column width={2}>{limitDropdown}</Grid.Column>
|
|
<Grid.Column width={1}>{tagQuerySelector}</Grid.Column>
|
|
</Grid.Row>
|
|
</Grid>
|
|
</Media>
|
|
{body}
|
|
{paginator}
|
|
</>
|
|
}
|
|
|
|
function getPhaseLabel(status) {
|
|
switch (status) {
|
|
case 'counting':
|
|
return 'Counting';
|
|
case 'comparing':
|
|
case 'upserting':
|
|
case 'deleting':
|
|
return 'Inserting';
|
|
case 'modeling':
|
|
return 'Modeling';
|
|
case 'indexing':
|
|
return 'Indexing';
|
|
case 'cleanup':
|
|
return 'Cleanup';
|
|
case 'planning':
|
|
return 'Move: Planning';
|
|
case 'moving':
|
|
return 'Move: Moving';
|
|
case 'reverting':
|
|
return 'Reverting';
|
|
case 'reorganizing':
|
|
return 'Reorganizing';
|
|
case 'batch_reorganizing':
|
|
return 'Batch Reorganizing';
|
|
case 'error':
|
|
return 'Error';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Map backend status to refresh phase number (1-5)
|
|
function getRefreshPhase(status) {
|
|
switch (status) {
|
|
case 'counting':
|
|
return 1;
|
|
case 'comparing':
|
|
case 'upserting':
|
|
case 'deleting':
|
|
return 2;
|
|
case 'modeling':
|
|
return 3;
|
|
case 'indexing':
|
|
return 4;
|
|
case 'cleanup':
|
|
return 5;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// Check if this is a global refresh (full media directory)
|
|
function isGlobalRefresh(progress) {
|
|
if (!progress || progress.status === 'idle') return false;
|
|
if (progress.task_type !== 'refresh') return false;
|
|
|
|
// Global refresh has a single path ending with the media directory name
|
|
const paths = progress.paths || [];
|
|
return paths.length === 1 && (paths[0].endsWith('/wrolpi') || paths[0] === '/media/wrolpi');
|
|
}
|
|
|
|
const REFRESH_PHASES = [
|
|
{key: 1, icon: 'search', title: 'Counting', description: 'Finding files'},
|
|
{key: 2, icon: 'database', title: 'Inserting', description: 'Updating database'},
|
|
{key: 3, icon: 'cogs', title: 'Modeling', description: 'Extracting metadata'},
|
|
{key: 4, icon: 'book', title: 'Indexing', description: 'Building search index'},
|
|
{key: 5, icon: 'check circle', title: 'Cleanup', description: 'Finalizing'},
|
|
];
|
|
|
|
export function RefreshSteps({progress}) {
|
|
const currentPhase = getRefreshPhase(progress?.status);
|
|
const {operation_processed, operation_total, status} = progress || {};
|
|
|
|
if (currentPhase === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Step.Group size='mini' fluid>
|
|
{REFRESH_PHASES.map(phase => {
|
|
const isCompleted = phase.key < currentPhase;
|
|
const isActive = phase.key === currentPhase;
|
|
const isDisabled = phase.key > currentPhase;
|
|
|
|
// Determine description for active phase
|
|
let description = phase.description;
|
|
if (isActive) {
|
|
if (operation_total > 0) {
|
|
description = `${operation_processed?.toLocaleString()} / ${operation_total?.toLocaleString()}`;
|
|
} else if (status === 'comparing') {
|
|
description = 'Comparing files...';
|
|
} else if (status === 'upserting') {
|
|
description = 'Updating files...';
|
|
} else if (status === 'deleting') {
|
|
description = 'Removing deleted...';
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Step
|
|
key={phase.key}
|
|
active={isActive}
|
|
completed={isCompleted}
|
|
disabled={isDisabled}
|
|
>
|
|
<Icon name={phase.icon}/>
|
|
<Step.Content>
|
|
<Step.Title>{phase.title}</Step.Title>
|
|
<Step.Description>{isActive || isCompleted ? description : ''}</Step.Description>
|
|
</Step.Content>
|
|
</Step>
|
|
);
|
|
})}
|
|
</Step.Group>
|
|
);
|
|
}
|
|
|
|
function RefreshProgressBar({status, operation_total, operation_processed, operation_percent, error}) {
|
|
const phaseLabel = getPhaseLabel(status);
|
|
|
|
if (!phaseLabel) {
|
|
return null;
|
|
}
|
|
|
|
// Show error state
|
|
if (status === 'error' && error) {
|
|
return <Progress error percent={100}>{`Error: ${error}`}</Progress>;
|
|
}
|
|
|
|
let label = `${phaseLabel}`;
|
|
if (operation_total > 0) {
|
|
label = `${label} (${operation_processed.toLocaleString()} / ${operation_total.toLocaleString()})`;
|
|
}
|
|
|
|
return <Progress active color='violet' percent={operation_percent || 0} progress>{label}</Progress>;
|
|
}
|
|
|
|
export function FilesRefreshProgress() {
|
|
const {status: progress} = useFileWorkerStatus();
|
|
|
|
if (!progress || progress.status === 'idle') {
|
|
return null;
|
|
}
|
|
|
|
const {status, operation_total, operation_processed, operation_percent, error} = progress;
|
|
|
|
// Show Steps component AND progress bar for global refresh operations
|
|
if (isGlobalRefresh(progress)) {
|
|
return <>
|
|
<RefreshSteps progress={progress}/>
|
|
<RefreshProgressBar
|
|
status={status}
|
|
operation_total={operation_total}
|
|
operation_processed={operation_processed}
|
|
operation_percent={operation_percent}
|
|
error={error}
|
|
/>
|
|
</>;
|
|
}
|
|
|
|
// Show just progress bar for other operations (moves, reorganize, targeted refresh)
|
|
return <RefreshProgressBar
|
|
status={status}
|
|
operation_total={operation_total}
|
|
operation_processed={operation_processed}
|
|
operation_percent={operation_percent}
|
|
error={error}
|
|
/>;
|
|
}
|
|
|
|
function FilesPage() {
|
|
useTitle('Files');
|
|
|
|
return <>
|
|
<FilesRefreshProgress/>
|
|
<FileBrowser/>
|
|
|
|
<HandPointMessage>
|
|
<p>You can also view your media directory at <a href={FILES_MEDIA_URI}>{FILES_MEDIA_URI}</a></p>
|
|
</HandPointMessage>
|
|
</>;
|
|
}
|
|
|
|
export function FilesRoute() {
|
|
return <PageContainer>
|
|
<Routes>
|
|
<Route path='/' exact element={<FilesPage/>}/>
|
|
</Routes>
|
|
</PageContainer>;
|
|
}
|
|
|
|
const useRefresh = () => {
|
|
const wrolModeEnabled = useWROLMode();
|
|
const globalRefreshActive = useStatusFlag('global_refresh_active');
|
|
const [loading, setLoading] = React.useState(false);
|
|
const [globalRefreshLoading, setGlobalRefreshLoading] = React.useState(false);
|
|
const prevGlobalRefreshActive = React.useRef(false);
|
|
|
|
const localRefreshFiles = async (paths) => {
|
|
setLoading(true);
|
|
try {
|
|
await refreshFiles(paths);
|
|
|
|
// Check if this was a global refresh (no paths = refresh all)
|
|
// If so, set globalRefreshLoading and schedule a fallback timeout
|
|
if (!paths || paths.length === 0) {
|
|
setGlobalRefreshLoading(true);
|
|
setTimeout(() => {
|
|
setGlobalRefreshLoading(false);
|
|
}, 10000);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
// Clear globalRefreshLoading when flag transitions from true to false
|
|
React.useEffect(() => {
|
|
if (prevGlobalRefreshActive.current && !globalRefreshActive) {
|
|
setGlobalRefreshLoading(false);
|
|
}
|
|
prevGlobalRefreshActive.current = globalRefreshActive;
|
|
}, [globalRefreshActive]);
|
|
|
|
const isGlobalRefreshing = globalRefreshLoading || globalRefreshActive;
|
|
|
|
return {
|
|
globalRefreshing: isGlobalRefreshing,
|
|
loading,
|
|
wrolModeEnabled,
|
|
refreshFiles: localRefreshFiles,
|
|
}
|
|
}
|
|
|
|
export function FilesRefreshButton({paths}) {
|
|
const {globalRefreshing, loading, wrolModeEnabled, refreshFiles} = useRefresh();
|
|
|
|
return <Button icon='refresh'
|
|
loading={loading || globalRefreshing}
|
|
onClick={() => refreshFiles(paths)}
|
|
disabled={wrolModeEnabled || globalRefreshing}/>
|
|
;
|
|
}
|