From ed3e91086031171f3ab6f045f0bb962e46a8a034 Mon Sep 17 00:00:00 2001 From: Brad Beattie Date: Wed, 18 Oct 2023 10:10:35 -0700 Subject: [PATCH 01/13] Update search.tsx --- src/apps/stable/routes/search.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index c9285f6075..10583c381e 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -11,6 +11,7 @@ import globalize from '../../../scripts/globalize'; const Search: FunctionComponent = () => { const [ query, setQuery ] = useState(); const [ searchParams ] = useSearchParams(); + if (!query && searchParams.get('q')) setQuery(searchParams.get('q') || ''); return ( Date: Wed, 18 Oct 2023 10:15:04 -0700 Subject: [PATCH 02/13] Update search.tsx --- src/apps/stable/routes/search.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index 10583c381e..ca7764f309 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -11,7 +11,7 @@ import globalize from '../../../scripts/globalize'; const Search: FunctionComponent = () => { const [ query, setQuery ] = useState(); const [ searchParams ] = useSearchParams(); - if (!query && searchParams.get('q')) setQuery(searchParams.get('q') || ''); + if (!query && searchParams.get('query')) setQuery(searchParams.get('query') || ''); return ( Date: Wed, 18 Oct 2023 10:15:52 -0700 Subject: [PATCH 03/13] Update index.js --- src/controllers/itemDetails/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/itemDetails/index.js b/src/controllers/itemDetails/index.js index 58e780bd7b..99f6d76f05 100644 --- a/src/controllers/itemDetails/index.js +++ b/src/controllers/itemDetails/index.js @@ -1258,11 +1258,11 @@ function renderTags(page, item) { } for (let i = 0, length = tags.length; i < length; i++) { - tagElements.push(tags[i]); + tagElements.push('' + tags[i] + ''); } if (tagElements.length) { - itemTags.innerText = globalize.translate('TagsValue', tagElements.join(', ')); + itemTags.innerHTML = globalize.translate('TagsValue', tagElements.join(', ')); itemTags.classList.remove('hide'); } else { itemTags.innerHTML = ''; From 6a1706ba78c41f24261350d272ce62d8a4241df3 Mon Sep 17 00:00:00 2001 From: Brad Beattie Date: Thu, 19 Oct 2023 10:22:34 -0700 Subject: [PATCH 04/13] Update SearchFields.tsx --- src/components/search/SearchFields.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/search/SearchFields.tsx b/src/components/search/SearchFields.tsx index cdc8c69a3c..70bbb8a76f 100644 --- a/src/components/search/SearchFields.tsx +++ b/src/components/search/SearchFields.tsx @@ -31,17 +31,23 @@ const createInputElement = () => ({ const normalizeInput = (value = '') => value.trim(); type SearchFieldsProps = { + query: string, onSearch?: (query: string) => void }; // eslint-disable-next-line @typescript-eslint/no-empty-function -const SearchFields: FunctionComponent = ({ onSearch = () => {} }: SearchFieldsProps) => { +const SearchFields: FunctionComponent = ({ onSearch = () => {}, query }: SearchFieldsProps) => { const element = useRef(null); const getSearchInput = () => element?.current?.querySelector('.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)); From ba099a0e3d600ee11287d47a8e0d6ef7dffa35a4 Mon Sep 17 00:00:00 2001 From: Brad Beattie Date: Thu, 19 Oct 2023 10:23:16 -0700 Subject: [PATCH 05/13] Update search.tsx --- src/apps/stable/routes/search.tsx | 34 +++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index ca7764f309..882e97d447 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useState } from 'react'; +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import Page from '../../../components/Page'; @@ -7,11 +7,37 @@ import SearchResults from '../../../components/search/SearchResults'; import SearchSuggestions from '../../../components/search/SearchSuggestions'; import LiveTVSearchResults from '../../../components/search/LiveTVSearchResults'; import globalize from '../../../scripts/globalize'; +import { history } from '../../../components/router/appRouter'; + +function usePrevious(value: string) { + const ref = useRef(''); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} const Search: FunctionComponent = () => { - const [ query, setQuery ] = useState(); const [ searchParams ] = useSearchParams(); - if (!query && searchParams.get('query')) setQuery(searchParams.get('query') || ''); + const [ query, setQuery ] = useState(searchParams.get('query') || ''); + const prevQuery = usePrevious(query); + + console.error('Search component initialized', { 'urlParam': searchParams.get('query'), 'obj.query': query, 'prevQuery': prevQuery }); + if (query == prevQuery && searchParams.get('query') != query) { + console.error('SET QUERY VIA URL', searchParams.get('query')); + setQuery(searchParams.get('query') || ''); + } + + useEffect(() => { + const newSearch = query ? `?query=${query}` : ''; + if (query != prevQuery && newSearch != history.location.search) { + /* Explicitly using `window.history.pushState` instead of `history.replace` as the use of the latter + triggers a re-rendering of this component, resulting in double-execution searches. If there's a + way to use `history` without this side effect, it would likely be preferable. */ + console.error('PUSH STATE VIA QUERY', query); + window.history.pushState({}, '', `/#${history.location.pathname}${newSearch}`); + } + }, [query, prevQuery]); return ( { title={globalize.translate('Search')} className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage' > - + {!query && Date: Thu, 19 Oct 2023 10:23:52 -0700 Subject: [PATCH 06/13] Update search.tsx --- src/apps/stable/routes/search.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index 882e97d447..309e7820cf 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -22,9 +22,7 @@ const Search: FunctionComponent = () => { const [ query, setQuery ] = useState(searchParams.get('query') || ''); const prevQuery = usePrevious(query); - console.error('Search component initialized', { 'urlParam': searchParams.get('query'), 'obj.query': query, 'prevQuery': prevQuery }); if (query == prevQuery && searchParams.get('query') != query) { - console.error('SET QUERY VIA URL', searchParams.get('query')); setQuery(searchParams.get('query') || ''); } @@ -34,7 +32,6 @@ const Search: FunctionComponent = () => { /* Explicitly using `window.history.pushState` instead of `history.replace` as the use of the latter triggers a re-rendering of this component, resulting in double-execution searches. If there's a way to use `history` without this side effect, it would likely be preferable. */ - console.error('PUSH STATE VIA QUERY', query); window.history.pushState({}, '', `/#${history.location.pathname}${newSearch}`); } }, [query, prevQuery]); From d6b8ce0f49e011210c76f75c4c8c6abf16c9e15d Mon Sep 17 00:00:00 2001 From: Brad Beattie Date: Thu, 19 Oct 2023 14:51:11 -0700 Subject: [PATCH 07/13] Update search.tsx --- src/apps/stable/routes/search.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index 309e7820cf..095f735852 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -19,11 +19,12 @@ function usePrevious(value: string) { const Search: FunctionComponent = () => { const [ searchParams ] = useSearchParams(); - const [ query, setQuery ] = useState(searchParams.get('query') || ''); + const urlQuery = searchParams.get('query') || ''; + const [ query, setQuery ] = useState(urlQuery); const prevQuery = usePrevious(query); - if (query == prevQuery && searchParams.get('query') != query) { - setQuery(searchParams.get('query') || ''); + if (query == prevQuery && urlQuery != query) { + setQuery(urlQuery); } useEffect(() => { From bdecaa99306700d7385b2e2deb785ca5a1c51d92 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 13 Feb 2024 01:09:08 -0500 Subject: [PATCH 08/13] Fix issues with search url param --- package-lock.json | 29 +++- package.json | 1 + src/apps/stable/routes/search.tsx | 59 +++---- src/components/search/LiveTVSearchResults.tsx | 156 +++++++++--------- src/components/search/SearchFields.tsx | 96 ++++------- src/components/search/SearchResults.tsx | 28 ++-- src/elements/emby-input/Input.tsx | 59 +++++++ src/hooks/usePrevious.ts | 17 ++ 8 files changed, 256 insertions(+), 189 deletions(-) create mode 100644 src/elements/emby-input/Input.tsx create mode 100644 src/hooks/usePrevious.ts diff --git a/package-lock.json b/package-lock.json index cf80543d0a..c371ea7149 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "screenfull": "6.0.2", "sortablejs": "1.15.2", "swiper": "11.0.5", + "usehooks-ts": "2.14.0", "webcomponents.js": "0.7.24", "whatwg-fetch": "3.6.20" }, @@ -12671,8 +12672,7 @@ "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -21900,6 +21900,20 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -31866,8 +31880,7 @@ "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, "lodash.memoize": { "version": "4.1.2", @@ -38668,6 +38681,14 @@ "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 91b87c28f3..ee026b811d 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "screenfull": "6.0.2", "sortablejs": "1.15.2", "swiper": "11.0.5", + "usehooks-ts": "2.14.0", "webcomponents.js": "0.7.24", "whatwg-fetch": "3.6.20" }, diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index 095f735852..da55e9d8e8 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -1,41 +1,36 @@ -import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import React, { type FC, useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; -import Page from '../../../components/Page'; -import SearchFields from '../../../components/search/SearchFields'; -import SearchResults from '../../../components/search/SearchResults'; -import SearchSuggestions from '../../../components/search/SearchSuggestions'; -import LiveTVSearchResults from '../../../components/search/LiveTVSearchResults'; -import globalize from '../../../scripts/globalize'; -import { history } from '../../../components/router/appRouter'; +import Page from 'components/Page'; +import SearchFields from 'components/search/SearchFields'; +import SearchResults from 'components/search/SearchResults'; +import SearchSuggestions from 'components/search/SearchSuggestions'; +import LiveTVSearchResults from 'components/search/LiveTVSearchResults'; +import { usePrevious } from 'hooks/usePrevious'; +import globalize from 'scripts/globalize'; -function usePrevious(value: string) { - const ref = useRef(''); - useEffect(() => { - ref.current = value; - }); - return ref.current; -} - -const Search: FunctionComponent = () => { - const [ searchParams ] = useSearchParams(); +const Search: FC = () => { + const navigate = useNavigate(); + const [ searchParams, setSearchParams ] = useSearchParams(); const urlQuery = searchParams.get('query') || ''; - const [ query, setQuery ] = useState(urlQuery); - const prevQuery = usePrevious(query); - - if (query == prevQuery && urlQuery != query) { - setQuery(urlQuery); - } + const [ query, setQuery ] = useState(urlQuery); + const prevQuery = usePrevious(query, ''); useEffect(() => { - const newSearch = query ? `?query=${query}` : ''; - if (query != prevQuery && newSearch != history.location.search) { - /* Explicitly using `window.history.pushState` instead of `history.replace` as the use of the latter - triggers a re-rendering of this component, resulting in double-execution searches. If there's a - way to use `history` without this side effect, it would likely be preferable. */ - window.history.pushState({}, '', `/#${history.location.pathname}${newSearch}`); + if (query !== prevQuery) { + if (query === '' && urlQuery !== '') { + // The query input has been cleared; navigate back to the search landing page + navigate(-1); + } else if (query !== urlQuery) { + // 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 ( = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => { +const LiveTVSearchResults: FC = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => { const [ movies, setMovies ] = useState([]); const [ episodes, setEpisodes ] = useState([]); const [ sports, setSports ] = useState([]); @@ -38,23 +39,24 @@ const LiveTVSearchResults: FunctionComponent = ({ serv const [ news, setNews ] = useState([]); const [ programs, setPrograms ] = useState([]); const [ channels, setChannels ] = useState([]); + 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(() => { - 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( apiClient?.getCurrentUserId(), { @@ -73,65 +75,67 @@ const LiveTVSearchResults: FunctionComponent = ({ serv setPrograms([]); setChannels([]); - if (query && collectionType === CollectionType.Livetv) { - 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([])); + if (!debouncedQuery || collectionType !== CollectionType.Livetv) { + return; } - }, [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 (
= ({ serv 'searchResults', 'padded-bottom-page', 'padded-top', - { 'hide': !query || collectionType !== CollectionType.Livetv } + { 'hide': !debouncedQuery || collectionType !== CollectionType.Livetv } )} > ({ - __html: `` -}); +import 'material-design-icons-iconfont'; -const normalizeInput = (value = '') => value.trim(); +import '../../styles/flexstyles.scss'; +import './searchfields.scss'; type SearchFieldsProps = { query: string, onSearch?: (query: string) => void }; -// eslint-disable-next-line @typescript-eslint/no-empty-function -const SearchFields: FunctionComponent = ({ onSearch = () => {}, query }: SearchFieldsProps) => { - const element = useRef(null); - - const getSearchInput = () => element?.current?.querySelector('.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 SearchFields: FC = ({ + onSearch = () => { /* no-op */ }, + query +}: SearchFieldsProps) => { const onAlphaPicked = useCallback((e: Event) => { const value = (e as CustomEvent).detail.value; - const searchInput = getSearchInput(); - - if (!searchInput) { - console.error('Unexpected null reference'); - return; - } if (value === 'backspace') { - const currentValue = searchInput.value; - searchInput.value = currentValue.length ? currentValue.substring(0, currentValue.length - 1) : ''; + onSearch(query.length ? query.substring(0, query.length - 1) : ''); } else { - searchInput.value += value; + onSearch(query + value); } + }, [ onSearch, query ]); - searchInput.dispatchEvent(new CustomEvent('input', { bubbles: true })); - }, []); + const onChange = useCallback((e: ChangeEvent) => { + onSearch(e.target.value); + }, [ onSearch ]); return ( -
+
{layoutManager.tv && !browser.tv && diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx index e267a4f5db..dbba7eb709 100644 --- a/src/components/search/SearchResults.tsx +++ b/src/components/search/SearchResults.tsx @@ -1,8 +1,9 @@ import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; import type { ApiClient } from 'jellyfin-apiclient'; 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 { useDebounceValue } from 'usehooks-ts'; import globalize from '../../scripts/globalize'; 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 */ -const SearchResults: FunctionComponent = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => { +const SearchResults: FC = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => { const [ movies, setMovies ] = useState([]); const [ shows, setShows ] = useState([]); const [ episodes, setEpisodes ] = useState([]); @@ -47,11 +48,12 @@ const SearchResults: FunctionComponent = ({ serverId = windo const [ books, setBooks ] = useState([]); const [ people, setPeople ] = useState([]); const [ collections, setCollections ] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [ isLoading, setIsLoading ] = useState(false); + const [ debouncedQuery ] = useDebounceValue(query, 500); const getDefaultParameters = useCallback(() => ({ ParentId: parentId, - searchTerm: query, + searchTerm: debouncedQuery, Limit: 100, Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount', Recursive: true, @@ -62,7 +64,7 @@ const SearchResults: FunctionComponent = ({ serverId = windo IncludeGenres: false, IncludeStudios: false, IncludeArtists: false - }), [parentId, query]); + }), [ parentId, debouncedQuery ]); const fetchArtists = useCallback((apiClient: ApiClient, params = {}) => ( apiClient?.getArtists( @@ -97,6 +99,10 @@ const SearchResults: FunctionComponent = ({ serverId = windo ).then(ensureNonNullItems) ), [getDefaultParameters]); + useEffect(() => { + if (query) setIsLoading(true); + }, [ query ]); + useEffect(() => { // Reset state setMovies([]); @@ -116,13 +122,11 @@ const SearchResults: FunctionComponent = ({ serverId = windo setPeople([]); setCollections([]); - if (!query) { + if (!debouncedQuery) { setIsLoading(false); return; } - setIsLoading(true); - const apiClient = ServerConnections.getApiClient(serverId); const fetchPromises = []; @@ -230,7 +234,7 @@ const SearchResults: FunctionComponent = ({ serverId = windo console.error('An error occurred while fetching data:', error); 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); @@ -240,7 +244,7 @@ const SearchResults: FunctionComponent = ({ serverId = windo 'searchResults', 'padded-bottom-page', 'padded-top', - { 'hide': !query || collectionType === CollectionType.Livetv } + { 'hide': !debouncedQuery || collectionType === CollectionType.Livetv } )} > {isLoading ? ( @@ -335,8 +339,8 @@ const SearchResults: FunctionComponent = ({ serverId = windo cardOptions={{ coverImage: true }} /> - {allEmpty && query && !isLoading && ( -
{globalize.translate('SearchResultsEmpty', query)}
+ {allEmpty && debouncedQuery && !isLoading && ( +
{globalize.translate('SearchResultsEmpty', debouncedQuery)}
)} )} diff --git a/src/elements/emby-input/Input.tsx b/src/elements/emby-input/Input.tsx new file mode 100644 index 0000000000..327976dc79 --- /dev/null +++ b/src/elements/emby-input/Input.tsx @@ -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, HTMLInputElement> { + id: string, + label?: string +} + +const Input: FC = ({ + 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 ( + <> + + + + ); +}; + +export default Input; diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts new file mode 100644 index 0000000000..c9e74361b9 --- /dev/null +++ b/src/hooks/usePrevious.ts @@ -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(value: T, initialValue?: T): T | undefined { + const ref = useRef(initialValue); + + useEffect(() => { + ref.current = value; + }); + + return ref.current; +} From dd79544593ab6284bcf7512f5653af9b6f97d1a5 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 13 Feb 2024 12:47:44 -0500 Subject: [PATCH 09/13] Babelify usehooks-ts --- webpack.common.js | 1 + 1 file changed, 1 insertion(+) diff --git a/webpack.common.js b/webpack.common.js index dd85d03183..45baccfb92 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -204,6 +204,7 @@ const config = { path.resolve(__dirname, 'node_modules/screenfull'), path.resolve(__dirname, 'node_modules/ssr-window'), path.resolve(__dirname, 'node_modules/swiper'), + path.resolve(__dirname, 'node_modules/usehooks-ts'), path.resolve(__dirname, 'src') ], use: [{ From 9fa0b4f5b2546df28469fea7e7a9115fbaa0dc14 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 13 Feb 2024 17:10:48 -0500 Subject: [PATCH 10/13] Escape html in tags --- src/controllers/itemDetails/index.js | 10 +++++++--- src/elements/emby-input/Input.tsx | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/controllers/itemDetails/index.js b/src/controllers/itemDetails/index.js index 99f6d76f05..e169a82c3a 100644 --- a/src/controllers/itemDetails/index.js +++ b/src/controllers/itemDetails/index.js @@ -1257,9 +1257,13 @@ function renderTags(page, item) { tags = []; } - for (let i = 0, length = tags.length; i < length; i++) { - tagElements.push('' + tags[i] + ''); - } + tags.forEach(tag => { + tagElements.push( + `` + + escapeHtml(tag) + + '' + ); + }); if (tagElements.length) { itemTags.innerHTML = globalize.translate('TagsValue', tagElements.join(', ')); diff --git a/src/elements/emby-input/Input.tsx b/src/elements/emby-input/Input.tsx index 327976dc79..63cde7084c 100644 --- a/src/elements/emby-input/Input.tsx +++ b/src/elements/emby-input/Input.tsx @@ -1,7 +1,7 @@ +import classNames from 'classnames'; import React, { type DetailedHTMLProps, type InputHTMLAttributes, type FC, useState, useCallback } from 'react'; import './emby-input.scss'; -import classNames from 'classnames'; interface InputProps extends DetailedHTMLProps, HTMLInputElement> { id: string, From 2d667cabbaa72e85588a3969f485bb4496ff6868 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 15 Feb 2024 11:28:10 -0500 Subject: [PATCH 11/13] Fix missing dependency in effect --- src/hooks/usePrevious.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts index c9e74361b9..a97ced0bd1 100644 --- a/src/hooks/usePrevious.ts +++ b/src/hooks/usePrevious.ts @@ -11,7 +11,7 @@ export function usePrevious(value: T, initialValue?: T): T | undefined { useEffect(() => { ref.current = value; - }); + }, [ value ]); return ref.current; } From eaab0364d7d40a00d126760e17221e923b41b498 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 16 Feb 2024 20:22:15 -0500 Subject: [PATCH 12/13] Fix weird navigation when changing query param --- src/apps/stable/routes/search.tsx | 35 ++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index da55e9d8e8..34438c1334 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -9,25 +9,36 @@ import LiveTVSearchResults from 'components/search/LiveTVSearchResults'; import { usePrevious } from 'hooks/usePrevious'; import globalize from 'scripts/globalize'; +const COLLECTION_TYPE_PARAM = 'collectionType'; +const PARENT_ID_PARAM = 'parentId'; +const QUERY_PARAM = 'query'; +const SERVER_ID_PARAM = 'serverId'; + const Search: FC = () => { const navigate = useNavigate(); const [ searchParams, setSearchParams ] = useSearchParams(); - const urlQuery = searchParams.get('query') || ''; + const urlQuery = searchParams.get(QUERY_PARAM) || ''; const [ query, setQuery ] = useState(urlQuery); const prevQuery = usePrevious(query, ''); useEffect(() => { if (query !== prevQuery) { if (query === '' && urlQuery !== '') { - // The query input has been cleared; navigate back to the search landing page - navigate(-1); + // The query input has been cleared; remove the url param + searchParams.delete(QUERY_PARAM); + setSearchParams(searchParams, { replace: true }); } else if (query !== urlQuery) { // Update the query url param value - searchParams.set('query', query); - setSearchParams(searchParams, { replace: !!urlQuery }); + searchParams.set(QUERY_PARAM, query); + setSearchParams(searchParams, { replace: true }); } } else if (query !== urlQuery) { // Update the query if the query url param has changed + if (!urlQuery) { + searchParams.delete(QUERY_PARAM); + setSearchParams(searchParams, { replace: true }); + } + setQuery(urlQuery); } }, [query, prevQuery, navigate, searchParams, setSearchParams, urlQuery]); @@ -41,19 +52,19 @@ const Search: FC = () => { {!query && } From ac29b9b3387f6838d9bd13b8ba8c41defc3adadd Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Sun, 18 Feb 2024 01:11:42 -0500 Subject: [PATCH 13/13] Remove unused variable and fix no results alignment --- src/apps/stable/routes/search.tsx | 5 ++--- src/components/search/SearchResults.tsx | 4 +++- src/components/search/searchfields.scss | 11 ----------- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index 34438c1334..5c483c9a8c 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -1,5 +1,5 @@ import React, { type FC, useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import Page from 'components/Page'; import SearchFields from 'components/search/SearchFields'; @@ -15,7 +15,6 @@ const QUERY_PARAM = 'query'; const SERVER_ID_PARAM = 'serverId'; const Search: FC = () => { - const navigate = useNavigate(); const [ searchParams, setSearchParams ] = useSearchParams(); const urlQuery = searchParams.get(QUERY_PARAM) || ''; const [ query, setQuery ] = useState(urlQuery); @@ -41,7 +40,7 @@ const Search: FC = () => { setQuery(urlQuery); } - }, [query, prevQuery, navigate, searchParams, setSearchParams, urlQuery]); + }, [query, prevQuery, searchParams, setSearchParams, urlQuery]); return ( = ({ serverId = window.ApiClient.ser /> {allEmpty && debouncedQuery && !isLoading && ( -
{globalize.translate('SearchResultsEmpty', debouncedQuery)}
+
+ {globalize.translate('SearchResultsEmpty', debouncedQuery)} +
)} )} diff --git a/src/components/search/searchfields.scss b/src/components/search/searchfields.scss index b93638bdf3..08d8515c86 100644 --- a/src/components/search/searchfields.scss +++ b/src/components/search/searchfields.scss @@ -9,14 +9,3 @@ font-size: 2em; align-self: flex-end; } - -.sorry-text { - font-size: 2em; - text-align: center; - font-family: inherit; - width: 100%; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -}