From dc7a785f94921aed62351341a1fa87d73c730278 Mon Sep 17 00:00:00 2001
From: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
Date: Mon, 4 May 2026 01:56:26 +0300
Subject: [PATCH] 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.
---
src/components/AlbumCard.tsx | 20 +++++---
src/components/ContextMenu.tsx | 92 ++++++++++++++++++++++++++++++++++
src/locales/de.ts | 2 +
src/locales/en.ts | 2 +
src/locales/es.ts | 2 +
src/locales/fr.ts | 2 +
src/locales/nb.ts | 2 +
src/locales/nl.ts | 2 +
src/locales/ru.ts | 2 +
src/locales/zh.ts | 2 +
src/pages/RandomAlbums.tsx | 1 +
src/styles/components.css | 20 +++++---
12 files changed, 133 insertions(+), 16 deletions(-)
diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx
index e9a699fb..36c28ff3 100644
--- a/src/components/AlbumCard.tsx
+++ b/src/components/AlbumCard.tsx
@@ -83,14 +83,18 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
)}
- {isOffline && !selectionMode && (
-
- {t('common.new', 'New')}
+ {(isNewAlbum || (isOffline && !selectionMode)) && (
+
+ {isNewAlbum && (
+
+ {t('common.new', 'New')}
+
+ )}
+ {isOffline && !selectionMode && (
+
+
+
+ )}
)}
{selectionMode && (
diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx
index e819a9ae..71f38c59 100644
--- a/src/components/ContextMenu.tsx
+++ b/src/components/ContextMenu.tsx
@@ -1231,10 +1231,28 @@ export default function ContextMenu() {
const album = item as SubsonicAlbum;
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') {
const artist = item as SubsonicArtist;
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;
}, [type, item, userRatingOverrides]);
@@ -1247,8 +1265,22 @@ export default function ContextMenu() {
applyAlbumRating(item as SubsonicAlbum, rating);
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') {
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]);
@@ -1922,6 +1954,14 @@ export default function ContextMenu() {
{type === 'multi-album' && (() => {
const albums = item as SubsonicAlbum[];
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 (
<>
@@ -1948,6 +1988,28 @@ export default function ContextMenu() {
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
+
e.stopPropagation()}
+ >
+
+ {
+ setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
+ for (const a of albums) applyAlbumRating(a, r);
+ }}
+ />
+
>
);
})()}
@@ -2008,6 +2070,14 @@ export default function ContextMenu() {
{type === 'multi-artist' && (() => {
const artists = item as SubsonicArtist[];
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 (
<>
@@ -2026,6 +2096,28 @@ export default function ContextMenu() {
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
+
e.stopPropagation()}
+ >
+
+ {
+ setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
+ for (const a of artists) applyArtistRating(a, r);
+ }}
+ />
+
>
);
})()}
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 7a75f294..60a05aa3 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -230,6 +230,8 @@ export const deTranslation = {
artistShort: 'Künstlerbewertung',
albumAriaLabel: 'Albumbewertung',
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.',
},
artistDetail: {
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 0690e140..73e3af06 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -232,6 +232,8 @@ export const enTranslation = {
artistShort: 'Artist rating',
albumAriaLabel: 'Album rating',
artistAriaLabel: 'Artist rating',
+ selectedArtistsRatingAriaLabel: 'Star rating for {{count}} selected artists',
+ selectedAlbumsRatingAriaLabel: 'Star rating for {{count}} selected albums',
saveFailed: 'Could not save rating.',
},
artistDetail: {
diff --git a/src/locales/es.ts b/src/locales/es.ts
index 9404008c..578c77d1 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -231,6 +231,8 @@ export const esTranslation = {
artistShort: 'Calificación del artista',
albumAriaLabel: 'Calificación del álbum',
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.',
},
artistDetail: {
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
index 0438ff9f..48bf8076 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -230,6 +230,8 @@ export const frTranslation = {
artistShort: 'Note de l’artiste',
albumAriaLabel: 'Note de l’album',
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.',
},
artistDetail: {
diff --git a/src/locales/nb.ts b/src/locales/nb.ts
index a8fe3111..1358e861 100644
--- a/src/locales/nb.ts
+++ b/src/locales/nb.ts
@@ -230,6 +230,8 @@ export const nbTranslation = {
artistShort: 'Artistvurdering',
albumAriaLabel: 'Albumvurdering',
artistAriaLabel: 'Artistvurdering',
+ selectedArtistsRatingAriaLabel: 'Stjernevurdering for {{count}} valgte artister',
+ selectedAlbumsRatingAriaLabel: 'Stjernevurdering for {{count}} valgte album',
saveFailed: 'Kunne ikke lagre vurderingen.',
},
artistDetail: {
diff --git a/src/locales/nl.ts b/src/locales/nl.ts
index 3b265784..b2bf39c0 100644
--- a/src/locales/nl.ts
+++ b/src/locales/nl.ts
@@ -229,6 +229,8 @@ export const nlTranslation = {
artistShort: 'Artiestbeoordeling',
albumAriaLabel: 'Albumbeoordeling',
artistAriaLabel: 'Artiestbeoordeling',
+ selectedArtistsRatingAriaLabel: 'Sterrenbeoordeling voor {{count}} geselecteerde artiesten',
+ selectedAlbumsRatingAriaLabel: 'Sterrenbeoordeling voor {{count}} geselecteerde albums',
saveFailed: 'Beoordeling opslaan mislukt.',
},
artistDetail: {
diff --git a/src/locales/ru.ts b/src/locales/ru.ts
index ff00606f..712d0ee7 100644
--- a/src/locales/ru.ts
+++ b/src/locales/ru.ts
@@ -241,6 +241,8 @@ export const ruTranslation = {
artistShort: 'Оценка исполнителя',
albumAriaLabel: 'Оценка альбома',
artistAriaLabel: 'Оценка исполнителя',
+ selectedArtistsRatingAriaLabel: 'Звёздная оценка для выбранных исполнителей ({{count}})',
+ selectedAlbumsRatingAriaLabel: 'Звёздная оценка для выбранных альбомов ({{count}})',
saveFailed: 'Не удалось сохранить оценку.',
},
artistDetail: {
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index d691f8ef..0a451091 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -228,6 +228,8 @@ export const zhTranslation = {
artistShort: '艺人评分',
albumAriaLabel: '专辑评分',
artistAriaLabel: '艺人评分',
+ selectedArtistsRatingAriaLabel: '为所选 {{count}} 位艺人设置星级评分',
+ selectedAlbumsRatingAriaLabel: '为所选 {{count}} 张专辑设置星级评分',
saveFailed: '无法保存评分。',
},
artistDetail: {
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx
index 9f31f30c..7ed0a6de 100644
--- a/src/pages/RandomAlbums.tsx
+++ b/src/pages/RandomAlbums.tsx
@@ -189,6 +189,7 @@ export default function RandomAlbums() {
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
+ selectedAlbums={selectedAlbums}
/>
))}
diff --git a/src/styles/components.css b/src/styles/components.css
index 2da38062..2a973614 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -437,10 +437,20 @@
box-shadow: var(--shadow-sm);
}
-.album-card-offline-badge {
+.album-card-cover-badges-tr {
position: absolute;
top: 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);
color: var(--ctp-crust);
border-radius: var(--radius-sm);
@@ -448,14 +458,10 @@
display: flex;
align-items: center;
justify-content: center;
- z-index: 2;
- pointer-events: none;
}
.album-card-new-badge {
- position: absolute;
- top: 6px;
- left: 6px;
+ flex-shrink: 0;
background: var(--accent);
color: var(--ctp-crust);
border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent);
@@ -465,8 +471,6 @@
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
- z-index: 2;
- pointer-events: none;
}
.album-card-play-overlay {