mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(ratings): add user ratings feature with average rating display and stats
This commit is contained in:
parent
bc107d1cee
commit
c0bc10284b
@ -41,6 +41,26 @@ func (u User) HasLibraryAccess(libraryID int) bool {
|
||||
|
||||
type Users []User
|
||||
|
||||
type RatingStat struct {
|
||||
Rating int `json:"rating"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type UserRatingStats struct {
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
SongStats []RatingStat `json:"songStats"`
|
||||
AlbumStats []RatingStat `json:"albumStats"`
|
||||
}
|
||||
|
||||
type RatedItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumID string `json:"albumId,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserRepository interface {
|
||||
ResourceRepository
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
@ -59,4 +79,7 @@ type UserRepository interface {
|
||||
// Library association methods
|
||||
GetUserLibraries(userID string) (Libraries, error)
|
||||
SetUserLibraries(userID string, libraryIDs []int) error
|
||||
|
||||
RatingStats() ([]UserRatingStats, error)
|
||||
RatingItems(userID, itemType string, rating int) ([]RatedItem, error)
|
||||
}
|
||||
|
||||
@ -478,6 +478,90 @@ func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *userRepository) RatingStats() ([]model.UserRatingStats, error) {
|
||||
type row struct {
|
||||
UserID string `db:"user_id"`
|
||||
UserName string `db:"user_name"`
|
||||
ItemType string `db:"item_type"`
|
||||
Rating int `db:"rating"`
|
||||
Count int `db:"cnt"`
|
||||
}
|
||||
|
||||
sel := Select(`a.user_id`, `u.user_name`, `a.item_type`, `a.rating`, `count(*) as cnt`).
|
||||
From(`annotation a`).
|
||||
Join(`"user" u ON a.user_id = u.id`).
|
||||
Where(Gt{"a.rating": 0}).
|
||||
GroupBy(`a.user_id`, `a.item_type`, `a.rating`).
|
||||
OrderBy(`u.user_name`, `a.item_type`, `a.rating DESC`)
|
||||
|
||||
var rows []row
|
||||
if err := r.queryAll(sel, &rows); err != nil && err != model.ErrNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byUser := map[string]*model.UserRatingStats{}
|
||||
order := []string{}
|
||||
for _, row := range rows {
|
||||
if _, ok := byUser[row.UserID]; !ok {
|
||||
byUser[row.UserID] = &model.UserRatingStats{
|
||||
UserID: row.UserID,
|
||||
UserName: row.UserName,
|
||||
}
|
||||
order = append(order, row.UserID)
|
||||
}
|
||||
stat := model.RatingStat{Rating: row.Rating, Count: row.Count}
|
||||
switch row.ItemType {
|
||||
case "media_file":
|
||||
byUser[row.UserID].SongStats = append(byUser[row.UserID].SongStats, stat)
|
||||
case "album":
|
||||
byUser[row.UserID].AlbumStats = append(byUser[row.UserID].AlbumStats, stat)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]model.UserRatingStats, 0, len(order))
|
||||
for _, uid := range order {
|
||||
result = append(result, *byUser[uid])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) RatingItems(userID, itemType string, rating int) ([]model.RatedItem, error) {
|
||||
type row struct {
|
||||
ID string `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Artist string `db:"artist"`
|
||||
AlbumID string `db:"album_id"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
var table, nameCol, artistCol, albumIDCol string
|
||||
switch itemType {
|
||||
case "album":
|
||||
table, nameCol, artistCol, albumIDCol = "album", "album.name", "album.album_artist", "'' as album_id"
|
||||
case "song":
|
||||
table, nameCol, artistCol, albumIDCol = "media_file", "media_file.title", "media_file.artist", "media_file.album_id"
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sel := Select(table+".id", nameCol+" as name", artistCol+" as artist", albumIDCol, table+".updated_at").
|
||||
From("annotation a").
|
||||
Join(table + " ON a.item_id = " + table + ".id").
|
||||
Where(Eq{"a.user_id": userID, "a.item_type": table, "a.rating": rating}).
|
||||
OrderBy("name")
|
||||
|
||||
var rows []row
|
||||
if err := r.queryAll(sel, &rows); err != nil && err != model.ErrNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]model.RatedItem, len(rows))
|
||||
for i, row := range rows {
|
||||
result[i] = model.RatedItem{ID: row.ID, Name: row.Name, Artist: row.Artist, AlbumID: row.AlbumID, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var _ model.UserRepository = (*userRepository)(nil)
|
||||
var _ rest.Repository = (*userRepository)(nil)
|
||||
var _ rest.Persistable = (*userRepository)(nil)
|
||||
|
||||
@ -84,6 +84,7 @@ func (api *Router) routes() http.Handler {
|
||||
api.addMissingFilesRoute(r)
|
||||
api.addKeepAliveRoute(r)
|
||||
api.addInsightsRoute(r)
|
||||
api.addRatingStatsRoute(r)
|
||||
|
||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||
api.addInspectRoute(r)
|
||||
|
||||
56
server/nativeapi/rating_stats.go
Normal file
56
server/nativeapi/rating_stats.go
Normal file
@ -0,0 +1,56 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func (api *Router) addRatingStatsRoute(r chi.Router) {
|
||||
r.Get("/ratingStats", getRatingStats(api.ds))
|
||||
r.Get("/ratingItems", getRatingItems(api.ds))
|
||||
}
|
||||
|
||||
func getRatingStats(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := ds.User(r.Context()).RatingStats()
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error getting rating stats", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
||||
log.Error(r.Context(), "Error encoding rating stats", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getRatingItems(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("userId")
|
||||
itemType := r.URL.Query().Get("type")
|
||||
ratingStr := r.URL.Query().Get("rating")
|
||||
|
||||
rating, err := strconv.Atoi(ratingStr)
|
||||
if err != nil || userID == "" || (itemType != "album" && itemType != "song") {
|
||||
http.Error(w, "invalid parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
items, err := ds.User(r.Context()).RatingItems(userID, itemType, rating)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error getting rating items", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(items); err != nil {
|
||||
log.Error(r.Context(), "Error encoding rating items", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
ui/package-lock.json
generated
39
ui/package-lock.json
generated
@ -129,6 +129,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -1743,6 +1744,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@ -1766,6 +1768,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@ -2471,6 +2474,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-2.5.2.tgz",
|
||||
"integrity": "sha512-tl64cLC2dUrGvu2nTHRDEA5Yv3RfwzMCIlVaoSUSq44LakKLGJdkPl8j/fb07llpFqz0a7gEAmy/8gLdmwgaLQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.3",
|
||||
"ajv": "^6.10.2",
|
||||
@ -2524,6 +2528,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-2.5.2.tgz",
|
||||
"integrity": "sha512-kZf2fq4urIBlFTCiBX95eKg8uojkyJj7FVDtIV739aVkJjE5+ihn1+kG1qLxYSxlGC7S24i12BZJzRetSRihBQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.15",
|
||||
"object-hash": "^2.0.0"
|
||||
@ -2539,6 +2544,7 @@
|
||||
"integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==",
|
||||
"deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@material-ui/styles": "^4.11.5",
|
||||
@ -2585,6 +2591,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz",
|
||||
"integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.4.4"
|
||||
},
|
||||
@ -3308,6 +3315,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
|
||||
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"hoist-non-react-statics": "^3.3.0"
|
||||
},
|
||||
@ -3360,6 +3368,7 @@
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@ -3381,6 +3390,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.91.tgz",
|
||||
"integrity": "sha512-xauZca6qMeCU3Moy0KxCM9jtf1vyk6qRYK39Ryf3afUqwgNUjRIGoDdS9BcGWgAMGSg1hvP4XcmlYrM66PtqeA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "^0.16",
|
||||
@ -3550,6 +3560,7 @@
|
||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
@ -3883,6 +3894,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -4567,6 +4579,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@ -4942,6 +4955,7 @@
|
||||
"resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz",
|
||||
"integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lodash.isequalwith": "^4.4.0",
|
||||
"prop-types": "^15.7.2"
|
||||
@ -5822,6 +5836,7 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@ -6405,6 +6420,7 @@
|
||||
"resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz",
|
||||
"integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.10.0"
|
||||
},
|
||||
@ -6421,6 +6437,7 @@
|
||||
"resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz",
|
||||
"integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"final-form": "^4.20.8"
|
||||
}
|
||||
@ -6835,6 +6852,7 @@
|
||||
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
@ -6948,6 +6966,7 @@
|
||||
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
|
||||
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.1.2",
|
||||
"loose-envify": "^1.2.0",
|
||||
@ -8550,6 +8569,7 @@
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
@ -9298,6 +9318,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
@ -9389,6 +9410,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz",
|
||||
"integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"classnames": "~2.3.1",
|
||||
"date-fns": "^1.29.0",
|
||||
@ -9803,6 +9825,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
|
||||
"integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"object-assign": "^4.1.1"
|
||||
@ -9884,6 +9907,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
|
||||
"integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"object-assign": "^4.1.1",
|
||||
@ -9956,6 +9980,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz",
|
||||
"integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.15.4"
|
||||
},
|
||||
@ -9973,6 +9998,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz",
|
||||
"integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.19.4"
|
||||
},
|
||||
@ -10078,6 +10104,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz",
|
||||
"integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.15.4",
|
||||
"@types/react-redux": "^7.1.20",
|
||||
@ -10113,6 +10140,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
|
||||
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.13",
|
||||
"history": "^4.9.0",
|
||||
@ -10133,6 +10161,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz",
|
||||
"integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.13",
|
||||
"history": "^4.9.0",
|
||||
@ -10314,6 +10343,7 @@
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
@ -10323,6 +10353,7 @@
|
||||
"resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz",
|
||||
"integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@redux-saga/core": "^1.4.2"
|
||||
}
|
||||
@ -10582,6 +10613,7 @@
|
||||
"integrity": "sha512-FAfGj5Ferzyna11iUwGdkYus/Y9d/H75PEpsseP5DZOsEsyPvP/Q7mJiSXhUYSEmyfHPaZyC8EsJCjqzDbtcfg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@ -11511,6 +11543,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -11732,6 +11765,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -11976,6 +12010,7 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@ -12100,6 +12135,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -12113,6 +12149,7 @@
|
||||
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.2",
|
||||
"@vitest/mocker": "4.1.2",
|
||||
@ -12632,6 +12669,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@ -12741,6 +12779,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
|
||||
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
|
||||
@ -23,6 +23,7 @@ import 'react-image-lightbox/style.css'
|
||||
import subsonic from '../subsonic'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
AverageRatingField,
|
||||
CollapsibleComment,
|
||||
DurationField,
|
||||
formatRange,
|
||||
@ -309,6 +310,10 @@ const AlbumDetails = (props) => {
|
||||
resource={'album'}
|
||||
size={isDesktop ? 'medium' : 'small'}
|
||||
/>
|
||||
<AverageRatingField
|
||||
record={record}
|
||||
size={isDesktop ? 'medium' : 'small'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isDesktop ? (
|
||||
|
||||
@ -16,6 +16,7 @@ import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import { playTracks } from '../actions'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
AverageRatingField,
|
||||
DateField,
|
||||
DurationField,
|
||||
QualityInfo,
|
||||
@ -143,6 +144,13 @@ const AlbumSongs = (props) => {
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
averageRating: isDesktop && config.enableStarRating && (
|
||||
<AverageRatingField
|
||||
source="averageRating"
|
||||
label="resources.song.fields.averageRating"
|
||||
sortable={false}
|
||||
/>
|
||||
),
|
||||
}
|
||||
}, [isDesktop, classes.ratingField])
|
||||
|
||||
@ -151,6 +159,7 @@ const AlbumSongs = (props) => {
|
||||
columns: toggleableFields,
|
||||
omittedColumns: ['title'],
|
||||
defaultOff: [
|
||||
'averageRating',
|
||||
'composer',
|
||||
'channels',
|
||||
'bpm',
|
||||
|
||||
@ -14,6 +14,7 @@ import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
AverageRatingField,
|
||||
CoverArtAvatar,
|
||||
DurationField,
|
||||
RangeField,
|
||||
@ -126,6 +127,9 @@ const AlbumTableView = ({
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
averageRating: config.enableStarRating && (
|
||||
<AverageRatingField source={'averageRating'} sortByOrder={'DESC'} />
|
||||
),
|
||||
createdAt: isDesktop && <DateField source="createdAt" showTime />,
|
||||
}
|
||||
}, [classes.ratingField, isDesktop])
|
||||
@ -133,7 +137,7 @@ const AlbumTableView = ({
|
||||
const columns = useSelectedFields({
|
||||
resource: 'album',
|
||||
columns: toggleableFields,
|
||||
defaultOff: ['createdAt', 'size', 'mood'],
|
||||
defaultOff: ['averageRating', 'createdAt', 'size', 'mood'],
|
||||
})
|
||||
|
||||
return isXsmall ? (
|
||||
|
||||
@ -22,6 +22,7 @@ import { useDrag } from 'react-dnd'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
ArtistContextMenu,
|
||||
AverageRatingField,
|
||||
CoverArtAvatar,
|
||||
List,
|
||||
useGetHandleArtistClick,
|
||||
@ -157,6 +158,9 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
averageRating: config.enableStarRating && (
|
||||
<AverageRatingField source="averageRating" sortByOrder={'DESC'} />
|
||||
),
|
||||
}),
|
||||
[classes.ratingField],
|
||||
)
|
||||
@ -164,6 +168,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
|
||||
const columns = useSelectedFields({
|
||||
resource: 'artist',
|
||||
columns: toggleableFields,
|
||||
defaultOff: ['averageRating'],
|
||||
})
|
||||
|
||||
return isXsmall ? (
|
||||
|
||||
44
ui/src/common/AverageRatingField.jsx
Normal file
44
ui/src/common/AverageRatingField.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Rating from '@material-ui/lab/Rating'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
rating: {
|
||||
color: '#ffb400',
|
||||
opacity: 0.6,
|
||||
},
|
||||
})
|
||||
|
||||
export const AverageRatingField = ({ className, size, ...rest }) => {
|
||||
const record = useRecordContext(rest) || {}
|
||||
const classes = useStyles()
|
||||
|
||||
const avg = record.averageRating || 0
|
||||
if (avg <= 0) return null
|
||||
|
||||
return (
|
||||
<span title={`Avg. Rating: ${avg}`}>
|
||||
<Rating
|
||||
className={clsx(className, classes.rating)}
|
||||
value={avg}
|
||||
precision={0.5}
|
||||
size={size}
|
||||
readOnly
|
||||
icon={<StarIcon fontSize="inherit" />}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
AverageRatingField.propTypes = {
|
||||
record: PropTypes.object,
|
||||
size: PropTypes.string,
|
||||
}
|
||||
|
||||
AverageRatingField.defaultProps = {
|
||||
size: 'small',
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
export * from './AddToPlaylistButton'
|
||||
export * from './AverageRatingField'
|
||||
export * from './ArtistLinkField'
|
||||
export * from './BatchPlayButton'
|
||||
export * from './BitrateField'
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
"starred": "Favourite",
|
||||
"comment": "Comment",
|
||||
"rating": "Rating",
|
||||
"averageRating": "Avg. Rating",
|
||||
"quality": "Quality",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Last Played",
|
||||
@ -76,6 +77,7 @@
|
||||
"updatedAt": "Updated at",
|
||||
"comment": "Comment",
|
||||
"rating": "Rating",
|
||||
"averageRating": "Avg. Rating",
|
||||
"createdAt": "Date added",
|
||||
"recordLabel": "Label",
|
||||
"catalogNum": "Catalog Number",
|
||||
@ -114,6 +116,7 @@
|
||||
"size": "Size",
|
||||
"playCount": "Plays",
|
||||
"rating": "Rating",
|
||||
"averageRating": "Avg. Rating",
|
||||
"genre": "Genre",
|
||||
"role": "Role",
|
||||
"missing": "Missing"
|
||||
@ -604,6 +607,7 @@
|
||||
"downloadOriginalFormat": "Download in original format"
|
||||
},
|
||||
"menu": {
|
||||
"userRatings": "User Ratings",
|
||||
"library": "Library",
|
||||
"librarySelector": {
|
||||
"allLibraries": "All Libraries (%{count})",
|
||||
|
||||
@ -5,6 +5,7 @@ import clsx from 'clsx'
|
||||
import { useTranslate, MenuItemLink, getResources } from 'react-admin'
|
||||
import ViewListIcon from '@material-ui/icons/ViewList'
|
||||
import AlbumIcon from '@material-ui/icons/Album'
|
||||
import PeopleIcon from '@material-ui/icons/People'
|
||||
import SubMenu from './SubMenu'
|
||||
import { humanize, pluralize } from 'inflection'
|
||||
import albumLists from '../album/albumLists'
|
||||
@ -126,6 +127,16 @@ const Menu = ({ dense = false }) => {
|
||||
)}
|
||||
</SubMenu>
|
||||
{resources.filter(subItems(undefined)).map(renderResourceMenuItemLink)}
|
||||
{config.enableStarRating && (
|
||||
<MenuItemLink
|
||||
to="/userRatings"
|
||||
activeClassName={classes.active}
|
||||
primaryText={translate('menu.userRatings')}
|
||||
leftIcon={<PeopleIcon />}
|
||||
sidebarIsOpen={open}
|
||||
dense={dense}
|
||||
/>
|
||||
)}
|
||||
{config.devSidebarPlaylists && open ? (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
import React from 'react'
|
||||
import { Route } from 'react-router-dom'
|
||||
import Personal from './personal/Personal'
|
||||
import UserRatings from './userRatings/UserRatings'
|
||||
import UserRatingItems from './userRatings/UserRatingItems'
|
||||
|
||||
const routes = [
|
||||
<Route exact path="/personal" render={() => <Personal />} key={'personal'} />,
|
||||
<Route exact path="/userRatings" render={() => <UserRatings />} key={'userRatings'} />,
|
||||
<Route
|
||||
exact
|
||||
path="/userRatings/:userId/:userName/:type/:rating"
|
||||
component={UserRatingItems}
|
||||
key={'userRatingItems'}
|
||||
/>,
|
||||
]
|
||||
|
||||
export default routes
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
SongInfo,
|
||||
SongTitleField,
|
||||
SongSimpleList,
|
||||
AverageRatingField,
|
||||
RatingField,
|
||||
useResourceRefresh,
|
||||
ArtistLinkField,
|
||||
@ -170,6 +171,9 @@ const SongList = (props) => {
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
averageRating: config.enableStarRating && (
|
||||
<AverageRatingField source="averageRating" sortByOrder={'DESC'} />
|
||||
),
|
||||
bpm: isDesktop && <NumberField source="bpm" />,
|
||||
genre: <TextField source="genre" />,
|
||||
mood: isDesktop && (
|
||||
@ -196,6 +200,7 @@ const SongList = (props) => {
|
||||
'bpm',
|
||||
'playDate',
|
||||
'albumArtist',
|
||||
'averageRating',
|
||||
'genre',
|
||||
'mood',
|
||||
'comment',
|
||||
|
||||
138
ui/src/userRatings/UserRatingItems.jsx
Normal file
138
ui/src/userRatings/UserRatingItems.jsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Avatar,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Typography,
|
||||
makeStyles,
|
||||
} from '@material-ui/core'
|
||||
import Rating from '@material-ui/lab/Rating'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import ArrowBackIcon from '@material-ui/icons/ArrowBack'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Title, useTranslate } from 'react-admin'
|
||||
import httpClient from '../dataProvider/httpClient'
|
||||
import { REST_URL } from '../consts'
|
||||
import subsonic from '../subsonic'
|
||||
import config from '../config'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: { padding: theme.spacing(2) },
|
||||
back: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: theme.palette.primary.main,
|
||||
textDecoration: 'none',
|
||||
marginBottom: theme.spacing(2),
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
},
|
||||
backIcon: { marginRight: theme.spacing(0.5), fontSize: 18 },
|
||||
header: { marginBottom: theme.spacing(2) },
|
||||
avatar: { width: 48, height: 48, borderRadius: 4 },
|
||||
listItem: { paddingLeft: 0, paddingRight: 0 },
|
||||
}))
|
||||
|
||||
const UserRatingItems = ({ match }) => {
|
||||
const { userId, userName, type, rating } = match.params
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const [items, setItems] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
httpClient(
|
||||
`${REST_URL}/ratingItems?userId=${encodeURIComponent(userId)}&type=${type}&rating=${rating}`,
|
||||
)
|
||||
.then(({ json }) => setItems(Array.isArray(json) ? json : []))
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [userId, type, rating])
|
||||
|
||||
const typeLabel =
|
||||
type === 'album'
|
||||
? translate('resources.album.name', { smart_count: 2 })
|
||||
: translate('resources.song.name', { smart_count: 2 })
|
||||
|
||||
const title = `${userName} · ${typeLabel} · ${rating}★`
|
||||
|
||||
const getCoverUrl = (item) => {
|
||||
const prefix = type === 'album' ? 'al-' : 'mf-'
|
||||
return subsonic.getCoverArtUrl(
|
||||
{ id: item.id, albumArtist: type === 'album' ? item.artist : undefined, album: type === 'song' ? item.name : undefined, updatedAt: item.updatedAt },
|
||||
40,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Title title={title} />
|
||||
<Link to="/userRatings" className={classes.back}>
|
||||
<ArrowBackIcon className={classes.backIcon} />
|
||||
{translate('menu.userRatings')}
|
||||
</Link>
|
||||
<div className={classes.header}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{userName}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="textSecondary">
|
||||
{typeLabel} ·{' '}
|
||||
<Rating
|
||||
value={parseInt(rating)}
|
||||
readOnly
|
||||
size="small"
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
icon={<StarIcon fontSize="inherit" style={{ color: '#ffb400' }} />}
|
||||
emptyIcon={<StarBorderIcon fontSize="inherit" style={{ color: '#ffb400', opacity: 0.4 }} />}
|
||||
/>
|
||||
</Typography>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{loading && <CircularProgress />}
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
{items && items.length === 0 && (
|
||||
<Typography color="textSecondary">No items found.</Typography>
|
||||
)}
|
||||
{items && items.length > 0 && (
|
||||
<List disablePadding>
|
||||
{items.map((item) => {
|
||||
const albumId = type === 'album' ? item.id : item.albumId
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
className={classes.listItem}
|
||||
divider
|
||||
button
|
||||
component={Link}
|
||||
to={`/album/${albumId}/show`}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
src={getCoverUrl(item)}
|
||||
variant="square"
|
||||
className={classes.avatar}
|
||||
/>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={item.name}
|
||||
secondary={item.artist}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserRatingItems
|
||||
246
ui/src/userRatings/UserRatings.jsx
Normal file
246
ui/src/userRatings/UserRatings.jsx
Normal file
@ -0,0 +1,246 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
makeStyles,
|
||||
} from '@material-ui/core'
|
||||
import Rating from '@material-ui/lab/Rating'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import PeopleIcon from '@material-ui/icons/People'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Title, useTranslate } from 'react-admin'
|
||||
import httpClient from '../dataProvider/httpClient'
|
||||
import { REST_URL } from '../consts'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
userCard: {
|
||||
marginBottom: theme.spacing(3),
|
||||
},
|
||||
userName: {
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
totalLabel: {
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
},
|
||||
row: {
|
||||
'&:nth-child(even)': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
},
|
||||
ratingCell: {
|
||||
width: 60,
|
||||
textAlign: 'right',
|
||||
paddingRight: theme.spacing(1),
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 'bold',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
countCell: {
|
||||
width: 50,
|
||||
textAlign: 'right',
|
||||
paddingRight: theme.spacing(1),
|
||||
fontWeight: 'bold',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
barCell: {
|
||||
padding: `${theme.spacing(0.5)}px ${theme.spacing(1)}px`,
|
||||
},
|
||||
barOuter: {
|
||||
height: 20,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
minWidth: 4,
|
||||
},
|
||||
barInner: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
minWidth: 4,
|
||||
transition: 'width 0.4s ease',
|
||||
},
|
||||
starsCell: {
|
||||
width: 110,
|
||||
paddingLeft: theme.spacing(1),
|
||||
},
|
||||
emptyMsg: {
|
||||
color: theme.palette.text.secondary,
|
||||
fontStyle: 'italic',
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
}))
|
||||
|
||||
const ratingColor = (rating) => {
|
||||
if (rating >= 4) return '#69c76f'
|
||||
if (rating >= 3) return '#b8c750'
|
||||
if (rating >= 2) return '#d4a843'
|
||||
return '#c75050'
|
||||
}
|
||||
|
||||
const ALL_RATINGS = [5, 4, 3, 2, 1]
|
||||
|
||||
const RatingTable = ({ stats, label, userId, userName, type }) => {
|
||||
const classes = useStyles()
|
||||
const history = useHistory()
|
||||
|
||||
const countMap = {}
|
||||
let total = 0
|
||||
;(stats || []).forEach(({ rating, count }) => {
|
||||
countMap[rating] = count
|
||||
total += count
|
||||
})
|
||||
|
||||
const maxCount = Math.max(...Object.values(countMap), 1)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography variant="subtitle1">
|
||||
{label}:{' '}
|
||||
<span className={classes.totalLabel}>{total}</span>
|
||||
</Typography>
|
||||
{total === 0 ? (
|
||||
<Typography className={classes.emptyMsg}>No ratings yet</Typography>
|
||||
) : (
|
||||
<table className={classes.table}>
|
||||
<tbody>
|
||||
{ALL_RATINGS.map((r) => {
|
||||
const count = countMap[r] || 0
|
||||
const width = count > 0 ? Math.max((count / maxCount) * 100, 2) : 2
|
||||
const linkTo = count > 0
|
||||
? `/userRatings/${encodeURIComponent(userId)}/${encodeURIComponent(userName)}/${type}/${r}`
|
||||
: null
|
||||
return (
|
||||
<tr
|
||||
key={r}
|
||||
className={classes.row}
|
||||
style={{ cursor: count > 0 ? 'pointer' : 'default' }}
|
||||
onClick={() => count > 0 && history.push(linkTo)}
|
||||
>
|
||||
<td className={classes.ratingCell}>{r}.0</td>
|
||||
<td className={classes.countCell}>{count}</td>
|
||||
<td className={classes.barCell}>
|
||||
<div className={classes.barOuter}>
|
||||
<div
|
||||
className={classes.barInner}
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: count > 0 ? ratingColor(r) : 'transparent',
|
||||
opacity: count > 0 ? 1 : 0.2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className={classes.starsCell}>
|
||||
<Rating
|
||||
value={r}
|
||||
readOnly
|
||||
size="small"
|
||||
icon={<StarIcon fontSize="inherit" style={{ color: '#ffb400' }} />}
|
||||
emptyIcon={<StarBorderIcon fontSize="inherit" style={{ color: '#ffb400', opacity: 0.4 }} />}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserRatingCard = ({ user }) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const [tab, setTab] = useState(0)
|
||||
|
||||
return (
|
||||
<Card className={classes.userCard}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" className={classes.userName}>
|
||||
<PeopleIcon fontSize="small" style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||
{user.userName}
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="scrollable"
|
||||
>
|
||||
<Tab label={translate('resources.song.name', { smart_count: 2 })} />
|
||||
<Tab label={translate('resources.album.name', { smart_count: 2 })} />
|
||||
</Tabs>
|
||||
<Divider />
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{tab === 0 && (
|
||||
<RatingTable
|
||||
stats={user.songStats}
|
||||
label={translate('resources.song.name', { smart_count: 2 })}
|
||||
userId={user.userId}
|
||||
userName={user.userName}
|
||||
type="song"
|
||||
/>
|
||||
)}
|
||||
{tab === 1 && (
|
||||
<RatingTable
|
||||
stats={user.albumStats}
|
||||
label={translate('resources.album.name', { smart_count: 2 })}
|
||||
userId={user.userId}
|
||||
userName={user.userName}
|
||||
type="album"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const UserRatings = () => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
httpClient(`${REST_URL}/ratingStats`)
|
||||
.then(({ json }) => {
|
||||
setData(Array.isArray(json) ? json : [])
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Title title={translate('menu.userRatings')} />
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{translate('menu.userRatings')}
|
||||
</Typography>
|
||||
{loading && <CircularProgress />}
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
{data && data.length === 0 && (
|
||||
<Typography color="textSecondary">No ratings yet.</Typography>
|
||||
)}
|
||||
{data &&
|
||||
data.map((user) => <UserRatingCard key={user.userId} user={user} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserRatings
|
||||
Loading…
x
Reference in New Issue
Block a user