Option to toggle fields in songs, albums & artists (#923)

* Add toggleColumns

- Add logic for toggling columns
- Add MenuComponent + useSelectedFields hook

* Refactoring

* eslint-fixes

* Typo

* skip menu in albumGridView

* add omittedFields

* Add toggling for playlists and albumSong

* Refactoring

* defaultProps - fix

* Add toggling for PlaylistSongs

* remove accidental console log

* Refactoring for future compatibility

* Hide ToggleMenu in albumGridView

* Add TopBarComponent in ToggleFieldsMenu

* Add defaultOff for useSelectedFields

* Fix edge case

* eslint fix

* Refactoring

* Add propType for forwardRef

* Fix issues

* add translation for grid and table

* add translation for grid and table

* Ignore menuBtn for spotify-ish and Ligera themes

* hide bpm by default in playlistSongs

* Add memoization

* Default album view must be Grid

Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Aldrin Jenson
2021-05-24 20:39:06 +05:30
committed by GitHub
parent 6a17717e30
commit cf8ee251ee
22 changed files with 681 additions and 215 deletions
+12
View File
@@ -1,6 +1,18 @@
export const SET_NOTIFICATIONS_STATE = 'SET_NOTIFICATIONS_STATE' export const SET_NOTIFICATIONS_STATE = 'SET_NOTIFICATIONS_STATE'
export const SET_TOGGLEABLE_FIELDS = 'SET_TOGGLEABLE_FIELDS'
export const SET_OMITTED_FIELDS = 'SET_OMITTED_FIELDS'
export const setNotificationsState = (enabled) => ({ export const setNotificationsState = (enabled) => ({
type: SET_NOTIFICATIONS_STATE, type: SET_NOTIFICATIONS_STATE,
data: enabled, data: enabled,
}) })
export const setToggleableFields = (obj) => ({
type: SET_TOGGLEABLE_FIELDS,
data: obj,
})
export const setOmittedFields = (obj) => ({
type: SET_OMITTED_FIELDS,
data: obj,
})
+48 -36
View File
@@ -14,8 +14,13 @@ import { RiPlayListAddFill, RiPlayList2Fill } from 'react-icons/ri'
import { playNext, addTracks, playTracks, shuffleTracks } from '../actions' import { playNext, addTracks, playTracks, shuffleTracks } from '../actions'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { formatBytes } from '../utils' import { formatBytes } from '../utils'
import { useMediaQuery } from '@material-ui/core' import { useMediaQuery, makeStyles } from '@material-ui/core'
import config from '../config' import config from '../config'
import ToggleFieldsMenu from '../common/ToggleFieldsMenu'
const useStyles = makeStyles({
toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' },
})
const AlbumActions = ({ const AlbumActions = ({
className, className,
@@ -27,7 +32,9 @@ const AlbumActions = ({
}) => { }) => {
const dispatch = useDispatch() const dispatch = useDispatch()
const translate = useTranslate() const translate = useTranslate()
const classes = useStyles()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const handlePlay = React.useCallback(() => { const handlePlay = React.useCallback(() => {
dispatch(playTracks(data, ids)) dispatch(playTracks(data, ids))
@@ -51,41 +58,46 @@ const AlbumActions = ({
return ( return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}> <TopToolbar className={className} {...sanitizeListRestProps(rest)}>
<Button <div className={classes.toolbar}>
onClick={handlePlay} <div>
label={translate('resources.album.actions.playAll')} <Button
> onClick={handlePlay}
<PlayArrowIcon /> label={translate('resources.album.actions.playAll')}
</Button> >
<Button <PlayArrowIcon />
onClick={handleShuffle} </Button>
label={translate('resources.album.actions.shuffle')} <Button
> onClick={handleShuffle}
<ShuffleIcon /> label={translate('resources.album.actions.shuffle')}
</Button> >
<Button <ShuffleIcon />
onClick={handlePlayNext} </Button>
label={translate('resources.album.actions.playNext')} <Button
> onClick={handlePlayNext}
<RiPlayList2Fill /> label={translate('resources.album.actions.playNext')}
</Button> >
<Button <RiPlayList2Fill />
onClick={handlePlayLater} </Button>
label={translate('resources.album.actions.addToQueue')} <Button
> onClick={handlePlayLater}
<RiPlayListAddFill /> label={translate('resources.album.actions.addToQueue')}
</Button> >
{config.enableDownloads && ( <RiPlayListAddFill />
<Button </Button>
onClick={handleDownload} {config.enableDownloads && (
label={ <Button
translate('resources.album.actions.download') + onClick={handleDownload}
(isDesktop ? ` (${formatBytes(record.size)})` : '') label={
} translate('resources.album.actions.download') +
> (isDesktop ? ` (${formatBytes(record.size)})` : '')
<CloudDownloadOutlinedIcon /> }
</Button> >
)} <CloudDownloadOutlinedIcon />
</Button>
)}
</div>
<div>{isNotSmall && <ToggleFieldsMenu resource="albumSong" />}</div>
</div>
</TopToolbar> </TopToolbar>
) )
} }
+17 -1
View File
@@ -6,9 +6,9 @@ import {
Filter, Filter,
NullableBooleanInput, NullableBooleanInput,
NumberInput, NumberInput,
Pagination,
ReferenceInput, ReferenceInput,
SearchInput, SearchInput,
Pagination,
useTranslate, useTranslate,
} from 'react-admin' } from 'react-admin'
import FavoriteIcon from '@material-ui/icons/Favorite' import FavoriteIcon from '@material-ui/icons/Favorite'
@@ -20,6 +20,7 @@ import AlbumGridView from './AlbumGridView'
import { AddToPlaylistDialog } from '../dialogs' import { AddToPlaylistDialog } from '../dialogs'
import albumLists, { defaultAlbumList } from './albumLists' import albumLists, { defaultAlbumList } from './albumLists'
import config from '../config' import config from '../config'
import useSelectedFields from '../common/useSelectedFields'
const AlbumFilter = (props) => { const AlbumFilter = (props) => {
const translate = useTranslate() const translate = useTranslate()
@@ -70,6 +71,21 @@ const AlbumList = (props) => {
.replace(/^\/album/, '') .replace(/^\/album/, '')
.replace(/^\//, '') .replace(/^\//, '')
// Workaround to force album columns to appear the first time.
// See https://github.com/navidrome/navidrome/pull/923#issuecomment-833004842
// TODO: Find a better solution
useSelectedFields({
resource: 'album',
columns: {
artist: 'artist',
songCount: 'songCount',
playCount: 'playCount',
year: 'year',
duration: 'duration',
rating: 'rating',
},
})
// If it does not have filter/sort params (usually coming from Menu), // If it does not have filter/sort params (usually coming from Menu),
// reload with correct filter/sort params // reload with correct filter/sort params
if (!location.search) { if (!location.search) {
+69 -25
View File
@@ -1,10 +1,71 @@
import React, { cloneElement } from 'react' import React, { cloneElement } from 'react'
import { Button, sanitizeListRestProps, TopToolbar } from 'react-admin' import {
import { ButtonGroup } from '@material-ui/core' Button,
sanitizeListRestProps,
TopToolbar,
useTranslate,
} from 'react-admin'
import {
ButtonGroup,
useMediaQuery,
Typography,
makeStyles,
} from '@material-ui/core'
import ViewHeadlineIcon from '@material-ui/icons/ViewHeadline' import ViewHeadlineIcon from '@material-ui/icons/ViewHeadline'
import ViewModuleIcon from '@material-ui/icons/ViewModule' import ViewModuleIcon from '@material-ui/icons/ViewModule'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { albumViewGrid, albumViewList } from '../actions' import { albumViewGrid, albumViewList } from '../actions'
import ToggleFieldsMenu from '../common/ToggleFieldsMenu'
const useStyles = makeStyles({
title: { margin: '1rem' },
buttonGroup: { width: '100%', justifyContent: 'center' },
leftButton: { paddingRight: '0.5rem' },
rightButton: { paddingLeft: '0.5rem' },
})
const AlbumViewToggler = React.forwardRef(
({ showTitle = true, disableElevation, fullWidth }, ref) => {
const dispatch = useDispatch()
const albumView = useSelector((state) => state.albumView)
const classes = useStyles()
const translate = useTranslate()
return (
<div ref={ref}>
{showTitle && (
<Typography className={classes.title}>
{translate('ra.toggleFieldsMenu.layout')}
</Typography>
)}
<ButtonGroup
variant="text"
color="primary"
aria-label="text primary button group"
className={classes.buttonGroup}
>
<Button
size="small"
className={classes.leftButton}
label={translate('ra.toggleFieldsMenu.grid')}
color={albumView.grid ? 'primary' : 'secondary'}
onClick={() => dispatch(albumViewGrid())}
>
<ViewModuleIcon fontSize="inherit" />
</Button>
<Button
size="small"
className={classes.rightButton}
label={translate('ra.toggleFieldsMenu.table')}
color={albumView.grid ? 'secondary' : 'primary'}
onClick={() => dispatch(albumViewList())}
>
<ViewHeadlineIcon fontSize="inherit" />
</Button>
</ButtonGroup>
</div>
)
}
)
const AlbumListActions = ({ const AlbumListActions = ({
currentSort, currentSort,
@@ -24,9 +85,7 @@ const AlbumListActions = ({
fullWidth, fullWidth,
...rest ...rest
}) => { }) => {
const dispatch = useDispatch() const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const albumView = useSelector((state) => state.albumView)
return ( return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}> <TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters && {filters &&
@@ -37,26 +96,11 @@ const AlbumListActions = ({
filterValues, filterValues,
context: 'button', context: 'button',
})} })}
<ButtonGroup {isNotSmall ? (
variant="text" <ToggleFieldsMenu resource="album" topbarComponent={AlbumViewToggler} />
color="primary" ) : (
aria-label="text primary button group" <AlbumViewToggler showTitle={false} />
> )}
<Button
size="small"
color={albumView.grid ? 'primary' : 'secondary'}
onClick={() => dispatch(albumViewGrid())}
>
<ViewModuleIcon fontSize="inherit" />
</Button>
<Button
size="small"
color={albumView.grid ? 'secondary' : 'primary'}
onClick={() => dispatch(albumViewList())}
>
<ViewHeadlineIcon fontSize="inherit" />
</Button>
</ButtonGroup>
</TopToolbar> </TopToolbar>
) )
} }
+33 -14
View File
@@ -1,4 +1,4 @@
import React from 'react' import React, { useMemo } from 'react'
import Paper from '@material-ui/core/Paper' import Paper from '@material-ui/core/Paper'
import Table from '@material-ui/core/Table' import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody' import TableBody from '@material-ui/core/TableBody'
@@ -28,6 +28,7 @@ import {
RatingField, RatingField,
} from '../common' } from '../common'
import config from '../config' import config from '../config'
import useSelectedFields from '../common/useSelectedFields'
const useStyles = makeStyles({ const useStyles = makeStyles({
columnIcon: { columnIcon: {
@@ -109,6 +110,36 @@ const AlbumListView = ({
const classes = useStyles() const classes = useStyles()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const toggleableFields = useMemo(() => {
return {
artist: <ArtistLinkField source="artist" />,
songCount: isDesktop && (
<NumberField source="songCount" sortByOrder={'DESC'} />
),
playCount: isDesktop && (
<NumberField source="playCount" sortByOrder={'DESC'} />
),
year: (
<RangeField source={'year'} sortBy={'maxYear'} sortByOrder={'DESC'} />
),
duration: isDesktop && <DurationField source="duration" />,
rating: config.enableStarRating && (
<RatingField
source={'rating'}
resource={'album'}
sortByOrder={'DESC'}
className={classes.ratingField}
/>
),
}
}, [classes.ratingField, isDesktop])
const columns = useSelectedFields({
resource: 'album',
columns: toggleableFields,
})
return isXsmall ? ( return isXsmall ? (
<SimpleList <SimpleList
primaryText={(r) => r.name} primaryText={(r) => r.name}
@@ -147,19 +178,7 @@ const AlbumListView = ({
{...rest} {...rest}
> >
<TextField source="name" /> <TextField source="name" />
<ArtistLinkField source="artist" /> {columns}
{isDesktop && <NumberField source="songCount" sortByOrder={'DESC'} />}
{isDesktop && <NumberField source="playCount" sortByOrder={'DESC'} />}
<RangeField source={'year'} sortBy={'maxYear'} sortByOrder={'DESC'} />
{isDesktop && <DurationField source="duration" />}
{config.enableStarRating && (
<RatingField
source={'rating'}
resource={'album'}
sortByOrder={'DESC'}
className={classes.ratingField}
/>
)}
<AlbumContextMenu <AlbumContextMenu
source={'starred'} source={'starred'}
sortBy={'starred ASC, starredAt ASC'} sortBy={'starred ASC, starredAt ASC'}
+3 -1
View File
@@ -12,7 +12,9 @@ import AlbumActions from './AlbumActions'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
albumActions: {}, albumActions: {
width: '100%',
},
}), }),
{ {
name: 'NDAlbumShow', name: 'NDAlbumShow',
+43 -26
View File
@@ -1,4 +1,4 @@
import React from 'react' import React, { useMemo } from 'react'
import { import {
BulkActionsToolbar, BulkActionsToolbar,
ListToolbar, ListToolbar,
@@ -24,6 +24,7 @@ import {
} from '../common' } from '../common'
import { AddToPlaylistDialog } from '../dialogs' import { AddToPlaylistDialog } from '../dialogs'
import { QualityInfo } from '../common/QualityInfo' import { QualityInfo } from '../common/QualityInfo'
import useSelectedFields from '../common/useSelectedFields'
import config from '../config' import config from '../config'
const useStyles = makeStyles( const useStyles = makeStyles(
@@ -87,6 +88,46 @@ const AlbumSongs = (props) => {
const classes = useStyles({ isDesktop }) const classes = useStyles({ isDesktop })
const dispatch = useDispatch() const dispatch = useDispatch()
const version = useVersion() const version = useVersion()
const toggleableFields = useMemo(() => {
return {
trackNumber: isDesktop && (
<TextField
source="trackNumber"
sortBy="discNumber asc, trackNumber asc"
label="#"
sortable={false}
/>
),
title: (
<SongTitleField
source="title"
sortable={false}
showTrackNumbers={!isDesktop}
/>
),
artist: isDesktop && <TextField source="artist" sortable={false} />,
duration: <DurationField source="duration" sortable={false} />,
quality: isDesktop && <QualityInfo source="quality" sortable={false} />,
bpm: isDesktop && <NumberField source="bpm" sortable={false} />,
rating: isDesktop && config.enableStarRating && (
<RatingField
source="rating"
resource={'albumSong'}
sortable={false}
className={classes.ratingField}
/>
),
}
}, [isDesktop, classes.ratingField])
const columns = useSelectedFields({
resource: 'albumSong',
columns: toggleableFields,
omittedColumns: ['title'],
defaultOff: ['bpm'],
})
return ( return (
<> <>
<ListToolbar <ListToolbar
@@ -113,31 +154,7 @@ const AlbumSongs = (props) => {
contextAlwaysVisible={!isDesktop} contextAlwaysVisible={!isDesktop}
classes={{ row: classes.row }} classes={{ row: classes.row }}
> >
{isDesktop && ( {columns}
<TextField
source="trackNumber"
sortBy="discNumber asc, trackNumber asc"
label="#"
sortable={false}
/>
)}
<SongTitleField
source="title"
sortable={false}
showTrackNumbers={!isDesktop}
/>
{isDesktop && <TextField source="artist" sortable={false} />}
<DurationField source="duration" sortable={false} />
{isDesktop && <QualityInfo source="quality" sortable={false} />}
{isDesktop && <NumberField source="bpm" sortable={false} />}
{isDesktop && config.enableStarRating && (
<RatingField
source="rating"
resource={'albumSong'}
sortable={false}
className={classes.ratingField}
/>
)}
<SongContextMenu <SongContextMenu
source={'starred'} source={'starred'}
sortable={false} sortable={false}
+27 -12
View File
@@ -1,4 +1,4 @@
import React from 'react' import React, { useMemo } from 'react'
import { useHistory } from 'react-router-dom' import { useHistory } from 'react-router-dom'
import { import {
Datagrid, Datagrid,
@@ -21,6 +21,8 @@ import {
} from '../common' } from '../common'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import config from '../config' import config from '../config'
import ArtistListActions from './ArtistListActions'
import useSelectedFields from '../common/useSelectedFields'
const useStyles = makeStyles({ const useStyles = makeStyles({
contextHeader: { contextHeader: {
@@ -64,6 +66,28 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
const handleArtistLink = useGetHandleArtistClick(width) const handleArtistLink = useGetHandleArtistClick(width)
const history = useHistory() const history = useHistory()
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const toggleableFields = useMemo(() => {
return {
albumCount: <NumberField source="albumCount" sortByOrder={'DESC'} />,
songCount: <NumberField source="songCount" sortByOrder={'DESC'} />,
playCount: <NumberField source="playCount" sortByOrder={'DESC'} />,
rating: config.enableStarRating && (
<RatingField
source="rating"
sortByOrder={'DESC'}
resource={'artist'}
className={classes.ratingField}
/>
),
}
}, [classes.ratingField])
const columns = useSelectedFields({
resource: 'artist',
columns: toggleableFields,
})
return isXsmall ? ( return isXsmall ? (
<ArtistSimpleList <ArtistSimpleList
linkType={(id) => history.push(handleArtistLink(id))} linkType={(id) => history.push(handleArtistLink(id))}
@@ -72,17 +96,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
) : ( ) : (
<Datagrid rowClick={handleArtistLink} classes={{ row: classes.row }}> <Datagrid rowClick={handleArtistLink} classes={{ row: classes.row }}>
<TextField source="name" /> <TextField source="name" />
<NumberField source="albumCount" sortByOrder={'DESC'} /> {columns}
<NumberField source="songCount" sortByOrder={'DESC'} />
<NumberField source="playCount" sortByOrder={'DESC'} />
{config.enableStarRating && (
<RatingField
source="rating"
sortByOrder={'DESC'}
resource={'artist'}
className={classes.ratingField}
/>
)}
<ArtistContextMenu <ArtistContextMenu
source={'starred'} source={'starred'}
sortBy={'starred ASC, starredAt ASC'} sortBy={'starred ASC, starredAt ASC'}
@@ -111,6 +125,7 @@ const ArtistList = (props) => {
exporter={false} exporter={false}
bulkActionButtons={false} bulkActionButtons={false}
filters={<ArtistFilter />} filters={<ArtistFilter />}
actions={<ArtistListActions />}
> >
<ArtistListView {...props} /> <ArtistListView {...props} />
</List> </List>
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
import { sanitizeListRestProps, TopToolbar } from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import ToggleFieldsMenu from '../common/ToggleFieldsMenu'
const ArtistListActions = ({ className, ...rest }) => {
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{isNotSmall && <ToggleFieldsMenu resource="artist" />}
</TopToolbar>
)
}
export default ArtistListActions
+109
View File
@@ -0,0 +1,109 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import IconButton from '@material-ui/core/IconButton'
import Menu from '@material-ui/core/Menu'
import MenuItem from '@material-ui/core/MenuItem'
import { makeStyles, Typography } from '@material-ui/core'
import MoreVertIcon from '@material-ui/icons/MoreVert'
import Checkbox from '@material-ui/core/Checkbox'
import { useDispatch, useSelector } from 'react-redux'
import { useTranslate } from 'react-admin'
import { setToggleableFields } from '../actions'
const useStyles = makeStyles({
menuIcon: {
position: 'relative',
top: '-0.5em',
},
menu: {
width: '24ch',
},
columns: {
maxHeight: '21rem',
overflow: 'auto',
},
title: {
margin: '1rem',
},
})
const ToggleFieldsMenu = ({ resource, topbarComponent: TopBarComponent }) => {
const [anchorEl, setAnchorEl] = useState(null)
const dispatch = useDispatch()
const translate = useTranslate()
const toggleableColumns = useSelector(
(state) => state.settings.toggleableFields[resource]
)
const omittedColumns =
useSelector((state) => state.settings.omittedFields[resource]) || []
const classes = useStyles()
const open = Boolean(anchorEl)
const handleOpen = (event) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const handleClick = (selectedColumn) => {
dispatch(
setToggleableFields({
[resource]: {
...toggleableColumns,
[selectedColumn]: !toggleableColumns[selectedColumn],
},
})
)
}
return (
<div className={classes.menuIcon}>
<IconButton
aria-label="more"
aria-controls="long-menu"
aria-haspopup="true"
onClick={handleOpen}
>
<MoreVertIcon />
</IconButton>
<Menu
id="long-menu"
anchorEl={anchorEl}
keepMounted
open={open}
onClose={handleClose}
classes={{
paper: classes.menu,
}}
>
{TopBarComponent && <TopBarComponent />}
{toggleableColumns ? (
<div>
<Typography className={classes.title}>
{translate('ra.toggleFieldsMenu.columnsToDisplay')}
</Typography>
<div className={classes.columns}>
{Object.entries(toggleableColumns).map(([key, val]) =>
!omittedColumns.includes(key) ? (
<MenuItem key={key} onClick={() => handleClick(key)}>
<Checkbox checked={val} />
{translate(`resources.${resource}.fields.${key}`)}
</MenuItem>
) : null
)}
</div>
</div>
) : null}
</Menu>
</div>
)
}
export default ToggleFieldsMenu
ToggleFieldsMenu.propTypes = {
resource: PropTypes.string.isRequired,
topbarComponent: PropTypes.elementType,
}
+79
View File
@@ -0,0 +1,79 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useDispatch, useSelector } from 'react-redux'
import { setOmittedFields, setToggleableFields } from '../actions'
const useSelectedFields = ({
resource,
columns,
omittedColumns = [],
defaultOff = [],
}) => {
const dispatch = useDispatch()
const resourceFields = useSelector(
(state) => state.settings.toggleableFields
)?.[resource]
const omittedFields = useSelector((state) => state.settings.omittedFields)?.[
resource
]
const [filteredComponents, setFilteredComponents] = useState([])
useEffect(() => {
if (
!resourceFields ||
Object.keys(resourceFields).length !== Object.keys(columns).length
) {
const obj = {}
for (const key of Object.keys(columns)) {
obj[key] = !defaultOff.includes(key)
}
dispatch(setToggleableFields({ [resource]: obj }))
}
if (!omittedFields) {
dispatch(setOmittedFields({ [resource]: omittedColumns }))
}
}, [
columns,
defaultOff,
dispatch,
omittedColumns,
omittedFields,
resource,
resourceFields,
])
useEffect(() => {
if (resourceFields) {
const filtered = []
const omitted = omittedColumns
for (const [key, val] of Object.entries(columns)) {
if (!val) omitted.push(key)
else if (resourceFields[key]) filtered.push(val)
}
if (filteredComponents.length !== filtered.length)
setFilteredComponents(filtered)
if (omittedFields.length !== omitted.length)
dispatch(setOmittedFields({ [resource]: omitted }))
}
}, [
resourceFields,
columns,
dispatch,
omittedColumns,
omittedFields,
resource,
filteredComponents.length,
])
return React.Children.toArray(filteredComponents)
}
export default useSelectedFields
useSelectedFields.propTypes = {
resource: PropTypes.string,
columns: PropTypes.object,
omittedColumns: PropTypes.arrayOf(PropTypes.string),
defaultOff: PropTypes.arrayOf(PropTypes.string),
}
+6
View File
@@ -278,6 +278,12 @@
"i18n_error": "Cannot load the translations for the specified language", "i18n_error": "Cannot load the translations for the specified language",
"canceled": "Action cancelled", "canceled": "Action cancelled",
"logged_out": "Your session has ended, please reconnect." "logged_out": "Your session has ended, please reconnect."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Columns To Display",
"layout": "Layout",
"grid": "Grid",
"table": "Table"
} }
}, },
"message": { "message": {
+54 -42
View File
@@ -19,15 +19,22 @@ import { M3U_MIME_TYPE, REST_URL } from '../consts'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { formatBytes } from '../utils' import { formatBytes } from '../utils'
import { useMediaQuery } from '@material-ui/core' import { useMediaQuery, makeStyles } from '@material-ui/core'
import config from '../config' import config from '../config'
import ToggleFieldsMenu from '../common/ToggleFieldsMenu'
const useStyles = makeStyles({
toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' },
})
const PlaylistActions = ({ className, ids, data, record, ...rest }) => { const PlaylistActions = ({ className, ids, data, record, ...rest }) => {
const dispatch = useDispatch() const dispatch = useDispatch()
const translate = useTranslate() const translate = useTranslate()
const classes = useStyles()
const dataProvider = useDataProvider() const dataProvider = useDataProvider()
const notify = useNotify() const notify = useNotify()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const getAllSongsAndDispatch = React.useCallback( const getAllSongsAndDispatch = React.useCallback(
(action) => { (action) => {
@@ -94,47 +101,52 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => {
return ( return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}> <TopToolbar className={className} {...sanitizeListRestProps(rest)}>
<Button <div className={classes.toolbar}>
onClick={handlePlay} <div>
label={translate('resources.album.actions.playAll')} <Button
> onClick={handlePlay}
<PlayArrowIcon /> label={translate('resources.album.actions.playAll')}
</Button> >
<Button <PlayArrowIcon />
onClick={handleShuffle} </Button>
label={translate('resources.album.actions.shuffle')} <Button
> onClick={handleShuffle}
<ShuffleIcon /> label={translate('resources.album.actions.shuffle')}
</Button> >
<Button <ShuffleIcon />
onClick={handlePlayNext} </Button>
label={translate('resources.album.actions.playNext')} <Button
> onClick={handlePlayNext}
<RiPlayList2Fill /> label={translate('resources.album.actions.playNext')}
</Button> >
<Button <RiPlayList2Fill />
onClick={handlePlayLater} </Button>
label={translate('resources.album.actions.addToQueue')} <Button
> onClick={handlePlayLater}
<RiPlayListAddFill /> label={translate('resources.album.actions.addToQueue')}
</Button> >
{config.enableDownloads && ( <RiPlayListAddFill />
<Button </Button>
onClick={handleDownload} {config.enableDownloads && (
label={ <Button
translate('resources.album.actions.download') + onClick={handleDownload}
(isDesktop ? ` (${formatBytes(record.size)})` : '') label={
} translate('resources.album.actions.download') +
> (isDesktop ? ` (${formatBytes(record.size)})` : '')
<CloudDownloadOutlinedIcon /> }
</Button> >
)} <CloudDownloadOutlinedIcon />
<Button </Button>
onClick={handleExport} )}
label={translate('resources.playlist.actions.export')} <Button
> onClick={handleExport}
<QueueMusicIcon /> label={translate('resources.playlist.actions.export')}
</Button> >
<QueueMusicIcon />
</Button>
</div>
<div>{isNotSmall && <ToggleFieldsMenu resource="playlistTrack" />}</div>
</div>
</TopToolbar> </TopToolbar>
) )
} }
+34 -14
View File
@@ -1,4 +1,4 @@
import React from 'react' import React, { useMemo } from 'react'
import { import {
Datagrid, Datagrid,
DateField, DateField,
@@ -11,8 +11,10 @@ import {
useNotify, useNotify,
} from 'react-admin' } from 'react-admin'
import Switch from '@material-ui/core/Switch' import Switch from '@material-ui/core/Switch'
import { DurationField, List, Writable, isWritable } from '../common'
import { useMediaQuery } from '@material-ui/core' import { useMediaQuery } from '@material-ui/core'
import { DurationField, List, Writable, isWritable } from '../common'
import useSelectedFields from '../common/useSelectedFields'
import PlaylistListActions from './PlaylistListActions'
const PlaylistFilter = (props) => ( const PlaylistFilter = (props) => (
<Filter {...props} variant={'outlined'}> <Filter {...props} variant={'outlined'}>
@@ -60,24 +62,42 @@ const PlaylistList = ({ permissions, ...props }) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const toggleableFields = useMemo(() => {
return {
owner: <TextField source="owner" />,
songCount: isDesktop && <NumberField source="songCount" />,
duration: isDesktop && <DurationField source="duration" />,
updatedAt: isDesktop && (
<DateField source="updatedAt" sortByOrder={'DESC'} />
),
public: !isXsmall && (
<TogglePublicInput
source="public"
permissions={permissions}
sortByOrder={'DESC'}
/>
),
}
}, [isDesktop, isXsmall, permissions])
const columns = useSelectedFields({
resource: 'playlist',
columns: toggleableFields,
})
return ( return (
<List {...props} exporter={false} filters={<PlaylistFilter />}> <List
{...props}
exporter={false}
filters={<PlaylistFilter />}
actions={<PlaylistListActions />}
>
<Datagrid <Datagrid
rowClick="show" rowClick="show"
isRowSelectable={(r) => isWritable(r && r.owner)} isRowSelectable={(r) => isWritable(r && r.owner)}
> >
<TextField source="name" /> <TextField source="name" />
<TextField source="owner" /> {columns}
{isDesktop && <NumberField source="songCount" />}
{isDesktop && <DurationField source="duration" />}
{isDesktop && <DateField source="updatedAt" sortByOrder={'DESC'} />}
{!isXsmall && (
<TogglePublicInput
source="public"
permissions={permissions}
sortByOrder={'DESC'}
/>
)}
<Writable> <Writable>
<EditButton /> <EditButton />
</Writable> </Writable>
+25
View File
@@ -0,0 +1,25 @@
import React from 'react'
import {
sanitizeListRestProps,
TopToolbar,
CreateButton,
useTranslate,
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import ToggleFieldsMenu from '../common/ToggleFieldsMenu'
const PlaylistListActions = ({ className, ...rest }) => {
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const translate = useTranslate()
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
<CreateButton basePath="/playlist">
{translate('ra.action.create')}
</CreateButton>
{isNotSmall && <ToggleFieldsMenu resource="playlist" />}
</TopToolbar>
)
}
export default PlaylistListActions
+3 -1
View File
@@ -13,7 +13,9 @@ import PlaylistActions from './PlaylistActions'
import { Title, isReadOnly } from '../common' import { Title, isReadOnly } from '../common'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
playlistActions: {}, playlistActions: {
width: '100%',
},
}), }),
{ {
name: 'NDPlaylistShow', name: 'NDPlaylistShow',
+23 -8
View File
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react' import React, { useCallback, useMemo } from 'react'
import { import {
BulkActionsToolbar, BulkActionsToolbar,
ListToolbar, ListToolbar,
@@ -28,6 +28,7 @@ import { AlbumLinkField } from '../song/AlbumLinkField'
import { playTracks } from '../actions' import { playTracks } from '../actions'
import PlaylistSongBulkActions from './PlaylistSongBulkActions' import PlaylistSongBulkActions from './PlaylistSongBulkActions'
import { QualityInfo } from '../common/QualityInfo' import { QualityInfo } from '../common/QualityInfo'
import useSelectedFields from '../common/useSelectedFields'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
@@ -127,6 +128,26 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
[playlistId, reorder, ids] [playlistId, reorder, ids]
) )
const toggleableFields = useMemo(() => {
return {
trackNumber: isDesktop && <TextField source="id" label={'#'} />,
title: <SongTitleField source="title" showTrackNumbers={false} />,
album: isDesktop && <AlbumLinkField source="album" />,
artist: isDesktop && <TextField source="artist" />,
duration: (
<DurationField source="duration" className={classes.draggable} />
),
quality: isDesktop && <QualityInfo source="quality" sortable={false} />,
bpm: isDesktop && <NumberField source="bpm" />,
}
}, [isDesktop, classes.draggable])
const columns = useSelectedFields({
resource: 'playlistTrack',
columns: toggleableFields,
defaultOff: ['bpm'],
})
return ( return (
<> <>
<ListToolbar <ListToolbar
@@ -161,13 +182,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
contextAlwaysVisible={!isDesktop} contextAlwaysVisible={!isDesktop}
classes={{ row: classes.row }} classes={{ row: classes.row }}
> >
{isDesktop && <TextField source="id" label={'#'} />} {columns}
<SongTitleField source="title" showTrackNumbers={false} />
{isDesktop && <AlbumLinkField source="album" />}
{isDesktop && <TextField source="artist" />}
<DurationField source="duration" className={classes.draggable} />
{isDesktop && <QualityInfo source="quality" sortable={false} />}
{isDesktop && <NumberField source="bpm" />}
<SongContextMenu <SongContextMenu
onAddToPlaylist={onAddToPlaylist} onAddToPlaylist={onAddToPlaylist}
showLove={false} showLove={false}
+23 -1
View File
@@ -1,7 +1,13 @@
import { SET_NOTIFICATIONS_STATE } from '../actions' import {
SET_NOTIFICATIONS_STATE,
SET_OMITTED_FIELDS,
SET_TOGGLEABLE_FIELDS,
} from '../actions'
const initialState = { const initialState = {
notifications: false, notifications: false,
toggleableFields: {},
omittedFields: {},
} }
export const settingsReducer = (previousState = initialState, payload) => { export const settingsReducer = (previousState = initialState, payload) => {
@@ -12,6 +18,22 @@ export const settingsReducer = (previousState = initialState, payload) => {
...previousState, ...previousState,
notifications: data, notifications: data,
} }
case SET_TOGGLEABLE_FIELDS:
return {
...previousState,
toggleableFields: {
...previousState.toggleableFields,
...data,
},
}
case SET_OMITTED_FIELDS:
return {
...previousState,
omittedFields: {
...previousState.omittedFields,
...data,
},
}
default: default:
return previousState return previousState
} }
+45 -32
View File
@@ -28,6 +28,7 @@ import { AddToPlaylistDialog } from '../dialogs'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import config from '../config' import config from '../config'
import useSelectedFields from '../common/useSelectedFields'
import { QualityInfo } from '../common/QualityInfo' import { QualityInfo } from '../common/QualityInfo'
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -77,6 +78,49 @@ const SongList = (props) => {
dispatch(setTrack(record)) dispatch(setTrack(record))
} }
const toggleableFields = React.useMemo(() => {
return {
album: isDesktop && (
<AlbumLinkField
source="album"
sortBy={
'album, order_album_artist_name, disc_number, track_number, title'
}
sortByOrder={'ASC'}
/>
),
artist: <TextField source="artist" />,
trackNumber: isDesktop && <NumberField source="trackNumber" />,
playCount: isDesktop && (
<NumberField source="playCount" sortByOrder={'DESC'} />
),
year: isDesktop && (
<FunctionField
source="year"
render={(r) => r.year || ''}
sortByOrder={'DESC'}
/>
),
quality: isDesktop && <QualityInfo source="quality" sortable={false} />,
duration: <DurationField source="duration" />,
rating: config.enableStarRating && (
<RatingField
source="rating"
sortByOrder={'DESC'}
resource={'song'}
className={classes.ratingField}
/>
),
bpm: isDesktop && <NumberField source="bpm" />,
}
}, [isDesktop, classes.ratingField])
const columns = useSelectedFields({
resource: 'song',
columns: toggleableFields,
defaultOff: ['bpm'],
})
return ( return (
<> <>
<List <List
@@ -98,38 +142,7 @@ const SongList = (props) => {
classes={{ row: classes.row }} classes={{ row: classes.row }}
> >
<SongTitleField source="title" showTrackNumbers={false} /> <SongTitleField source="title" showTrackNumbers={false} />
{isDesktop && ( {columns}
<AlbumLinkField
source="album"
sortBy={
'album, order_album_artist_name, disc_number, track_number, title'
}
sortByOrder={'ASC'}
/>
)}
<TextField source="artist" />
{isDesktop && <NumberField source="trackNumber" />}
{isDesktop && (
<NumberField source="playCount" sortByOrder={'DESC'} />
)}
{isDesktop && (
<FunctionField
source="year"
render={(r) => r.year || ''}
sortByOrder={'DESC'}
/>
)}
{isDesktop && <QualityInfo source="quality" sortable={false} />}
<DurationField source="duration" />
{isDesktop && <NumberField source="bpm" />}
{config.enableStarRating && (
<RatingField
source="rating"
sortByOrder={'DESC'}
resource={'song'}
className={classes.ratingField}
/>
)}
<SongContextMenu <SongContextMenu
source={'starred'} source={'starred'}
sortBy={'starred ASC, starredAt ASC'} sortBy={'starred ASC, starredAt ASC'}
+4
View File
@@ -1,6 +1,8 @@
import React, { cloneElement } from 'react' import React, { cloneElement } from 'react'
import { sanitizeListRestProps, TopToolbar } from 'react-admin' import { sanitizeListRestProps, TopToolbar } from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { ShuffleAllButton } from '../common' import { ShuffleAllButton } from '../common'
import ToggleFieldsMenu from '../common/ToggleFieldsMenu'
export const SongListActions = ({ export const SongListActions = ({
currentSort, currentSort,
@@ -20,6 +22,7 @@ export const SongListActions = ({
ids, ids,
...rest ...rest
}) => { }) => {
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
return ( return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}> <TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters && {filters &&
@@ -31,6 +34,7 @@ export const SongListActions = ({
context: 'button', context: 'button',
})} })}
<ShuffleAllButton filters={filterValues} /> <ShuffleAllButton filters={filterValues} />
{isNotSmall && <ToggleFieldsMenu resource="song" />}
</TopToolbar> </TopToolbar>
) )
} }
+4 -1
View File
@@ -16,7 +16,7 @@ const musicListActions = {
backgroundColor: 'inherit !important', backgroundColor: 'inherit !important',
}, },
}, },
'button:first-child': { 'button:first-child:not(:only-child)': {
'@media screen and (max-width: 720px)': { '@media screen and (max-width: 720px)': {
transform: 'scale(1.5)', transform: 'scale(1.5)',
margin: '1rem', margin: '1rem',
@@ -40,6 +40,9 @@ const musicListActions = {
boxShadow: '0px 0px 4px 0px #5656567d', boxShadow: '0px 0px 4px 0px #5656567d',
}, },
}, },
'button:only-child': {
margin: '1.5rem',
},
'button:first-child>span:first-child': { 'button:first-child>span:first-child': {
padding: 0, padding: 0,
color: bLight['300'], color: bLight['300'],
+4 -1
View File
@@ -19,7 +19,7 @@ const musicListActions = {
backgroundColor: 'inherit !important', backgroundColor: 'inherit !important',
}, },
}, },
'button:first-child': { 'button:first-child:not(:only-child)': {
'@media screen and (max-width: 720px)': { '@media screen and (max-width: 720px)': {
transform: 'scale(1.5)', transform: 'scale(1.5)',
margin: '1rem', margin: '1rem',
@@ -42,6 +42,9 @@ const musicListActions = {
border: 0, border: 0,
}, },
}, },
'button:only-child': {
margin: '1.5rem',
},
'button:first-child>span:first-child': { 'button:first-child>span:first-child': {
padding: 0, padding: 0,
}, },