mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 13:40:21 +01:00
Deep-link Search By mode via URL hash (#1311)
Closes #1228. Search By mode (General/Author/Title/Series/Manual), content type and the search query now live in the URL as a hash fragment, updated live as you search, not just parsed once on load. A shared/bookmarked link like `#search_by=manual&q=foundation` reopens in that exact mode with the query filled in. Following the direction from the issue thread: - Hash fragment instead of query string, so it stays browser side only. - Live updates via `history.replaceState`, no history spam per keystroke. - Default Search By persisted in a client side cookie as fallback when there's no hash, no user accounts needed. Tested manually against a local build (search-by switching, hash live update, deep link reload, cookie fallback when there's no hash, and hash overriding a stale cookie).
This commit is contained in:
@@ -1,71 +1,79 @@
|
||||
# URL Search Parameters
|
||||
|
||||
You can trigger searches directly via URL by adding query parameters. This enables bookmarking searches and sharing links.
|
||||
You can trigger searches directly via URL. This enables bookmarking searches and sharing links.
|
||||
|
||||
Parameters live in the URL **hash** (`#…`), so they stay in the browser and are never sent to
|
||||
the server. Shelfmark also keeps the hash in sync as you search, so the address bar always
|
||||
holds a shareable link to what you're looking at.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```
|
||||
http://your-server:8084/?q=harry+potter
|
||||
http://your-server:8084/#q=harry+potter
|
||||
```
|
||||
|
||||
Older query-string links (`/?q=harry+potter`) still work: they're read once on load and
|
||||
rewritten to the hash form.
|
||||
|
||||
## Supported Parameters
|
||||
|
||||
| Parameter | Description | Example |
|
||||
|-----------|-------------|---------|
|
||||
| `q` or `query` | Main search query | `/?q=dune` |
|
||||
| `author` | Filter by author name | `/?author=frank+herbert` |
|
||||
| `title` | Filter by book title | `/?title=foundation` |
|
||||
| `isbn` | Filter by ISBN | `/?isbn=978-0747532699` |
|
||||
| `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` |
|
||||
| `format` | Filter by file format | `/?format=epub` |
|
||||
| `content` | Filter by content type | `/?content=fiction` |
|
||||
| `content_type` | Select media type (`ebook`, `audiobook`, or `combined`) in Universal mode only | `/?q=dune&content_type=audiobook` |
|
||||
| `sort` | Sort order for results | `/?sort=newest` |
|
||||
| `q` or `query` | Main search query | `/#q=dune` |
|
||||
| `author` | Filter by author name | `/#author=frank+herbert` |
|
||||
| `title` | Filter by book title | `/#title=foundation` |
|
||||
| `isbn` | Filter by ISBN | `/#isbn=978-0747532699` |
|
||||
| `lang` | Filter by language (ISO 639-1 code) | `/#lang=en` |
|
||||
| `format` | Filter by file format | `/#format=epub` |
|
||||
| `content` | Filter by content type | `/#content=fiction` |
|
||||
| `content_type` | Select media type (`ebook`, `audiobook`, or `combined`) in Universal mode only | `/#q=dune&content_type=audiobook` |
|
||||
| `sort` | Sort order for results | `/#sort=newest` |
|
||||
| `search_by` | "Search By" target the query applies to (`general`, `author`, `title`, `isbn`, a metadata provider field like `series`, or `manual`) | `/#search_by=author&q=frank+herbert` |
|
||||
|
||||
## Multiple Values
|
||||
|
||||
Some parameters support multiple values by repeating the parameter:
|
||||
|
||||
```
|
||||
/?lang=en&lang=de&lang=fr
|
||||
/?format=epub&format=mobi&format=azw3
|
||||
/#lang=en&lang=de&lang=fr
|
||||
/#format=epub&format=mobi&format=azw3
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
**Simple search:**
|
||||
```
|
||||
/?q=lord+of+the+rings
|
||||
/#q=lord+of+the+rings
|
||||
```
|
||||
|
||||
**Search with author filter:**
|
||||
```
|
||||
/?q=dune&author=frank+herbert
|
||||
/#q=dune&author=frank+herbert
|
||||
```
|
||||
|
||||
**Search with format and language:**
|
||||
```
|
||||
/?q=harry+potter&format=epub&lang=en
|
||||
/#q=harry+potter&format=epub&lang=en
|
||||
```
|
||||
|
||||
**Author search with multiple formats:**
|
||||
```
|
||||
/?author=stephen+king&format=epub&format=mobi
|
||||
/#author=stephen+king&format=epub&format=mobi
|
||||
```
|
||||
|
||||
**Search with sort order:**
|
||||
```
|
||||
/?q=science+fiction&sort=newest
|
||||
/#q=science+fiction&sort=newest
|
||||
```
|
||||
|
||||
**Universal search as audiobook:**
|
||||
```
|
||||
/?q=dune&content_type=audiobook
|
||||
/#q=dune&content_type=audiobook
|
||||
```
|
||||
|
||||
**Universal search forcing combined (ebook + audiobook):**
|
||||
```
|
||||
/?q=dune&content_type=combined
|
||||
/#q=dune&content_type=combined
|
||||
```
|
||||
|
||||
## Search Mode Behavior
|
||||
@@ -77,13 +85,28 @@ When Search Mode is set to Direct, all parameters are used to filter results fro
|
||||
|
||||
### Universal Mode
|
||||
|
||||
`q`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities.
|
||||
`q`, `search_by`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities — except when `search_by` names one of the provider's own search fields, in which case `q` is sent as that field's value.
|
||||
|
||||
`content_type=combined` forces combined mode (search ebook and audiobook providers together), overriding the last-used preference. It is silently ignored if combined mode is unavailable (e.g. the combined selector is disabled in settings, or either content type is blocked by request policy).
|
||||
|
||||
## Search By
|
||||
|
||||
`search_by` picks which target the `q` value is applied to, matching the selector next to the
|
||||
search box. It can be deep-linked on its own (`/#search_by=manual`) to open the app in that
|
||||
mode with an empty query.
|
||||
|
||||
`search_by=manual` fills the search box but does not auto-run: manual search opens the release
|
||||
browser from an explicit submit.
|
||||
|
||||
A `search_by` naming a target that isn't available (wrong search mode, or a metadata provider
|
||||
that doesn't offer that field) is ignored, and the query falls back to a general search.
|
||||
|
||||
## Notes
|
||||
|
||||
- URL parameters are read once on page load
|
||||
- The URL is not updated when you perform searches manually
|
||||
- URL parameters are read once on page load, and again if the hash is replaced in an open tab
|
||||
(e.g. pasting a shared link into the address bar)
|
||||
- The hash is kept in sync with the search box, Search By target and filters as you search
|
||||
- Spaces should be encoded as `+` or `%20`
|
||||
- Invalid or unknown parameters are silently ignored
|
||||
- Your last-used Search By target is remembered in browser storage and used when a link
|
||||
doesn't specify one
|
||||
|
||||
@@ -41,7 +41,7 @@ import { useRequests } from './hooks/useRequests';
|
||||
import { useSearch } from './hooks/useSearch';
|
||||
import { primeSettingsCache } from './hooks/useSettings';
|
||||
import { useToast } from './hooks/useToast';
|
||||
import { useUrlSearch } from './hooks/useUrlSearch';
|
||||
import { useExternalHashChange, useSyncUrlSearchHash, useUrlSearch } from './hooks/useUrlSearch';
|
||||
import { primeUsersCache } from './hooks/useUsersFetch';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import {
|
||||
@@ -93,7 +93,7 @@ import { getConfiguredMetadataProviderForContentType } from './utils/metadataPro
|
||||
import { getEffectiveMetadataSort } from './utils/metadataSort';
|
||||
import { isRecord } from './utils/objectHelpers';
|
||||
import { policyTrace } from './utils/policyTrace';
|
||||
import { buildQueryTargets, getDefaultQueryTargetKey } from './utils/queryTargets';
|
||||
import { buildQueryTargets, findQueryTarget, getDefaultQueryTargetKey } from './utils/queryTargets';
|
||||
import { buildReleaseDownloadPayload, type ReleaseDownloadOptions } from './utils/releasePayload';
|
||||
import { applyRequestNoteToPayload } from './utils/requestConfirmation';
|
||||
import { bookFromRequestData } from './utils/requestFulfil';
|
||||
@@ -110,6 +110,8 @@ import {
|
||||
applyDirectPolicyModeToButtonState,
|
||||
applyUniversalPolicyModeToButtonState,
|
||||
} from './utils/requestPolicyUi';
|
||||
import { getSearchByPreference, setSearchByPreference } from './utils/searchByPreference';
|
||||
import { buildUrlSearchHash } from './utils/urlSearchHash';
|
||||
|
||||
// eslint-disable-next-line import/no-unassigned-import -- global app stylesheet is loaded for side effects
|
||||
import './styles.css';
|
||||
@@ -617,7 +619,12 @@ function App() {
|
||||
const [configuredCombinedMetadataProvider, setConfiguredCombinedMetadataProvider] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [activeQueryTarget, setActiveQueryTarget] = useState('general');
|
||||
// Falls back to the stored "Search By" default from the user's last-used mode;
|
||||
// an invalid/stale value is harmless since effectiveActiveQueryTarget below re-validates
|
||||
// it against the current queryTargets once config/search fields are known.
|
||||
const [activeQueryTarget, setActiveQueryTarget] = useState(
|
||||
() => getSearchByPreference() || 'general',
|
||||
);
|
||||
const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false);
|
||||
const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState<boolean>(() =>
|
||||
getInitialPinnedPreference(),
|
||||
@@ -693,8 +700,18 @@ function App() {
|
||||
|
||||
// URL-based search: parse URL params for automatic search on page load
|
||||
const urlSearchEnabled = isAuthenticated && config !== null;
|
||||
const { parsedParams, wasProcessed } = useUrlSearch({ enabled: urlSearchEnabled });
|
||||
// Bumped when the hash changes to something we didn't write - a shared link pasted into
|
||||
// an already-open tab. Re-parses the URL and remounts the bootstrap so it applies.
|
||||
const [urlSearchNonce, setUrlSearchNonce] = useState(0);
|
||||
const { parsedParams, wasProcessed } = useUrlSearch({
|
||||
enabled: urlSearchEnabled,
|
||||
nonce: urlSearchNonce,
|
||||
});
|
||||
const [hasExecutedUrlSearchBootstrap, setHasExecutedUrlSearchBootstrap] = useState(false);
|
||||
useExternalHashChange(() => {
|
||||
setHasExecutedUrlSearchBootstrap(false);
|
||||
setUrlSearchNonce((value) => value + 1);
|
||||
});
|
||||
|
||||
const prevSearchModeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
@@ -1934,6 +1951,15 @@ function App() {
|
||||
return getDefaultQueryTargetKey(queryTargets);
|
||||
}, [queryTargets, activeQueryTarget]);
|
||||
|
||||
// Persist only what the user explicitly picked in the selector. Persisting the derived
|
||||
// `effectiveActiveQueryTarget` instead would overwrite the stored default with `general`
|
||||
// every time it collapses for reasons the user didn't choose: a cold load before the
|
||||
// metadata search fields resolve, the logo reset, logout, or a `view_series` browse.
|
||||
const handleQueryTargetChange = useCallback((nextTarget: string) => {
|
||||
setActiveQueryTarget(nextTarget);
|
||||
setSearchByPreference(nextTarget);
|
||||
}, []);
|
||||
|
||||
const activeQueryOption = useMemo(
|
||||
() =>
|
||||
queryTargets.find((target) => target.key === effectiveActiveQueryTarget) ?? queryTargets[0],
|
||||
@@ -1986,6 +2012,23 @@ function App() {
|
||||
return searchFieldValues[activeQueryOption.field.key] ?? '';
|
||||
}, [activeQueryOption, searchInput, searchFieldValues]);
|
||||
|
||||
// Keep the URL hash fragment live as search state changes. Gated until any URL-driven
|
||||
// bootstrap has applied (or there was nothing to apply), so we don't clobber a shared
|
||||
// link's params with the initial default state before they've been read.
|
||||
const readyToSyncUrlHash = wasProcessed && (!parsedParams || hasExecutedUrlSearchBootstrap);
|
||||
const urlSearchHash = useMemo(
|
||||
() =>
|
||||
buildUrlSearchHash({
|
||||
queryValue: activeQueryValue,
|
||||
searchBy: effectiveActiveQueryTarget,
|
||||
contentType,
|
||||
combinedMode,
|
||||
advancedFilters,
|
||||
}),
|
||||
[activeQueryValue, effectiveActiveQueryTarget, contentType, combinedMode, advancedFilters],
|
||||
);
|
||||
useSyncUrlSearchHash({ enabled: readyToSyncUrlHash, hash: urlSearchHash });
|
||||
|
||||
const activeQueryValueLabel = useMemo(() => {
|
||||
if (!activeQueryOption?.field) {
|
||||
return undefined;
|
||||
@@ -2401,7 +2444,7 @@ function App() {
|
||||
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={effectiveActiveQueryTarget}
|
||||
onQueryTargetChange={setActiveQueryTarget}
|
||||
onQueryTargetChange={handleQueryTargetChange}
|
||||
activeQueryField={activeQueryField}
|
||||
/>
|
||||
</div>
|
||||
@@ -2468,7 +2511,7 @@ function App() {
|
||||
onQueryValueChange={handleActiveQueryValueChange}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={effectiveActiveQueryTarget}
|
||||
onQueryTargetChange={setActiveQueryTarget}
|
||||
onQueryTargetChange={handleQueryTargetChange}
|
||||
showAdvanced={effectiveShowAdvanced}
|
||||
onAdvancedToggle={
|
||||
hasAdvancedContent ? () => setShowAdvanced(!effectiveShowAdvanced) : undefined
|
||||
@@ -2749,14 +2792,31 @@ function App() {
|
||||
const adminSettingsWarmup = adminSettingsWarmupKey ? (
|
||||
<AdminSettingsWarmupMount key={adminSettingsWarmupKey} />
|
||||
) : null;
|
||||
// A `search_by` deep link can name a metadata provider field that isn't in queryTargets
|
||||
// until the search-fields fetch resolves. Bootstrapping before then runs the search
|
||||
// against the wrong target *and* lets the sync effect rewrite the shared hash without
|
||||
// `search_by`, so hold the one-shot mount until the fields have settled (the session
|
||||
// resolves to null on failure, so this can't hang).
|
||||
const searchFieldsSettled =
|
||||
metadataConfigSessionKey === null ||
|
||||
activeMetadataConfigState?.sessionKey === metadataConfigSessionKey;
|
||||
const awaitingSearchByTarget = Boolean(
|
||||
parsedParams?.searchBy && !findQueryTarget(queryTargets, parsedParams.searchBy),
|
||||
);
|
||||
const urlSearchBootstrapMount =
|
||||
wasProcessed && parsedParams && config && !hasExecutedUrlSearchBootstrap ? (
|
||||
wasProcessed &&
|
||||
parsedParams &&
|
||||
config &&
|
||||
!hasExecutedUrlSearchBootstrap &&
|
||||
(searchFieldsSettled || !awaitingSearchByTarget) ? (
|
||||
<UrlSearchBootstrapMount
|
||||
key={urlSearchNonce}
|
||||
parsedParams={parsedParams}
|
||||
config={config}
|
||||
contentType={contentType}
|
||||
combinedMode={combinedMode}
|
||||
combinedModeAllowed={combinedModeAllowed}
|
||||
queryTargets={queryTargets}
|
||||
advancedFilters={advancedFilters}
|
||||
resolvedMetadataDefaultSort={resolvedMetadataDefaultSort}
|
||||
resolvedMetadataSortOptions={resolvedMetadataSortOptions}
|
||||
@@ -2766,6 +2826,7 @@ function App() {
|
||||
setAdvancedFilters={setAdvancedFilters}
|
||||
setShowAdvanced={setShowAdvanced}
|
||||
setActiveQueryTarget={setActiveQueryTarget}
|
||||
setSearchFieldValue={updateSearchFieldValue}
|
||||
runSearchWithPolicyRefresh={runSearchWithPolicyRefresh}
|
||||
onComplete={() => {
|
||||
setHasExecutedUrlSearchBootstrap(true);
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { useMountEffect } from '@/hooks/useMountEffect';
|
||||
import type { AppConfig, AdvancedFilterState, ContentType, SearchMode, SortOption } from '@/types';
|
||||
import type {
|
||||
AppConfig,
|
||||
AdvancedFilterState,
|
||||
ContentType,
|
||||
QueryTargetOption,
|
||||
SearchMode,
|
||||
SortOption,
|
||||
} from '@/types';
|
||||
import { buildSearchQuery } from '@/utils/buildSearchQuery';
|
||||
import { resolveDefaultLanguageCodes } from '@/utils/languageFilters';
|
||||
import { getEffectiveMetadataSort } from '@/utils/metadataSort';
|
||||
import type { ParsedUrlSearch } from '@/utils/parseUrlSearchParams';
|
||||
import { findQueryTarget } from '@/utils/queryTargets';
|
||||
|
||||
const ADVANCED_FILTER_VISIBILITY_KEYS = ['content', 'lang', 'formats'] as const;
|
||||
|
||||
@@ -15,6 +23,7 @@ interface UrlSearchBootstrapMountProps {
|
||||
contentType: ContentType;
|
||||
combinedMode: boolean;
|
||||
combinedModeAllowed: boolean;
|
||||
queryTargets: QueryTargetOption[];
|
||||
advancedFilters: AdvancedFilterState;
|
||||
resolvedMetadataDefaultSort: string;
|
||||
resolvedMetadataSortOptions: SortOption[];
|
||||
@@ -24,10 +33,12 @@ interface UrlSearchBootstrapMountProps {
|
||||
setAdvancedFilters: Dispatch<SetStateAction<AdvancedFilterState>>;
|
||||
setShowAdvanced: (value: boolean) => void;
|
||||
setActiveQueryTarget: (value: string) => void;
|
||||
setSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void;
|
||||
runSearchWithPolicyRefresh: (opts: {
|
||||
query: string;
|
||||
contentTypeOverride?: ContentType;
|
||||
searchModeOverride?: SearchMode;
|
||||
fieldValues?: Record<string, string | number | boolean>;
|
||||
}) => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
@@ -38,6 +49,7 @@ export const UrlSearchBootstrapMount = ({
|
||||
contentType,
|
||||
combinedMode,
|
||||
combinedModeAllowed,
|
||||
queryTargets,
|
||||
advancedFilters,
|
||||
resolvedMetadataDefaultSort,
|
||||
resolvedMetadataSortOptions,
|
||||
@@ -47,6 +59,7 @@ export const UrlSearchBootstrapMount = ({
|
||||
setAdvancedFilters,
|
||||
setShowAdvanced,
|
||||
setActiveQueryTarget,
|
||||
setSearchFieldValue,
|
||||
runSearchWithPolicyRefresh,
|
||||
onComplete,
|
||||
}: UrlSearchBootstrapMountProps) => {
|
||||
@@ -69,6 +82,15 @@ export const UrlSearchBootstrapMount = ({
|
||||
setCombinedMode(false);
|
||||
}
|
||||
|
||||
const urlSearchByTarget = findQueryTarget(queryTargets, parsedParams.searchBy);
|
||||
const urlSearchByOverride = urlSearchByTarget?.key;
|
||||
|
||||
// Search By target can be deep-linked on its own (e.g. `#search_by=manual`, no query),
|
||||
// so apply it even when there's nothing else to search for.
|
||||
if (urlSearchByOverride) {
|
||||
setActiveQueryTarget(urlSearchByOverride);
|
||||
}
|
||||
|
||||
if (!parsedParams.hasSearchParams) {
|
||||
return;
|
||||
}
|
||||
@@ -79,12 +101,8 @@ export const UrlSearchBootstrapMount = ({
|
||||
bookLanguages,
|
||||
);
|
||||
|
||||
if (parsedParams.searchInput) {
|
||||
setSearchInput(parsedParams.searchInput);
|
||||
}
|
||||
|
||||
let nextQueryTarget = 'general';
|
||||
if (parsedSearchMode === 'direct') {
|
||||
let nextQueryTarget = urlSearchByOverride || 'general';
|
||||
if (parsedSearchMode === 'direct' && !urlSearchByOverride) {
|
||||
if (parsedParams.advancedFilters.isbn) {
|
||||
nextQueryTarget = 'isbn';
|
||||
} else if (parsedParams.advancedFilters.author) {
|
||||
@@ -95,6 +113,39 @@ export const UrlSearchBootstrapMount = ({
|
||||
}
|
||||
setActiveQueryTarget(nextQueryTarget);
|
||||
|
||||
// Route `q` through the active target, mirroring how the live search dispatch reads it:
|
||||
// direct fields and text fields are typed into searchInput, other provider fields are
|
||||
// dispatched as fieldValues. Legacy links carry the value under the field's own param
|
||||
// (`?author=herbert`) instead, so fall back to that.
|
||||
const targetKey = urlSearchByTarget?.field?.key ?? nextQueryTarget;
|
||||
const legacyDirectValue =
|
||||
targetKey === 'isbn' || targetKey === 'author' || targetKey === 'title'
|
||||
? parsedParams.advancedFilters[targetKey]
|
||||
: undefined;
|
||||
const targetQueryValue = parsedParams.searchInput || legacyDirectValue || '';
|
||||
|
||||
const usesProviderFieldValue =
|
||||
urlSearchByTarget?.source === 'provider-field' &&
|
||||
urlSearchByTarget.field !== undefined &&
|
||||
urlSearchByTarget.field.type !== 'TextSearchField';
|
||||
|
||||
if (usesProviderFieldValue && urlSearchByTarget?.field && targetQueryValue) {
|
||||
setSearchFieldValue(urlSearchByTarget.field.key, targetQueryValue);
|
||||
} else if (targetQueryValue) {
|
||||
setSearchInput(targetQueryValue);
|
||||
}
|
||||
|
||||
const urlFieldValues =
|
||||
urlSearchByTarget?.source === 'provider-field' && urlSearchByTarget.field && targetQueryValue
|
||||
? { [urlSearchByTarget.field.key]: targetQueryValue }
|
||||
: undefined;
|
||||
|
||||
// Manual mode opens the release browser off an explicit submit - it has no results
|
||||
// list to bootstrap, so a deep link fills the input and stops there.
|
||||
if (urlSearchByTarget?.source === 'manual') {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedUrlMetadataSort =
|
||||
parsedSearchMode === 'universal'
|
||||
? getEffectiveMetadataSort({
|
||||
@@ -134,16 +185,13 @@ export const UrlSearchBootstrapMount = ({
|
||||
};
|
||||
|
||||
const query = buildSearchQuery({
|
||||
searchInput:
|
||||
parsedSearchMode === 'direct' && nextQueryTarget !== 'general'
|
||||
? ''
|
||||
: parsedParams.searchInput,
|
||||
searchInput: nextQueryTarget === 'general' ? targetQueryValue : '',
|
||||
showAdvanced: true,
|
||||
advancedFilters: {
|
||||
...mergedFilters,
|
||||
isbn: nextQueryTarget === 'isbn' ? parsedParams.advancedFilters.isbn || '' : '',
|
||||
author: nextQueryTarget === 'author' ? parsedParams.advancedFilters.author || '' : '',
|
||||
title: nextQueryTarget === 'title' ? parsedParams.advancedFilters.title || '' : '',
|
||||
isbn: nextQueryTarget === 'isbn' ? targetQueryValue : '',
|
||||
author: nextQueryTarget === 'author' ? targetQueryValue : '',
|
||||
title: nextQueryTarget === 'title' ? targetQueryValue : '',
|
||||
},
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
@@ -154,6 +202,7 @@ export const UrlSearchBootstrapMount = ({
|
||||
query,
|
||||
contentTypeOverride: urlContentTypeOverride,
|
||||
searchModeOverride: parsedSearchMode,
|
||||
fieldValues: urlFieldValues,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import type { ParsedUrlSearch } from '../utils/parseUrlSearchParams';
|
||||
import { parseUrlSearchParams } from '../utils/parseUrlSearchParams';
|
||||
import { useLatestCallback } from './useLatestCallback';
|
||||
import { useDependencyEffect, useMountEffect } from './useMountEffect';
|
||||
|
||||
interface UseUrlSearchOptions {
|
||||
/** Only process URL params after auth check and config are loaded */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Bump to re-read the URL - used when the hash changes underneath us
|
||||
* (someone pastes a shared link into an already-open tab).
|
||||
*/
|
||||
nonce?: number;
|
||||
}
|
||||
|
||||
interface UseUrlSearchReturn {
|
||||
@@ -16,11 +22,39 @@ interface UseUrlSearchReturn {
|
||||
wasProcessed: boolean;
|
||||
}
|
||||
|
||||
/** Debounce for the write side: searchInput changes on every keystroke, and Safari
|
||||
* throws SecurityError past ~100 replaceState calls per 30s. */
|
||||
const HASH_SYNC_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Hook to parse URL search parameters on initial page load.
|
||||
* Last hash this module wrote. Lets the hashchange listener tell "the user pasted a
|
||||
* new link" apart from "our own sync effect just ran".
|
||||
*/
|
||||
let lastWrittenHash: string | null = null;
|
||||
|
||||
const stripHash = (value: string): string => (value.startsWith('#') ? value.slice(1) : value);
|
||||
|
||||
const readUrlSearchParams = (): { params: URLSearchParams; fromQueryString: boolean } => {
|
||||
const hash = stripHash(window.location.hash);
|
||||
if (hash) {
|
||||
return { params: new URLSearchParams(hash), fromQueryString: false };
|
||||
}
|
||||
const search = window.location.search.startsWith('?')
|
||||
? window.location.search.slice(1)
|
||||
: window.location.search;
|
||||
return { params: new URLSearchParams(search), fromQueryString: Boolean(search) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to parse the URL on initial page load.
|
||||
*
|
||||
* This is a read-only operation - URL params are parsed once when enabled,
|
||||
* and the URL is not updated when users perform searches.
|
||||
* Search config lives in a hash fragment (e.g. `#q=dune&search_by=manual`)
|
||||
* rather than query params, so it stays browser-side only and never looks
|
||||
* like a server-processed query string.
|
||||
*
|
||||
* Query-string links (`?q=dune`) shipped before the hash and are still honoured
|
||||
* when the hash is empty: they're read once and rewritten to `#…` so the two
|
||||
* can't drift as the live sync below updates the URL.
|
||||
*
|
||||
* @example
|
||||
* // In App.tsx:
|
||||
@@ -33,19 +67,112 @@ interface UseUrlSearchReturn {
|
||||
* // Trigger search with parsed params
|
||||
* }
|
||||
*/
|
||||
export function useUrlSearch({ enabled }: UseUrlSearchOptions): UseUrlSearchReturn {
|
||||
const [searchParams] = useSearchParams();
|
||||
const parsedParams = useMemo(() => {
|
||||
export function useUrlSearch({ enabled, nonce = 0 }: UseUrlSearchOptions): UseUrlSearchReturn {
|
||||
const read = useMemo(() => {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseUrlSearchParams(searchParams);
|
||||
return parsed.hasSearchParams || parsed.contentType || parsed.combinedMode ? parsed : null;
|
||||
}, [enabled, searchParams]);
|
||||
const { params, fromQueryString } = readUrlSearchParams();
|
||||
const parsed = parseUrlSearchParams(params);
|
||||
const hasAnything = Boolean(
|
||||
parsed.hasSearchParams || parsed.contentType || parsed.combinedMode || parsed.searchBy,
|
||||
);
|
||||
|
||||
return { parsed: hasAnything ? parsed : null, fromQueryString, hasAnything };
|
||||
// Intentionally read once per enable/nonce - live updates come from useSyncUrlSearchHash.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, nonce]);
|
||||
|
||||
// Migrate a legacy query-string link to the hash form once, so the live sync has a single
|
||||
// source of truth and the URL the user re-shares is the one the app keeps updating.
|
||||
const shouldMigrate = Boolean(read?.fromQueryString && read.hasAnything);
|
||||
useDependencyEffect(() => {
|
||||
if (!shouldMigrate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHash = new URLSearchParams(window.location.search).toString();
|
||||
lastWrittenHash = nextHash;
|
||||
try {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}#${nextHash}`,
|
||||
);
|
||||
} catch {
|
||||
// replaceState is rate-limited in Safari - the parsed params still apply
|
||||
}
|
||||
}, [shouldMigrate]);
|
||||
|
||||
return {
|
||||
parsedParams,
|
||||
parsedParams: read?.parsed ?? null,
|
||||
wasProcessed: enabled,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseSyncUrlSearchHashOptions {
|
||||
/** Only write once URL params (if any) have been applied to search state */
|
||||
enabled: boolean;
|
||||
/** Hash fragment (without leading `#`) that should reflect current search state */
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the URL hash fragment in sync with the current search state.
|
||||
*
|
||||
* Uses history.replaceState (not pushState), so every keystroke or
|
||||
* Search By change updates the URL live without pushing a new browser
|
||||
* history entry per change. Debounced, because Safari throws SecurityError
|
||||
* past roughly 100 replaceState calls per 30 seconds.
|
||||
*/
|
||||
export function useSyncUrlSearchHash({ enabled, hash }: UseSyncUrlSearchHashOptions): void {
|
||||
useDependencyEffect(() => {
|
||||
// Debounced, so a burst of keystrokes collapses into one history write.
|
||||
const timer = enabled
|
||||
? window.setTimeout(() => {
|
||||
if (stripHash(window.location.hash) === hash) {
|
||||
lastWrittenHash = hash;
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${window.location.pathname}${window.location.search}${hash ? `#${hash}` : ''}`;
|
||||
lastWrittenHash = hash;
|
||||
try {
|
||||
window.history.replaceState(window.history.state, '', url);
|
||||
} catch {
|
||||
// Rate-limited (Safari) or a sandboxed frame - the UI state is still correct,
|
||||
// only the shareable URL lags behind.
|
||||
}
|
||||
}, HASH_SYNC_DEBOUNCE_MS)
|
||||
: undefined;
|
||||
|
||||
return () => {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [enabled, hash]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `onExternalChange` when the hash changes to something this module didn't write -
|
||||
* i.e. someone pasted a shared link into an already-open tab, or used back/forward.
|
||||
*/
|
||||
export function useExternalHashChange(onExternalChange: () => void): void {
|
||||
// The listener outlives the Effect that registers it and fires from a DOM event,
|
||||
// which is outside useEffectEvent's contract - see useLatestCallback.
|
||||
const notify = useLatestCallback(onExternalChange);
|
||||
|
||||
useMountEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
if (stripHash(window.location.hash) === lastWrittenHash) {
|
||||
return;
|
||||
}
|
||||
notify();
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,4 +60,32 @@ describe('parseUrlSearchParams', () => {
|
||||
expect(parsed.contentType).toBe(undefined);
|
||||
expect(parsed.combinedMode).toBe(true);
|
||||
});
|
||||
|
||||
it('parses search_by as the Search By target', () => {
|
||||
const parsed = parseUrlSearchParams(new URLSearchParams('search_by=manual&q=dune'));
|
||||
|
||||
expect(parsed.searchBy).toBe('manual');
|
||||
expect(parsed.searchInput).toBe('dune');
|
||||
expect(parsed.hasSearchParams).toBe(true);
|
||||
});
|
||||
|
||||
it('trims search_by but keeps its casing for case-sensitive provider field keys', () => {
|
||||
const parsed = parseUrlSearchParams(new URLSearchParams('search_by=+MANUAL+'));
|
||||
|
||||
expect(parsed.searchBy).toBe('MANUAL');
|
||||
});
|
||||
|
||||
it('keeps search_by-only links from auto-triggering a blank search', () => {
|
||||
const parsed = parseUrlSearchParams(new URLSearchParams('search_by=manual'));
|
||||
|
||||
expect(parsed.searchBy).toBe('manual');
|
||||
expect(parsed.searchInput).toBe('');
|
||||
expect(parsed.hasSearchParams).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves search_by undefined when absent', () => {
|
||||
const parsed = parseUrlSearchParams(new URLSearchParams('q=dune'));
|
||||
|
||||
expect(parsed.searchBy).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { buildQueryTargets, getDefaultQueryTargetKey } from '../utils/queryTargets';
|
||||
import {
|
||||
buildQueryTargets,
|
||||
findQueryTarget,
|
||||
getDefaultQueryTargetKey,
|
||||
} from '../utils/queryTargets';
|
||||
|
||||
describe('queryTargets', () => {
|
||||
it('builds direct-mode query targets', () => {
|
||||
@@ -44,3 +48,33 @@ describe('queryTargets', () => {
|
||||
expect(getDefaultQueryTargetKey([])).toBe('general');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findQueryTarget', () => {
|
||||
const targets = buildQueryTargets({ searchMode: 'direct' });
|
||||
|
||||
it('returns undefined for a missing or empty key', () => {
|
||||
expect(findQueryTarget(targets, undefined)).toBeUndefined();
|
||||
expect(findQueryTarget(targets, '')).toBeUndefined();
|
||||
expect(findQueryTarget(targets, 'series')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches an exact key', () => {
|
||||
expect(findQueryTarget(targets, 'author')?.key).toBe('author');
|
||||
});
|
||||
|
||||
it('falls back to a case-insensitive match for custom provider field keys', () => {
|
||||
const providerTargets = buildQueryTargets({
|
||||
searchMode: 'universal',
|
||||
metadataSearchFields: [
|
||||
{
|
||||
key: 'hardcoverList',
|
||||
label: 'List',
|
||||
type: 'TextSearchField',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(findQueryTarget(providerTargets, 'hardcoverlist')?.key).toBe('hardcoverList');
|
||||
expect(findQueryTarget(providerTargets, 'hardcoverList')?.key).toBe('hardcoverList');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getSearchByPreference, setSearchByPreference } from '../utils/searchByPreference';
|
||||
|
||||
// No jsdom in this project's vitest setup, so stub the slice of localStorage this uses.
|
||||
const makeStorage = (impl?: Partial<Storage>) => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
...impl,
|
||||
};
|
||||
};
|
||||
|
||||
const stubWindow = (storage: unknown) => {
|
||||
vi.stubGlobal('window', { localStorage: storage });
|
||||
};
|
||||
|
||||
describe('searchByPreference', () => {
|
||||
beforeEach(() => {
|
||||
stubWindow(makeStorage());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(getSearchByPreference()).toBe(null);
|
||||
});
|
||||
|
||||
it('round-trips a value through set/get', () => {
|
||||
setSearchByPreference('manual');
|
||||
expect(getSearchByPreference()).toBe('manual');
|
||||
});
|
||||
|
||||
it('overwrites a previously stored value', () => {
|
||||
setSearchByPreference('manual');
|
||||
setSearchByPreference('author');
|
||||
expect(getSearchByPreference()).toBe('author');
|
||||
});
|
||||
|
||||
it('survives storage being unavailable', () => {
|
||||
stubWindow(
|
||||
makeStorage({
|
||||
getItem: () => {
|
||||
throw new Error('storage disabled');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('storage disabled');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => setSearchByPreference('author')).not.toThrow();
|
||||
expect(getSearchByPreference()).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildUrlSearchHash } from '../utils/urlSearchHash';
|
||||
|
||||
describe('buildUrlSearchHash', () => {
|
||||
it('builds a hash reflecting query, search_by and a non-default content_type', () => {
|
||||
const hash = buildUrlSearchHash({
|
||||
queryValue: 'dune',
|
||||
searchBy: 'manual',
|
||||
contentType: 'audiobook',
|
||||
combinedMode: false,
|
||||
advancedFilters: {},
|
||||
});
|
||||
|
||||
expect(new URLSearchParams(hash).get('q')).toBe('dune');
|
||||
expect(new URLSearchParams(hash).get('search_by')).toBe('manual');
|
||||
expect(new URLSearchParams(hash).get('content_type')).toBe('audiobook');
|
||||
});
|
||||
|
||||
it('omits content_type when it is the ebook default', () => {
|
||||
const hash = buildUrlSearchHash({
|
||||
queryValue: 'dune',
|
||||
searchBy: 'general',
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
advancedFilters: {},
|
||||
});
|
||||
|
||||
expect(new URLSearchParams(hash).has('content_type')).toBe(false);
|
||||
});
|
||||
|
||||
it('omits search_by when it is the general default', () => {
|
||||
const hash = buildUrlSearchHash({
|
||||
queryValue: 'dune',
|
||||
searchBy: 'general',
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
advancedFilters: {},
|
||||
});
|
||||
|
||||
expect(new URLSearchParams(hash).has('search_by')).toBe(false);
|
||||
});
|
||||
|
||||
it('sets content_type=combined when combinedMode is true', () => {
|
||||
const hash = buildUrlSearchHash({
|
||||
queryValue: 'dune',
|
||||
searchBy: 'general',
|
||||
contentType: 'ebook',
|
||||
combinedMode: true,
|
||||
advancedFilters: {},
|
||||
});
|
||||
|
||||
expect(new URLSearchParams(hash).get('content_type')).toBe('combined');
|
||||
});
|
||||
|
||||
it('mirrors advanced filters (isbn/author/title/sort/content/lang/format)', () => {
|
||||
const hash = buildUrlSearchHash({
|
||||
queryValue: '',
|
||||
searchBy: 'author',
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
advancedFilters: {
|
||||
author: 'frank herbert',
|
||||
lang: ['en', 'de'],
|
||||
formats: ['epub'],
|
||||
sort: 'newest',
|
||||
},
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
expect(params.get('author')).toBe('frank herbert');
|
||||
expect(params.get('sort')).toBe('newest');
|
||||
expect(params.getAll('lang')).toEqual(['en', 'de']);
|
||||
expect(params.getAll('format')).toEqual(['epub']);
|
||||
});
|
||||
|
||||
it('produces an empty string when there is nothing to reflect', () => {
|
||||
const hash = buildUrlSearchHash({
|
||||
queryValue: '',
|
||||
searchBy: 'general',
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
advancedFilters: {},
|
||||
});
|
||||
|
||||
expect(hash).toBe('');
|
||||
});
|
||||
|
||||
it('serializes a non-text provider field value so the target round-trips', () => {
|
||||
const hash = buildUrlSearchHash({
|
||||
queryValue: 'id:1234',
|
||||
searchBy: 'series',
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
advancedFilters: {},
|
||||
});
|
||||
|
||||
expect(new URLSearchParams(hash).get('q')).toBe('id:1234');
|
||||
expect(new URLSearchParams(hash).get('search_by')).toBe('series');
|
||||
});
|
||||
|
||||
it('serializes numeric and checkbox field values', () => {
|
||||
const numeric = buildUrlSearchHash({
|
||||
queryValue: 2024,
|
||||
searchBy: 'year',
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
advancedFilters: {},
|
||||
});
|
||||
expect(new URLSearchParams(numeric).get('q')).toBe('2024');
|
||||
|
||||
const checked = buildUrlSearchHash({
|
||||
queryValue: true,
|
||||
searchBy: 'signed',
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
advancedFilters: {},
|
||||
});
|
||||
expect(new URLSearchParams(checked).get('q')).toBe('1');
|
||||
|
||||
const unchecked = buildUrlSearchHash({
|
||||
queryValue: false,
|
||||
searchBy: 'signed',
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
advancedFilters: {},
|
||||
});
|
||||
expect(new URLSearchParams(unchecked).has('q')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,12 @@ export interface ParsedUrlSearch {
|
||||
advancedFilters: Partial<AdvancedFilterState>;
|
||||
contentType?: ContentType;
|
||||
combinedMode?: boolean;
|
||||
/**
|
||||
* "Search By" target (e.g. general/author/title/series/manual), from the `search_by`
|
||||
* param. Kept verbatim - provider field keys are matched case-insensitively against
|
||||
* the live targets, so a custom provider's camelCase key still resolves.
|
||||
*/
|
||||
searchBy?: string;
|
||||
hasSearchParams: boolean;
|
||||
}
|
||||
|
||||
@@ -34,21 +40,28 @@ const parseContentTypeParam = (
|
||||
* In Universal mode, query/sort are used for search text, and content_type
|
||||
* selects ebook, audiobook, or combined (search both at once).
|
||||
*
|
||||
* Params live in the URL hash (legacy query strings are still accepted and
|
||||
* rewritten to a hash on load - see useUrlSearch).
|
||||
*
|
||||
* @example
|
||||
* // Direct mode: /?q=harry+potter&author=rowling&format=epub&lang=en
|
||||
* // Universal mode: /?q=dune&sort=popularity
|
||||
* // Universal combined: /?q=dune&content_type=combined
|
||||
* // Direct mode: /#q=harry+potter&author=rowling&format=epub&lang=en
|
||||
* // Universal mode: /#q=dune&sort=popularity
|
||||
* // Universal combined: /#q=dune&content_type=combined
|
||||
* // Search By deep link: /#search_by=manual&q=dune
|
||||
*/
|
||||
export function parseUrlSearchParams(searchParams: URLSearchParams): ParsedUrlSearch {
|
||||
const contentTypeParam = parseContentTypeParam(
|
||||
searchParams.get('content_type') || searchParams.get('contentType'),
|
||||
);
|
||||
|
||||
const searchByParam = (searchParams.get('search_by') || '').trim();
|
||||
|
||||
const result: ParsedUrlSearch = {
|
||||
searchInput: '',
|
||||
advancedFilters: {},
|
||||
contentType: contentTypeParam.contentType,
|
||||
combinedMode: contentTypeParam.combinedMode,
|
||||
searchBy: searchByParam || undefined,
|
||||
hasSearchParams: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -85,3 +85,24 @@ export const buildQueryTargets = ({
|
||||
export const getDefaultQueryTargetKey = (targets: QueryTargetOption[]): string => {
|
||||
return targets[0]?.key || 'general';
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a "Search By" key (e.g. from a URL hash) against the live targets.
|
||||
*
|
||||
* Exact match first, then case-insensitive: built-in keys are lowercase, but a
|
||||
* custom metadata provider can declare a camelCase field key.
|
||||
*/
|
||||
export const findQueryTarget = (
|
||||
targets: QueryTargetOption[],
|
||||
key: string | undefined,
|
||||
): QueryTargetOption | undefined => {
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
const exact = targets.find((target) => target.key === key);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
const lowered = key.toLowerCase();
|
||||
return targets.find((target) => target.key.toLowerCase() === lowered);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const SEARCH_BY_STORAGE_KEY = 'shelfmark_search_by';
|
||||
|
||||
/**
|
||||
* Reads the client-side default "Search By" target the user last picked.
|
||||
*
|
||||
* localStorage (not a user account/setting) so it works without any server-side
|
||||
* user management, and unlike a cookie it isn't sent on every HTTP request -
|
||||
* it's a purely browser-side UI preference.
|
||||
*/
|
||||
export const getSearchByPreference = (): string | null => {
|
||||
try {
|
||||
return window.localStorage.getItem(SEARCH_BY_STORAGE_KEY);
|
||||
} catch {
|
||||
// localStorage can throw when storage is disabled or the quota is exhausted
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setSearchByPreference = (value: string): void => {
|
||||
try {
|
||||
window.localStorage.setItem(SEARCH_BY_STORAGE_KEY, value);
|
||||
} catch {
|
||||
// Preference is best-effort - a failure here must not break search
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { AdvancedFilterState, ContentType } from '../types';
|
||||
|
||||
export interface UrlSearchHashState {
|
||||
/**
|
||||
* Value of the active "Search By" target - the text input for general/direct/text
|
||||
* fields, or the selected provider-field value. Serialized as `q` so a link
|
||||
* round-trips back into whichever target `searchBy` names.
|
||||
*/
|
||||
queryValue: string | number | boolean;
|
||||
searchBy: string;
|
||||
contentType: ContentType;
|
||||
combinedMode: boolean;
|
||||
advancedFilters: Partial<AdvancedFilterState>;
|
||||
}
|
||||
|
||||
const serializeQueryValue = (value: string | number | boolean): string => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '1' : '';
|
||||
}
|
||||
return typeof value === 'number' ? String(value) : value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the URL hash fragment (without the leading `#`) that mirrors the
|
||||
* current search state, using the same param names parseUrlSearchParams reads.
|
||||
*
|
||||
* Kept as a hash fragment (not query params) so it stays browser-side only,
|
||||
* rather than looking like a server-processed query string.
|
||||
*/
|
||||
export const buildUrlSearchHash = (state: UrlSearchHashState): string => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const queryValue = serializeQueryValue(state.queryValue);
|
||||
if (queryValue) {
|
||||
params.set('q', queryValue);
|
||||
}
|
||||
if (state.searchBy && state.searchBy !== 'general') {
|
||||
params.set('search_by', state.searchBy);
|
||||
}
|
||||
if (state.combinedMode) {
|
||||
params.set('content_type', 'combined');
|
||||
} else if (state.contentType && state.contentType !== 'ebook') {
|
||||
// 'ebook' is the app's default content type - omit it like 'general' search_by,
|
||||
// so a plain default-state URL doesn't carry a hash at all.
|
||||
params.set('content_type', state.contentType);
|
||||
}
|
||||
|
||||
const { isbn, author, title, sort, content, lang, formats } = state.advancedFilters;
|
||||
if (isbn) params.set('isbn', isbn);
|
||||
if (author) params.set('author', author);
|
||||
if (title) params.set('title', title);
|
||||
if (sort) params.set('sort', sort);
|
||||
if (content) params.set('content', content);
|
||||
for (const value of lang ?? []) {
|
||||
if (value) params.append('lang', value);
|
||||
}
|
||||
for (const value of formats ?? []) {
|
||||
if (value) params.append('format', value);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
Reference in New Issue
Block a user