mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat: batch rate/like songs and compact bulk action labels
- New BatchRateButton: select multiple songs, set rating (1-5 stars), clear rating, or toggle like/unlike in one dialog - Combined star+heart icon for the button - Short labels for all bulk actions (Now, Next, Later, Playlist, Rate) with full-text tooltips on hover - Increased gap and hover effect on bulk action buttons - i18n: English and German translations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2a43c4683e
commit
5e5513bd34
@ -49,7 +49,17 @@
|
||||
"playNext": "Als nächstes abspielen",
|
||||
"info": "Mehr Informationen",
|
||||
"showInPlaylist": "In Wiedergabeliste anzeigen",
|
||||
"instantMix": "Sofort-Mix"
|
||||
"instantMix": "Sofort-Mix",
|
||||
"playNowShort": "Jetzt",
|
||||
"playNextShort": "Nächstes",
|
||||
"addToQueueShort": "Später",
|
||||
"addToPlaylistShort": "Liste",
|
||||
"batchRate": "Bewerten",
|
||||
"batchRateShort": "Bewerten",
|
||||
"batchRateTitle": "%{smart_count} Titel bewerten |||| %{smart_count} Titel bewerten",
|
||||
"clearRating": "Bewertung löschen",
|
||||
"like": "Favorisieren",
|
||||
"unlike": "Nicht mehr favorisieren"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -592,6 +602,7 @@
|
||||
"noSimilarSongsFound": "Keine ähnlichen Titel gefunden",
|
||||
"noTopSongsFound": "Keine beliebten Titel gefunden",
|
||||
"startingInstantMix": "Lade Sofort-Mix...",
|
||||
"batchRateSuccess": "Bewertung erfolgreich angewendet",
|
||||
"uploadCover": "Cover hochladen",
|
||||
"removeCover": "Cover entfernen",
|
||||
"coverUploaded": "Cover aktualisiert",
|
||||
|
||||
@ -5,7 +5,7 @@ import { Button, useTranslate, useUnselectAll } from 'react-admin'
|
||||
import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd'
|
||||
import { openAddToPlaylist } from '../actions'
|
||||
|
||||
export const AddToPlaylistButton = ({ resource, selectedIds, className }) => {
|
||||
export const AddToPlaylistButton = ({ resource, selectedIds, className, label }) => {
|
||||
const translate = useTranslate()
|
||||
const dispatch = useDispatch()
|
||||
const unselectAll = useUnselectAll()
|
||||
@ -19,13 +19,15 @@ export const AddToPlaylistButton = ({ resource, selectedIds, className }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const caption = label ? translate(label) : translate('resources.song.actions.addToPlaylist')
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-controls="simple-menu"
|
||||
aria-haspopup="true"
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
label={translate('resources.song.actions.addToPlaylist')}
|
||||
label={caption}
|
||||
>
|
||||
<PlaylistAddIcon />
|
||||
</Button>
|
||||
|
||||
194
ui/src/common/BatchRateButton.jsx
Normal file
194
ui/src/common/BatchRateButton.jsx
Normal file
@ -0,0 +1,194 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
useDataProvider,
|
||||
useTranslate,
|
||||
useUnselectAll,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
} from 'react-admin'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button as MuiButton,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@material-ui/core'
|
||||
import Rating from '@material-ui/lab/Rating'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import FavoriteIcon from '@material-ui/icons/Favorite'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import ClearIcon from '@material-ui/icons/Clear'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import subsonic from '../subsonic'
|
||||
import config from '../config'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
comboIcon: {
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
starPart: {
|
||||
position: 'absolute',
|
||||
top: -1,
|
||||
left: 0,
|
||||
fontSize: 20,
|
||||
opacity: 0.9,
|
||||
},
|
||||
heartPart: {
|
||||
position: 'absolute',
|
||||
bottom: -1,
|
||||
right: -2,
|
||||
fontSize: 14,
|
||||
opacity: 0.9,
|
||||
},
|
||||
ratingRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
loveRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
})
|
||||
|
||||
const ComboIcon = () => {
|
||||
const classes = useStyles()
|
||||
return (
|
||||
<span className={classes.comboIcon}>
|
||||
<StarBorderIcon className={classes.starPart} />
|
||||
<FavoriteBorderIcon className={classes.heartPart} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const BatchRateButton = ({
|
||||
resource,
|
||||
selectedIds,
|
||||
className,
|
||||
label: labelOverride,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [rating, setRating] = useState(0)
|
||||
const [starred, setStarred] = useState(null) // null = don't change, true/false = set
|
||||
const translate = useTranslate()
|
||||
const dataProvider = useDataProvider()
|
||||
const unselectAll = useUnselectAll()
|
||||
const notify = useNotify()
|
||||
const refresh = useRefresh()
|
||||
const classes = useStyles()
|
||||
|
||||
const handleOpen = () => {
|
||||
setRating(0)
|
||||
setStarred(null)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
if (rating > 0) {
|
||||
await subsonic.setRating(id, rating)
|
||||
}
|
||||
if (starred === true) {
|
||||
await subsonic.star(id)
|
||||
} else if (starred === false) {
|
||||
await subsonic.unstar(id)
|
||||
}
|
||||
}
|
||||
// Clear rating if "delete" was chosen (rating === -1)
|
||||
if (rating === -1) {
|
||||
for (const id of selectedIds) {
|
||||
await subsonic.setRating(id, 0)
|
||||
}
|
||||
}
|
||||
// Force React-Admin to re-fetch the records, then refresh the view
|
||||
await dataProvider.getMany(resource, { ids: selectedIds })
|
||||
notify('message.batchRateSuccess', { type: 'info' })
|
||||
refresh()
|
||||
} catch (e) {
|
||||
notify('ra.page.error', { type: 'warning' })
|
||||
}
|
||||
unselectAll(resource)
|
||||
}
|
||||
|
||||
const caption = labelOverride || translate('resources.song.actions.batchRate')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
aria-label={caption}
|
||||
onClick={handleOpen}
|
||||
label={caption}
|
||||
className={className}
|
||||
>
|
||||
<ComboIcon />
|
||||
</Button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||
<DialogTitle style={{ textAlign: 'center' }}>{translate('resources.song.actions.batchRateTitle', { smart_count: selectedIds?.length || 0 })}</DialogTitle>
|
||||
<DialogContent>
|
||||
<div className={classes.ratingRow}>
|
||||
<Tooltip title={translate('resources.song.actions.clearRating')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setRating(rating === -1 ? 0 : -1)}
|
||||
color={rating === -1 ? 'secondary' : 'default'}
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Rating
|
||||
value={rating > 0 ? rating : 0}
|
||||
onChange={(_, val) => setRating(val || 0)}
|
||||
emptyIcon={<StarBorderIcon fontSize="inherit" />}
|
||||
/>
|
||||
</div>
|
||||
{config.enableFavourites && (
|
||||
<div className={classes.loveRow}>
|
||||
<Tooltip title={translate('resources.song.actions.unlike')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setStarred(starred === false ? null : false)}
|
||||
color={starred === false ? 'secondary' : 'default'}
|
||||
>
|
||||
<FavoriteBorderIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={translate('resources.song.actions.like')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setStarred(starred === true ? null : true)}
|
||||
color={starred === true ? 'secondary' : 'default'}
|
||||
>
|
||||
<FavoriteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MuiButton onClick={() => setOpen(false)}>
|
||||
{translate('ra.action.cancel')}
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
onClick={handleApply}
|
||||
color="primary"
|
||||
disabled={rating === 0 && starred === null}
|
||||
>
|
||||
{translate('ra.action.confirm')}
|
||||
</MuiButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,53 +1,88 @@
|
||||
import React, { Fragment, useEffect } from 'react'
|
||||
import { useUnselectAll } from 'react-admin'
|
||||
import { useTranslate, useUnselectAll } from 'react-admin'
|
||||
import { addTracks, playNext, playTracks } from '../actions'
|
||||
import { RiPlayList2Fill, RiPlayListAddFill } from 'react-icons/ri'
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||
import { BatchPlayButton } from './index'
|
||||
import { AddToPlaylistButton } from './AddToPlaylistButton'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { Tooltip } from '@material-ui/core'
|
||||
import { BatchShareButton } from './BatchShareButton'
|
||||
import { BatchRateButton } from './BatchRateButton'
|
||||
import config from '../config'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
button: {
|
||||
color: theme.palette.type === 'dark' ? 'white' : undefined,
|
||||
marginRight: 5,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const TipButton = ({ labelKey, children }) => {
|
||||
const translate = useTranslate()
|
||||
return (
|
||||
<Tooltip title={translate(labelKey)}>
|
||||
<span>{children}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export const SongBulkActions = (props) => {
|
||||
const classes = useStyles()
|
||||
const unselectAll = useUnselectAll()
|
||||
|
||||
useEffect(() => {
|
||||
unselectAll(props.resource)
|
||||
}, [unselectAll, props.resource])
|
||||
return (
|
||||
<Fragment>
|
||||
<BatchPlayButton
|
||||
{...props}
|
||||
action={playTracks}
|
||||
label={'resources.song.actions.playNow'}
|
||||
icon={<PlayArrowIcon />}
|
||||
className={classes.button}
|
||||
/>
|
||||
<BatchPlayButton
|
||||
{...props}
|
||||
action={playNext}
|
||||
label={'resources.song.actions.playNext'}
|
||||
icon={<RiPlayList2Fill />}
|
||||
className={classes.button}
|
||||
/>
|
||||
<BatchPlayButton
|
||||
{...props}
|
||||
action={addTracks}
|
||||
label={'resources.song.actions.addToQueue'}
|
||||
icon={<RiPlayListAddFill />}
|
||||
className={classes.button}
|
||||
/>
|
||||
<TipButton labelKey={'resources.song.actions.playNow'}>
|
||||
<BatchPlayButton
|
||||
{...props}
|
||||
action={playTracks}
|
||||
label={'resources.song.actions.playNowShort'}
|
||||
icon={<PlayArrowIcon />}
|
||||
className={classes.button}
|
||||
/>
|
||||
</TipButton>
|
||||
<TipButton labelKey={'resources.song.actions.playNext'}>
|
||||
<BatchPlayButton
|
||||
{...props}
|
||||
action={playNext}
|
||||
label={'resources.song.actions.playNextShort'}
|
||||
icon={<RiPlayList2Fill />}
|
||||
className={classes.button}
|
||||
/>
|
||||
</TipButton>
|
||||
<TipButton labelKey={'resources.song.actions.addToQueue'}>
|
||||
<BatchPlayButton
|
||||
{...props}
|
||||
action={addTracks}
|
||||
label={'resources.song.actions.addToQueueShort'}
|
||||
icon={<RiPlayListAddFill />}
|
||||
className={classes.button}
|
||||
/>
|
||||
</TipButton>
|
||||
{config.enableSharing && (
|
||||
<BatchShareButton {...props} className={classes.button} />
|
||||
<TipButton labelKey={'resources.song.actions.share'}>
|
||||
<BatchShareButton {...props} className={classes.button} />
|
||||
</TipButton>
|
||||
)}
|
||||
<TipButton labelKey={'resources.song.actions.addToPlaylist'}>
|
||||
<AddToPlaylistButton
|
||||
{...props}
|
||||
className={classes.button}
|
||||
label={'resources.song.actions.addToPlaylistShort'}
|
||||
/>
|
||||
</TipButton>
|
||||
{config.enableStarRating && (
|
||||
<TipButton labelKey={'resources.song.actions.batchRate'}>
|
||||
<BatchRateButton {...props} className={classes.button} />
|
||||
</TipButton>
|
||||
)}
|
||||
<AddToPlaylistButton {...props} className={classes.button} />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@ -51,7 +51,17 @@
|
||||
"download": "Download",
|
||||
"playNext": "Play Next",
|
||||
"info": "Get Info",
|
||||
"instantMix": "Instant Mix"
|
||||
"instantMix": "Instant Mix",
|
||||
"playNowShort": "Now",
|
||||
"playNextShort": "Next",
|
||||
"addToQueueShort": "Later",
|
||||
"addToPlaylistShort": "Playlist",
|
||||
"batchRate": "Rate",
|
||||
"batchRateShort": "Rate",
|
||||
"batchRateTitle": "Rate %{smart_count} song |||| Rate %{smart_count} songs",
|
||||
"clearRating": "Clear Rating",
|
||||
"like": "Like",
|
||||
"unlike": "Unlike"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -571,6 +581,7 @@
|
||||
"songsAddedToPlaylist": "Added 1 song to playlist |||| Added %{smart_count} songs to playlist",
|
||||
"noSimilarSongsFound": "No similar songs found",
|
||||
"startingInstantMix": "Loading Instant Mix...",
|
||||
"batchRateSuccess": "Rating applied successfully",
|
||||
"noTopSongsFound": "No top songs found",
|
||||
"noPlaylistsAvailable": "None available",
|
||||
"delete_user_title": "Delete user '%{name}'",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user