wrolpi/app/src/components/Files.js
Roland e00f0439da
ui: the last of Semantic UI, in name and in dead CSS
The library has been uninstalled for some time, but the codebase still read as
though it were there: identifiers named after it, rules written against its
class names, and about 180 comments explaining a rule in terms of a thing that
is no longer installed.

Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap ->
navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames ->
iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors ->
NavColors in wrolpi/schema.py, which is what validates nav_color on the way in.
ColorToSemanticHexColor had no callers at all and is gone.

The baseline ratchet has done its job -- the list reached zero, so the JSON is
deleted and the guard keeps only the checks with teeth: no package, no import,
no `className="ui "`, no toast library.  Its Theme.tsx check went with the
wrappers it was watching for.  It also asserts its own walk found files, so a
clean result cannot mean it scanned nothing.

Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or
`.modal`, none of which any component has set since the stylesheet stopped
loading, plus the form skeletons, ui-alerts and progress-text rules that
nothing renders.  Two of them were doing harm rather than nothing: the drag
tints had a dark-theme variant under `.inverted` that stopped applying, leaving
15% of a fixed blue over a dark row, and now mix a token instead.

Two selectors naming markup that no longer exists were live bugs.  The file
browser's filter had `.ui.input.file-browser-filter`, so it rendered at the
browser default width on a bar laid out to give it 34em -- now qualified by its
container, because `.wrolpi-action-input` sets `width: 100%` at the same
specificity and is loaded later.  And SearchBox takes named props rather than a
rest spread, so the `id` DestinationForm hands it was dropped in silence and
the label it renders pointed at nothing; the download forms' Destination field
has had no associated label since the migration.

"Semantic roles" survives in four places, where it means what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:54 -06:00

1168 lines
43 KiB
JavaScript

