Route to manage current search state

- By using route, it makes it more readily available for the varying component structure
- I believe I handn't done this earlier to avoid refactoring, but it wasn't a complex task
- Removing link borders, the browser-provided standard underline hover is now much more astheticly pleasing
This commit is contained in:
James Barnsley
2023-03-05 20:45:38 +13:00
parent 1657073b76
commit d88f9b56f5
8 changed files with 142 additions and 351 deletions

View File

@ -53,7 +53,7 @@ const Content = () => (
<Route path="queue/history" element={<QueueHistory />}/>
<Route path="settings/debug" element={<Debug />} />
<Route path="settings/*" element={<Settings />} />
<Route path="search/" element={<Search />} />
<Route path="search" element={<Search />} />
<Route path="search/:type/:term" element={<Search />} />
<Route path="artist/:uri/*" element={<Artist />} />
<Route path="album/:uri/" element={<Album />} />

View File

@ -1,5 +1,6 @@
import React from 'react';
import { connect } from 'react-redux';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { sortItems } from '../util/arrays';
import URILink from './URILink';
import Icon from './Icon';
@ -11,38 +12,21 @@ import { makeSearchResultsSelector, getSortSelector } from '../util/selectors';
const SearchResults = ({
type,
query,
sortField,
sortReverse: sortReverseProp,
uri_schemes_priority,
all,
results: rawResults,
}) => {
const encodedTerm = encodeURIComponent(query.term);
let results = rawResults;
let sortReverse = sortReverseProp;
if (!results) return null;
let sort_map = null;
switch (sortField) {
case 'uri':
sort_map = uri_schemes_priority;
break;
case 'followers':
// Followers (aka popularlity works in reverse-numerical order)
// Ie "more popular" is a bigger number
sortReverse = !sortReverse;
break;
default:
break;
}
const { term } = useParams();
const { sortField, sortReverse } = useSelector(
(state) => getSortSelector(state, 'search_results'),
);
const searchResultsSelector = makeSearchResultsSelector(term, type);
const rawResults = useSelector(searchResultsSelector);
const encodedTerm = encodeURIComponent(term);
let results = [...rawResults];
results = sortItems(
results,
(type === 'tracks' && sortField === 'followers' ? 'popularity' : sortField),
sortReverse,
sort_map,
);
const resultsCount = results.length;
@ -50,7 +34,7 @@ const SearchResults = ({
results = results.slice(0, 6);
}
if (results.length <= 0) return null;
if (all && !results.length) return null;
return (
<div>
@ -72,56 +56,59 @@ const SearchResults = ({
</URILink>
)}
</h4>
<section className="grid-wrapper">
{type === 'artists' && <Grid items={results} show_source_icon mini={all} />}
{type === 'albums' && <Grid items={results} show_source_icon mini={all} />}
{type === 'playlists' && <Grid items={results} show_source_icon mini={all} />}
{type === 'tracks' && (
<TrackList
source={{
uri: `iris:search:${query.type}:${encodedTerm}`,
name: 'Search results',
type: 'search',
}}
tracks={results}
show_source_icon
/>
)}
{/* <LazyLoadListener enabled={this.props.artists_more && spotify_search_enabled} loadMore={loadMore} /> */}
{results.length > 0 && (
<section className="grid-wrapper">
{type === 'artists' && <Grid items={results} show_source_icon mini={all} />}
{type === 'albums' && <Grid items={results} show_source_icon mini={all} />}
{type === 'playlists' && <Grid items={results} show_source_icon mini={all} />}
{type === 'tracks' && (
<TrackList
source={{
uri: `iris:search:${type}:${encodedTerm}`,
name: 'Search results',
type: 'search',
}}
tracks={results}
show_source_icon
/>
)}
{/* <LazyLoadListener enabled={this.props.artists_more && spotify_search_enabled} loadMore={loadMore} /> */}
{resultsCount > results.length && (
<Button uri={`iris:search:${type}:${encodedTerm}`} uriType="search" unencoded>
<I18n path={`search.${type}.more`} count={resultsCount} />
</Button>
)}
</section>
{resultsCount > results.length && (
<Button uri={`iris:search:${type}:${encodedTerm}`} uriType="search" unencoded>
<I18n path={`search.${type}.more`} count={resultsCount} />
</Button>
)}
</section>
)}
</div>
);
};
const mapStateToProps = (state, ownProps) => {
const {
query: {
term,
},
type,
} = ownProps;
const {
ui: {
uri_schemes_priority = [],
},
} = state;
const searchResultsSelector = makeSearchResultsSelector(term, type);
const { sortField, sortReverse } = getSortSelector(state, 'search_results');
const AllSearchResults = () => (
<>
<div className="search-result-sections cf">
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="artists" all />
</div>
</section>
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="albums" all />
</div>
</section>
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="playlists" all />
</div>
</section>
</div>
<SearchResults type="tracks" all />
</>
);
return {
results: searchResultsSelector(state),
uri_schemes_priority,
sortField,
sortReverse,
};
};
const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
export {
SearchResults,
AllSearchResults,
}

View File

@ -1,294 +1,94 @@
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import Header from '../components/Header';
import Icon from '../components/Icon';
import DropdownField from '../components/Fields/DropdownField';
import SearchForm from '../components/Fields/SearchForm';
import SearchResults from '../components/SearchResults';
import * as coreActions from '../services/core/actions';
import * as uiActions from '../services/ui/actions';
import * as mopidyActions from '../services/mopidy/actions';
import * as spotifyActions from '../services/spotify/actions';
import { titleCase } from '../util/helpers';
import { withRouter } from '../util';
import { AllSearchResults, SearchResults } from '../components/SearchResults';
import { startSearch } from '../services/core/actions';
import {
set,
hideContextMenu,
setWindowTitle,
} from '../services/ui/actions';
import { i18n } from '../locale';
import { getSortSelector } from '../util/selectors';
class Search extends React.Component {
constructor(props) {
super(props);
this.state = { term: props.term || '' };
}
const Search = () => {
const { term, type = 'all' } = useParams();
const dispatch = useDispatch();
const navigate = useNavigate();
const lastQuery = useSelector((state) => state.core?.search_results?.query);
const { sortField, sortReverse } = useSelector(
(state) => getSortSelector(state, 'search_results'),
);
componentDidMount = () => {
const {
uiActions: {
setWindowTitle,
},
} = this.props;
setWindowTitle('Search');
// Auto-focus on the input field
useEffect(() => {
dispatch(setWindowTitle('Search'));
$(document).find('.search-form input').focus();
this.digestUri();
}
}, []);
componentDidUpdate = ({ term: prevTerm }) => {
const { term: termProp } = this.props;
if (prevTerm !== termProp) {
this.search();
useEffect(() => {
if (term && type && term !== lastQuery?.term) {
dispatch(setWindowTitle(i18n('search.title_window', { term: decodeURIComponent(term) })));
dispatch(startSearch({ term, type }));
}
}, [term, type])
const onSubmit = (nextTerm) => {
const encodedTerm = encodeURIComponent(nextTerm);
navigate(`/search/${type}/${encodedTerm}`);
}
onSubmit = (term) => {
const { navigate, type } = this.props;
const encodedTerm = encodeURIComponent(term);
const onReset = () => navigate('/search');
this.setState(
{ term },
() => {
navigate(`/search/${type}/${encodedTerm}`);
},
);
const onSortChange = (value) => {
dispatch(set({ uri_schemes_search_enabled: value }));
dispatch(hideContextMenu());
}
onReset = () => {
const { navigate } = this.props;
navigate('/search');
}
const sortOptions = [
{ value: 'followers', label: i18n('common.popularity') },
{ value: 'name', label: i18n('common.name') },
{ value: 'artist', label: i18n('common.artist') },
{ value: 'duration', label: i18n('common.duration') },
];
onSortChange = (value) => {
const { uiActions: { hideContextMenu } } = this.props;
this.setSort(value);
hideContextMenu();
}
const options = (
<DropdownField
icon="swap_vert"
name={i18n('common.sort')}
value={sortField}
options={sortOptions}
selected_icon={sortField ? (sortReverse ? 'keyboard_arrow_up' : 'keyboard_arrow_down') : null}
handleChange={onSortChange}
valueAsLabel
/>
);
onSourceChange = (value) => {
const {
uiActions: {
set,
hideContextMenu,
},
} = this.props;
set({ uri_schemes_search_enabled: value });
hideContextMenu();
}
return (
<div className="view search-view">
<Header options={options}>
<Icon name="search" type="material" />
</Header>
onSourceClose = () => {
this.search(true);
};
<SearchForm
key={`search_form_${type}_${term}`}
term={term}
onSubmit={onSubmit}
onReset={onReset}
/>
digestUri = () => {
const { term } = this.props;
if (term) {
this.setState({ term }, this.search);
} else {
this.clearSearch();
}
}
clearSearch = () => {
const {
uiActions: {
setWindowTitle,
},
} = this.props;
setWindowTitle(i18n('search.title'));
this.setState({ term: '' });
}
search = (force = false) => {
const {
coreActions: {
startSearch,
},
uiActions: {
setWindowTitle,
},
search_results_query: {
type: existingType,
term: existingTerm,
},
type,
} = this.props;
const { term } = this.state;
setWindowTitle(i18n('search.title_window', { term: decodeURIComponent(term) }));
if ((type && term && (force || existingType !== type || existingTerm !== term))) {
startSearch({ type, term });
}
}
setSort = (value) => {
const {
sort,
sort_reverse,
uiActions: {
set,
},
} = this.props;
let reverse = false;
if (sort === value) reverse = !sort_reverse;
const data = {
search_results_sort_reverse: reverse,
search_results_sort: value,
};
set(data);
}
render = () => {
const { term } = this.state;
const {
uri_schemes,
sort,
sort_reverse,
uri_schemes_search_enabled,
uiActions,
type,
} = this.props;
const sort_options = [
{ value: 'followers', label: i18n('common.popularity') },
{ value: 'name', label: i18n('common.name') },
{ value: 'artist', label: i18n('common.artist') },
{ value: 'duration', label: i18n('common.duration') },
];
const provider_options = uri_schemes.map((item) => ({
value: item,
label: titleCase(item.replace(':', '').replace('+', ' ')),
}));
const options = (
<>
<DropdownField
icon="swap_vert"
name={i18n('common.sort')}
value={sort}
valueAsLabel
options={sort_options}
selected_icon={sort_reverse ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
handleChange={this.onSortChange}
/>
<DropdownField
icon="cloud"
name={i18n('common.sources')}
value={uri_schemes_search_enabled}
options={provider_options}
handleChange={this.onSourceChange}
onClose={this.onSourceClose}
/>
</>
);
let searchResults;
switch (type) {
case 'artists':
searchResults = <SearchResults type="artists" query={{ term, type: 'artists' }} />;
break;
case 'albums':
searchResults = <SearchResults type="albums" query={{ term, type: 'albums' }} />;
break;
case 'playlists':
searchResults = <SearchResults type="playlists" query={{ term, type: 'playlists' }} />;
break;
case 'tracks':
searchResults = <SearchResults type="tracks" query={{ term, type: 'tracks' }} />
break;
default:
searchResults = (
<>
<div className="search-result-sections cf">
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="artists" query={{ term, type: 'artists' }} all />
</div>
</section>
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="albums" query={{ term, type: 'albums' }} all />
</div>
</section>
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="playlists" query={{ term, type: 'playlists' }} all />
</div>
</section>
</div>
<SearchResults type="tracks" query={{ term, type: 'tracks' }} all />
</>
);
}
return (
<div className="view search-view">
<Header options={options} uiActions={uiActions}>
<Icon name="search" type="material" />
</Header>
<SearchForm
key={`search_form_${type}_${term}`}
term={term}
onSubmit={this.onSubmit}
onReset={this.onReset}
/>
<div className="content-wrapper">
{searchResults}
</div>
<div className="content-wrapper">
{type != 'all' ? (
<SearchResults type={type} />
) : (
<AllSearchResults />
)}
</div>
);
}
</div>
);
}
const mapStateToProps = (state, ownProps) => {
const {
params: {
type,
term,
},
navigation,
} = ownProps;
const {
mopidy: {
uri_schemes = [],
},
ui: {
uri_schemes_search_enabled = [],
search_results_sort: sort = 'followers.total',
search_results_sort_reverse,
},
core: {
search_results: {
query: search_results_query = {},
} = {},
},
} = state;
return {
type: type || 'all',
term,
navigation,
uri_schemes,
uri_schemes_search_enabled,
sort,
sort_reverse: !!search_results_sort_reverse,
search_results_query,
};
};
const mapDispatchToProps = (dispatch) => ({
coreActions: bindActionCreators(coreActions, dispatch),
uiActions: bindActionCreators(uiActions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch),
});
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Search));
export default Search;

View File

@ -11,6 +11,10 @@
border-bottom: 0 !important;
cursor: pointer;
a {
text-decoration: none !important;
}
&__wrapper {
display: inline-block;
}

View File

@ -111,6 +111,7 @@
cursor: pointer;
color: colour('white');
border: 0 !important;
text-decoration: none !important;
margin: 0 5px;
&:hover {

View File

@ -18,6 +18,7 @@
display: block;
box-sizing: border-box;
border: none !important;
text-decoration: none !important;
cursor: pointer;
&__inner {

View File

@ -195,10 +195,8 @@ main {
cursor: pointer;
&:not(.control):not(.action):not(.button) {
border-bottom: 1px solid transparent;
&:hover {
border-color: colour('mid_grey');
text-decoration: underline;
}
}
}

View File

@ -5,7 +5,7 @@
position: absolute;
top: 30px;
left: 90px;
right: 270px;
right: 170px;
input {
@include feature_font();