Fix issues with search url param

This commit is contained in:
Bill Thornton 2024-02-13 01:09:08 -05:00
parent d6b8ce0f49
commit bdecaa9930
8 changed files with 256 additions and 189 deletions

29
package-lock.json generated
View File

@ -58,6 +58,7 @@
"screenfull": "6.0.2", "screenfull": "6.0.2",
"sortablejs": "1.15.2", "sortablejs": "1.15.2",
"swiper": "11.0.5", "swiper": "11.0.5",
"usehooks-ts": "2.14.0",
"webcomponents.js": "0.7.24", "webcomponents.js": "0.7.24",
"whatwg-fetch": "3.6.20" "whatwg-fetch": "3.6.20"
}, },
@ -12671,8 +12672,7 @@
"node_modules/lodash.debounce": { "node_modules/lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
"dev": true
}, },
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
@ -21900,6 +21900,20 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/usehooks-ts": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz",
"integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -31866,8 +31880,7 @@
"lodash.debounce": { "lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
"dev": true
}, },
"lodash.memoize": { "lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
@ -38668,6 +38681,14 @@
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"requires": {} "requires": {}
}, },
"usehooks-ts": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz",
"integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==",
"requires": {
"lodash.debounce": "^4.0.8"
}
},
"util-deprecate": { "util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -119,6 +119,7 @@
"screenfull": "6.0.2", "screenfull": "6.0.2",
"sortablejs": "1.15.2", "sortablejs": "1.15.2",
"swiper": "11.0.5", "swiper": "11.0.5",
"usehooks-ts": "2.14.0",
"webcomponents.js": "0.7.24", "webcomponents.js": "0.7.24",
"whatwg-fetch": "3.6.20" "whatwg-fetch": "3.6.20"
}, },

View File