import React, {useContext, useState} from "react";
import {
CardGroupCentered,
CardPoster,
encodeMediaPath,
ErrorMessage,
ExternalCardLink,
FileIcon,
findPosterPath,
HandPointMessage,
humanFileSize,
isoDatetimeToAgoPopup,
PageContainer,
Paginator,
PreviewLink,
SearchInput,
TagIcon,
Toggle,
textEllipsis,
mimetypeColor,
useTitle,
} from "./Common";
import {
usePages,
useSearchDate,
useSearchFiles,
useSearchCensored,
useSearchDeep,
useSearchFilter,
useSearchOrder,
useSearchView,
useStatusFlag,
useWROLMode
} from "../hooks/customHooks";
import {useFileWorkerStatus} from "../contexts/FileWorkerStatusContext";
import {IconArticle, IconLayoutList, IconSortAscending, IconSortDescending} from "@tabler/icons-react";
import {Link, Route, Routes, useSearchParams} from "react-router";
import {CardPlaceholder} from "./Placeholder";
import {ArchiveCard, ArchiveRowCells} from "./Archive";
import {QueryContext} from "../contexts/contexts";
import {
Button,
Card,
Divider,
Group,
Header,
Icon,
IconButton,
Label,
Modal,
Panel,
Placeholder,
Progress,
Radio,
Stack,
Table,
Tooltip,
} from "./ui";
import {DateRangeForm, dateRangeIsEmpty, MonthsForm} from "./DatesSelector";
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) {
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}>{part}</b> : <span key={i}>{part}</span>);
}
function EbookCard({file, sortField}) {
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 title = file.title || file.stem || file.name;
const header = detailUrl
? <Link to={detailUrl} className='no-link-underscore card-link card-title-ellipsis'>{title}</Link>
: <ExternalCardLink to={viewerUrl || downloadUrl} className='card-title-ellipsis'>{title}</ExternalCardLink>;
const meta = <>
{file.author ? <div><b>{file.author}</b></div> : null}
{sortField === 'published_datetime'
? <div>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</div>
: file.size && <div>{humanFileSize(file.size)}</div>
}
{hint && <div style={{fontSize: '0.85em', marginTop: '0.3em'}}>
<b>{hint.label}</b>
{hint.snippet && <span style={{marginLeft: '0.4em', opacity: 0.8}}>
{renderHighlightedSnippet(hint.snippet)}
</span>}
</div>}
</>;
return <Card media={<CardPoster file={file} to={detailUrl}/>}
title={<Tooltip label={title}>{header}</Tooltip>}
meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}
/>
}
function ImageCard({file}) {
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'>
<span>{textEllipsis(title)}</span>
</ExternalCardLink>;
const dt = file.published_datetime || file.published_modified_datetime || file.modified;
const meta = <>
<div>{isoDatetimeToAgoPopup(dt, false)}</div>
<div>{humanFileSize(file.size)}</div>
</>;
return <Card media={<PreviewLink file={file}><CardPoster file={file}/></PreviewLink>}
title={<Tooltip label={title}>{header}</Tooltip>}
meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}
/>
}
function FileCard({file}) {
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 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;
const header = <PreviewLink file={file}>
<Tooltip label={title}><span className='card-title-ellipsis'>{title}</span></Tooltip>
</PreviewLink>;
const meta = <>
{author && <div><b>{author}</b></div>}
{sortField === 'published_datetime'
? <div>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</div>
: <>
<div>{isoDatetimeToAgoPopup(dt, false)}</div>
<div>{size}</div>
</>
}
</>;
return <Card media={<CardPoster file={file}/>} title={header} meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}/>
}
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 <Panel>No results!</Panel>
} 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 = <img alt='' src={url} width='50'/>;
}
// Fragment for SelectableRow
return <React.Fragment>
<Table.Cell>
<center>
{poster}
</center>
</Table.Cell>
<Table.Cell>
<PreviewLink file={file}>
<p>{textEllipsis(file.title || file.stem || file.primary_path)}</p>
</PreviewLink>
</Table.Cell>
<Table.Cell>{humanFileSize(file.size)}</Table.Cell>
</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 = <img alt='' src={coverSrc} width='50'/>;
}
// Fragment for SelectableRow
return <React.Fragment>
<Table.Cell>
<center>{cover}</center>
</Table.Cell>
<Table.Cell>
<PreviewLink file={file}>
<FileRowTagIcon file={file}/>
{textEllipsis(file.title || file.stem)}
</PreviewLink>
</Table.Cell>
<Table.Cell>{humanFileSize(file.size)}</Table.Cell>
</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>
<Table.Cell>
<center><FileIcon file={file} size='large'/></center>
</Table.Cell>
<Table.Cell>
<FileRowTagIcon file={file}/>
<PreviewLink file={file}>{textEllipsis(file.title || file.name || file.stem)}</PreviewLink>
</Table.Cell>
<Table.Cell/>
</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 <Panel>No results!</Panel>
} else if (files === undefined) {
return <ErrorMessage>Could not search!</ErrorMessage>
}
return <Placeholder lines={3}/>
}
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 <IconButton icon='list' label='Switch to list view' onClick={() => setView('list')}/>;
} else if (view === 'list') {
return <IconButton icon='th' label='Switch to cards view' onClick={() => setView('cards')}/>;
}
return <IconButton icon={IconArticle} label='Switch to headline view' onClick={() => setView('headline')}/>;
}
// Cycle: cards -> list
if (view === 'list') {
return <IconButton icon='th' label='Switch to cards view' onClick={() => setView('cards')}/>;
}
return <IconButton icon={IconLayoutList} label='Switch to list view' onClick={() => setView('list')}/>;
}
export function FilesView(
{
files,
activePage,
totalPages,
selectElem,
selectedKeys,
onSelect,
setPage,
headlines = false,
},
) {
const {view} = useSearchView();
/*
* No margin of its own. Videos, Archives, Docs and Files all render this as a top-level block
* of the page, and the page spaces its blocks (see `wrolpi-stack`) -- so a `marginTop: 2em`
* here added to that and put the pager twice as far from the results as anything else on the
* page sits from its neighbour. Zim keeps its own paginator, inside an accordion panel that
* does not stack, and is untouched.
*/
const paginator = <div>
<Paginator activePage={activePage} totalPages={totalPages} onPageChange={setPage}/>
</div>;
let body;
if (view === 'list') {
const footer = selectElem ?
<>
<Table.HeaderCell colSpan='3'>{selectElem}</Table.HeaderCell>
</> : 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}/>
return {
body,
paginator,
viewButton,
}
}
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 => <Radio
key={i['text']}
label={i['text']}
checked={filter === i['value']}
onChange={() => setFilter(i['value'])}
/>
);
// Use violet color when filter has been applied.
const buttonColor = filter ? 'violet' : 'grey';
if (filters && filters.length > 0) {
return <>
{/* Audited: unchanged at `small` (440px), recorded so the default cannot move it. */}
<Modal size='small' open={open} onClose={() => setOpen(false)} closeIcon>
{modalHeader || <Modal.Header>Filter</Modal.Header>}
<Modal.Content>
<Stack gap='xs'>
{filterFields}
</Stack>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={handleClear}>Clear</Button>
<Button role='cancel' onClick={() => setOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
<IconButton icon='filter' label='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'}}
/>
}
// see `filterToMimetypes`
export const fileMimetypeFilterOptions = [
{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'},
];
export const docMimetypeFilterOptions = [
{key: 'pdf', text: 'PDF', value: 'pdf'},
{key: 'epub', text: 'EPUB', value: 'epub'},
{key: 'comic', text: 'Comic', value: 'comic'},
{key: 'mobi', text: 'MOBI', value: 'mobi'},
];
export function FileSearchFilterButton({size = 'medium'}) {
return <SearchFilter filters={fileMimetypeFilterOptions} size={size}/>
}
export function DocSearchFilterButton({size = 'medium'}) {
return <SearchFilter filters={docMimetypeFilterOptions} size={size}/>
}
// The app-wide default page size (matches usePages' fallback); used to reset Results Per Page on Clear All.
const DEFAULT_SEARCH_LIMIT = 24;
// A single section within the comprehensive SearchFilterModal. Vertical spacing between sections is
// provided by the surrounding Stack.
function SearchFilterSection({header, children}) {
return <>
<Header as='h4' style={{marginBottom: '0.6em'}}>{header}</Header>
{children}
</>
}
// A comprehensive modal which combines every search option (sort, tags, file-type filter, dates, limit)
// into a single dialog. Which sections appear is controlled by the props, so each page can show only what
// is relevant. All state is URL-query driven (same hooks the individual controls use), so no extra wiring
// is required on the page.
export function SearchFilterModal(
{
open,
onClose,
sorts = null,
fileFilterOptions = null,
showDates = false,
showTags = true,
showLimit = true,
showCensored = false,
showDeep = false,
limitOptions = [12, 24, 48, 96],
},
) {
// Committed (URL) state — read only; drafts below hold the user's in-progress edits.
const {filter} = useSearchFilter();
const {sort} = useSearchOrder();
const {dateRange, months} = useSearchDate();
const {activeTags, anyTag} = useSearch();
const {censored} = useSearchCensored();
const {deep} = useSearchDeep();
const {limit} = usePages(DEFAULT_SEARCH_LIMIT);
const {updateQuery} = useContext(QueryContext);
const emptyRange = [null, null];
const seededRange = dateRange && (dateRange[0] || dateRange[1]) ? dateRange : emptyRange;
const seededMonths = (months || []).map(i => parseInt(i));
// Every selection is drafted locally and only applied to the URL (which performs the search) when the
// modal is closed — via Done, the close icon, or clicking away.
const [draftSort, setDraftSort] = useState(null);
const [draftFilter, setDraftFilter] = useState(null);
const [draftTags, setDraftTags] = useState([]);
const [draftAnyTag, setDraftAnyTag] = useState(false);
const [draftLimit, setDraftLimit] = useState(null);
const [draftMonths, setDraftMonths] = useState([]);
const [draftRange, setDraftRange] = useState(emptyRange);
const [draftCensored, setDraftCensored] = useState(false);
const [draftDeep, setDraftDeep] = useState(false);
// Seed the drafts from the URL each time the modal is opened.
React.useEffect(() => {
if (open) {
setDraftSort(sort || null);
setDraftFilter(filter || null);
setDraftTags(activeTags || []);
setDraftAnyTag(anyTag || false);
setDraftLimit(limit);
setDraftMonths(seededMonths);
setDraftRange(seededRange);
setDraftCensored(censored || false);
setDraftDeep(deep || false);
}
}, [open]);
const handleClose = () => {
// Apply every changed draft in a SINGLE updateQuery — sequential setters would each merge against
// the same stale searchParams and clobber one another. Only reset pagination if something changed.
const params = {};
if (sorts && (draftSort || null) !== (sort || null)) {
params.order = draftSort;
}
if (fileFilterOptions && (draftFilter || null) !== (filter || null)) {
params.filter = draftFilter;
}
if (showTags && (draftAnyTag !== anyTag || JSON.stringify(draftTags) !== JSON.stringify(activeTags))) {
if (draftAnyTag) {
params.tag = [];
params.anyTag = 'true';
} else {
params.tag = draftTags;
params.anyTag = null;
}
}
if (showLimit && draftLimit !== limit) {
params.l = draftLimit;
}
if (showDates && (JSON.stringify(draftMonths) !== JSON.stringify(seededMonths)
|| JSON.stringify(draftRange) !== JSON.stringify(seededRange))) {
let newFromDate = null;
let newToDate = null;
if (draftRange[0] <= draftRange[1]) {
newFromDate = draftRange[0];
newToDate = draftRange[1];
}
params.fromDate = newFromDate;
params.toDate = newToDate;
params.month = draftMonths;
}
if (showCensored && draftCensored !== censored) {
params.censored = draftCensored ? 'true' : null;
}
if (showDeep && draftDeep !== deep) {
params.deep = draftDeep ? 'true' : null;
}
if (Object.keys(params).length > 0) {
params.o = 0;
updateQuery(params);
}
if (onClose) {
onClose();
}
}
const handleClearAll = () => {
// Reset the form only; nothing is applied until the modal is closed.
setDraftSort(null);
setDraftFilter(null);
setDraftTags([]);
setDraftAnyTag(false);
setDraftLimit(DEFAULT_SEARCH_LIMIT);
setDraftMonths([]);
setDraftRange(emptyRange);
setDraftCensored(false);
setDraftDeep(false);
}
// Sort section (operates on the draft).
const sortKey = draftSort ? draftSort.replace(/^-/, '') : (sorts ? sorts[0]['value'] : null);
const desc = draftSort ? draftSort.startsWith('-') : true;
const sortButtons = sorts && sorts.map(o =>
<Button key={o['value']}
color={o['value'] === sortKey ? 'violet' : undefined}
onClick={() => setDraftSort(desc ? `-${o['value']}` : o['value'])}
style={{margin: '0.2em'}}
>{o['text']}</Button>
);
const directionToggle = <Button icon={desc ? IconSortDescending : IconSortAscending}
onClick={() => {
const base = sortKey || sorts[0]['value'];
setDraftSort(desc ? base : `-${base}`);
}}>
{desc ? 'Descending' : 'Ascending'}
</Button>;
// File-type filter section (single-select radios; clicking the active one clears it). Wraps so more
// options fit on each line.
const filterFields = fileFilterOptions && fileFilterOptions.map(i =>
<Radio key={i['value']}
label={i['text']}
checked={draftFilter === i['value']}
onChange={() => setDraftFilter(draftFilter === i['value'] ? null : i['value'])}
/>
);
// Results-per-page as buttons (matches the Sort By buttons and avoids a dropdown menu being clipped by
// the scrolling modal content).
const limitButtons = limitOptions.map(i =>
<Button key={i}
color={(draftLimit || limitOptions[0]) === i ? 'violet' : undefined}
onClick={() => setDraftLimit(i)}
style={{margin: '0.2em'}}
>{i}</Button>
);
return <Modal open={open} onClose={handleClose} closeIcon size='large'>
<Modal.Header>Search Filters</Modal.Header>
<Modal.Content>
<Stack gap='lg'>
{sorts &&
<SearchFilterSection header='Sort By'>
<div style={{marginBottom: '1em'}}>{directionToggle}</div>
<div>{sortButtons}</div>
</SearchFilterSection>}
{fileFilterOptions &&
<SearchFilterSection header='File Type'>
<Group gap='md'>{filterFields}</Group>
</SearchFilterSection>}
{(showTags || showLimit) &&
<Group align='flex-start' grow wrap='wrap'>
{showTags &&
<div style={{minWidth: 220}}>
<SearchFilterSection header='Tags'>
<TagsSelector hideGroup hideEdit showAny
active={draftAnyTag || (draftTags && draftTags.length > 0)}
selectedTagNames={draftTags}
anyTag={draftAnyTag}
filterByOverlap={true}
onChange={(tagNames, newAnyTag) => {
if (newAnyTag) {
setDraftAnyTag(true);
setDraftTags([]);
} else {
setDraftAnyTag(false);
setDraftTags(tagNames);
}
}}
style={{marginLeft: '0.3em', marginTop: '0.3em'}}
/>
</SearchFilterSection>
</div>}
{showLimit &&
<div style={{minWidth: 160}}>
<SearchFilterSection header='Results Per Page'>
<div>{limitButtons}</div>
</SearchFilterSection>
</div>}
</Group>}
{showCensored &&
<SearchFilterSection header='Availability'>
<Toggle label='Only censored (no longer available to download)'
checked={draftCensored}
onChange={setDraftCensored}
/>
</SearchFilterSection>}
{showDeep &&
<SearchFilterSection header='Search Depth'>
<Toggle label='Deep search (captions, document text; slower)'
checked={draftDeep}
onChange={setDraftDeep}
/>
</SearchFilterSection>}
{showDates &&
<SearchFilterSection header='Published Date'>
<MonthsForm monthsSelected={draftMonths} setMonthsSelected={setDraftMonths}/>
<Divider/>
<DateRangeForm dateRange={draftRange} setDateRange={setDraftRange}/>
</SearchFilterSection>}
</Stack>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={handleClearAll} style={{marginRight: 'auto'}}>Clear All</Button>
<Button role='primary' onClick={handleClose}>Done</Button>
</Modal.Actions>
</Modal>
}
// The trigger button which opens the comprehensive SearchFilterModal. Turns violet and displays a count
// badge when any of its filters are active.
export function SearchFilterButton(
{
sorts = null,
fileFilterOptions = null,
showDates = false,
showTags = true,
showLimit = true,
showCensored = false,
showDeep = false,
limitOptions = [12, 24, 48, 96],
size = 'medium',
content = 'Filter',
},
) {
const [open, setOpen] = useState(false);
const {filter} = useSearchFilter();
const {sort} = useSearchOrder();
const {dateRange, months} = useSearchDate();
const {activeTags, anyTag} = useSearch();
const {censored} = useSearchCensored();
const {deep} = useSearchDeep();
let count = 0;
if (fileFilterOptions && filter) {
count += 1;
}
if (sorts && sort) {
count += 1;
}
if (showTags && ((activeTags && activeTags.length > 0) || anyTag)) {
count += 1;
}
if (showDates && ((months && months.length > 0) || !dateRangeIsEmpty(dateRange))) {
count += 1;
}
if (showCensored && censored) {
count += 1;
}
if (showDeep && deep) {
count += 1;
}
const active = count > 0;
return <>
<Button icon='filter' color={active ? 'violet' : 'grey'} size={size} onClick={() => setOpen(true)}>
{content}
{count > 0 &&
<Label style={{marginLeft: '0.7em'}}>{count}</Label>}
</Button>
<SearchFilterModal
open={open}
onClose={() => setOpen(false)}
sorts={sorts}
fileFilterOptions={fileFilterOptions}
showDates={showDates}
showTags={showTags}
showLimit={showLimit}
showCensored={showCensored}
showDeep={showDeep}
limitOptions={limitOptions}
/>
</>
}
// The single-line search control row shared by the Videos/Archives/Docs result pages:
// [ view toggle ] [ search input (grows) ] [ Filter button ]
// The text input is managed locally and only submits its value on enter/clear. Filter options are
// forwarded to the SearchFilterButton so each page shows only the sections relevant to it.
export function SearchControlBar(
{
searchStr,
setSearchStr,
placeholder = 'Search...',
inputRef,
viewButton,
sorts = null,
fileFilterOptions = null,
showDates = false,
showCensored = false,
showDeep = false,
},
) {
const [localSearchStr, setLocalSearchStr] = useState(searchStr || '');
const searchInput = <SearchInput
searchStr={localSearchStr}
onChange={setLocalSearchStr}
onClear={() => setLocalSearchStr(null)}
onSubmit={setSearchStr}
placeholder={placeholder}
inputRef={inputRef}
/>;
// No `marginBottom`: this is a top-level block on Videos, Archives and Docs, and the page
// spaces its own blocks. Carrying one here added to that gap.
return <div style={{display: 'flex', alignItems: 'center', gap: '0.5em'}}>
{viewButton}
<div style={{flexGrow: 1, minWidth: 0}}>{searchInput}</div>
<SearchFilterButton sorts={sorts} fileFilterOptions={fileFilterOptions} showDates={showDates}
showCensored={showCensored} showDeep={showDeep}/>
</div>
}
// Reminds the user that the fast search only matches titles/authors/descriptions. Used by the module
// pages (Videos/Archives/Docs), which have no deep estimate, so it shows whenever a fast search is
// active; FilesSearchView has a count-aware variant.
export function DeepSearchHint({searchStr, files}) {
const {deep} = useSearchDeep();
const {updateQuery} = useContext(QueryContext);
if (!searchStr || deep || !Array.isArray(files)) {
return null;
}
return <Panel>
{files.length === 0 ? 'Not finding what you need?' : 'More results may be available in deep search.'}
<Button role='primary'
onClick={() => updateQuery({deep: 'true', o: 0})}
style={{marginLeft: '1em'}}
>Try deep search</Button>
</Panel>
}
export function FilesSearchView({
showView = true,
emptySearch = false,
model,
deepEstimate = null,
}) {
const {searchFiles, total, searchStr, pages} = useSearchFiles(24, emptySearch, model);
const {deep} = useSearchDeep();
const {updateQuery} = useContext(QueryContext);
const {body, paginator, viewButton} = FilesView(
{
files: searchFiles,
activePage: pages.activePage,
totalPages: pages.totalPages,
selectElem: null,
selectedKeys: null,
onSelect: null,
setPage: pages.setPage,
headlines: true,
},
);
// The default search only matches titles/authors/descriptions; offer deep search when it would find
// more. `deepEstimate` comes from the search estimates; without it, fall back to a generic hint on
// zero results.
let deepHint = null;
if (Boolean(searchStr) && !deep && searchFiles) {
const moreCount = deepEstimate !== null && deepEstimate !== undefined && total !== null
? deepEstimate - total : null;
// Enable deep search and reset pagination in a single update (like the filter modal does), so the
// user starts at page 1 of the deep results.
const deepButton = <Button role='primary'
onClick={() => updateQuery({deep: 'true', o: 0})}
style={{marginLeft: '1em'}}
>Try deep search</Button>;
if (searchFiles.length === 0 && (moreCount === null || moreCount > 0)) {
deepHint = <Panel>
{moreCount > 0
? `No results in fast search. ${moreCount} result${moreCount === 1 ? '' : 's'} available in deep search.`
: 'Not finding what you need?'}
{deepButton}
</Panel>;
} else if (searchFiles.length > 0 && moreCount > 0) {
deepHint = <Panel>
{`${moreCount} more result${moreCount === 1 ? '' : 's'} available in deep search.`}
{deepButton}
</Panel>;
}
}
return <>
{/* No margin: another top-level block, spaced by the page. */}
{showView && <div>{viewButton}</div>}
{body}
{deepHint}
{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: 'disk', title: 'Inserting', description: 'Updating database'},
{key: 3, icon: 'settings', title: 'Modeling', description: 'Extracting metadata'},
{key: 4, icon: 'book', title: 'Indexing', description: 'Building search index'},
{key: 5, icon: 'check circle', title: 'Cleanup', description: 'Finalizing'},
];
// A row of phase markers, brightness/weight (via the active
// border) carrying the current stage rather than a bespoke component from the shared library.
function RefreshStepItem({phase, isActive, isCompleted, isDisabled, description}) {
return <div style={{
display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 160px', padding: '0.5em 0.75em',
opacity: isDisabled ? 0.5 : 1,
borderBottom: isActive ? '2px solid var(--info)' : '2px solid transparent',
}}>
{/* The same three roles Status maps complete/active/pending onto. Not the Status
component itself: these rows carry a per-phase glyph, which is the point of them. */}
<Icon name={phase.icon} size='large'
style={{color: isCompleted ? 'var(--success)' : isActive ? 'var(--info)' : 'var(--neutral)'}}/>
<div>
<div style={{fontWeight: 600, fontSize: '0.8125rem'}}>{phase.title}</div>
<div style={{fontSize: '0.75rem', color: 'var(--muted)'}}>{isActive || isCompleted ? description : ''}</div>
</div>
</div>
}
export function RefreshSteps({progress}) {
const currentPhase = getRefreshPhase(progress?.status);
const {operation_processed, operation_total, status} = progress || {};
if (currentPhase === 0) {
return null;
}
return <div style={{display: 'flex', flexWrap: 'wrap', border: '1px solid var(--border)'}}>
{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 <RefreshStepItem key={phase.key} phase={phase} isActive={isActive} isCompleted={isCompleted}
isDisabled={isDisabled} description={description}/>
})}
</div>
}
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 percent={100} color='danger' label={`Error: ${error}`}/>;
}
let label = `${phaseLabel}`;
if (operation_total > 0) {
label = `${label} (${operation_processed.toLocaleString()} / ${operation_total.toLocaleString()})`;
}
return <Progress percent={operation_percent || 0} color='violet' label={label}/>;
}
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, showLabel}) {
const {globalRefreshing, loading, wrolModeEnabled, refreshFiles} = useRefresh();
const disabled = wrolModeEnabled || globalRefreshing;
const busy = loading || globalRefreshing;
if (showLabel) {
return <Button role='cancel' icon='refresh' loading={busy} disabled={disabled}
onClick={() => refreshFiles(paths)}>Refresh</Button>;
}
return <IconButton role='cancel' icon='refresh' label='Refresh' loading={busy} disabled={disabled}
onClick={() => refreshFiles(paths)}/>;
}