wrolpi/app/src/components/Search.js

572 lines
24 KiB
JavaScript
Raw Normal View History

import React, {useState} from "react";
import {Link, Route, Routes, useLocation, useNavigate} from "react-router";
Consolidate search options into a single Filter modal (#497) * Consolidate search options into a single Filter modal Reworks the file-search UI on the Dashboard, Videos, Archives, and Docs pages. The crowded row of controls (sort, tags, limit, file-type filter, date selector) is replaced with a single "Filter" button that opens one comprehensive modal. - Add SearchFilterModal + SearchFilterButton (violet + count badge when active). Sections (Sort, File Type, Tags, Results Per Page, Published Date) are shown per-page via props; layout uses a Semantic Grid. - Selections are drafted locally and applied in a single updateQuery only on close/Done, so the search isn't re-run on every field change. - Results Per Page and Sort use buttons (fewer clicks, no clipped dropdown). - Extract shared SearchControlBar (view toggle + input + Filter) for the three results pages; move the global-search view toggle onto the tab row. - Rework the Dashboard search line into one adaptive flex row; clear button only shows when there's an active search. - Remove now-dead FilesView limit/tag returns and SearchLimitDropdown. * Address review: reset limit on Clear All; use replace over replaceAll - handleClearAll now resets Results Per Page to the default page size (DEFAULT_SEARCH_LIMIT = 24) so "Clear All" actually clears every setting. - Replace start-anchored replaceAll(/^-/g) with replace(/^-/) for the sort key. - Add a test covering the Clear All limit reset. * Remove now-unused Media import from Files.js The FilesSearchView refactor dropped the last <Media> usage in this file, leaving an unused import (a build warning). Remove it. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:49:15 -06:00
import {FilesSearchView, SearchViewButton} from "./Files";
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
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";
2023-11-20 11:07:19 -07:00
import _ from "lodash";
import {TagsContext} from "../Tags";
ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have.
2026-07-30 12:21:30 -06:00
import {Accordion, Header, Icon, Label, Loading, Panel, Stack} from "./ui";
import {QueryContext} from "../contexts/contexts";
2025-12-26 18:59:13 -07:00
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',
2024-06-25 17:05:41 -06:00
description: 'Convert Volts, Ohms, Amps, Watts, Power Loss.'
},
2024-06-20 09:51:29 -06:00
{
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));
}
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
// 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) => {
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
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();
2024-01-01 11:31:17 -07:00
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);
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
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) => {
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
// Convert the suggestions from the Backend to what SearchBox expects.
const lowerSearchStr = searchStr ? searchStr.toLowerCase() : '';
let results = {};
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
// 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.
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
if (dropdownEstimate > 0) {
results.fileGroups = {
name: 'Files', results: [
{
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
title: dropdownEstimate.toString(),
type: 'files',
2024-02-12 12:12:07 -07:00
// Add search query onto current location.
location: getLocationStr({q: searchStr}, '/search'),
}
]
};
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
} 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: [
2024-02-12 12:12:07 -07:00
// 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({
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
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);
}
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
}, [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 <>
ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have.
2026-07-30 12:21:30 -06:00
<Header as='h4'>{title}</Header>
{description}
</>
}
// No specific renderer, use the generic.
return <span>{title}</span>
};
2024-01-01 11:31:17 -07:00
return {
suggestions,
suggestionsResults,
suggestionsSums,
searchStr, setSearchStr,
setSearchTags,
months, dateRange, setDates, clearDate,
2024-01-01 11:31:17 -07:00
handleResultSelect,
resultRenderer,
loading,
2024-01-01 11:31:17 -07:00
}
}
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'},
];
Consolidate search options into a single Filter modal (#497) * Consolidate search options into a single Filter modal Reworks the file-search UI on the Dashboard, Videos, Archives, and Docs pages. The crowded row of controls (sort, tags, limit, file-type filter, date selector) is replaced with a single "Filter" button that opens one comprehensive modal. - Add SearchFilterModal + SearchFilterButton (violet + count badge when active). Sections (Sort, File Type, Tags, Results Per Page, Published Date) are shown per-page via props; layout uses a Semantic Grid. - Selections are drafted locally and applied in a single updateQuery only on close/Done, so the search isn't re-run on every field change. - Results Per Page and Sort use buttons (fewer clicks, no clipped dropdown). - Extract shared SearchControlBar (view toggle + input + Filter) for the three results pages; move the global-search view toggle onto the tab row. - Rework the Dashboard search line into one adaptive flex row; clear button only shows when there's an active search. - Remove now-dead FilesView limit/tag returns and SearchLimitDropdown. * Address review: reset limit on Clear All; use replace over replaceAll - handleClearAll now resets Results Per Page to the default page size (DEFAULT_SEARCH_LIMIT = 24) so "Clear All" actually clears every setting. - Replace start-anchored replaceAll(/^-/g) with replace(/^-/) for the sort key. - Add a test covering the Clear All limit reset. * Remove now-unused Media import from Files.js The FilesSearchView refactor dropped the last <Media> usage in this file, leaving an unused import (a build warning). Remove it. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:49:15 -06:00
// 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>
Consolidate search options into a single Filter modal (#497) * Consolidate search options into a single Filter modal Reworks the file-search UI on the Dashboard, Videos, Archives, and Docs pages. The crowded row of controls (sort, tags, limit, file-type filter, date selector) is replaced with a single "Filter" button that opens one comprehensive modal. - Add SearchFilterModal + SearchFilterButton (violet + count badge when active). Sections (Sort, File Type, Tags, Results Per Page, Published Date) are shown per-page via props; layout uses a Semantic Grid. - Selections are drafted locally and applied in a single updateQuery only on close/Done, so the search isn't re-run on every field change. - Results Per Page and Sort use buttons (fewer clicks, no clipped dropdown). - Extract shared SearchControlBar (view toggle + input + Filter) for the three results pages; move the global-search view toggle onto the tab row. - Rework the Dashboard search line into one adaptive flex row; clear button only shows when there's an active search. - Remove now-dead FilesView limit/tag returns and SearchLimitDropdown. * Address review: reset limit on Clear All; use replace over replaceAll - handleClearAll now resets Results Per Page to the default page size (DEFAULT_SEARCH_LIMIT = 24) so "Clear All" actually clears every setting. - Replace start-anchored replaceAll(/^-/g) with replace(/^-/) for the sort key. - Add a test covering the Clear All limit reset. * Remove now-unused Media import from Files.js The FilesSearchView refactor dropped the last <Media> usage in this file, leaving an unused import (a build warning). Remove it. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:49:15 -06:00
<TabLinks links={links} right={isFilesTab ? <SearchViewButton headlines/> : null}/>
<Routes>
Search: fast ABC mode by default, opt-in deep search The file_group.textsearch tsvector indexes all four text columns; d_text (captions, document text, zip contents) is ~90% of the data, making ts_rank searches take seconds on large libraries. Add a textsearch_abc generated column (title/author/description only, ~8x smaller) and default all searches to it, with deep=true opting into the full column. Database: - Migration adds the generated column and a GIN index (rewrites the table; a few minutes on large libraries). Backend: - FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and DocSearchRequest accept `deep`; each search function picks the column. - /api/search_file_estimates returns both counts in one index-bound query (COUNT FILTER on the abc subset): `file_groups` (fast) and `file_groups_deep`. UI (one coherent story across the count surfaces): - The typing dropdown always suggests the deep count (what is findable). - The tab badge shows the active mode's count so it matches the result list. - The gap is reconciled by a hint offering deep search: count-aware on the global search ("No results in fast search. N results available in deep search." / "N more results available..."), generic on the module pages (no deep estimate there). The hint resets pagination. - "Search Depth" toggle in the search filter modals, persisted as ?deep=true. Tests that relied on d_text matches now pass deep=True, with fast-mode assertions alongside. Also skip flaky test_download_channel on CircleCI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:08 -06:00
<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>
}
2024-01-01 11:31:17 -07:00
export function SearchIconButton() {
ui: the corner triggers, the More button, and the search modal Four faults, each measured in the running app before and after. The search glyph carried Semantic's `item` class, which has had no rules at all since Semantic was removed: no pointer cursor, no hover, and an 18.6px hit area against a 54.4px bar. It is the same kind of thing as Help and Admin and now takes the same class. The share glyph did compute `cursor: pointer` -- it is an `<a href>` -- but its target was the 24px box the icon occupies, so the pointer appeared only if you found it exactly. Corner anchors now stretch and pad like a link. The More button rendered with NO class at all: a bare grey UA button while the rest of the bar took the user's colour. Menu.Target clones its child and hands it a className of its own, and the trigger spread those cloned props after its own `className` attribute, so the clone replaced ours. It is merged now. This was mine, from extracting NavDropdownTrigger last commit -- and it also made the previous commit's "measured and drawn are the same component" claim only half true, because the hidden placeholder is not cloned and so kept its class. The bar still wrapped, for a reason the padding fix could not reach: the corner does not keep a constant width. The Cmd-K hint renders only once useKeyboardDetected has seen a keypress, so it appears at an arbitrary later moment and widens the corner by ~30px; the load, memory, temperature and drive warnings come and go with the machine's state. None of that resizes the container, which was the only thing observed -- so nothing recalculated. That is the other half of "at certain widths": the same viewport wrapped or not depending on whether the user had touched a key. The corner and the home link are observed too now. The search modal showed an input and the first line of the first group. The suggestion list is absolutely positioned, which is right everywhere else -- a list that reflows the page while you type is unusable -- but in a modal it fails twice: an absolute list adds no height, so the panel sized itself to the search box alone at 114px, and the panel's own `overflow: auto` then clipped the list at that edge. In the modal the list sits in flow; the inverse is asserted so the rule cannot leak to the pages where overlaying is correct. Two of the new tests were wrong first and reverting the fix left them green: they asserted hand-written markup carrying the classes I expected the components to have, rather than the real SearchIconButton and ShareButton. They use the real components now. Every fix was then planted against: 5 red for the More class, 2 for the search class, 2 for the corner hit area, 1 for the observer, 2 for the modal. 1115 jest / 62 suites, 196 in ui-layout.cy.js, clean npm run build. Confirmed live: share and search both pointer at 54.4px, More white on the bar colour with a transparent face, and the modal's list in flow and unclipped.
2026-08-02 14:45:49 -06:00
/*
* A single button which opens the search modal via keyboard shortcuts context.
*
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
* `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
ui: the corner triggers, the More button, and the search modal Four faults, each measured in the running app before and after. The search glyph carried Semantic's `item` class, which has had no rules at all since Semantic was removed: no pointer cursor, no hover, and an 18.6px hit area against a 54.4px bar. It is the same kind of thing as Help and Admin and now takes the same class. The share glyph did compute `cursor: pointer` -- it is an `<a href>` -- but its target was the 24px box the icon occupies, so the pointer appeared only if you found it exactly. Corner anchors now stretch and pad like a link. The More button rendered with NO class at all: a bare grey UA button while the rest of the bar took the user's colour. Menu.Target clones its child and hands it a className of its own, and the trigger spread those cloned props after its own `className` attribute, so the clone replaced ours. It is merged now. This was mine, from extracting NavDropdownTrigger last commit -- and it also made the previous commit's "measured and drawn are the same component" claim only half true, because the hidden placeholder is not cloned and so kept its class. The bar still wrapped, for a reason the padding fix could not reach: the corner does not keep a constant width. The Cmd-K hint renders only once useKeyboardDetected has seen a keypress, so it appears at an arbitrary later moment and widens the corner by ~30px; the load, memory, temperature and drive warnings come and go with the machine's state. None of that resizes the container, which was the only thing observed -- so nothing recalculated. That is the other half of "at certain widths": the same viewport wrapped or not depending on whether the user had touched a key. The corner and the home link are observed too now. The search modal showed an input and the first line of the first group. The suggestion list is absolutely positioned, which is right everywhere else -- a list that reflows the page while you type is unusable -- but in a modal it fails twice: an absolute list adds no height, so the panel sized itself to the search box alone at 114px, and the panel's own `overflow: auto` then clipped the list at that edge. In the modal the list sits in flow; the inverse is asserted so the rule cannot leak to the pages where overlaying is correct. Two of the new tests were wrong first and reverting the fix left them green: they asserted hand-written markup carrying the classes I expected the components to have, rather than the real SearchIconButton and ShareButton. They use the real components now. Every fix was then planted against: 5 red for the More class, 2 for the search class, 2 for the corner hit area, 1 for the observer, 2 for the modal. 1115 jest / 62 suites, 196 in ui-layout.cy.js, clean npm run build. Confirmed live: share and search both pointer at 54.4px, More white on the bar colour with a transparent face, and the modal's list in flow and unclipped.
2026-08-02 14:45:49 -06:00
* 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.
*/
2025-12-26 18:59:13 -07:00
const {openSearchModal} = React.useContext(KeyboardShortcutsContext);
2025-12-26 18:59:13 -07:00
return (
ui: the corner triggers, the More button, and the search modal Four faults, each measured in the running app before and after. The search glyph carried Semantic's `item` class, which has had no rules at all since Semantic was removed: no pointer cursor, no hover, and an 18.6px hit area against a 54.4px bar. It is the same kind of thing as Help and Admin and now takes the same class. The share glyph did compute `cursor: pointer` -- it is an `<a href>` -- but its target was the 24px box the icon occupies, so the pointer appeared only if you found it exactly. Corner anchors now stretch and pad like a link. The More button rendered with NO class at all: a bare grey UA button while the rest of the bar took the user's colour. Menu.Target clones its child and hands it a className of its own, and the trigger spread those cloned props after its own `className` attribute, so the clone replaced ours. It is merged now. This was mine, from extracting NavDropdownTrigger last commit -- and it also made the previous commit's "measured and drawn are the same component" claim only half true, because the hidden placeholder is not cloned and so kept its class. The bar still wrapped, for a reason the padding fix could not reach: the corner does not keep a constant width. The Cmd-K hint renders only once useKeyboardDetected has seen a keypress, so it appears at an arbitrary later moment and widens the corner by ~30px; the load, memory, temperature and drive warnings come and go with the machine's state. None of that resizes the container, which was the only thing observed -- so nothing recalculated. That is the other half of "at certain widths": the same viewport wrapped or not depending on whether the user had touched a key. The corner and the home link are observed too now. The search modal showed an input and the first line of the first group. The suggestion list is absolutely positioned, which is right everywhere else -- a list that reflows the page while you type is unusable -- but in a modal it fails twice: an absolute list adds no height, so the panel sized itself to the search box alone at 114px, and the panel's own `overflow: auto` then clipped the list at that edge. In the modal the list sits in flow; the inverse is asserted so the rule cannot leak to the pages where overlaying is correct. Two of the new tests were wrong first and reverting the fix left them green: they asserted hand-written markup carrying the classes I expected the components to have, rather than the real SearchIconButton and ShareButton. They use the real components now. Every fix was then planted against: 5 red for the More class, 2 for the search class, 2 for the corner hit area, 1 for the observer, 2 for the modal. 1115 jest / 62 suites, 196 in ui-layout.cy.js, clean npm run build. Confirmed live: share and search both pointer at 54.4px, More white on the bar colour with a transparent face, and the modal's list in flow and unclipped.
2026-08-02 14:45:49 -06:00
<a className='wrolpi-navbar-link' onClick={openSearchModal}
ui: fix the cypress failures, and two real defects behind them All 43 cypress specs pass now (was 36 of 43). Five of the seven failures were stale selectors, two were real. Real, and both mine: The search modal stopped focusing its input. Mantine mounts modal content on a later tick, so the effect that focused it ran while the ref was still null; Semantic's modal mounted synchronously, which is why focusing there had been enough. Fixed declaratively -- the field asks for focus itself, and SearchBox also sets `data-autofocus`, which is what Mantine's focus trap honours when it takes focus away from a plain autoFocus. Escape closed the suggestion list and the enclosing modal at once, so a user correcting a mistyped query lost the whole modal. Escape now dismisses the suggestions and stops there; a second Escape, with the list closed, still closes the modal. Stale selectors, all in the specs: Semantic's icon font (`i.search.icon`) is SVG now, its `.ui.modal`/`.ui.dimmer` are the library's modal and overlay, and one test asserted a button carried the classes `ui button secondary`. That last one is rewritten to check what a user can actually perceive -- that Edit is a real link pointing at the edit route -- because framework class names say nothing and break on every library change. Two accessibility gaps the tests walked into: The nav's search control was an anchor with an onClick, no name and no keyboard path. It now has a name, a role and Enter/Space activation, which is also what let the spec stop matching on icon-font classes. The search dialog had no accessible name at all; it has one now. One `{force: true}` remains, with its reason recorded: Cypress judges that field covered because it sits in a fixed modal under a sticky header, and I verified by hand at Cypress's own viewport that it is the topmost element at its centre, unobscured, and focused on open. Also closed a hole in the ratchet that this uncovered. A file could drop its `semantic-ui-react` imports, leave the baseline, and still render Semantic through Theme.tsx's wrappers -- which is exactly how a Semantic modal survived in the search shortcut long after that file looked migrated. A test now forbids it, and the three files doing it (Calculators, KeyboardShortcutsProvider, and Common.js reaching for theme names) import from the library or from themes/names instead.
2026-07-30 12:53:19 -06:00
role='button' tabIndex={0} aria-label='Search'
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && openSearchModal()}>
<Icon name='search'/>
<ShortcutHint shortcutKey="K"/>
</a>
2025-12-26 18:59:13 -07:00
);
2024-01-01 11:31:17 -07:00
}
function SearchChannelPreview({channel}) {
ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have.
2026-07-30 12:21:30 -06:00
return <div>
<Link to={`/videos/channel/${channel.id}/video`}>
{channel.name}
</Link>
</div>
}
function OtherSearchView({loading}) {
const {searchParams} = React.useContext(QueryContext);
ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have.
2026-07-30 12:21:30 -06:00
const [activeValue, setActiveValue] = React.useState('channels');
const activeTags = searchParams.getAll('tag');
const {channels, loading: channelsLoading} = useSearchChannels(activeTags);
if (loading || channelsLoading) {
ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have.
2026-07-30 12:21:30 -06:00
return <Panel><Loading/></Panel>
}
ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have.
2026-07-30 12:21:30 -06:00
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>
ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have.
2026-07-30 12:21:30 -06:00
}