@ -1,41 +1,36 @@
import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; import React, { type FC, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import Page from '../../../components/Page'; import Page from 'components/Page';
import SearchFields from '../../../components/search/SearchFields'; import SearchFields from 'components/search/SearchFields';
import SearchResults from '../../../components/search/SearchResults'; import SearchResults from 'components/search/SearchResults';
import SearchSuggestions from '../../../components/search/SearchSuggestions'; import SearchSuggestions from 'components/search/SearchSuggestions';
import LiveTVSearchResults from '../../../components/search/LiveTVSearchResults'; import LiveTVSearchResults from 'components/search/LiveTVSearchResults';
import globalize from '../../../scripts/globalize'; import { usePrevious } from 'hooks/usePrevious';
import { history } from '../../../components/router/appRouter'; import globalize from 'scripts/globalize';
function usePrevious(value: string) { const Search: FC = () => {
const ref = useRef<string>(''); const navigate = useNavigate();
useEffect(() => { const [ searchParams, setSearchParams ] = useSearchParams();
ref.current = value;
});
return ref.current;
}
const Search: FunctionComponent = () => {
const [ searchParams ] = useSearchParams();
const urlQuery = searchParams.get('query') || ''; const urlQuery = searchParams.get('query') || '';
const [ query, setQuery ] = useState<string>(urlQuery); const [ query, setQuery ] = useState(urlQuery);
const prevQuery = usePrevious(query); const prevQuery = usePrevious(query, '');
if (query == prevQuery && urlQuery != query) {
setQuery(urlQuery);
}
useEffect(() => { useEffect(() => {
const newSearch = query ? `?query=${query}` : ''; if (query !== prevQuery) {
if (query != prevQuery && newSearch != history.location.search) { if (query === '' && urlQuery !== '') {
/* Explicitly using `window.history.pushState` instead of `history.replace` as the use of the latter // The query input has been cleared; navigate back to the search landing page
triggers a re-rendering of this component, resulting in double-execution searches. If there's a navigate(-1);
way to use `history` without this side effect, it would likely be preferable. */ } else if (query !== urlQuery) {
window.history.pushState({}, '', `/#${history.location.pathname}${newSearch}`); // Update the query url param value
searchParams.set('query', query);
setSearchParams(searchParams, { replace: !!urlQuery });
}
} else if (query !== urlQuery) {
// Update the query if the query url param has changed
setQuery(urlQuery);
} }
}, [query, prevQuery]); }, [query, prevQuery, navigate, searchParams, setSearchParams, urlQuery]);
return ( return (
<Page <Page

View File

@ -2,7 +2,8 @@ import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import type { ApiClient } from 'jellyfin-apiclient'; import type { ApiClient } from 'jellyfin-apiclient';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { FunctionComponent, useEffect, useState } from 'react'; import React, { type FC, useCallback, useEffect, useState } from 'react';
import { useDebounceValue } from 'usehooks-ts';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections'; import ServerConnections from '../ServerConnections';
@ -30,7 +31,7 @@ type LiveTVSearchResultsProps = {
/* /*
* React component to display search result rows for live tv library search * React component to display search result rows for live tv library search
*/ */
const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => { const LiveTVSearchResults: FC<LiveTVSearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => {
const [ movies, setMovies ] = useState<BaseItemDto[]>([]); const [ movies, setMovies ] = useState<BaseItemDto[]>([]);
const [ episodes, setEpisodes ] = useState<BaseItemDto[]>([]); const [ episodes, setEpisodes ] = useState<BaseItemDto[]>([]);
const [ sports, setSports ] = useState<BaseItemDto[]>([]); const [ sports, setSports ] = useState<BaseItemDto[]>([]);
@ -38,23 +39,24 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
const [ news, setNews ] = useState<BaseItemDto[]>([]); const [ news, setNews ] = useState<BaseItemDto[]>([]);
const [ programs, setPrograms ] = useState<BaseItemDto[]>([]); const [ programs, setPrograms ] = useState<BaseItemDto[]>([]);
const [ channels, setChannels ] = useState<BaseItemDto[]>([]); const [ channels, setChannels ] = useState<BaseItemDto[]>([]);
const [ debouncedQuery ] = useDebounceValue(query, 500);
const getDefaultParameters = useCallback(() => ({
ParentId: parentId,
searchTerm: debouncedQuery,
Limit: 24,
Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
}), [ parentId, debouncedQuery ]);
useEffect(() => { useEffect(() => {
const getDefaultParameters = () => ({
ParentId: parentId,
searchTerm: query,
Limit: 24,
Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
});
const fetchItems = (apiClient: ApiClient, params = {}) => apiClient?.getItems( const fetchItems = (apiClient: ApiClient, params = {}) => apiClient?.getItems(
apiClient?.getCurrentUserId(), apiClient?.getCurrentUserId(),
{ {
@ -73,65 +75,67 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
setPrograms([]); setPrograms([]);
setChannels([]); setChannels([]);
if (query && collectionType === CollectionType.Livetv) { if (!debouncedQuery || collectionType !== CollectionType.Livetv) {
const apiClient = ServerConnections.getApiClient(serverId); return;
// Movies row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: true
})
.then(result => setMovies(result.Items || []))
.catch(() => setMovies([]));
// Episodes row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: true,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setEpisodes(result.Items || []))
.catch(() => setEpisodes([]));
// Sports row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsSports: true
})
.then(result => setSports(result.Items || []))
.catch(() => setSports([]));
// Kids row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsKids: true
})
.then(result => setKids(result.Items || []))
.catch(() => setKids([]));
// News row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsNews: true
})
.then(result => setNews(result.Items || []))
.catch(() => setNews([]));
// Programs row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: false,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setPrograms(result.Items || []))
.catch(() => setPrograms([]));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items || []))
.catch(() => setChannels([]));
} }
}, [collectionType, parentId, query, serverId]);
const apiClient = ServerConnections.getApiClient(serverId);
// Movies row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: true
})
.then(result => setMovies(result.Items || []))
.catch(() => setMovies([]));
// Episodes row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: true,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setEpisodes(result.Items || []))
.catch(() => setEpisodes([]));
// Sports row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsSports: true
})
.then(result => setSports(result.Items || []))
.catch(() => setSports([]));
// Kids row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsKids: true
})
.then(result => setKids(result.Items || []))
.catch(() => setKids([]));
// News row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsNews: true
})
.then(result => setNews(result.Items || []))
.catch(() => setNews([]));
// Programs row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: false,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setPrograms(result.Items || []))
.catch(() => setPrograms([]));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items || []))
.catch(() => setChannels([]));
}, [collectionType, debouncedQuery, getDefaultParameters, parentId, serverId]);
return ( return (
<div <div
@ -139,7 +143,7 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
'searchResults', 'searchResults',
'padded-bottom-page', 'padded-bottom-page',
'padded-top', 'padded-top',
{ 'hide': !query || collectionType !== CollectionType.Livetv } { 'hide': !debouncedQuery || collectionType !== CollectionType.Livetv }
)} )}
> >
<SearchResultsRow <SearchResultsRow

