From 3a8075a0779b9cd6f77bccb9dd0e47243272b000 Mon Sep 17 00:00:00 2001 From: fannta1990 Date: Mon, 9 Feb 2026 10:04:11 +0100 Subject: [PATCH 1/5] Created Rating and Review Feature as well as added a Top Rated books list to the Stats page --- client/components/app/SideRail.vue | 11 ++ client/components/modals/ReviewModal.vue | 99 ++++++++++ client/components/tables/ReviewsTable.vue | 116 ++++++++++++ client/components/ui/StarRating.vue | 55 ++++++ client/pages/item/_id/index.vue | 6 + client/pages/library/_library/ratings.vue | 109 +++++++++++ client/pages/library/_library/stats.vue | 23 +++ client/store/globals.js | 16 +- client/strings/en-us.json | 11 ++ server/Database.js | 6 + server/controllers/LibraryController.js | 42 +++++ server/controllers/ReviewController.js | 171 ++++++++++++++++++ .../v2.33.0-create-reviews-table.js | 110 +++++++++++ server/models/Review.js | 82 +++++++++ server/routers/ApiRouter.js | 6 + 15 files changed, 861 insertions(+), 2 deletions(-) create mode 100644 client/components/modals/ReviewModal.vue create mode 100644 client/components/tables/ReviewsTable.vue create mode 100644 client/components/ui/StarRating.vue create mode 100644 client/pages/library/_library/ratings.vue create mode 100644 server/controllers/ReviewController.js create mode 100644 server/migrations/v2.33.0-create-reviews-table.js create mode 100644 server/models/Review.js diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 9fa7661a1..7aac5a7c8 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -68,6 +68,14 @@
+ + star + +

{{ $strings.ButtonRatings }}

