mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-03 20:47:35 +03:00
Artist Detail Page (first cut) (#1287)
* Configure fetching from API and route * pretty * Remove errors * Remove errors * Remove errors * Complete page for Desktop view * Fix error * Add xs Artist page * Remove unused import * Add styles for theme * Change route path * Remove artId useEffect array * Remove array * Fix cover load err * Add redirect on err * Remove route * What's in a name? consistency :) * Fix err * Fix UI changes * Fetch album from resource * Renaming done * Review changes * Some touch-up * Small refactor, to make naming and structure more consistent with AlbumShow * Make artist's album list similar to original implementation * Reuse AlbumGridView, to avoid duplication * Add feature flag to enable new Artist Page, default false * Better biography styling. Small refactorings, * Don't encode quotes and other symbols * Moved AlbumShow to correct folder Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
210dc6b12e
commit
482c2dec0c
15 changed files with 382 additions and 7 deletions
|
@ -73,6 +73,7 @@ type configOptions struct {
|
|||
DevEnableShare bool
|
||||
DevSidebarPlaylists bool
|
||||
DevEnableBufferedScrobble bool
|
||||
DevShowArtistPage bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
|
@ -238,6 +239,7 @@ func init() {
|
|||
viper.SetDefault("devenableshare", false)
|
||||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", false)
|
||||
viper.SetDefault("devshowartistpage", false)
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
|
|
|
@ -48,6 +48,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
|
|||
"devSidebarPlaylists": conf.Server.DevSidebarPlaylists,
|
||||
"lastFMEnabled": conf.Server.LastFM.Enabled,
|
||||
"lastFMApiKey": conf.Server.LastFM.ApiKey,
|
||||
"devShowArtistPage": conf.Server.DevShowArtistPage,
|
||||
}
|
||||
auth := handleLoginFromHeaders(ds, r)
|
||||
if auth != nil {
|
||||
|
|
|
@ -243,6 +243,17 @@ var _ = Describe("serveIndex", func() {
|
|||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("lastFMApiKey", "APIKEY-123"))
|
||||
})
|
||||
It("sets the devShowArtistPage", func() {
|
||||
conf.Server.DevShowArtistPage = true
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("devShowArtistPage", true))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__="([^"]*)`)
|
||||
|
|
|
@ -113,7 +113,7 @@ const Cover = withContentRect('bounds')(
|
|||
}
|
||||
)
|
||||
|
||||
const AlbumGridTile = ({ showArtist, record, basePath }) => {
|
||||
const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => {
|
||||
const classes = useStyles()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'), {
|
||||
noSsr: true,
|
||||
|
@ -121,7 +121,6 @@ const AlbumGridTile = ({ showArtist, record, basePath }) => {
|
|||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.albumContainer}>
|
||||
<Link
|
||||
|
@ -166,7 +165,6 @@ const LoadedAlbumGrid = ({ ids, data, basePath, width }) => {
|
|||
const classes = useStyles()
|
||||
const { filterValues } = useListContext()
|
||||
const isArtistView = !!(filterValues && filterValues.artist_id)
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<GridList
|
||||
|
|
307
ui/src/artist/ArtistShow.js
Normal file
307
ui/src/artist/ArtistShow.js
Normal file
|
@ -0,0 +1,307 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Typography, Collapse, Link } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import Card from '@material-ui/core/Card'
|
||||
import CardContent from '@material-ui/core/CardContent'
|
||||
import CardMedia from '@material-ui/core/CardMedia'
|
||||
import {
|
||||
useTranslate,
|
||||
useShowController,
|
||||
ShowContextProvider,
|
||||
useRecordContext,
|
||||
useShowContext,
|
||||
ReferenceManyField,
|
||||
} from 'react-admin'
|
||||
import subsonic from '../subsonic'
|
||||
import AlbumGridView from '../album/AlbumGridView'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
padding: '1em',
|
||||
'& .MuiTypography-h5': {
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
padding: 'unset',
|
||||
background: ({ img }) => `url(${img})`,
|
||||
},
|
||||
},
|
||||
bgContainer: {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
height: '15rem',
|
||||
width: '100vw',
|
||||
padding: 'unset',
|
||||
backdropFilter: 'blur(1px)',
|
||||
backgroundPosition: '50% 30%',
|
||||
background: `linear-gradient(to bottom, rgba(52 52 52 / 72%), rgba(21 21 21))`,
|
||||
},
|
||||
},
|
||||
albumList: {
|
||||
margin: '20px',
|
||||
display: 'grid',
|
||||
},
|
||||
details: {
|
||||
display: 'flex',
|
||||
flex: '1',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
bioBlock: {
|
||||
display: 'inline-block',
|
||||
marginTop: '1em',
|
||||
float: 'left',
|
||||
wordBreak: 'break-all',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
link: {
|
||||
margin: '1px',
|
||||
},
|
||||
mdetails: {
|
||||
display: 'none',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '7rem',
|
||||
marginLeft: '0.5rem',
|
||||
flex: '1',
|
||||
},
|
||||
},
|
||||
mbio: {
|
||||
display: 'none',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
display: 'flex',
|
||||
marginLeft: '3%',
|
||||
marginRight: '3%',
|
||||
zIndex: '1',
|
||||
'& p': {
|
||||
whiteSpace: ({ expanded }) => (expanded ? 'unset' : 'nowrap'),
|
||||
overflow: 'hidden',
|
||||
width: '95vw',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
},
|
||||
},
|
||||
content: {
|
||||
flex: '1 0 auto',
|
||||
'& .MuiTypography-root': {
|
||||
display: ({ expanded }) => (expanded ? 'block' : '-webkit-inline-box'),
|
||||
boxOrient: 'vertical',
|
||||
lineClamp: '3',
|
||||
},
|
||||
},
|
||||
cover: {
|
||||
width: 151,
|
||||
boxShadow: '0px 0px 6px 0px #565656',
|
||||
borderRadius: '5px',
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
borderRadius: '7em',
|
||||
},
|
||||
},
|
||||
martImage: {
|
||||
marginLeft: '1em',
|
||||
maxHeight: '10rem',
|
||||
backgroundColor: 'inherit',
|
||||
display: 'none',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
marginTop: '4rem',
|
||||
maxHeight: '7rem',
|
||||
width: '7rem',
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
artImage: {
|
||||
maxHeight: '9.5rem',
|
||||
backgroundColor: 'inherit',
|
||||
display: 'flex',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
marginTop: '4rem',
|
||||
maxHeight: '7rem',
|
||||
width: '7rem',
|
||||
},
|
||||
},
|
||||
artDetail: {
|
||||
flex: '1',
|
||||
padding: '3%',
|
||||
display: 'flex',
|
||||
minHeight: '10rem',
|
||||
'& .MuiPaper-elevation1': {
|
||||
boxShadow: 'none',
|
||||
padding: '4px',
|
||||
},
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
artistSummary: {
|
||||
marginBottom: '1em',
|
||||
},
|
||||
}),
|
||||
{ name: 'NDArtistPage' }
|
||||
)
|
||||
|
||||
const ArtistDetails = () => {
|
||||
const [artistInfo, setArtistInfo] = useState()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const record = useRecordContext()
|
||||
const artistId = record?.id
|
||||
|
||||
const title = record.name
|
||||
let completeBioLink = ''
|
||||
const link = artistInfo?.biography?.match(
|
||||
/<a\s+(?:[^>]*?\s+)?href=(["'])(.*?)\1/
|
||||
)
|
||||
if (link) {
|
||||
completeBioLink = link[2]
|
||||
}
|
||||
const biography = artistInfo?.biography?.replace(new RegExp('<.*>', 'g'), '')
|
||||
const translate = useTranslate()
|
||||
|
||||
const img = artistInfo?.largeImageUrl
|
||||
const classes = useStyles({ img, expanded })
|
||||
|
||||
useEffect(() => {
|
||||
subsonic
|
||||
.getArtistInfo(artistId)
|
||||
.then((resp) => resp.json['subsonic-response'])
|
||||
.then((data) => {
|
||||
if (data.status === 'ok') {
|
||||
setArtistInfo(data.artistInfo)
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('error on artist page', e)
|
||||
})
|
||||
}, [artistId, record])
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setExpanded(!expanded)
|
||||
}, [expanded, setExpanded])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.root}>
|
||||
<div className={classes.bgContainer}>
|
||||
<Card className={classes.martImage}>
|
||||
<CardMedia
|
||||
className={classes.cover}
|
||||
image={`${artistInfo?.mediumImageUrl}`}
|
||||
title={title}
|
||||
/>
|
||||
</Card>
|
||||
<div className={classes.mdetails}>
|
||||
<Typography component="h5" variant="h5">
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
<Card className={classes.artDetail}>
|
||||
<Card className={classes.artImage}>
|
||||
<CardMedia
|
||||
className={classes.cover}
|
||||
image={`${artistInfo?.mediumImageUrl}`}
|
||||
title={title}
|
||||
/>
|
||||
</Card>
|
||||
<div className={classes.details}>
|
||||
<CardContent className={classes.content}>
|
||||
<Typography component="h5" variant="h5">
|
||||
{title}
|
||||
</Typography>
|
||||
<Collapse
|
||||
collapsedHeight={'4.5em'}
|
||||
in={expanded}
|
||||
timeout={'auto'}
|
||||
className={classes.bioBlock}
|
||||
>
|
||||
<Typography variant={'body1'} onClick={handleExpandClick}>
|
||||
<span dangerouslySetInnerHTML={{ __html: biography }} />
|
||||
{completeBioLink !== '' && (
|
||||
<Link
|
||||
href={completeBioLink}
|
||||
className={classes.link}
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
>
|
||||
{translate('message.lastfmLink')}
|
||||
</Link>
|
||||
)}
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.mbio}>
|
||||
<Collapse collapsedHeight={'1.5em'} in={expanded} timeout={'auto'}>
|
||||
<Typography variant={'body1'} onClick={handleExpandClick}>
|
||||
{biography}
|
||||
<Link
|
||||
href={completeBioLink}
|
||||
className={classes.link}
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
>
|
||||
{translate('message.lastfmLink')}
|
||||
</Link>
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistAlbums = ({ ...props }) => {
|
||||
const { ids } = props
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
|
||||
return (
|
||||
<div className={classes.albumList}>
|
||||
<div className={classes.artistSummary}>
|
||||
{ids.length +
|
||||
' ' +
|
||||
translate('resources.album.name', { smart_count: ids.length })}
|
||||
</div>
|
||||
<AlbumGridView {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumShowLayout = (props) => {
|
||||
const showContext = useShowContext(props)
|
||||
const record = useRecordContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
{record && <ArtistDetails />}
|
||||
{record && (
|
||||
<ReferenceManyField
|
||||
{...showContext}
|
||||
addLabel={false}
|
||||
reference="album"
|
||||
target="artist_id"
|
||||
sort={{ field: 'maxYear', order: 'ASC' }}
|
||||
filter={{ artist_id: record?.id }}
|
||||
perPage={0}
|
||||
pagination={null}
|
||||
>
|
||||
<ArtistAlbums />
|
||||
</ReferenceManyField>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistShow = (props) => {
|
||||
const controllerProps = useShowController(props)
|
||||
return (
|
||||
<ShowContextProvider value={controllerProps}>
|
||||
<AlbumShowLayout {...controllerProps} />
|
||||
</ShowContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistShow
|
|
@ -1,11 +1,13 @@
|
|||
import React from 'react'
|
||||
import ArtistList from './ArtistList'
|
||||
import ArtistShow from './ArtistShow'
|
||||
import DynamicMenuIcon from '../layout/DynamicMenuIcon'
|
||||
import MicNoneOutlinedIcon from '@material-ui/icons/MicNoneOutlined'
|
||||
import MicIcon from '@material-ui/icons/Mic'
|
||||
|
||||
export default {
|
||||
list: ArtistList,
|
||||
show: ArtistShow,
|
||||
icon: (
|
||||
<DynamicMenuIcon
|
||||
path={'artist'}
|
||||
|
|
|
@ -1,19 +1,22 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Link } from 'react-admin'
|
||||
import { useAlbumsPerPage } from './index'
|
||||
import { withWidth } from '@material-ui/core'
|
||||
import { useAlbumsPerPage } from './index'
|
||||
import config from '../config'
|
||||
|
||||
export const useGetHandleArtistClick = (width) => {
|
||||
const [perPage] = useAlbumsPerPage(width)
|
||||
|
||||
return (id) => {
|
||||
return `/album?filter={"artist_id":"${id}"}&order=ASC&sort=maxYear&displayedFilters={"compilation":true}&perPage=${perPage}`
|
||||
return config.devShowArtistPage
|
||||
? `/artist/${id}/show`
|
||||
: `/album?filter={"artist_id":"${id}"}&order=ASC&sort=maxYear&displayedFilters={"compilation":true}&perPage=${perPage}`
|
||||
}
|
||||
}
|
||||
|
||||
export const ArtistLinkField = withWidth()(({ record, className, width }) => {
|
||||
const artistLink = useGetHandleArtistClick(width)
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={artistLink(record.albumArtistId)}
|
||||
|
|
|
@ -23,6 +23,7 @@ const defaultConfig = {
|
|||
lastFMEnabled: true,
|
||||
lastFMApiKey: '9b94a5515ea66b2da3ec03c12300327e',
|
||||
enableCoverAnimation: true,
|
||||
devShowArtistPage: true,
|
||||
}
|
||||
|
||||
let config
|
||||
|
|
|
@ -310,7 +310,8 @@
|
|||
"openIn": {
|
||||
"lastfm": "Open in Last.fm",
|
||||
"musicbrainz": "Open in MusicBrainz"
|
||||
}
|
||||
},
|
||||
"lastfmLink": "Read More..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Library",
|
||||
|
|
|
@ -49,6 +49,7 @@ const getCoverArtUrl = (record, size) => {
|
|||
...(record.updatedAt && { _: record.updatedAt }),
|
||||
...(size && { size }),
|
||||
}
|
||||
|
||||
if (record.coverArtId) {
|
||||
return baseUrl(url('getCoverArt', record.coverArtId, options))
|
||||
} else {
|
||||
|
@ -56,6 +57,10 @@ const getCoverArtUrl = (record, size) => {
|
|||
}
|
||||
}
|
||||
|
||||
const getArtistInfo = (id) => {
|
||||
return httpClient(url('getArtistInfo', id))
|
||||
}
|
||||
|
||||
const streamUrl = (id) => {
|
||||
return baseUrl(url('stream', id, { ts: true }))
|
||||
}
|
||||
|
@ -72,4 +77,5 @@ export default {
|
|||
getScanStatus,
|
||||
getCoverArtUrl,
|
||||
streamUrl,
|
||||
getArtistInfo,
|
||||
}
|
||||
|
|
|
@ -32,6 +32,15 @@ export default {
|
|||
boxShadow: '3px 3px 5px #000000a3',
|
||||
},
|
||||
},
|
||||
NDArtistPage: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important',
|
||||
},
|
||||
more: {
|
||||
boxShadow: '-10px 0px 18px 5px #303030!important',
|
||||
},
|
||||
},
|
||||
},
|
||||
player: {
|
||||
theme: 'dark',
|
||||
|
|
|
@ -28,7 +28,14 @@ export default {
|
|||
color: '#eee',
|
||||
},
|
||||
},
|
||||
NDArtistPage: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(0 0 0))!important',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
player: {
|
||||
theme: 'dark',
|
||||
stylesheet: require('./dark.css.js'),
|
||||
|
|
|
@ -27,6 +27,15 @@ export default {
|
|||
color: '#eee',
|
||||
},
|
||||
},
|
||||
NDArtistPage: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important',
|
||||
},
|
||||
more: {
|
||||
boxShadow: '-10px 0px 18px 5px #303030!important',
|
||||
},
|
||||
},
|
||||
},
|
||||
player: {
|
||||
theme: 'dark',
|
||||
|
|
|
@ -359,6 +359,15 @@ export default {
|
|||
marginTop: '-50px',
|
||||
},
|
||||
},
|
||||
NDArtistPage: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgb(255 255 255 / 51%), rgb(240 242 245))!important',
|
||||
},
|
||||
more: {
|
||||
boxShadow: '-10px 0px 18px 5px #f0f2f5!important',
|
||||
},
|
||||
},
|
||||
RaLayout: {
|
||||
content: {
|
||||
padding: '0 !important',
|
||||
|
|
|
@ -46,6 +46,15 @@ export default {
|
|||
color: '#0085ff',
|
||||
},
|
||||
},
|
||||
NDArtistPage: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgb(255 255 255 / 51%), rgb(250 250 250))!important',
|
||||
},
|
||||
more: {
|
||||
boxShadow: '-10px 0px 18px 5px #fafafa!important',
|
||||
},
|
||||
},
|
||||
},
|
||||
player: {
|
||||
theme: 'light',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue