wrolpi/app/src/components/Search.js
Roland 048fea07ca
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-05 19:15:57 -06:00

571 lines
24 KiB
JavaScript

import React, {useState} from "react";
import {Link, Route, Routes, useLocation, useNavigate} from "react-router";
import {FilesSearchView, SearchViewButton} from "./Files";
import {useLatestRequest, usePages, useSearchChannels, useSearchDate, useSearchDeep, useSearchFilter} from "../hooks/customHooks";
import {ShortcutHint} from "./ShortcutHint";
import {ZimSearchView} from "./Zim";
import {MapSearchView} from "./MapSearchView";
import {searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions} from "../api";
import {filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
import _ from "lodash";
import {TagsContext} from "../Tags";
import {Accordion, Header, Icon, Label, Loading, Panel, Stack} from "./ui";
import {QueryContext} from "../contexts/contexts";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
const SUGGESTED_APPS = [
{location: '/admin', title: 'Downloads', description: 'View and control your downloads'},
{location: '/admin/settings', title: 'Hotspot', description: 'Control your hotspot'},
{location: '/admin/settings', title: 'Restart', description: 'Restart your WROLPi'},
{location: '/admin/settings', title: 'Settings', description: 'View and modify your settings'},
{location: '/admin/settings', title: 'Shutdown', description: 'Shutdown your WROLPi'},
{location: '/admin/status', title: 'Status', description: 'View the status of this WROLPi server'},
{location: '/admin/wrol', title: 'WROL Mode', description: 'Enable or disable WROL Mode'},
{location: '/archives', title: 'Archives', description: 'View your Archives'},
{location: '/archives/domains', title: 'Domains', description: 'View the domains of your Archives'},
{location: '/files', title: 'Files', description: 'View your files'},
{location: '/help', title: 'Help', description: 'Help documents for WROLPi'},
{location: '/inventory', title: 'Inventory', description: 'Track and organize your food storage'},
{location: '/map', title: 'Map', description: 'View your Map'},
{location: '/map/manage', title: 'Manage Map', description: 'Manage your Map'},
{location: '/more/calculators?calc=otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'},
{location: '/more/statistics', title: 'Statistics', description: 'View the statistics of your WROLPi'},
{
location: '/more/calculators?calc=vin',
title: 'Vin Decoder',
description: 'Decode and analyze vehicle VIN numbers'
},
{location: '/videos', title: 'Videos', description: 'View your Videos'},
{location: '/videos/channel', title: 'Channels', description: 'View the Channels of your Videos'},
{location: '/zim', title: 'Zim', description: 'View your Zims'},
{location: '/zim/manage', title: 'Manage Zim', description: 'Manage your Zims'},
{
location: '/more/calculators?calc=temperature',
title: 'Temperature Calculator',
description: 'Convert Celsius, Fahrenheit, Kelvin'
},
{
location: '/more/calculators?calc=electrical',
title: 'Electrical Calculator',
description: 'Convert Volts, Ohms, Amps, Watts, Power Loss.'
},
{
location: '/more/calculators?calc=antenna',
title: 'Antenna Calculator',
description: 'Calculate dipole length, wavelength.'
},
{
location: '/more/calculators?calc=drive',
title: 'Drive Ratio Calculator',
description: 'Calculate pulley, gear, and sprocket ratios, RPM, torque, belt and chain length.'
},
{
location: '/more/calculators?calc=water',
title: 'Water Storage Calculator',
description: 'Calculate water storage for your household: drinking and hygiene needs, how much to store, and how many drums, buckets, or bottles you need.'
},
];
export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false, model) => {
const navigate = useNavigate();
const {dateRange, setDateRange, months, setDates, isEmpty: datesIsEmpty} = useSearchDate();
const {searchParams, updateQuery, getLocationStr} = React.useContext(QueryContext);
// `searchStr` means actually fetch the files/zims.
const searchStr = searchParams.get('q');
// User can search only by Tags, `searchStr` not required.
const activeTags = searchParams.getAll('tag');
const pages = usePages(defaultLimit, totalPages);
// text/html, video*, image*, etc.
const filter = searchParams.get('filter');
// archive/video/doc/etc.
const model_ = searchParams.get('model') || model;
const anySearch = (!datesIsEmpty) || searchStr || (activeTags && activeTags.length > 0) || filter || model;
const isEmpty = !anySearch;
const setSearchStr = (value) => {
const searchQuery = {q: value, o: 0};
if (filter) {
searchQuery['filter'] = filter;
}
const location = getLocationStr(searchQuery, '/search');
navigate(location);
}
const clearSearch = () => {
navigate({pathname: window.location.pathname, search: ''});
}
const setTags = (tags) => {
updateQuery({tag: tags, anyTag: null});
}
const addTag = (name) => {
const newTags = [...activeTags, name];
setTags(newTags);
}
const removeTag = (name) => {
const newTags = activeTags.filter(i => i !== name);
setTags(newTags);
}
const anyTag = searchParams.get('anyTag') === 'true';
const setAnyTag = (value) => {
updateQuery({tag: [], anyTag: value ? 'true' : null});
}
return {
activeTags, addTag, removeTag, anyTag, setAnyTag, setTags,
filter,
model: model_,
pages,
searchParams,
searchStr,
setSearchStr,
clearSearch,
months, dateRange, setDateRange, setDates,
anySearch, isEmpty,
}
}
export function useSuggestions(searchStr, tagNames, filter, anyTag) {
const defaultSuggestions = {
fileGroups: [],
channels: [],
domains: [],
authors: [],
subjects: [],
zimsEstimates: [],
}
const [suggestions, setSuggestions] = React.useState(defaultSuggestions);
const {dateRange, months} = useSearchDate();
// channels/domains.
const {data: generalData, sendRequest: sendGeneralReqeust, loading: generalLoading} = useLatestRequest(500);
const {data: filesData, sendRequest: sendFilesRequest, loading: filesLoading} = useLatestRequest(500);
// Zims are slow, so they are separate.
const {data: zimData, sendRequest: sendZimRequest, loading: zimLoading} = useLatestRequest(500);
const {data: otherData, sendRequest: sendOtherRequest, loading: otherLoading} = useLatestRequest(500);
const {data: mapData, sendRequest: sendMapRequest, loading: mapLoading} = useLatestRequest(500);
React.useEffect(() => {
if (searchStr || (tagNames && tagNames.length > 0)) {
const mimetypes = filterToMimetypes(filter);
setSuggestions(defaultSuggestions);
if (searchStr && searchStr.length > 0) {
// We can't search Channels/Domains without some string to filter by.
sendGeneralReqeust(async () => await searchSuggestions(searchStr));
sendMapRequest(async () => await searchEstimateMap(searchStr));
}
// The files estimate always uses deep search server-side, so users know results are possible.
sendFilesRequest(async () => await searchEstimateFiles(searchStr, tagNames, mimetypes, months, dateRange, anyTag));
sendZimRequest(async () => await searchEstimateZims(searchStr, tagNames));
sendOtherRequest(async () => await searchEstimateOthers(tagNames));
}
}, [
searchStr,
sendGeneralReqeust,
sendZimRequest,
JSON.stringify(tagNames),
JSON.stringify(months),
JSON.stringify(dateRange),
filter,
anyTag,
]);
React.useEffect(() => {
if (!_.isEmpty(generalData)) {
setSuggestions((prevState) => {
return {
...prevState,
channels: generalData.channels,
domains: generalData.domains,
authors: generalData.authors,
subjects: generalData.subjects,
}
});
}
}, [setSuggestions, generalData]);
React.useEffect(() => {
if (!_.isEmpty(filesData)) {
setSuggestions((prevState) => {
return {...prevState, fileGroups: filesData.fileGroups, fileGroupsDeep: filesData.fileGroupsDeep}
});
}
}, [setSuggestions, filesData]);
React.useEffect(() => {
if (!_.isEmpty(zimData)) {
setSuggestions((prevState) => {
return {...prevState, zimsEstimates: zimData.zimsEstimates}
});
}
}, [setSuggestions, zimData]);
React.useEffect(() => {
if (!_.isEmpty(otherData)) {
setSuggestions((prevState) => {
return {...prevState, otherEstimates: otherData.others}
});
}
}, [setSuggestions, otherData]);
React.useEffect(() => {
if (!_.isEmpty(mapData)) {
setSuggestions((prevState) => {
return {...prevState, mapPlaces: mapData.mapPlaces}
});
}
}, [setSuggestions, mapData]);
return {suggestions, loading: generalLoading || zimLoading || filesLoading}
}
export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag) {
const navigate = useNavigate();
const {filter} = useSearchFilter();
const [searchStr, setSearchStr] = React.useState(defaultSearchStr || '');
const [searchTags, setSearchTags] = React.useState(defaultTagNames);
const {SingleTag, fuzzyMatchTagsByName} = React.useContext(TagsContext);
const {dateRange, months, setDates, clearDate} = useSearchDate();
const {suggestions, loading} = useSuggestions(searchStr, searchTags, filter, anyTag);
const {deep} = useSearchDeep();
const {getLocationStr} = React.useContext(QueryContext);
// The results that will be displayed by <Search>.
const [suggestionsResults, setSuggestionsResults] = useState({});
// The results summarized.
const [suggestionsSums, setSuggestionsSums] = useState({});
// Disable selecting a result briefly after new suggestions arrive.
const [selectDisabled, setSelectDisabled] = React.useState(false);
const selectDisableTimerRef = React.useRef(null);
const noResults = [{title: 'No results'}];
const normalizeSuggestionsResults = (newSuggestions) => {
// Convert the suggestions from the Backend to what SearchBox expects.
const lowerSearchStr = searchStr ? searchStr.toLowerCase() : '';
let results = {};
// The dropdown always suggests the deep count so users know what is findable. The search results
// page shows the active mode's count, and offers deep search when it found less than promised.
const dropdownEstimate = newSuggestions.fileGroupsDeep ?? newSuggestions.fileGroups;
// The tab badge shows the count matching the active search mode, so it agrees with the result list.
const fileGroupsEstimate = deep ? newSuggestions.fileGroupsDeep : newSuggestions.fileGroups;
// Suggested results are ordered.
if (dropdownEstimate > 0) {
results.fileGroups = {
name: 'Files', results: [
{
title: dropdownEstimate.toString(),
type: 'files',
// Add search query onto current location.
location: getLocationStr({q: searchStr}, '/search'),
}
]
};
} else if (dropdownEstimate === 0) {
// Tell the user there are no files.
results.fileGroups = {name: 'Files', results: noResults};
}
const zimSum = newSuggestions.zimsEstimates && newSuggestions.zimsEstimates.length > 0
? newSuggestions.zimsEstimates.reduce((i, j) => i + j.estimate, 0)
: null;
if (newSuggestions && zimSum > 0) {
results.zimsSum = {
name: 'Zims', results: [
// Navigating to Zims is not relative. We don't want to keep filters or other extra params.
{title: zimSum.toString(), type: 'zims', location: `/search/zim?q=${encodeURIComponent(searchStr)}`}
],
};
} else if (newSuggestions && zimSum === 0) {
results.zimsSum = {name: 'Zims', results: noResults};
}
if (newSuggestions.channels && newSuggestions.channels.length > 0) {
results.channels = {
name: 'Channels', results: newSuggestions.channels.map(i => {
return {type: 'channel', title: i['name'], id: i['id'], location: `/videos/channel/${i.id}/video`}
})
}
} else if (newSuggestions.channels && newSuggestions.channels.length === 0) {
results.channels = {name: 'Channels', results: noResults};
}
if (newSuggestions.domains && newSuggestions.domains.length > 0) {
results.domains = {
name: 'Domains', results: newSuggestions.domains.map(i => {
return {
type: 'domain',
title: i.domain,
id: i.id,
domain: i.domain,
location: `/archives?domain=${i.domain}`
}
})
}
}
if (newSuggestions.authors && newSuggestions.authors.length > 0) {
results.authors = {
name: 'Authors', results: newSuggestions.authors.map(i => {
return {
type: 'author',
title: i.name,
id: i.id,
location: `/docs?author=${encodeURIComponent(i.name)}`
}
})
}
}
if (newSuggestions.subjects && newSuggestions.subjects.length > 0) {
results.subjects = {
name: 'Subjects', results: newSuggestions.subjects.map(i => {
return {
type: 'subject',
title: i.name,
id: i.id,
location: `/docs?subject=${encodeURIComponent(i.name)}`
}
})
}
}
// Match at most 5 Tags.
const matchingTags = searchStr ? fuzzyMatchTagsByName(searchStr).slice(0, 5) : null;
if (matchingTags && matchingTags.length > 0) {
results.tags = {
name: 'Tags', results: matchingTags.map(i => {
return {type: 'tag', title: i.name, location: `/search?tag=${encodeURIComponent(i.name)}`}
})
}
}
const otherSum = suggestions.otherEstimates ? _.sum(Object.values(suggestions.otherEstimates)) : 0;
const matchingApps = SUGGESTED_APPS.filter(i =>
i.title.toLowerCase().includes(lowerSearchStr)
|| fuzzyMatch(i.title.toLowerCase(), lowerSearchStr)
|| i.description.toLowerCase().includes(lowerSearchStr)
|| fuzzyMatch(i.description.toLowerCase(), lowerSearchStr));
if (matchingApps && matchingApps.length > 0) {
// Match at most 5 apps.
results.apps = {name: 'Apps', results: matchingApps.slice(0, 5)};
}
// Detect coordinates (e.g., "40.76, -111.89" or "40.76 -111.89").
const coordMatch = searchStr && searchStr.trim().match(
/^(-?\d+\.?\d*)[,\s]+(-?\d+\.?\d*)$/
);
// Map places suggestion in dropdown.
if (newSuggestions.mapPlaces > 0) {
results['map places'] = {
name: 'Map Places', results: [{
type: 'search',
title: `Map: ${newSuggestions.mapPlaces} places`,
description: 'Search map locations',
location: `/search/map?q=${encodeURIComponent(searchStr)}`,
}]
};
}
if (coordMatch) {
const lat = parseFloat(coordMatch[1]);
const lon = parseFloat(coordMatch[2]);
if (lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
// Derive zoom from decimal precision: 0dp=3, 1dp=7, 2dp=10, 3dp=13, 4dp=16, 5+=18
const maxDecimals = Math.max(
(coordMatch[1].split('.')[1] || '').length,
(coordMatch[2].split('.')[1] || '').length,
);
const zoom = Math.min([3, 7, 10, 13, 16, 18][maxDecimals] || 18, 18);
results.map = {
name: 'Map', results: [{
type: 'map',
title: `${lat}, ${lon}`,
description: 'View on map',
location: `/map?lat=${lat}&lon=${lon}&z=${zoom}`,
}]
};
}
}
setSuggestionsResults(results);
setSuggestionsSums({
fileGroups: fileGroupsEstimate,
fileGroupsDeep: newSuggestions.fileGroupsDeep,
zims: zimSum,
otherSum: otherSum,
mapPlaces: newSuggestions.mapPlaces || 0,
channels: newSuggestions.channels.length,
domains: newSuggestions.domains.length,
authors: newSuggestions.authors?.length,
subjects: newSuggestions.subjects?.length,
tags: matchingTags?.length,
apps: matchingApps?.length,
});
}
const suggestionSelectDelay = 250;
React.useEffect(() => {
setSuggestionsSums({});
setSuggestionsResults({});
if (!_.isEmpty(suggestions)) {
// Start a brief window where selecting results is disabled to prevent accidental clicks
// when the dropdown re-renders with new suggestions.
if (selectDisableTimerRef.current) {
clearTimeout(selectDisableTimerRef.current);
}
setSelectDisabled(true);
selectDisableTimerRef.current = setTimeout(() => setSelectDisabled(false), suggestionSelectDelay);
normalizeSuggestionsResults(suggestions);
}
}, [JSON.stringify(suggestions), deep]);
// Clear timer on unmount.
React.useEffect(() => {
return () => {
if (selectDisableTimerRef.current) {
clearTimeout(selectDisableTimerRef.current);
}
};
}, []);
// User clicked on a result in the dropdown.
const handleResultSelect = ({result}) => {
if (selectDisabled) {
console.debug('Selection temporarily disabled after suggestions update');
return;
}
if (result.location) {
console.info(`useSearchSuggestions Navigating: ${result.location}`)
navigate(result.location);
} else {
console.error('No location to navigate');
}
}
const resultRenderer = ({type, title, description}) => {
if (type === 'tag') {
return <SingleTag name={title}/>;
}
if (description) {
return <>
<Header as='h4'>{title}</Header>
{description}
</>
}
// No specific renderer, use the generic.
return <span>{title}</span>
};
return {
suggestions,
suggestionsResults,
suggestionsSums,
searchStr, setSearchStr,
setSearchTags,
months, dateRange, setDates, clearDate,
handleResultSelect,
resultRenderer,
loading,
}
}
export function SearchView({suggestions, suggestionsSums, loading}) {
const location = useLocation();
const search = location.search;
const filesTabName = <span>Files <Label>{normalizeEstimate(suggestionsSums?.fileGroups)}</Label></span>;
const zimsTabName = <span>Zims <Label>{normalizeEstimate(suggestionsSums?.zims)}</Label></span>;
const mapTabName = <span>Map <Label>{normalizeEstimate(suggestionsSums?.mapPlaces)}</Label></span>;
const othersTabName = <span>Other <Label>{normalizeEstimate(suggestionsSums?.otherSum)}</Label></span>;
const links = [
{text: filesTabName, to: `/search${search}`, key: 'filesSearch_', end: true},
{text: zimsTabName, to: `/search/zim${search}`, key: 'zimsSearch'},
{text: mapTabName, to: `/search/map${search}`, key: 'mapSearch'},
{text: othersTabName, to: `/search/other${search}`, key: 'othersSearch'},
];
// The view toggle only applies to the Files tab; show it in the tab row's right slot there.
const isFilesTab = !/\/(zim|map|other)$/.test(location.pathname);
return <React.Fragment>
<TabLinks links={links} right={isFilesTab ? <SearchViewButton headlines/> : null}/>
<Routes>
<Route path='/*' element={<FilesSearchView showView={false}
deepEstimate={suggestionsSums?.fileGroupsDeep}/>}/>
<Route path='/zim' exact element={<ZimSearchView suggestions={suggestions} loading={loading}/>}/>
<Route path='/map' exact element={<MapSearchView/>}/>
<Route path='/other' exact element={<OtherSearchView loading={loading}/>}/>
</Routes>
</React.Fragment>
}
export function SearchIconButton() {
/*
* A single button which opens the search modal via keyboard shortcuts context.
*
* `wrolpi-navbar-link`, not the leftover `item` class, which carries no rules at all
* now that the stylesheet defining it is gone: no pointer cursor, no hover highlight, and a hit area of
* whatever the 18px glyph occupied rather than the height of the bar. It is the same
* kind of thing as Help and Admin and now has the same affordance.
*/
const {openSearchModal} = React.useContext(KeyboardShortcutsContext);
return (
<a className='wrolpi-navbar-link' onClick={openSearchModal}
role='button' tabIndex={0} aria-label='Search'
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && openSearchModal()}>
<Icon name='search'/>
<ShortcutHint shortcutKey="K"/>
</a>
);
}
function SearchChannelPreview({channel}) {
return <div>
<Link to={`/videos/channel/${channel.id}/video`}>
{channel.name}
</Link>
</div>
}
function OtherSearchView({loading}) {
const {searchParams} = React.useContext(QueryContext);
const [activeValue, setActiveValue] = React.useState('channels');
const activeTags = searchParams.getAll('tag');
const {channels, loading: channelsLoading} = useSearchChannels(activeTags);
if (loading || channelsLoading) {
return <Panel><Loading/></Panel>
}
return <Accordion value={activeValue} onChange={setActiveValue}>
<Accordion.Item value='channels'>
<Accordion.Control>
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8}}>
<Header as='h3' style={{margin: 0}}>Channels</Header>
<Label>{normalizeEstimate(channels.length)}</Label>
</span>
</Accordion.Control>
<Accordion.Panel>
<Stack gap='xs'>
{!_.isEmpty(channels) ?
channels.map(i => <SearchChannelPreview key={i.id} channel={i}/>)
: 'No Channels'}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
}