+ +
+ + @@ -174,6 +182,9 @@ export default { isNarratorsPage() { return this.$route.name === 'library-library-narrators' }, + isRatingsPage() { + return this.$route.name === 'library-library-ratings' + }, isPlaylistsPage() { return this.paramId === 'playlists' }, diff --git a/client/components/modals/ReviewModal.vue b/client/components/modals/ReviewModal.vue new file mode 100644 index 000000000..fd063a781 --- /dev/null +++ b/client/components/modals/ReviewModal.vue @@ -0,0 +1,99 @@ + + + diff --git a/client/components/tables/ReviewsTable.vue b/client/components/tables/ReviewsTable.vue new file mode 100644 index 000000000..fb3f73f5c --- /dev/null +++ b/client/components/tables/ReviewsTable.vue @@ -0,0 +1,116 @@ + + + diff --git a/client/components/ui/StarRating.vue b/client/components/ui/StarRating.vue new file mode 100644 index 000000000..67b30c55a --- /dev/null +++ b/client/components/ui/StarRating.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 1d8f0f20b..1ebb9dd0a 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -128,6 +128,8 @@
+ + @@ -143,6 +145,7 @@ +
@@ -434,6 +437,9 @@ export default { } }, methods: { + onReviewUpdated(review) { + this.$root.$emit('review-updated', review) + }, selectBookmark(bookmark) { if (!bookmark) return if (this.isStreaming) { diff --git a/client/pages/library/_library/ratings.vue b/client/pages/library/_library/ratings.vue new file mode 100644 index 000000000..779e74ec1 --- /dev/null +++ b/client/pages/library/_library/ratings.vue @@ -0,0 +1,109 @@ + + + diff --git a/client/pages/library/_library/stats.vue b/client/pages/library/_library/stats.vue index 0fcc2a414..40caec360 100644 --- a/client/pages/library/_library/stats.vue +++ b/client/pages/library/_library/stats.vue @@ -43,6 +43,26 @@ +
+

{{ $strings.HeaderStatsTopRated }}

+ +

{{ $strings.HeaderStatsLongestItems }}

{{ $strings.MessageNoItems }}

@@ -154,6 +174,9 @@ export default { top10Authors() { return this.authorsWithCount?.slice(0, 10) || [] }, + top10RatedItems() { + return this.libraryStats?.topRatedItems || [] + }, currentLibraryId() { return this.$store.state.libraries.currentLibraryId }, diff --git a/client/store/globals.js b/client/store/globals.js index 7b416196a..d9fc8a96e 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -25,8 +25,10 @@ export const state = () => ({ selectedRawCoverUrl: null, selectedMediaItemShare: null, isCasting: false, // Actively casting - isChromecastInitialized: false, // Script loadeds - showBatchQuickMatchModal: false, + isChromecastInitialized: false, // Script loadeds + showReviewModal: false, + selectedReviewItem: null, + showBatchQuickMatchModal: false, dateFormats: [ { text: 'MM/DD/YYYY', @@ -204,6 +206,16 @@ export const mutations = { setShowBatchQuickMatchModal(state, val) { state.showBatchQuickMatchModal = val }, + setShowReviewModal(state, val) { + state.showReviewModal = val + }, + setReviewModal(state, { libraryItem, review }) { + state.selectedReviewItem = { + libraryItem, + review + } + state.showReviewModal = true + }, resetSelectedMediaItems(state) { state.selectedMediaItems = [] }, diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..e63d83011 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -73,10 +73,13 @@ "ButtonQuickEmbed": "Quick Embed", "ButtonQuickEmbedMetadata": "Quick Embed Metadata", "ButtonQuickMatch": "Quick Match", + "ButtonRatings": "Ratings", "ButtonReScan": "Re-Scan", "ButtonRead": "Read", "ButtonReadLess": "Read less", "ButtonReadMore": "Read more", + "ButtonReviewEdit": "Edit Review", + "ButtonReviewWrite": "Write a Review", "ButtonRefresh": "Refresh", "ButtonRemove": "Remove", "ButtonRemoveAll": "Remove All", @@ -208,6 +211,7 @@ "HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)", "HeaderStatsRecentSessions": "Recent Sessions", "HeaderStatsTop10Authors": "Top 10 Authors", + "HeaderStatsTopRated": "Top 10 Best Rated", "HeaderStatsTop5Genres": "Top 5 Genres", "HeaderTableOfContents": "Table of Contents", "HeaderTools": "Tools", @@ -255,6 +259,7 @@ "LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthors": "Authors", + "LabelAverageRating": "Average Rating", "LabelAutoDownloadEpisodes": "Auto Download Episodes", "LabelAutoFetchMetadata": "Auto Fetch Metadata", "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", @@ -481,6 +486,7 @@ "LabelNextScheduledRun": "Next scheduled run", "LabelNoApiKeys": "No API keys", "LabelNoCustomMetadataProviders": "No custom metadata providers", + "LabelNoReviews": "No reviews yet.", "LabelNoEpisodesSelected": "No episodes selected", "LabelNotFinished": "Not Finished", "LabelNotStarted": "Not Started", @@ -550,6 +556,7 @@ "LabelReadAgain": "Read Again", "LabelReadEbookWithoutProgress": "Read ebook without keeping progress", "LabelRecentSeries": "Recent Series", + "LabelRating": "Rating", "LabelRecentlyAdded": "Recently Added", "LabelRecommended": "Recommended", "LabelRedo": "Redo", @@ -561,6 +568,8 @@ "LabelRemoveCover": "Remove cover", "LabelRemoveMetadataFile": "Remove metadata files in library item folders", "LabelRemoveMetadataFileHelp": "Remove all metadata.json and metadata.abs files in your {0} folders.", + "LabelReviewComment": "Comment", + "LabelReviews": "Reviews", "LabelRowsPerPage": "Rows per page", "LabelSearchTerm": "Search Term", "LabelSearchTitle": "Search Title", @@ -816,6 +825,7 @@ "MessageFeedURLWillBe": "Feed URL will be {0}", "MessageFetching": "Fetching...", "MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.", + "MessageGoRateBooks": "Go rate some books to see them here!", "MessageHeatmapListeningTimeTooltip": "{0} listening on {1}", "MessageHeatmapNoListeningSessions": "No listening sessions on {0}", "MessageImportantNotice": "Important Notice!", @@ -963,6 +973,7 @@ "PlaceholderNewPlaylist": "New playlist name", "PlaceholderSearch": "Search..", "PlaceholderSearchEpisode": "Search episode..", + "PlaceholderReviewWrite": "Write a personal comment...", "StatsAuthorsAdded": "authors added", "StatsBooksAdded": "books added", "StatsBooksAdditional": "Some additions include…", diff --git a/server/Database.js b/server/Database.js index 213c2c61b..0054e6be1 100644 --- a/server/Database.js +++ b/server/Database.js @@ -162,6 +162,11 @@ class Database { return this.models.device } + /** @type {typeof import('./models/Review')} */ + get reviewModel() { + return this.models.review + } + /** * Check if db file exists * @returns {boolean} @@ -345,6 +350,7 @@ class Database { require('./models/Setting').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/MediaItemShare').init(this.sequelize) + require('./models/Review').init(this.sequelize) return this.sequelize.sync({ force, alter: false }) } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 55ef45690..10113444c 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -995,6 +995,48 @@ class LibraryController { stats.totalSize = bookStats.totalSize stats.totalDuration = bookStats.totalDuration stats.numAudioTracks = bookStats.numAudioFiles + + // Get top 10 rated items + const topRatedReviews = await Database.reviewModel.findAll({ + attributes: [ + 'libraryItemId', + [Sequelize.fn('AVG', Sequelize.col('rating')), 'avgRating'], + [Sequelize.fn('COUNT', Sequelize.col('id')), 'numReviews'] + ], + include: [ + { + model: Database.libraryItemModel, + attributes: ['id'], + where: { libraryId: req.library.id }, + include: [ + { + model: Database.bookModel, + attributes: ['id'], + include: [ + { + model: Database.bookMetadataModel, + attributes: ['title'] + } + ] + } + ] + } + ], + group: ['libraryItemId', 'libraryItem.id', 'libraryItem.book.id', 'libraryItem.book.bookMetadata.id'], + order: [ + [Sequelize.literal('avgRating'), 'DESC'], + [Sequelize.literal('numReviews'), 'DESC'] + ], + limit: 10 + }) + stats.topRatedItems = topRatedReviews.map((r) => { + return { + id: r.libraryItemId, + title: r.libraryItem?.book?.bookMetadata?.title || 'Unknown', + avgRating: parseFloat(r.getDataValue('avgRating')), + numReviews: parseInt(r.getDataValue('numReviews')) + } + }) } else { const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id) const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id) diff --git a/server/controllers/ReviewController.js b/server/controllers/ReviewController.js new file mode 100644 index 000000000..4f34533b4 --- /dev/null +++ b/server/controllers/ReviewController.js @@ -0,0 +1,171 @@ +const Database = require('../Database') +const Logger = require('../Logger') + +class ReviewController { + constructor() {} + + /** + * POST: /api/items/:id/review + * Create or update the current user's review for a library item. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async createUpdate(req, res) { + const { rating, reviewText } = req.body + const libraryItemId = req.params.id + + if (isNaN(rating) || rating < 1 || rating > 5) { + return res.status(400).send('Invalid rating. Must be an integer between 1 and 5.') + } + + const cleanReviewText = reviewText ? String(reviewText).trim().substring(0, 5000) : null + + try { + const [review, created] = await Database.reviewModel.findOrCreate({ + where: { + userId: req.user.id, + libraryItemId + }, + defaults: { + rating, + reviewText: cleanReviewText + } + }) + + if (!created) { + review.rating = rating + review.reviewText = cleanReviewText + await review.save() + } + + // Load user for toOldJSON + review.user = req.user + + res.json(review.toOldJSON()) + } catch (error) { + Logger.error(`[ReviewController] Failed to create/update review`, error) + res.status(500).send('Failed to save review') + } + } + + /** + * GET: /api/items/:id/reviews + * Get all reviews for a library item. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async findAllForItem(req, res) { + const libraryItemId = req.params.id + + try { + const reviews = await Database.reviewModel.findAll({ + where: { libraryItemId }, + include: [ + { + model: Database.userModel, + attributes: ['id', 'username'] + } + ], + order: [['createdAt', 'DESC']] + }) + + res.json(reviews.map((r) => r.toOldJSON())) + } catch (error) { + Logger.error(`[ReviewController] Failed to fetch reviews for item ${libraryItemId}`, error) + res.status(500).send('Failed to fetch reviews') + } + } + + /** + * DELETE: /api/items/:id/review + * Delete the current user's review for a library item. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async delete(req, res) { + const libraryItemId = req.params.id + + try { + const review = await Database.reviewModel.findOne({ + where: { + userId: req.user.id, + libraryItemId + } + }) + + if (!review) { + return res.sendStatus(404) + } + + await review.destroy() + res.sendStatus(200) + } catch (error) { + Logger.error(`[ReviewController] Failed to delete review for item ${libraryItemId}`, error) + res.status(500).send('Failed to delete review') + } + } + + /** + * GET: /api/me/reviews + * Get all reviews by the current user. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async findAllForUser(req, res) { + try { + const reviews = await Database.reviewModel.findAll({ + where: { userId: req.user.id }, + include: [ + { + model: Database.libraryItemModel, + include: [ + { + model: Database.bookModel + }, + { + model: Database.podcastModel + } + ] + } + ], + order: [['createdAt', 'DESC']] + }) + + res.json(reviews.map((r) => { + const json = r.toOldJSON() + if (r.libraryItem) { + json.libraryItem = r.libraryItem.toOldJSONMinified() + } + return json + })) + } catch (error) { + Logger.error(`[ReviewController] Failed to fetch reviews for user ${req.user.id}`, error) + res.status(500).send('Failed to fetch reviews') + } + } + + /** + * Middleware for review routes. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ + async middleware(req, res, next) { + // Basic library item access check + req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id) + if (!req.libraryItem?.media) return res.sendStatus(404) + + if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) { + return res.sendStatus(403) + } + + next() + } +} + +module.exports = new ReviewController() diff --git a/server/migrations/v2.33.0-create-reviews-table.js b/server/migrations/v2.33.0-create-reviews-table.js new file mode 100644 index 000000000..e9198cd83 --- /dev/null +++ b/server/migrations/v2.33.0-create-reviews-table.js @@ -0,0 +1,110 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.33.0' +const migrationName = `${migrationVersion}-create-reviews-table` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration creates a reviews table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + // Check if table exists + if (await queryInterface.tableExists('reviews')) { + logger.info(`${loggerPrefix} table "reviews" already exists`) + } else { + // Create table + logger.info(`${loggerPrefix} creating table "reviews"`) + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + await queryInterface.createTable('reviews', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + rating: { + type: DataTypes.INTEGER, + allowNull: false + }, + reviewText: { + type: DataTypes.TEXT, + allowNull: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + }, + userId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + }, + libraryItemId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'libraryItems' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + } + }) + + // Add unique constraint on (userId, libraryItemId) + await queryInterface.addIndex('reviews', ['userId', 'libraryItemId'], { + unique: true, + name: 'reviews_user_id_library_item_id_unique' + }) + + logger.info(`${loggerPrefix} created table "reviews"`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script removes the reviews table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('reviews')) { + logger.info(`${loggerPrefix} dropping table "reviews"`) + await queryInterface.dropTable('reviews') + logger.info(`${loggerPrefix} dropped table "reviews"`) + } else { + logger.info(`${loggerPrefix} table "reviews" does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/Review.js b/server/models/Review.js new file mode 100644 index 000000000..e1b3c80e3 --- /dev/null +++ b/server/models/Review.js @@ -0,0 +1,82 @@ +const { DataTypes, Model } = require('sequelize') + +class Review extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {number} */ + this.rating + /** @type {string} */ + this.reviewText + /** @type {UUIDV4} */ + this.userId + /** @type {UUIDV4} */ + this.libraryItemId + /** @type {Date} */ + this.updatedAt + /** @type {Date} */ + this.createdAt + } + + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + rating: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + min: 1, + max: 5 + } + }, + reviewText: { + type: DataTypes.TEXT, + allowNull: true + } + }, + { + sequelize, + modelName: 'review', + indexes: [ + { + unique: true, + fields: ['userId', 'libraryItemId'] + } + ] + } + ) + + const { user, libraryItem } = sequelize.models + + user.hasMany(Review, { onDelete: 'CASCADE' }) + Review.belongsTo(user) + + libraryItem.hasMany(Review, { onDelete: 'CASCADE' }) + Review.belongsTo(libraryItem) + } + + toOldJSON() { + return { + id: this.id, + rating: this.rating, + reviewText: this.reviewText, + userId: this.userId, + libraryItemId: this.libraryItemId, + updatedAt: this.updatedAt.valueOf(), + createdAt: this.createdAt.valueOf(), + user: this.user ? { + id: this.user.id, + username: this.user.username + } : undefined + } + } +} + +module.exports = Review diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..57cedb865 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -35,6 +35,7 @@ const MiscController = require('../controllers/MiscController') const ShareController = require('../controllers/ShareController') const StatsController = require('../controllers/StatsController') const ApiKeyController = require('../controllers/ApiKeyController') +const ReviewController = require('../controllers/ReviewController') class ApiRouter { constructor(Server) { @@ -127,6 +128,10 @@ class ApiRouter { this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this)) this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this)) + this.router.get('/items/:id/reviews', ReviewController.middleware.bind(this), ReviewController.findAllForItem.bind(this)) + this.router.post('/items/:id/review', ReviewController.middleware.bind(this), ReviewController.createUpdate.bind(this)) + this.router.delete('/items/:id/review', ReviewController.middleware.bind(this), ReviewController.delete.bind(this)) + // // User Routes // @@ -188,6 +193,7 @@ class ApiRouter { this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this)) this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this)) + this.router.get('/me/reviews', ReviewController.findAllForUser.bind(this)) // // Backup Routes From e4e2770fbd514dca5c6b7c309a443b81d8e16181 Mon Sep 17 00:00:00 2001 From: fannta1990 Date: Mon, 9 Feb 2026 10:58:17 +0100 Subject: [PATCH 2/5] Fixed error for Ratings page and stats page & added controlls to make Rating system and Ratings page optional (admin can turn it on or off for the server) --- client/components/app/SideRail.vue | 8 ++- client/components/modals/ReviewModal.vue | 4 +- client/components/tables/ReviewsTable.vue | 2 +- client/pages/config/index.vue | 20 ++++++ client/pages/item/_id/index.vue | 7 ++- client/pages/library/_library/stats.vue | 5 +- client/strings/en-us.json | 4 ++ server/controllers/LibraryController.js | 77 +++++++++++------------ server/controllers/ReviewController.js | 42 ++++++++++++- server/objects/settings/ServerSettings.js | 8 +++ 10 files changed, 128 insertions(+), 49 deletions(-) diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 7aac5a7c8..c7b7127ef 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -68,7 +68,7 @@
- + star

{{ $strings.ButtonRatings }}

@@ -207,6 +207,12 @@ export default { numIssues() { return this.$store.state.libraries.issues || 0 }, + enableReviews() { + return this.$store.getters['getServerSetting']('enableReviews') + }, + showReviewsInSidebar() { + return this.$store.getters['getServerSetting']('showReviewsInSidebar') + }, versionData() { return this.$store.state.versionData || {} }, diff --git a/client/components/modals/ReviewModal.vue b/client/components/modals/ReviewModal.vue index fd063a781..1a57d79e3 100644 --- a/client/components/modals/ReviewModal.vue +++ b/client/components/modals/ReviewModal.vue @@ -1,6 +1,6 @@ @@ -346,6 +346,9 @@ export default { isQueued() { return this.$store.getters['getIsMediaQueued'](this.libraryItemId) }, + enableReviews() { + return this.$store.getters['getServerSetting']('enableReviews') + }, userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] }, diff --git a/client/pages/library/_library/stats.vue b/client/pages/library/_library/stats.vue index 40caec360..d27c363f6 100644 --- a/client/pages/library/_library/stats.vue +++ b/client/pages/library/_library/stats.vue @@ -43,7 +43,7 @@
-
+

{{ $strings.HeaderStatsTopRated }}

+ + diff --git a/server/controllers/ReviewController.js b/server/controllers/ReviewController.js index 0cb0d0bcf..3d61fced7 100644 --- a/server/controllers/ReviewController.js +++ b/server/controllers/ReviewController.js @@ -272,11 +272,54 @@ class ReviewController { return json }) + // Collect unique reviewers for the filter dropdown + const allReviewers = await Database.reviewModel.findAll({ + attributes: ['userId'], + include: [ + { + model: Database.libraryItemModel, + where: { libraryId }, + required: true, + attributes: [] + }, + { + model: Database.userModel, + attributes: ['id', 'username'] + } + ], + group: ['review.userId'] + }) + const reviewers = allReviewers + .filter((r) => r.user) + .map((r) => ({ id: r.user.id, username: r.user.username })) + .filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i) + + // Get counts for each rating level + const ratingCountsResults = await Database.reviewModel.findAll({ + attributes: ['rating', [Database.sequelize.fn('COUNT', Database.sequelize.col('review.id')), 'count']], + include: [ + { + model: Database.libraryItemModel, + where: { libraryId }, + required: true, + attributes: [] + } + ], + group: ['rating'] + }) + const ratingCounts = {} + for (let i = 1; i <= 5; i++) ratingCounts[i] = 0 + ratingCountsResults.forEach((r) => { + ratingCounts[r.rating] = parseInt(r.get('count')) + }) + res.json({ reviews: results, total: count, page: pageNum, - limit: limitNum + limit: limitNum, + reviewers, + ratingCounts }) } catch (error) { Logger.error(`[ReviewController] Failed to fetch reviews for library ${libraryId}`, error) From 633bc4805e53650d795fbf7e59676b4ec2d62213 Mon Sep 17 00:00:00 2001 From: fannta1990 Date: Mon, 9 Feb 2026 22:31:44 +0800 Subject: [PATCH 5/5] Add review deletion functionality and UI enhancements - Implemented delete functionality for reviews in both the ReviewModal and ReviewsTable components. - Added confirmation prompts for review deletion actions. - Updated ReviewController to allow deletion of reviews by admins or the review owner. - Enhanced event handling to refresh the review list upon deletion. - Improved UI to include delete buttons for admins in the ratings page and reviews table. --- client/components/modals/ReviewModal.vue | 24 +++++++++++++++- client/components/tables/ReviewsTable.vue | 30 ++++++++++++++++++-- client/pages/library/_library/ratings.vue | 22 +++++++++++++-- server/controllers/ReviewController.js | 34 +++++++++++++++++++++++ server/routers/ApiRouter.js | 1 + 5 files changed, 106 insertions(+), 5 deletions(-) diff --git a/client/components/modals/ReviewModal.vue b/client/components/modals/ReviewModal.vue index 5dfa953d6..c236b2c97 100644 --- a/client/components/modals/ReviewModal.vue +++ b/client/components/modals/ReviewModal.vue @@ -23,6 +23,10 @@
+ + delete + {{ $strings.ButtonDelete }} + {{ $strings.ButtonCancel }} {{ $strings.ButtonSubmit }}
@@ -36,13 +40,15 @@ * Managed via the 'globals' Vuex store. * * @emit review-updated - Emits the new/updated review object on the root event bus. + * @emit review-deleted - Emits the libraryItemId of the deleted review on the root event bus. */ export default { data() { return { rating: 0, reviewText: '', - processing: false + processing: false, + processingDelete: false } }, watch: { @@ -78,6 +84,22 @@ export default { } }, methods: { + async deleteReview() { + if (!confirm('Are you sure you want to delete this review?')) return + + this.processingDelete = true + try { + await this.$axios.$delete(`/api/items/${this.libraryItem.id}/review`) + this.$root.$emit('review-deleted', { libraryItemId: this.libraryItem.id, reviewId: this.selectedReviewItem.review.id }) + this.$toast.success('Review deleted') + this.show = false + } catch (error) { + console.error('Failed to delete review', error) + this.$toast.error('Failed to delete review') + } finally { + this.processingDelete = false + } + }, async submit() { if (!this.rating) { this.$toast.error('Please select a rating') diff --git a/client/components/tables/ReviewsTable.vue b/client/components/tables/ReviewsTable.vue index 533559402..a0111e73c 100644 --- a/client/components/tables/ReviewsTable.vue +++ b/client/components/tables/ReviewsTable.vue @@ -31,7 +31,12 @@

{{ review.user.username }}

-

{{ $formatDate(review.createdAt, dateFormat) }}

+
+

{{ $formatDate(review.createdAt, dateFormat) }}

+ +

{{ review.reviewText }}

@@ -44,7 +49,7 @@ diff --git a/client/pages/library/_library/ratings.vue b/client/pages/library/_library/ratings.vue index 760e580c9..635a9369a 100644 --- a/client/pages/library/_library/ratings.vue +++ b/client/pages/library/_library/ratings.vue @@ -101,10 +101,13 @@ -
+
+
@@ -121,7 +124,7 @@ - + @@ -160,6 +163,9 @@ export default { currentUser() { return this.$store.state.user.user }, + isAdmin() { + return this.currentUser.type === 'admin' || this.currentUser.type === 'root' + }, sortItems() { return [ { value: 'newest', text: this.$strings.LabelSortNewestFirst }, @@ -259,6 +265,18 @@ export default { isReviewAuthor(review) { return review.userId === this.currentUser.id }, + async deleteReviewAdmin(review) { + if (!confirm(`Are you sure you want to delete ${review.user?.username || 'this'}'s review?`)) return + + try { + await this.$axios.$delete(`/api/reviews/${review.id}`) + this.fetchReviews() + this.$toast.success('Review deleted') + } catch (error) { + console.error('Failed to delete review', error) + this.$toast.error('Failed to delete review') + } + }, editReview(review) { this.$store.commit('globals/setReviewModal', { libraryItem: review.libraryItem, diff --git a/server/controllers/ReviewController.js b/server/controllers/ReviewController.js index 3d61fced7..78a88fe86 100644 --- a/server/controllers/ReviewController.js +++ b/server/controllers/ReviewController.js @@ -108,6 +108,40 @@ class ReviewController { } } + /** + * DELETE: /api/reviews/:id + * Delete a review by ID. + * Admin or the owner of the review can delete it. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async deleteById(req, res) { + if (!Database.serverSettings.enableReviews) { + return res.status(403).send('Review feature is disabled') + } + + const { id } = req.params + + try { + const review = await Database.reviewModel.findByPk(id) + if (!review) { + return res.sendStatus(404) + } + + // Check if user is owner or admin + if (review.userId !== req.user.id && req.user.type !== 'admin' && req.user.type !== 'root') { + return res.status(403).send('Not authorized to delete this review') + } + + await review.destroy() + res.sendStatus(200) + } catch (error) { + Logger.error(`[ReviewController] Failed to delete review ${id}`, error) + res.status(500).send('Failed to delete review') + } + } + /** * GET: /api/me/reviews * Get all reviews by the current user. diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index ae1ab85f3..d798e751a 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -132,6 +132,7 @@ class ApiRouter { this.router.get('/items/:id/reviews', ReviewController.middleware.bind(this), ReviewController.findAllForItem.bind(this)) this.router.post('/items/:id/review', ReviewController.middleware.bind(this), ReviewController.createUpdate.bind(this)) this.router.delete('/items/:id/review', ReviewController.middleware.bind(this), ReviewController.delete.bind(this)) + this.router.delete('/reviews/:id', ReviewController.deleteById.bind(this)) // // User Routes