mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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>
571 lines
24 KiB
JavaScript
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>
|
|
}
|