Add review and rating features with sorting and filtering options

- Implemented a new ReviewController to handle review creation, updates, and retrieval for library items.
- Added pagination, sorting, and filtering capabilities for reviews in the API.
- Updated frontend components to support review display, including a new ReviewsTable and enhanced ratings UI.
- Introduced new strings for user interface elements related to reviews and ratings.
- Added tests for the ReviewController and Review model to ensure functionality and validation.
- Enabled the option to toggle the review feature in server settings.
This commit is contained in:
fannta1990 2026-02-09 21:08:18 +08:00
parent e4e2770fbd
commit 41e8906312
12 changed files with 603 additions and 43 deletions

View file

@ -30,6 +30,12 @@
</template>
<script>
/**
* A modal for writing or editing a review.
* Managed via the 'globals' Vuex store.
*
* @emit review-updated - Emits the new/updated review object on the root event bus.
*/
export default {
data() {
return {

View file

@ -42,8 +42,13 @@
</template>
<script>
/**
* A table component to display reviews for a specific library item.
* Listens for global 'review-updated' events to refresh the view locally.
*/
export default {
props: {
/** The library item object to show reviews for */
libraryItem: {
type: Object,
required: true

View file

@ -7,16 +7,25 @@
</template>
<script>
/**
* A reusable 5-star rating component.
* Supports read-only display and interactive rating selection.
*
* @emit input - Emits the selected rating (1-5)
*/
export default {
props: {
/** The current rating value (1-5) */
value: {
type: Number,
default: 0
},
/** If true, the rating cannot be changed */
readonly: {
type: Boolean,
default: false
},
/** The size of the stars in pixels */
size: {
type: Number,
default: 24

View file

@ -1,55 +1,109 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-hidden">
<div class="w-full h-full overflow-y-auto px-4 py-6 md:p-8">
<div class="max-w-5xl mx-auto">
<div class="flex items-center mb-8">
<span class="material-symbols text-4xl text-yellow-400 mr-4">star</span>
<h1 class="text-3xl font-semibold">{{ $strings.ButtonRatings }}</h1>
</div>
<!-- Toolbar -->
<div class="w-full bg-primary border-b border-black-300 px-4 py-2 flex items-center gap-4 flex-wrap z-10">
<div class="flex items-center gap-2 mr-4">
<span class="material-symbols text-2xl text-yellow-400">star</span>
<h1 class="text-xl font-semibold whitespace-nowrap">{{ $strings.ButtonRatings }}</h1>
</div>
<!-- Sort Dropdown -->
<ui-dropdown :value="selectedSort" :items="sortItems" small outlined label-key="label" class="w-48" @input="onSortInput" />
<!-- Filter by User -->
<ui-dropdown :value="selectedUserFilter" :items="userFilterItems" small outlined label-key="label" class="w-48" @input="onUserFilterInput" />
<!-- Filter by Rating -->
<ui-dropdown :value="selectedRatingFilter" :items="ratingFilterItems" small outlined label-key="label" class="w-32" @input="onRatingFilterInput" />
<!-- Search -->
<div class="flex-grow max-w-sm ml-auto">
<ui-text-input v-model="searchQuery" small :placeholder="$strings.PlaceholderSearchReviews" clearable />
</div>
</div>
<div class="w-full h-full overflow-y-auto px-4 py-6 md:p-8 pb-32">
<div class="max-w-6xl mx-auto">
<div v-if="loading" class="flex justify-center py-20">
<widgets-loading-spinner />
</div>
<div v-else-if="!reviews.length" class="text-center py-20 text-gray-400 italic">
<p class="text-xl mb-2">{{ $strings.LabelNoReviews }}</p>
<p>{{ $strings.MessageGoRateBooks }}</p>
<p v-if="!searchQuery && !selectedUserFilter && !selectedRatingFilter">{{ $strings.MessageGoRateBooks }}</p>
</div>
<div v-else class="grid grid-cols-1 gap-6">
<div v-for="review in reviews" :key="review.id" class="bg-primary/40 rounded-xl overflow-hidden flex flex-col md:flex-row hover:bg-primary/60 transition-colors border border-white/5">
<div class="w-full md:w-32 h-48 md:h-auto flex-shrink-0 relative group cursor-pointer" @click="goToItem(review.libraryItem)">
<covers-book-cover :library-item="review.libraryItem" :width="128" />
</div>
<div class="p-6 flex-grow flex flex-col">
<div class="flex flex-col md:flex-row md:items-start justify-between mb-4 gap-2">
<div>
<nuxt-link :to="`/item/${review.libraryItemId}`" class="text-xl font-semibold hover:underline text-gray-100">
{{ review.libraryItem.media.metadata.title }}
</nuxt-link>
<p class="text-gray-400 text-sm">
{{ review.libraryItem.media.metadata.authorName }}
</p>
</div>
<div class="flex flex-col items-end">
<ui-star-rating :value="review.rating" readonly :size="20" />
<p class="text-xs text-gray-500 mt-1">{{ $formatDate(review.createdAt, dateFormat) }}</p>
</div>
<div v-else class="flex flex-col gap-2">
<!-- Table Header -->
<div class="hidden md:flex items-center px-4 py-2 text-xs font-bold uppercase tracking-wider text-gray-500 border-b border-white/5">
<div class="w-12"></div> <!-- Cover -->
<div class="flex-grow px-4">{{ $strings.LabelBook }}</div>
<div class="w-32 px-4">{{ $strings.LabelUser }}</div>
<div class="w-32 px-4 text-center">{{ $strings.LabelRating }}</div>
<div class="w-24 text-right">{{ $strings.LabelDate }}</div>
<div class="w-10"></div> <!-- Actions -->
</div>
<!-- Review Rows -->
<div v-for="review in filteredReviews" :key="review.id" class="bg-primary/20 rounded-lg hover:bg-primary/40 transition-colors border border-white/5 overflow-hidden">
<div class="flex items-center p-2 md:p-3 gap-4">
<!-- Cover -->
<div class="w-12 h-18 flex-shrink-0 cursor-pointer" @click="goToItem(review.libraryItem)">
<covers-book-cover :library-item="review.libraryItem" :width="48" />
</div>
<p v-if="review.reviewText" class="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap flex-grow italic bg-black/20 p-4 rounded-lg border border-white/5">
"{{ review.reviewText }}"
</p>
<!-- Title/Author -->
<div class="flex-grow min-w-0">
<nuxt-link :to="`/item/${review.libraryItemId}`" class="font-semibold truncate block hover:underline text-gray-100">
{{ getTitle(review) }}
</nuxt-link>
<p class="text-xs text-gray-400 truncate">{{ getAuthor(review) }}</p>
</div>
<div class="mt-4 flex justify-end">
<ui-btn small outlined @click="editReview(review)">
{{ $strings.ButtonEdit }}
<!-- Username -->
<div class="hidden md:block w-32 px-4 truncate text-sm text-gray-300">
{{ review.user ? review.user.username : 'Unknown' }}
</div>
<!-- Rating -->
<div class="w-32 flex flex-col items-center flex-shrink-0">
<ui-star-rating :value="review.rating" readonly :size="16" />
</div>
<!-- Date (Desktop) -->
<div class="hidden md:block w-24 text-right text-xs text-gray-500">
{{ $formatDate(review.createdAt, dateFormat) }}
</div>
<!-- Actions -->
<div class="w-10 flex justify-end">
<ui-btn v-if="isReviewAuthor(review)" icon small flat @click.stop="editReview(review)">
<span class="material-symbols text-lg">edit</span>
</ui-btn>
</div>
</div>
<!-- Review Text (if exists) -->
<div v-if="review.reviewText" class="px-4 md:px-16 pb-3 pt-1">
<div class="text-sm text-gray-300 italic bg-black/20 p-3 rounded border border-white/5 relative group">
<span class="line-clamp-2 group-hover:line-clamp-none transition-all duration-300 whitespace-pre-wrap">
"{{ review.reviewText }}"
</span>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="totalReviews > limit" class="mt-8 flex justify-center gap-4 items-center pb-8">
<ui-btn small :disabled="page === 0" @click="changePage(page - 1)">
<span class="material-symbols">chevron_left</span>
</ui-btn>
<span class="text-sm text-gray-400">Page {{ page + 1 }} of {{ Math.ceil(totalReviews / limit) }}</span>
<ui-btn small :disabled="page >= Math.ceil(totalReviews / limit) - 1" @click="changePage(page + 1)">
<span class="material-symbols">chevron_right</span>
</ui-btn>
</div>
</div>
</div>
@ -67,7 +121,15 @@ export default {
data() {
return {
reviews: [],
loading: true
totalReviews: 0,
loading: true,
selectedSort: 'newest',
selectedUserFilter: null,
selectedRatingFilter: null,
searchQuery: '',
page: 0,
limit: 50,
users: []
}
},
computed: {
@ -76,24 +138,108 @@ export default {
},
dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat')
},
currentUser() {
return this.$store.state.user.user
},
sortItems() {
return [
{ value: 'newest', label: this.$strings.LabelSortNewestFirst },
{ value: 'oldest', label: this.$strings.LabelSortOldestFirst },
{ value: 'highest', label: this.$strings.LabelSortHighestRated },
{ value: 'lowest', label: this.$strings.LabelSortLowestRated }
]
},
userFilterItems() {
const items = [{ value: null, label: this.$strings.LabelAllUsers }]
this.users.forEach((u) => {
items.push({ value: u.id, label: u.username })
})
return items
},
ratingFilterItems() {
const items = [{ value: null, label: this.$strings.LabelAllReviews }]
for (let i = 5; i >= 1; i--) {
items.push({ value: i, label: `${i} ${i === 1 ? 'Star' : 'Stars'}` })
}
return items
},
filteredReviews() {
if (!this.searchQuery) return this.reviews
const q = this.searchQuery.toLowerCase()
return this.reviews.filter((r) => {
const title = this.getTitle(r).toLowerCase()
const author = this.getAuthor(r).toLowerCase()
return title.includes(q) || author.includes(q)
})
}
},
methods: {
async fetchUsers() {
if (!this.currentUser.isAdminOrUp) return
try {
const data = await this.$axios.$get('/api/users')
this.users = data.users || []
} catch (error) {
console.error('Failed to fetch users', error)
}
},
async fetchReviews() {
this.loading = true
try {
const reviews = await this.$axios.$get('/api/me/reviews')
// Filter by current library
this.reviews = reviews.filter((r) => r.libraryItem && r.libraryItem.libraryId === this.libraryId)
const params = {
sort: this.selectedSort,
limit: this.limit,
page: this.page
}
if (this.selectedUserFilter) {
params.filter = `user.${this.selectedUserFilter}`
} else if (this.selectedRatingFilter) {
params.filter = `rating.${this.selectedRatingFilter}`
}
const data = await this.$axios.$get(`/api/libraries/${this.libraryId}/reviews`, { params })
this.reviews = data.reviews
this.totalReviews = data.total
} catch (error) {
console.error('Failed to fetch user reviews', error)
console.error('Failed to fetch library reviews', error)
this.$toast.error('Failed to fetch reviews')
} finally {
this.loading = false
}
},
onSortInput(val) {
this.selectedSort = val
this.page = 0
this.fetchReviews()
},
onUserFilterInput(val) {
this.selectedUserFilter = val
this.selectedRatingFilter = null
this.page = 0
this.fetchReviews()
},
onRatingFilterInput(val) {
this.selectedRatingFilter = val
this.selectedUserFilter = null
this.page = 0
this.fetchReviews()
},
changePage(newPage) {
this.page = newPage
this.fetchReviews()
},
getTitle(review) {
return review.libraryItem?.media?.metadata?.title || 'Unknown Title'
},
getAuthor(review) {
return review.libraryItem?.media?.metadata?.authorName || 'Unknown Author'
},
goToItem(item) {
this.$router.push(`/item/${item.id}`)
if (item) this.$router.push(`/item/${item.id}`)
},
isReviewAuthor(review) {
return review.userId === this.currentUser.id
},
editReview(review) {
this.$store.commit('globals/setReviewModal', {
@ -103,6 +249,7 @@ export default {
}
},
mounted() {
this.fetchUsers()
this.fetchReviews()
}
}

View file

@ -241,10 +241,19 @@
"LabelAddedDate": "Added {0}",
"LabelAdminUsersOnly": "Admin users only",
"LabelAll": "All",
"LabelAllReviews": "All Reviews",
"LabelAllEpisodesDownloaded": "All episodes downloaded",
"LabelAllUsers": "All Users",
"LabelAllUsersExcludingGuests": "All users excluding guests",
"LabelAllUsersIncludingGuests": "All users including guests",
"LabelFilterByRating": "Filter by Rating",
"LabelFilterByUser": "Filter by User",
"LabelSortHighestRated": "Highest Rated",
"LabelSortLowestRated": "Lowest Rated",
"LabelSortNewestFirst": "Newest First",
"LabelSortOldestFirst": "Oldest First",
"LabelSortTitleAZ": "Title A-Z",
"PlaceholderSearchReviews": "Search by title or author...",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelApiKeyCreated": "API Key \"{0}\" created successfully.",
"LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.",