mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(ui): bulk entity ratings, Random Albums multi-select, album New badge (#446)
Add star rating rows to multi-artist and multi-album context menus so the selection shares one rating control (mixed ratings show empty until set; keyboard navigation supported). Pass selectedAlbums into AlbumCard on Random Albums so multi-select context menu works. Add i18n aria labels for bulk rating controls. Move the New album badge to the top-right of the cover and stack it with the offline badge to avoid overlap.
This commit is contained in:
@@ -83,14 +83,18 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(isNewAlbum || (isOffline && !selectionMode)) && (
|
||||||
|
<div className="album-card-cover-badges-tr">
|
||||||
|
{isNewAlbum && (
|
||||||
|
<div className="album-card-new-badge" aria-label={t('common.new', 'New')}>
|
||||||
|
{t('common.new', 'New')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isOffline && !selectionMode && (
|
{isOffline && !selectionMode && (
|
||||||
<div className="album-card-offline-badge" aria-label="Offline available">
|
<div className="album-card-offline-badge" aria-label="Offline available">
|
||||||
<HardDriveDownload size={12} />
|
<HardDriveDownload size={12} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isNewAlbum && (
|
|
||||||
<div className="album-card-new-badge" aria-label={t('common.new', 'New')}>
|
|
||||||
{t('common.new', 'New')}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
|
|||||||
@@ -1231,10 +1231,28 @@ export default function ContextMenu() {
|
|||||||
const album = item as SubsonicAlbum;
|
const album = item as SubsonicAlbum;
|
||||||
if (album.id === id) return userRatingOverrides[id] ?? album.userRating ?? 0;
|
if (album.id === id) return userRatingOverrides[id] ?? album.userRating ?? 0;
|
||||||
}
|
}
|
||||||
|
if (kind === 'album' && type === 'multi-album') {
|
||||||
|
const albums = item as SubsonicAlbum[];
|
||||||
|
const compositeId = [...albums.map(a => a.id)].sort().join('\x1e');
|
||||||
|
if (id !== compositeId) return userRatingOverrides[id] ?? 0;
|
||||||
|
if (albums.length === 0) return 0;
|
||||||
|
const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
|
||||||
|
const first = vals[0];
|
||||||
|
return vals.every(v => v === first) ? first : 0;
|
||||||
|
}
|
||||||
if (kind === 'artist' && type === 'artist') {
|
if (kind === 'artist' && type === 'artist') {
|
||||||
const artist = item as SubsonicArtist;
|
const artist = item as SubsonicArtist;
|
||||||
if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0;
|
if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0;
|
||||||
}
|
}
|
||||||
|
if (kind === 'artist' && type === 'multi-artist') {
|
||||||
|
const artists = item as SubsonicArtist[];
|
||||||
|
const compositeId = [...artists.map(a => a.id)].sort().join('\x1e');
|
||||||
|
if (id !== compositeId) return userRatingOverrides[id] ?? 0;
|
||||||
|
if (artists.length === 0) return 0;
|
||||||
|
const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
|
||||||
|
const first = vals[0];
|
||||||
|
return vals.every(v => v === first) ? first : 0;
|
||||||
|
}
|
||||||
return userRatingOverrides[id] ?? 0;
|
return userRatingOverrides[id] ?? 0;
|
||||||
}, [type, item, userRatingOverrides]);
|
}, [type, item, userRatingOverrides]);
|
||||||
|
|
||||||
@@ -1247,8 +1265,22 @@ export default function ContextMenu() {
|
|||||||
applyAlbumRating(item as SubsonicAlbum, rating);
|
applyAlbumRating(item as SubsonicAlbum, rating);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (kind === 'album' && type === 'multi-album') {
|
||||||
|
const albums = item as SubsonicAlbum[];
|
||||||
|
const compositeId = [...albums.map(a => a.id)].sort().join('\x1e');
|
||||||
|
if (id !== compositeId) return;
|
||||||
|
for (const a of albums) applyAlbumRating(a, rating);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (kind === 'artist' && type === 'artist') {
|
if (kind === 'artist' && type === 'artist') {
|
||||||
applyArtistRating(item as SubsonicArtist, rating);
|
applyArtistRating(item as SubsonicArtist, rating);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'artist' && type === 'multi-artist') {
|
||||||
|
const artists = item as SubsonicArtist[];
|
||||||
|
const compositeId = [...artists.map(a => a.id)].sort().join('\x1e');
|
||||||
|
if (id !== compositeId) return;
|
||||||
|
for (const a of artists) applyArtistRating(a, rating);
|
||||||
}
|
}
|
||||||
}, [applySongRating, applyAlbumRating, applyArtistRating, type, item]);
|
}, [applySongRating, applyAlbumRating, applyArtistRating, type, item]);
|
||||||
|
|
||||||
@@ -1922,6 +1954,14 @@ export default function ContextMenu() {
|
|||||||
{type === 'multi-album' && (() => {
|
{type === 'multi-album' && (() => {
|
||||||
const albums = item as SubsonicAlbum[];
|
const albums = item as SubsonicAlbum[];
|
||||||
const albumIds = albums.map(a => a.id);
|
const albumIds = albums.map(a => a.id);
|
||||||
|
const albumRatingDisabled = entityRatingSupport === 'track_only';
|
||||||
|
const multiAlbumRatingId = [...albumIds].sort().join('\x1e');
|
||||||
|
const unifiedAlbumRating = (() => {
|
||||||
|
if (albums.length === 0) return 0;
|
||||||
|
const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
|
||||||
|
const first = vals[0];
|
||||||
|
return vals.every(v => v === first) ? first : 0;
|
||||||
|
})();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
@@ -1948,6 +1988,28 @@ export default function ContextMenu() {
|
|||||||
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="context-menu-rating-row"
|
||||||
|
data-rating-kind="album"
|
||||||
|
data-rating-id={multiAlbumRatingId}
|
||||||
|
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
|
<StarRating
|
||||||
|
value={
|
||||||
|
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
|
||||||
|
? keyboardRating.value
|
||||||
|
: unifiedAlbumRating
|
||||||
|
}
|
||||||
|
disabled={albumRatingDisabled}
|
||||||
|
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
|
||||||
|
onChange={r => {
|
||||||
|
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
|
||||||
|
for (const a of albums) applyAlbumRating(a, r);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -2008,6 +2070,14 @@ export default function ContextMenu() {
|
|||||||
{type === 'multi-artist' && (() => {
|
{type === 'multi-artist' && (() => {
|
||||||
const artists = item as SubsonicArtist[];
|
const artists = item as SubsonicArtist[];
|
||||||
const artistIds = artists.map(a => a.id);
|
const artistIds = artists.map(a => a.id);
|
||||||
|
const artistRatingDisabled = entityRatingSupport === 'track_only';
|
||||||
|
const multiArtistRatingId = [...artistIds].sort().join('\x1e');
|
||||||
|
const unifiedArtistRating = (() => {
|
||||||
|
if (artists.length === 0) return 0;
|
||||||
|
const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
|
||||||
|
const first = vals[0];
|
||||||
|
return vals.every(v => v === first) ? first : 0;
|
||||||
|
})();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
@@ -2026,6 +2096,28 @@ export default function ContextMenu() {
|
|||||||
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="context-menu-rating-row"
|
||||||
|
data-rating-kind="artist"
|
||||||
|
data-rating-id={multiArtistRatingId}
|
||||||
|
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
|
<StarRating
|
||||||
|
value={
|
||||||
|
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
|
||||||
|
? keyboardRating.value
|
||||||
|
: unifiedArtistRating
|
||||||
|
}
|
||||||
|
disabled={artistRatingDisabled}
|
||||||
|
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
|
||||||
|
onChange={r => {
|
||||||
|
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
|
||||||
|
for (const a of artists) applyArtistRating(a, r);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ export const deTranslation = {
|
|||||||
artistShort: 'Künstlerbewertung',
|
artistShort: 'Künstlerbewertung',
|
||||||
albumAriaLabel: 'Albumbewertung',
|
albumAriaLabel: 'Albumbewertung',
|
||||||
artistAriaLabel: 'Künstlerbewertung',
|
artistAriaLabel: 'Künstlerbewertung',
|
||||||
|
selectedArtistsRatingAriaLabel: 'Sternebewertung für {{count}} ausgewählte Künstler',
|
||||||
|
selectedAlbumsRatingAriaLabel: 'Sternebewertung für {{count}} ausgewählte Alben',
|
||||||
saveFailed: 'Bewertung konnte nicht gespeichert werden.',
|
saveFailed: 'Bewertung konnte nicht gespeichert werden.',
|
||||||
},
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ export const enTranslation = {
|
|||||||
artistShort: 'Artist rating',
|
artistShort: 'Artist rating',
|
||||||
albumAriaLabel: 'Album rating',
|
albumAriaLabel: 'Album rating',
|
||||||
artistAriaLabel: 'Artist rating',
|
artistAriaLabel: 'Artist rating',
|
||||||
|
selectedArtistsRatingAriaLabel: 'Star rating for {{count}} selected artists',
|
||||||
|
selectedAlbumsRatingAriaLabel: 'Star rating for {{count}} selected albums',
|
||||||
saveFailed: 'Could not save rating.',
|
saveFailed: 'Could not save rating.',
|
||||||
},
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ export const esTranslation = {
|
|||||||
artistShort: 'Calificación del artista',
|
artistShort: 'Calificación del artista',
|
||||||
albumAriaLabel: 'Calificación del álbum',
|
albumAriaLabel: 'Calificación del álbum',
|
||||||
artistAriaLabel: 'Calificación del artista',
|
artistAriaLabel: 'Calificación del artista',
|
||||||
|
selectedArtistsRatingAriaLabel: 'Calificación con estrellas para {{count}} artistas seleccionados',
|
||||||
|
selectedAlbumsRatingAriaLabel: 'Calificación con estrellas para {{count}} álbumes seleccionados',
|
||||||
saveFailed: 'No se pudo guardar la calificación.',
|
saveFailed: 'No se pudo guardar la calificación.',
|
||||||
},
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ export const frTranslation = {
|
|||||||
artistShort: 'Note de l’artiste',
|
artistShort: 'Note de l’artiste',
|
||||||
albumAriaLabel: 'Note de l’album',
|
albumAriaLabel: 'Note de l’album',
|
||||||
artistAriaLabel: 'Note de l’artiste',
|
artistAriaLabel: 'Note de l’artiste',
|
||||||
|
selectedArtistsRatingAriaLabel: 'Note sur {{count}} artistes sélectionnés',
|
||||||
|
selectedAlbumsRatingAriaLabel: 'Note sur {{count}} albums sélectionnés',
|
||||||
saveFailed: 'Impossible d’enregistrer la note.',
|
saveFailed: 'Impossible d’enregistrer la note.',
|
||||||
},
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ export const nbTranslation = {
|
|||||||
artistShort: 'Artistvurdering',
|
artistShort: 'Artistvurdering',
|
||||||
albumAriaLabel: 'Albumvurdering',
|
albumAriaLabel: 'Albumvurdering',
|
||||||
artistAriaLabel: 'Artistvurdering',
|
artistAriaLabel: 'Artistvurdering',
|
||||||
|
selectedArtistsRatingAriaLabel: 'Stjernevurdering for {{count}} valgte artister',
|
||||||
|
selectedAlbumsRatingAriaLabel: 'Stjernevurdering for {{count}} valgte album',
|
||||||
saveFailed: 'Kunne ikke lagre vurderingen.',
|
saveFailed: 'Kunne ikke lagre vurderingen.',
|
||||||
},
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ export const nlTranslation = {
|
|||||||
artistShort: 'Artiestbeoordeling',
|
artistShort: 'Artiestbeoordeling',
|
||||||
albumAriaLabel: 'Albumbeoordeling',
|
albumAriaLabel: 'Albumbeoordeling',
|
||||||
artistAriaLabel: 'Artiestbeoordeling',
|
artistAriaLabel: 'Artiestbeoordeling',
|
||||||
|
selectedArtistsRatingAriaLabel: 'Sterrenbeoordeling voor {{count}} geselecteerde artiesten',
|
||||||
|
selectedAlbumsRatingAriaLabel: 'Sterrenbeoordeling voor {{count}} geselecteerde albums',
|
||||||
saveFailed: 'Beoordeling opslaan mislukt.',
|
saveFailed: 'Beoordeling opslaan mislukt.',
|
||||||
},
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
|
|||||||
@@ -241,6 +241,8 @@ export const ruTranslation = {
|
|||||||
artistShort: 'Оценка исполнителя',
|
artistShort: 'Оценка исполнителя',
|
||||||
albumAriaLabel: 'Оценка альбома',
|
albumAriaLabel: 'Оценка альбома',
|
||||||
artistAriaLabel: 'Оценка исполнителя',
|
artistAriaLabel: 'Оценка исполнителя',
|
||||||
|
selectedArtistsRatingAriaLabel: 'Звёздная оценка для выбранных исполнителей ({{count}})',
|
||||||
|
selectedAlbumsRatingAriaLabel: 'Звёздная оценка для выбранных альбомов ({{count}})',
|
||||||
saveFailed: 'Не удалось сохранить оценку.',
|
saveFailed: 'Не удалось сохранить оценку.',
|
||||||
},
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ export const zhTranslation = {
|
|||||||
artistShort: '艺人评分',
|
artistShort: '艺人评分',
|
||||||
albumAriaLabel: '专辑评分',
|
albumAriaLabel: '专辑评分',
|
||||||
artistAriaLabel: '艺人评分',
|
artistAriaLabel: '艺人评分',
|
||||||
|
selectedArtistsRatingAriaLabel: '为所选 {{count}} 位艺人设置星级评分',
|
||||||
|
selectedAlbumsRatingAriaLabel: '为所选 {{count}} 张专辑设置星级评分',
|
||||||
saveFailed: '无法保存评分。',
|
saveFailed: '无法保存评分。',
|
||||||
},
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export default function RandomAlbums() {
|
|||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
selected={selectedIds.has(a.id)}
|
selected={selectedIds.has(a.id)}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
|
selectedAlbums={selectedAlbums}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -437,10 +437,20 @@
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-card-offline-badge {
|
.album-card-cover-badges-tr {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card-offline-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
background: color-mix(in srgb, var(--accent) 85%, transparent);
|
background: color-mix(in srgb, var(--accent) 85%, transparent);
|
||||||
color: var(--ctp-crust);
|
color: var(--ctp-crust);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -448,14 +458,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-card-new-badge {
|
.album-card-new-badge {
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
top: 6px;
|
|
||||||
left: 6px;
|
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--ctp-crust);
|
color: var(--ctp-crust);
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent);
|
border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent);
|
||||||
@@ -465,8 +471,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-card-play-overlay {
|
.album-card-play-overlay {
|
||||||
|
|||||||
Reference in New Issue
Block a user