View File

@ -1,95 +1,61 @@
import debounce from 'lodash-es/debounce'; import React, { type ChangeEvent, type FC, useCallback } from 'react';
import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react';
import AlphaPicker from '../alphaPicker/AlphaPickerComponent'; import AlphaPicker from '../alphaPicker/AlphaPickerComponent';
import Input from 'elements/emby-input/Input';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
import 'material-design-icons-iconfont';
import '../../elements/emby-input/emby-input';
import '../../styles/flexstyles.scss';
import './searchfields.scss';
import layoutManager from '../layoutManager'; import layoutManager from '../layoutManager';
import browser from '../../scripts/browser'; import browser from '../../scripts/browser';
// There seems to be some compatibility issues here between import 'material-design-icons-iconfont';
// React and our legacy web components, so we need to inject
// them as an html string for now =/
const createInputElement = () => ({
__html: `<input
is="emby-input"
class="searchfields-txtSearch"
type="text"
data-keyboard="true"
placeholder="${globalize.translate('Search')}"
autocomplete="off"
maxlength="40"
autofocus
/>`
});
const normalizeInput = (value = '') => value.trim(); import '../../styles/flexstyles.scss';
import './searchfields.scss';
type SearchFieldsProps = { type SearchFieldsProps = {
query: string, query: string,
onSearch?: (query: string) => void onSearch?: (query: string) => void
}; };
// eslint-disable-next-line @typescript-eslint/no-empty-function const SearchFields: FC<SearchFieldsProps> = ({
const SearchFields: FunctionComponent<SearchFieldsProps> = ({ onSearch = () => {}, query }: SearchFieldsProps) => { onSearch = () => { /* no-op */ },
const element = useRef<HTMLDivElement>(null); query
}: SearchFieldsProps) => {
const getSearchInput = () => element?.current?.querySelector<HTMLInputElement>('.searchfields-txtSearch');
const debouncedOnSearch = useMemo(() => debounce(onSearch, 400), [onSearch]);
const initSearchInput = getSearchInput();
if (initSearchInput) {
initSearchInput.value = query;
}
useEffect(() => {
getSearchInput()?.addEventListener('input', e => {
debouncedOnSearch(normalizeInput((e.target as HTMLInputElement).value));
});
getSearchInput()?.focus();
return () => {
debouncedOnSearch.cancel();
};
}, [debouncedOnSearch]);
const onAlphaPicked = useCallback((e: Event) => { const onAlphaPicked = useCallback((e: Event) => {
const value = (e as CustomEvent).detail.value; const value = (e as CustomEvent).detail.value;
const searchInput = getSearchInput();
if (!searchInput) {
console.error('Unexpected null reference');
return;
}
if (value === 'backspace') { if (value === 'backspace') {
const currentValue = searchInput.value; onSearch(query.length ? query.substring(0, query.length - 1) : '');
searchInput.value = currentValue.length ? currentValue.substring(0, currentValue.length - 1) : '';
} else { } else {
searchInput.value += value; onSearch(query + value);
} }
}, [ onSearch, query ]);
searchInput.dispatchEvent(new CustomEvent('input', { bubbles: true })); const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
}, []); onSearch(e.target.value);
}, [ onSearch ]);
return ( return (
<div <div className='padded-left padded-right searchFields'>
className='padded-left padded-right searchFields'
ref={element}
>
<div className='searchFieldsInner flex align-items-center justify-content-center'> <div className='searchFieldsInner flex align-items-center justify-content-center'>
<span className='searchfields-icon material-icons search' aria-hidden='true' /> <span className='searchfields-icon material-icons search' aria-hidden='true' />
<div <div
className='inputContainer flex-grow' className='inputContainer flex-grow'
style={{ marginBottom: 0 }} style={{ marginBottom: 0 }}
dangerouslySetInnerHTML={createInputElement()} >
/> <Input
id='searchTextInput'
className='searchfields-txtSearch'
type='text'
data-keyboard='true'
placeholder={globalize.translate('Search')}
autoComplete='off'
maxLength={40}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={query}
onChange={onChange}
/>
</div>
</div> </div>
{layoutManager.tv && !browser.tv {layoutManager.tv && !browser.tv
&& <AlphaPicker onAlphaPicked={onAlphaPicked} /> && <AlphaPicker onAlphaPicked={onAlphaPicked} />

View File

@ -1,8 +1,9 @@
import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import type { ApiClient } from 'jellyfin-apiclient'; import type { ApiClient } from 'jellyfin-apiclient';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react'; import React, { type FC, useCallback, useEffect, useState } from 'react';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useDebounceValue } from 'usehooks-ts';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections'; import ServerConnections from '../ServerConnections';
@ -30,7 +31,7 @@ const isTVShows = (collectionType: string) => collectionType === CollectionType.
/* /*
* React component to display search result rows for global search and non-live tv library search * React component to display search result rows for global search and non-live tv library search
*/ */
const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => { const SearchResults: FC<SearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => {
const [ movies, setMovies ] = useState<BaseItemDto[]>([]); const [ movies, setMovies ] = useState<BaseItemDto[]>([]);
const [ shows, setShows ] = useState<BaseItemDto[]>([]); const [ shows, setShows ] = useState<BaseItemDto[]>([]);
const [ episodes, setEpisodes ] = useState<BaseItemDto[]>([]); const [ episodes, setEpisodes ] = useState<BaseItemDto[]>([]);
@ -47,11 +48,12 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
const [ books, setBooks ] = useState<BaseItemDto[]>([]); const [ books, setBooks ] = useState<BaseItemDto[]>([]);
const [ people, setPeople ] = useState<BaseItemDto[]>([]); const [ people, setPeople ] = useState<BaseItemDto[]>([]);
const [ collections, setCollections ] = useState<BaseItemDto[]>([]); const [ collections, setCollections ] = useState<BaseItemDto[]>([]);
const [isLoading, setIsLoading] = useState(false); const [ isLoading, setIsLoading ] = useState(false);
const [ debouncedQuery ] = useDebounceValue(query, 500);
const getDefaultParameters = useCallback(() => ({ const getDefaultParameters = useCallback(() => ({
ParentId: parentId, ParentId: parentId,
searchTerm: query, searchTerm: debouncedQuery,
Limit: 100, Limit: 100,
Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount', Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
Recursive: true, Recursive: true,
@ -62,7 +64,7 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
IncludeGenres: false, IncludeGenres: false,
IncludeStudios: false, IncludeStudios: false,
IncludeArtists: false IncludeArtists: false
}), [parentId, query]); }), [ parentId, debouncedQuery ]);
const fetchArtists = useCallback((apiClient: ApiClient, params = {}) => ( const fetchArtists = useCallback((apiClient: ApiClient, params = {}) => (
apiClient?.getArtists( apiClient?.getArtists(
@ -97,6 +99,10 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
).then(ensureNonNullItems) ).then(ensureNonNullItems)
), [getDefaultParameters]); ), [getDefaultParameters]);
useEffect(() => {
if (query) setIsLoading(true);
}, [ query ]);
useEffect(() => { useEffect(() => {
// Reset state // Reset state
setMovies([]); setMovies([]);
@ -116,13 +122,11 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
setPeople([]); setPeople([]);
setCollections([]); setCollections([]);
if (!query) { if (!debouncedQuery) {
setIsLoading(false); setIsLoading(false);
return; return;
} }
setIsLoading(true);
const apiClient = ServerConnections.getApiClient(serverId); const apiClient = ServerConnections.getApiClient(serverId);
const fetchPromises = []; const fetchPromises = [];
@ -230,7 +234,7 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
console.error('An error occurred while fetching data:', error); console.error('An error occurred while fetching data:', error);
setIsLoading(false); // Set loading to false even if an error occurs setIsLoading(false); // Set loading to false even if an error occurs
}); });
}, [collectionType, fetchArtists, fetchItems, fetchPeople, query, serverId]); }, [collectionType, fetchArtists, fetchItems, fetchPeople, debouncedQuery, serverId]);
const allEmpty = [movies, shows, episodes, videos, programs, channels, playlists, artists, albums, songs, photoAlbums, photos, audioBooks, books, people, collections].every(arr => arr.length === 0); const allEmpty = [movies, shows, episodes, videos, programs, channels, playlists, artists, albums, songs, photoAlbums, photos, audioBooks, books, people, collections].every(arr => arr.length === 0);
@ -240,7 +244,7 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
'searchResults', 'searchResults',
'padded-bottom-page', 'padded-bottom-page',
'padded-top', 'padded-top',
{ 'hide': !query || collectionType === CollectionType.Livetv } { 'hide': !debouncedQuery || collectionType === CollectionType.Livetv }
)} )}
> >
{isLoading ? ( {isLoading ? (
@ -335,8 +339,8 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
cardOptions={{ coverImage: true }} cardOptions={{ coverImage: true }}
/> />
{allEmpty && query && !isLoading && ( {allEmpty && debouncedQuery && !isLoading && (
<div className='sorry-text'>{globalize.translate('SearchResultsEmpty', query)}</div> <div className='sorry-text'>{globalize.translate('SearchResultsEmpty', debouncedQuery)}</div>
)} )}
</> </>
)} )}

