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 . 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 ; } if (description) { return <>
{title}
{description} } // No specific renderer, use the generic. return {title} }; 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 = Files ; const zimsTabName = Zims ; const mapTabName = Map ; const othersTabName = Other ; 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 : null}/> }/> }/> }/> }/> } 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 ( (e.key === 'Enter' || e.key === ' ') && openSearchModal()}> ); } function SearchChannelPreview({channel}) { return
{channel.name}
} 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 } return
Channels
{!_.isEmpty(channels) ? channels.map(i => ) : 'No Channels'}
}