Upgrading lists

This commit is contained in:
James Barnsley
2021-03-01 21:31:22 +13:00
parent a67a4a7f67
commit fc54c6445b
18 changed files with 746 additions and 934 deletions

View File

@ -21,7 +21,7 @@ const Grid = memo(({
item={item}
getLink={getLink}
itemHeight={itemHeight || '300px'}
isFirstInGrid={index === 0}
isFirst={index === 0}
setItemHeight={setItemHeight}
/>
))

View File

@ -1,4 +1,4 @@
import React from "react";
import React from 'react';
import handleViewport from 'react-in-viewport';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
@ -69,7 +69,7 @@ const GridItemComponent = ({
show_source_icon,
grid_glow_enabled,
spotify_available,
isFirstInGrid,
isFirst,
uiActions: {
showContextMenu,
},
@ -90,7 +90,7 @@ const GridItemComponent = ({
// Listen for changes to our height, and pass it up to our Grid. This is then used to build the
// placeholder elements when out of viewport. We only care about the first item because this
// represents the same heights for everything else (in almost all circumstances).
if (isFirstInGrid && forwardedRef.current) {
if (isFirst && forwardedRef.current) {
const { current: { clientHeight } } = forwardedRef;
if (clientHeight !== itemHeight) {
setItemHeight(clientHeight);
@ -134,7 +134,7 @@ const GridItemComponent = ({
return (
<span className={`grid__item grid__item--${item.type}`} ref={forwardedRef}>
{inViewport || isFirstInGrid ? (
{inViewport || isFirst ? (
<Link
to={to}
onClick={scrollTo}

View File

@ -1,52 +1,39 @@
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { withRouter } from 'react-router';
import ListItem from './ListItem';
import * as uiActions from '../services/ui/actions';
import * as lastfmActions from '../services/lastfm/actions';
import * as discogsActions from '../services/discogs/actions';
import React, { useState } from 'react';
import { ListItem } from './ListItem';
import ErrorBoundary from './ErrorBoundary';
class List extends React.Component {
handleContextMenu(e, item) {
const { handleContextMenu } = this.props;
const List = ({
items,
className,
...rest
}) => {
if (!items || !items.length) return null;
const [itemHeight, setItemHeight] = useState(0);
if (handleContextMenu) {
e.preventDefault();
handleContextMenu(e, item);
}
}
render = () => {
const {
rows,
className,
...rest
} = this.props;
if (!rows) return null;
return (
<div className={`list ${className}`}>
return (
<div className={`list ${className}`}>
<ErrorBoundary>
{
rows.map((item, index) => (
items.map((item, index) => (
<ListItem
key={index}
key={`${index}_${item.uri || item.name}`}
item={item}
handleContextMenu={(e) => this.handleContextMenu(e, item)}
itemHeight={itemHeight || '300px'}
isFirst={index === 0}
setItemHeight={setItemHeight}
{...rest}
/>
))
}
</div>
);
}
))
}
</ErrorBoundary>
</div>
);
};
export default {
List,
}
const mapDispatchToProps = (dispatch) => ({
uiActions: bindActionCreators(uiActions, dispatch),
lastfmActions: bindActionCreators(lastfmActions, dispatch),
discogsActions: bindActionCreators(discogsActions, dispatch),
});
export default withRouter(connect(mapDispatchToProps)(List));
export {
List,
};

View File

@ -1,4 +1,6 @@
import React from 'react';
import handleViewport from 'react-in-viewport';
import { useDispatch, useSelector } from 'react-redux';
import LinksSentence from './LinksSentence';
import { dater } from './Dater';
import { nice_number } from './NiceNumber';
@ -7,101 +9,27 @@ import ContextMenuTrigger from './ContextMenuTrigger';
import Icon from './Icon';
import Thumbnail from './Thumbnail';
import Popularity from './Popularity';
import {
uriType,
scrollTo,
} from '../util/helpers';
import Link from './Link';
import { I18n } from '../locale';
import { encodeUri } from '../util/format';
export default class ListItem extends React.Component {
componentDidMount() {
const { item, mopidyActions, discogsActions } = this.props;
if (!item) return;
import * as uiActions from '../services/ui/actions';
import * as lastfmActions from '../services/lastfm/actions';
import * as mopidyActions from '../services/mopidy/actions';
import * as spotifyActions from '../services/spotify/actions';
// If the item that has just been mounted doesn't have images,
// try fetching them from LastFM
if (!item.images) {
switch (uriType(item.uri)) {
case 'artist':
if (discogsActions) {
//discogsActions.getArtistImages(item.uri, item);
}
break;
const getValue = (item = {}, name = '') => {
const { [name]: value } = item;
case 'album':
if (mopidyActions) {
mopidyActions.getImages([item.uri]);
}
break;
}
}
}
handleClick = (e) => {
const {
history,
link_prefix = '',
link,
item,
} = this.props;
if (e.target.tagName.toLowerCase() !== 'a') {
e.preventDefault();
history.push(link ? link(item) : `${link_prefix}${encodeUri(item.uri)}`);
scrollTo();
}
}
handleMouseDown = (e) => {
const {
history,
link_prefix = '',
link,
item,
} = this.props;
if (e.target.tagName.toLowerCase() !== 'a') {
e.preventDefault();
history.push(link ? link(item) : `${link_prefix}${encodeUri(item.uri)}`);
scrollTo();
}
}
handleContextMenu = (e) => {
const {
handleContextMenu,
item,
} = this.props;
if (handleContextMenu) {
e.preventDefault();
handleContextMenu(e, item);
}
}
/**
* TODO
*
* THIS WHOLE BLOCK NEEDS A REVISIT.
* Surely there is a cleaner way to pull values? Perhaps simplify it like our new sortItems?
*/
renderValue = (key) => {
const {
item: {
[key]: value,
...item
} = {},
} = this.props;
if (key === 'tracks') {
switch (name) {
case 'tracks': {
const {
tracks_total: total,
} = item;
if (!total && !value) return null;
return <I18n path="specs.tracks" count={nice_number(total || value.length)} />;
}
if (key === 'artists') {
case 'artists': {
const {
artists_total: total,
artists_uris: array,
@ -110,7 +38,7 @@ export default class ListItem extends React.Component {
if (!total && !array) return null;
return <I18n path="specs.artists" count={nice_number(total || array.length)} />;
}
if (key === 'albums') {
case 'albums': {
const {
albums_total: total,
albums_uris: array,
@ -118,74 +46,124 @@ export default class ListItem extends React.Component {
if (!total && !array) return null;
return <I18n path="specs.albums" count={total || array.length} />;
}
// All options beyond here don't play well with null/undefined
if (!value) return null;
if (key === 'followers') {
return <I18n path="specs.followers" count={nice_number(value)} />;
}
if (key === 'listeners') {
return <I18n path="specs.listeners" count={nice_number(value)} />;
}
if (key === 'added_at') {
return <I18n path="specs.added_ago" time={dater('ago', value)} />;
}
if (key === 'last_modified') {
return <I18n path="specs.updated_ago" time={dater('ago', value)} />;
}
if (key === 'owner') {
return <URILink type="user" uri={value.uri}>{value.id}</URILink>;
}
if (key === 'popularity') {
return <Popularity full popularity={value} />;
}
if (value === true) return <Icon name="check" />;
if (typeof (value) === 'number') return <span>{value.toLocaleString()}</span>;
return value;
default:
break;
}
render = () => {
const {
item,
middle_column,
right_column,
thumbnail,
details,
nocontext,
} = this.props;
if (!item) {
return null;
// All options beyond here don't play well with null/undefined
if (!value) return null;
switch (name) {
case 'followers':
return <I18n path="specs.followers" count={nice_number(value)} />;
case 'listeners':
return <I18n path="specs.listeners" count={nice_number(value)} />;
case 'added_at':
return <I18n path="specs.added_ago" time={dater('ago', value)} />;
case 'last_modified':
return <I18n path="specs.updated_ago" time={dater('ago', value)} />;
case 'owner':
return <URILink type="user" uri={value.uri}>{value.id}</URILink>;
case 'popularity':
return <Popularity full popularity={value} />;
default:
break;
}
if (value === true) return <Icon name="check" />;
if (typeof (value) === 'number') return <span>{value.toLocaleString()}</span>;
return value;
};
const ListItemComponent = ({
item,
middle_column,
right_column,
thumbnail,
details,
nocontext,
forwardedRef,
getLink,
isFirst,
inViewport,
itemHeight,
setItemHeight,
}) => {
if (!item) return null;
// Listen for changes to our height, and pass it up to our Grid. This is then used to build the
// placeholder elements when out of viewport. We only care about the first item because this
// represents the same heights for everything else (in almost all circumstances).
if (isFirst && forwardedRef.current) {
const { current: { clientHeight } } = forwardedRef;
if (clientHeight !== itemHeight) {
console.debug({ clientHeight, itemHeight })
setItemHeight(clientHeight);
}
}
let class_name = 'list__item';
if (item.type) class_name += ` list__item--${item.type}`;
if (item.loading) class_name += ' list__item--loading';
if (middle_column) class_name += ' list__item--has-middle-column';
if (thumbnail) class_name += ' list__item--has-thumbnail';
if (details) class_name += ' list__item--has-details';
let class_name = 'list__item';
if (item.type) class_name += ` list__item--${item.type}`;
if (item.loading) class_name += ' list__item--loading';
if (middle_column) class_name += ' list__item--has-middle-column';
if (thumbnail) class_name += ' list__item--has-thumbnail';
if (details) class_name += ' list__item--has-details';
// Return our placeholder as soon as possible, avoiding additional hooks and code
if (!inViewport && !isFirst) {
return (
<div
className={class_name}
onClick={this.handleClick}
onContextMenu={this.handleContextMenu}
>
<div className={class_name} ref={forwardedRef}>
<div style={{ height: itemHeight }} />
</div>
);
}
// Build link
let to = '';
if (getLink) {
to = getLink(item);
} else if (item.link) {
to = item.link;
} else {
to = `/${item.type}/${encodeUri(item.uri)}`;
}
const dispatch = useDispatch();
const onContextMenu = (e) => {
e.preventDefault();
dispatch(
uiActions.showContextMenu({
e,
context: item.type,
uris: [item.uri],
items: [item],
tracklist_uri: item.uri, // not needed?
}),
);
};
const grid_glow_enabled = useSelector((state) => state.ui.grid_glow_enabled);
const spotify_available = useSelector((state) => state.spotify.access_token);
return (
<div className={class_name} onContextMenu={onContextMenu} ref={forwardedRef}>
<Link to={to} className="list__item__inner">
{
right_column && !nocontext && (
<div className="list__item__column list__item__column--right">
{
right_column.map((column, index) => (
<span className={`list__item__column__item list__item__column__item--${column.replace('.', '_')}`} key={index}>
{this.renderValue(column, item)}
right_column.map((column) => (
<span
className={`list__item__column__item list__item__column__item--${column.replace('.', '_')}`}
key={`${column}`}
>
{getValue(item, column)}
</span>
))
}
))
}
{!nocontext && (
<ContextMenuTrigger
className="list__item__column__item list__item__column__item--context-menu-trigger subtle"
onTrigger={this.handleContextMenu}
onTrigger={onContextMenu}
/>
)}
</div>
@ -202,7 +180,7 @@ export default class ListItem extends React.Component {
<div className="list__item__column__item list__item__column__item--name">
{
item.name !== undefined
? this.renderValue('name')
? getValue(item, 'name')
: <span className="grey-text">{item.uri}</span>
}
</div>
@ -210,19 +188,19 @@ export default class ListItem extends React.Component {
{details ? (
<ul className="list__item__column__item list__item__column__item--details details">
{
details.map((detail, index) => {
const value = this.renderValue(detail);
if (!value) return null;
return (
details.map((detail) => {
const value = getValue(item, detail);
if (!value) return null;
return (
<li
className={`details__item details__item--${detail.replace('.', '_')}`}
key={index}
key={detail}
>
{value}
</li>
);
})
}
);
})
}
</ul>
) : null}
</div>
@ -230,18 +208,28 @@ export default class ListItem extends React.Component {
{middle_column && (
<div className="list__item__column list__item__column--middle">
{
middle_column.map((column, index) => (
middle_column.map((column) => (
<span
className={`list__item__column__item list__item__column__item--${column.replace('.', '_')}`}
key={index}
key={column}
>
{this.renderValue(column)}
{getValue(item, column)}
</span>
))
}
}
</div>
)}
</div>
);
}
}
</Link>
</div>
);
};
const ListItem = handleViewport(ListItemComponent);
export {
ListItem,
};
export default {
ListItem,
};

View File

@ -377,39 +377,41 @@ export default class Track extends React.Component {
onTouchStart={this.handleTouchStart}
onTouchEnd={this.handleTouchEnd}
>
<div className="list__item__column list__item__column--name">
<div className="list__item__column__item--name">
{track.name ? track.name : <span className="mid_grey-text">{track.uri}</span>}
{track.playing && <Icon className={`js--${play_state}`} name="playing" type="css" />}
<div className="list__item__inner">
<div className="list__item__column list__item__column--name">
<div className="list__item__column__item--name">
{track.name ? track.name : <span className="mid_grey-text">{track.uri}</span>}
{track.playing && <Icon className={`js--${play_state}`} name="playing" type="css" />}
</div>
{track_details && (
<ul className="list__item__column__item--details">
{track_details}
</ul>
)}
</div>
{track_details && (
<ul className="list__item__column__item--details">
{track_details}
</ul>
)}
</div>
{track_middle_column}
<div className="list__item__column list__item__column--right">
{drag_zone}
{track.is_explicit && <span className="flag flag--dark">EXPLICIT</span>}
{(track_context === 'album' || track_context === 'artist') && track.track_number && (
<span className="mid_grey-text list__item__column__item list__item__column__item--track-number">
<span>
<I18n path="track.title" />
&nbsp;
{track_middle_column}
<div className="list__item__column list__item__column--right">
{drag_zone}
{track.is_explicit && <span className="flag flag--dark">EXPLICIT</span>}
{(track_context === 'album' || track_context === 'artist') && track.track_number && (
<span className="mid_grey-text list__item__column__item list__item__column__item--track-number">
<span>
<I18n path="track.title" />
&nbsp;
</span>
{track.track_number}
</span>
{track.track_number}
)}
<span className="list__item__column__item list__item__column__item--duration">
{track.duration ? <Dater type="length" data={track.duration} /> : '-'}
</span>
)}
<span className="list__item__column__item list__item__column__item--duration">
{track.duration ? <Dater type="length" data={track.duration} /> : '-'}
</span>
{show_source_icon && (
<span className="list__item__column__item list__item__column__item--source">
<Icon type="fontawesome" name={sourceIcon(track.uri)} fixedWidth />
</span>
)}
<ContextMenuTrigger className="list__item__column__item--context-menu-trigger subtle" onTrigger={(e) => this.props.handleContextMenu(e)} />
{show_source_icon && (
<span className="list__item__column__item list__item__column__item--source">
<Icon type="fontawesome" name={sourceIcon(track.uri)} fixedWidth />
</span>
)}
<ContextMenuTrigger className="list__item__column__item--context-menu-trigger subtle" onTrigger={(e) => this.props.handleContextMenu(e)} />
</div>
</div>
</div>
</ErrorBoundary>

View File

@ -1,11 +1,11 @@
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import List from '../../components/List';
import Header from '../../components/Header';
import DropdownField from '../../components/Fields/DropdownField';
import FilterField from '../../components/Fields/FilterField';
import { Grid } from '../../components/Grid';
import { List } from '../../components/List';
import Icon from '../../components/Icon';
import * as coreActions from '../../services/core/actions';
import * as uiActions from '../../services/ui/actions';
@ -179,13 +179,11 @@ class LibraryAlbums extends React.Component {
return (
<section className="content-wrapper">
<List
handleContextMenu={this.handleContextMenu}
rows={albums}
items={albums}
details={['followers', 'listeners']}
right_column={['source', 'albums']}
className="artists"
thumbnail
details={['artists']}
right_column={['added_at', 'tracks']}
className="albums"
link_prefix="/album/"
/>
</section>
);

View File

@ -2,10 +2,10 @@ import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import Header from '../../components/Header';
import List from '../../components/List';
import DropdownField from '../../components/Fields/DropdownField';
import FilterField from '../../components/Fields/FilterField';
import { Grid } from '../../components/Grid';
import { List } from '../../components/List';
import Icon from '../../components/Icon';
import * as uiActions from '../../services/ui/actions';
import * as coreActions from '../../services/core/actions';
@ -165,13 +165,11 @@ class LibraryArtists extends React.Component {
return (
<section className="content-wrapper">
<List
handleContextMenu={(e, item) => this.handleContextMenu(e, item)}
rows={artists}
thumbnail
items={artists}
details={['followers', 'listeners']}
right_column={['source', 'albums']}
className="artists"
link_prefix="/artist/"
thumbnail
/>
</section>
);

View File

@ -3,13 +3,12 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import Loader from '../../components/Loader';
import Header from '../../components/Header';
import List from '../../components/List';
import TrackList from '../../components/TrackList';
import { Grid } from '../../components/Grid';
import { List } from '../../components/List';
import DropdownField from '../../components/Fields/DropdownField';
import FilterField from '../../components/Fields/FilterField';
import Icon from '../../components/Icon';
import URILink from '../../components/URILink';
import ErrorBoundary from '../../components/ErrorBoundary';
import LazyLoadListener from '../../components/LazyLoadListener';
import * as uiActions from '../../services/ui/actions';
@ -43,9 +42,9 @@ const Subdirectories = ({ items, view }) => {
if (view === 'list') {
return (
<List
rows={items}
items={items}
className="library-local-directory-list"
link={link}
getLink={link}
nocontext
/>
);

View File

@ -1,14 +1,12 @@
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import Button from '../../components/Button';
import { Grid } from '../../components/Grid';
import List from '../../components/List';
import { List } from '../../components/List';
import DropdownField from '../../components/Fields/DropdownField';
import Header from '../../components/Header';
import FilterField from '../../components/Fields/FilterField';
import LazyLoadListener from '../../components/LazyLoadListener';
import Icon from '../../components/Icon';
import * as coreActions from '../../services/core/actions';
import * as uiActions from '../../services/ui/actions';
@ -152,13 +150,10 @@ class LibraryPlaylists extends React.Component {
return (
<section className="content-wrapper">
<List
handleContextMenu={(e, item) => this.handleContextMenu(e, item)}
rows={playlists}
items={playlists}
thumbnail
details={['owner', 'tracks', 'last_modified']}
right_column={['source']}
className="playlists"
link_prefix="/playlist/"
/>
</section>
);

View File

@ -1,4 +1,3 @@
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
@ -32,8 +31,6 @@ class LibraryTracks extends React.Component {
this.state = {
filter: '',
limit: 50,
per_page: 50,
};
}
@ -47,13 +44,6 @@ class LibraryTracks extends React.Component {
},
} = this.props;
// Restore any limit defined in our location state
if (state.limit) {
this.setState({
limit: state.limit,
});
}
setWindowTitle(i18n('library.tracks.title'));
this.getMopidyLibrary();
this.getSpotifyLibrary();
@ -126,23 +116,6 @@ class LibraryTracks extends React.Component {
});
}
loadMore = () => {
const {
limit,
per_page,
} = this.state;
const {
location: {
state,
},
history,
} = this.props;
const new_limit = limit + per_page;
this.setState({ limit: new_limit });
history.replace({ state: { ...state, limit: new_limit } });
}
setSort = (value) => {
const {
sort,

View File

@ -1,8 +1,8 @@
.list {
margin: 0 -20px 0 -20px;
&__item {
@include clearfix();
@include animate(0.1s);
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
@ -10,28 +10,6 @@
-ms-user-select: none;
user-select: none;
display: flex;
position: relative;
margin: 0 -20px 0 -20px;
padding: 16px 16px 16px 20px;
border-radius: 3px;
justify-content: space-between;
align-content: center;
&:before {
pointer-events: none;
content: '';
display: block;
position: absolute;
top: -5px;
left: 0;
bottom: 0;
right: 0;
height: 3px;
background: colour('blue');
opacity: 0;
}
&:not(:first-child){
border-top: 1px solid rgba(128,128,128,0.05);
@ -40,6 +18,31 @@
}
}
&__inner {
@include animate(0.1s);
padding: 16px 16px 16px 20px;
border-radius: 3px;
align-content: center;
display: flex;
position: relative;
justify-content: space-between;
border-bottom: none !important;
&:before {
pointer-events: none;
content: '';
display: block;
position: absolute;
top: -5px;
left: 0;
bottom: 0;
right: 0;
height: 3px;
background: colour('blue');
opacity: 0;
}
}
:root .notouch:not(.dragging) &:not(.list__item--no-interaction){
cursor: pointer;