View File

@ -0,0 +1,59 @@
import React, { type DetailedHTMLProps, type InputHTMLAttributes, type FC, useState, useCallback } from 'react';
import './emby-input.scss';
import classNames from 'classnames';
interface InputProps extends DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
id: string,
label?: string
}
const Input: FC<InputProps> = ({
id,
label,
className,
onBlur,
onFocus,
...props
}) => {
const [ isFocused, setIsFocused ] = useState(false);
const onBlurInternal = useCallback(e => {
setIsFocused(false);
onBlur?.(e);
}, [ onBlur ]);
const onFocusInternal = useCallback(e => {
setIsFocused(true);
onFocus?.(e);
}, [ onFocus ]);
return (
<>
<label
htmlFor={id}
className={classNames(
'inputLabel',
{
inputLabelUnfocused: !isFocused,
inputLabelFocused: isFocused
}
)}
>
{label}
</label>
<input
id={id}
className={classNames(
'emby-input',
className
)}
onBlur={onBlurInternal}
onFocus={onFocusInternal}
{...props}
/>
</>
);
};
export default Input;

17
src/hooks/usePrevious.ts Normal file
View File

@ -0,0 +1,17 @@
import { useEffect, useRef } from 'react';
/**
* A hook that returns the previous value of a stateful value.
* @param value A stateful value created by a `useState` hook.
* @param initialValue The default value.
* @returns The previous value.
*/
export function usePrevious<T>(value: T, initialValue?: T): T | undefined {
const ref = useRef<T | undefined>(initialValue);
useEffect(() => {
ref.current = value;
});
return ref.current;
}