This commit is contained in:
fannta1990 2026-05-10 16:48:15 -06:00 committed by GitHub
commit 7e3c632041
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1671 additions and 2 deletions

View file

@ -68,6 +68,14 @@
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary && enableReviews && showReviewsInSidebar" :to="`/library/${currentLibraryId}/ratings`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isRatingsPage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">star</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonRatings }}</p>
<div v-show="isRatingsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/stats`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isStatsPage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">&#xf190;</span>
@ -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'
},
@ -196,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 || {}
},

View file

@ -0,0 +1,128 @@
<template>
<modals-modal v-model="show" name="review-modal" :width="500">
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<h2 class="text-xl font-semibold mb-4">{{ title }}</h2>
<div class="mb-6">
<p class="text-gray-200 mb-2">{{ $strings.LabelRating }}</p>
<ui-star-rating v-model="rating" :size="40" />
</div>
<div class="mb-6">
<label for="review-text" class="block text-gray-200 mb-2">{{ $strings.LabelReviewComment }}</label>
<textarea
id="review-text"
v-model="reviewText"
class="w-full bg-primary border border-gray-600 rounded-md p-2 text-white focus:outline-hidden focus:border-yellow-400"
rows="5"
maxlength="5000"
:placeholder="$strings.PlaceholderReviewWrite"
@keydown.enter.prevent="submit"
></textarea>
<p class="text-right text-xs text-gray-400 mt-1">{{ reviewText.length }}/5000</p>
</div>
<div class="flex justify-end gap-2">
<ui-btn v-if="selectedReviewItem?.review" color="bg-error" class="mr-auto" :loading="processingDelete" @click="deleteReview">
<span class="material-symbols text-base mr-1">delete</span>
{{ $strings.ButtonDelete }}
</ui-btn>
<ui-btn @click="show = false">{{ $strings.ButtonCancel }}</ui-btn>
<ui-btn color="bg-success" :loading="processing" @click="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</div>
</modals-modal>
</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.
* @emit review-deleted - Emits the libraryItemId of the deleted review on the root event bus.
*/
export default {
data() {
return {
rating: 0,
reviewText: '',
processing: false,
processingDelete: false
}
},
watch: {
show(val) {
if (val) {
if (this.selectedReviewItem?.review) {
this.rating = this.selectedReviewItem.review.rating
this.reviewText = this.selectedReviewItem.review.reviewText || ''
} else {
this.rating = 0
this.reviewText = ''
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showReviewModal
},
set(val) {
this.$store.commit('globals/setShowReviewModal', val)
}
},
selectedReviewItem() {
return this.$store.state.globals.selectedReviewItem
},
libraryItem() {
return this.selectedReviewItem?.libraryItem
},
title() {
return this.selectedReviewItem?.review ? this.$strings.ButtonReviewEdit : this.$strings.ButtonReviewWrite
}
},
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')
return
}
this.processing = true
try {
const payload = {
rating: this.rating,
reviewText: this.reviewText
}
const review = await this.$axios.$post(`/api/items/${this.libraryItem.id}/review`, payload)
this.$root.$emit('review-updated', review)
this.$toast.success('Review submitted')
this.show = false
} catch (error) {
console.error('Failed to submit review', error)
this.$toast.error('Failed to submit review')
} finally {
this.processing = false
}
}
}
}
</script>

View file

@ -0,0 +1,147 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-2 md:pr-4">{{ $strings.LabelReviews }}</p>
<div v-if="averageRating" class="flex items-center">
<ui-star-rating :value="averageRating" readonly :size="18" />
<span class="text-sm text-gray-300 ml-2">({{ averageRating.toFixed(1) }})</span>
</div>
<div class="grow" />
<ui-btn small :color="userReview ? '' : 'bg-success'" class="mr-4" @click.stop="writeReview">
{{ userReview ? $strings.ButtonReviewEdit : $strings.ButtonReviewWrite }}
</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showReviews ? 'transform rotate-180' : ''">
<span class="material-symbols text-4xl">&#xe313;</span>
</div>
</div>
<transition name="slide">
<div class="w-full bg-bg/20" v-show="showReviews">
<div v-if="!reviews.length" class="p-6 text-center text-gray-400 italic">
{{ $strings.LabelNoReviews }}
</div>
<div v-else class="divide-y divide-white/5">
<div v-for="review in reviews" :key="review.id" class="p-4 md:p-6">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<p class="font-semibold text-gray-100 mr-3">{{ review.user.username }}</p>
<ui-star-rating :value="review.rating" readonly :size="16" />
</div>
<div class="flex items-center gap-2">
<p class="text-xs text-gray-400">{{ $formatDate(review.createdAt, dateFormat) }}</p>
<button v-if="isAdmin && review.userId !== user.id" class="p-1 rounded hover:bg-white/10 text-gray-400 hover:text-error transition-colors" title="Delete Review" @click.stop="deleteReviewAdmin(review)">
<span class="material-symbols text-base">delete</span>
</button>
</div>
</div>
<p v-if="review.reviewText" class="text-gray-200 whitespace-pre-wrap text-sm leading-relaxed">{{ review.reviewText }}</p>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
/**
* A table component to display reviews for a specific library item.
* Listens for global 'review-updated' and 'review-deleted' events to refresh the view locally.
*/
export default {
props: {
/** The library item object to show reviews for */
libraryItem: {
type: Object,
required: true
}
},
data() {
return {
showReviews: false,
reviews: [],
loading: false
}
},
computed: {
user() {
return this.$store.state.user.user
},
isAdmin() {
return this.user.type === 'admin' || this.user.type === 'root'
},
userReview() {
return this.reviews.find((r) => r.userId === this.user.id)
},
averageRating() {
if (!this.reviews.length) return 0
const sum = this.reviews.reduce((acc, r) => acc + r.rating, 0)
return sum / this.reviews.length
},
dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat')
}
},
methods: {
clickBar() {
this.showReviews = !this.showReviews
},
async fetchReviews() {
this.loading = true
try {
this.reviews = await this.$axios.$get(`/api/items/${this.libraryItem.id}/reviews`)
} catch (error) {
console.error('Failed to fetch reviews', error)
} finally {
this.loading = false
}
},
writeReview() {
this.$store.commit('globals/setReviewModal', {
libraryItem: this.libraryItem,
review: this.userReview
})
},
async deleteReviewAdmin(review) {
if (!confirm(`Are you sure you want to delete ${review.user.username}'s review?`)) return
try {
await this.$axios.$delete(`/api/reviews/${review.id}`)
this.reviews = this.reviews.filter(r => r.id !== review.id)
this.$toast.success('Review deleted')
} catch (error) {
console.error('Failed to delete review', error)
this.$toast.error('Failed to delete review')
}
},
onReviewUpdated(review) {
const index = this.reviews.findIndex((r) => r.id === review.id)
if (index !== -1) {
this.$set(this.reviews, index, review)
} else {
this.reviews.unshift(review)
}
}
},
mounted() {
this.fetchReviews()
this.$root.$on('review-updated', (review) => {
if (review.libraryItemId === this.libraryItem.id) {
this.onReviewUpdated(review)
}
})
this.$root.$on('review-deleted', ({ libraryItemId, reviewId }) => {
if (libraryItemId === this.libraryItem.id) {
this.reviews = this.reviews.filter(r => r.id !== reviewId)
}
})
},
beforeDestroy() {
this.$root.$off('review-updated')
this.$root.$off('review-deleted')
}
}
</script>

View file

@ -0,0 +1,64 @@
<template>
<div class="flex items-center" :class="{ 'cursor-pointer': !readonly }">
<div v-for="n in 5" :key="n" class="relative px-0.5" @mouseenter="hoverStar(n)" @mouseleave="hoverStar(0)" @click="setRating(n)">
<span class="material-symbols text-yellow-400" :style="{ fontSize: size + 'px' }" :class="{ fill: n <= displayRating }">star</span>
</div>
</div>
</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
}
},
data() {
return {
hoverRating: 0
}
},
computed: {
displayRating() {
return this.hoverRating || this.value
}
},
methods: {
hoverStar(n) {
if (this.readonly) return
this.hoverRating = n
},
setRating(n) {
if (this.readonly) return
this.$emit('input', n)
}
}
}
</script>
<style scoped>
.material-symbols {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.material-symbols.fill {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
</style>

View file

@ -103,6 +103,26 @@
<ui-toggle-switch v-model="newServerSettings.allowIframe" :label="$strings.LabelSettingsAllowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsAllowIframe }}</p>
</div>
<div role="article" :aria-label="$strings.LabelSettingsEnableReviewsHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsEnableReviews" v-model="newServerSettings.enableReviews" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableReviews', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableReviewsHelp">
<p class="pl-4">
<span id="settings-enable-reviews">{{ $strings.LabelSettingsEnableReviews }}</span>
<span class="material-symbols icon-text">info</span>
</p>
</ui-tooltip>
</div>
<div v-if="newServerSettings.enableReviews" role="article" :aria-label="$strings.LabelSettingsShowRatingsPageHelp" class="flex items-center py-2 mb-2">
<ui-toggle-switch :label="$strings.LabelSettingsShowRatingsPage" v-model="newServerSettings.showReviewsInSidebar" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('showReviewsInSidebar', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsShowRatingsPageHelp">
<p class="pl-4">
<span id="settings-show-ratings-page">{{ $strings.LabelSettingsShowRatingsPage }}</span>
<span class="material-symbols icon-text">info</span>
</p>
</ui-tooltip>
</div>
</div>
<div class="flex-1">

View file

@ -128,6 +128,8 @@
<button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">{{ showFullDescription ? $strings.ButtonReadLess : $strings.ButtonReadMore }} <span class="material-symbols text-xl pl-1" v-html="showFullDescription ? 'expand_less' : '&#xe313;'" /></button>
</div>
<tables-reviews-table v-if="enableReviews" :library-item="libraryItem" class="mt-6" />
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
<tables-tracks-table v-if="tracks.length" :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
@ -143,6 +145,7 @@
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" :download-queue="episodeDownloadsQueued" :episodes-downloading="episodesDownloading" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :playback-rate="1" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
<modals-review-modal v-if="enableReviews" />
</div>
</template>
@ -343,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']
},
@ -434,6 +440,9 @@ export default {
}
},
methods: {
onReviewUpdated(review) {
this.$root.$emit('review-updated', review)
},
selectBookmark(bookmark) {
if (!bookmark) return
if (this.isStreaming) {

View file

@ -0,0 +1,297 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-hidden relative">
<!-- Toolbar - same height as Series toolbar -->
<div class="w-full h-10 relative z-40">
<div id="ratings-toolbar" class="absolute top-0 left-0 w-full h-full flex items-center px-2 md:px-8">
<p class="hidden md:block text-sm">
{{ $formatNumber(totalReviews) }} {{ $strings.ButtonRatings }}
</p>
<div class="grow hidden sm:inline-block" />
<!-- Sort Select -->
<controls-sort-select v-model="selectedSort" :descending.sync="sortDesc" :items="sortItems" class="w-36 sm:w-44 h-7.5 ml-1 sm:ml-4" @change="onSortChange" />
<!-- Filter by User -->
<div ref="userFilter" class="relative ml-1 sm:ml-4 h-7.5" v-click-outside="closeUserMenu">
<button type="button" class="h-full border border-gray-500 hover:border-gray-400 rounded-sm shadow-xs px-3 text-left cursor-pointer flex items-center" @click.prevent="showUserMenu = !showUserMenu">
<span class="block truncate text-xs" :class="selectedUserFilter ? 'text-yellow-400' : 'text-gray-200'">{{ selectedUserText }}</span>
<span class="material-symbols text-lg ml-1">expand_more</span>
</button>
<ul v-show="showUserMenu" class="absolute z-10 mt-1 w-44 bg-bg border border-black-200 shadow-lg max-h-60 rounded-md py-1 ring-1 ring-black/5 overflow-auto text-sm" role="menu">
<li class="select-none relative py-1.5 px-3 cursor-pointer hover:bg-white/5" :class="!selectedUserFilter ? 'bg-white/5 text-yellow-400' : 'text-gray-200'" @click="setUserFilter(null)">
{{ $strings.LabelAllUsers }}
</li>
<li v-for="u in reviewers" :key="u.id" class="select-none relative py-1.5 px-3 cursor-pointer hover:bg-white/5" :class="selectedUserFilter === u.id ? 'bg-white/5 text-yellow-400' : 'text-gray-200'" @click="setUserFilter(u.id)">
{{ u.username }}
</li>
</ul>
</div>
<!-- Filter by Rating -->
<div ref="ratingFilter" class="relative ml-1 sm:ml-4 h-7.5" v-click-outside="closeRatingMenu">
<button type="button" class="h-full border border-gray-500 hover:border-gray-400 rounded-sm shadow-xs px-3 text-left cursor-pointer flex items-center" @click.prevent="showRatingMenu = !showRatingMenu">
<span class="block truncate text-xs" :class="selectedRatingFilter ? 'text-yellow-400' : 'text-gray-200'">{{ selectedRatingText }}</span>
<span class="material-symbols text-lg ml-1">expand_more</span>
</button>
<ul v-show="showRatingMenu" class="absolute z-10 mt-1 w-32 bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black/5 overflow-auto text-sm" role="menu">
<li class="select-none relative py-1.5 px-3 cursor-pointer hover:bg-white/5" :class="!selectedRatingFilter ? 'bg-white/5 text-yellow-400' : 'text-gray-200'" @click="setRatingFilter(null)">
{{ $strings.LabelAllReviews }} ({{ totalReviews }})
</li>
<li v-for="n in 5" :key="n" class="select-none relative py-1.5 px-3 cursor-pointer hover:bg-white/5 flex items-center" :class="selectedRatingFilter === (6 - n) ? 'bg-white/5 text-yellow-400' : 'text-gray-200'" @click="setRatingFilter(6 - n)">
<ui-star-rating :value="6 - n" readonly :size="12" />
<span class="ml-1.5 text-xs text-gray-500">({{ ratingCounts[6 - n] || 0 }})</span>
</li>
</ul>
</div>
<!-- Search -->
<div class="ml-1 sm:ml-4 h-7.5 w-40 sm:w-52">
<input v-model="searchQuery" type="text" class="w-full h-full bg-primary/60 border border-gray-500 hover:border-gray-400 rounded-sm px-2 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-gray-400" :placeholder="$strings.PlaceholderSearchReviews" />
</div>
</div>
</div>
<div class="w-full overflow-y-auto px-2 md:px-8 py-4 pb-32" style="height: calc(100% - 40px)">
<div v-if="loading" class="flex justify-center py-20">
<widgets-loading-spinner />
</div>
<div v-else-if="!filteredReviews.length" class="text-center py-20 text-gray-400 italic">
<p class="text-xl mb-2">{{ $strings.LabelNoReviews }}</p>
<p v-if="!searchQuery && !selectedUserFilter && !selectedRatingFilter">{{ $strings.MessageGoRateBooks }}</p>
</div>
<div v-else class="flex flex-col gap-px">
<!-- Review Rows -->
<div v-for="review in filteredReviews" :key="review.id" class="flex items-start bg-primary/20 hover:bg-primary/40 transition-colors border-b border-white/5 py-2 px-2 md:px-4 gap-3">
<!-- Cover -->
<div class="w-10 flex-shrink-0 cursor-pointer" @click="goToItem(review.libraryItem)">
<covers-book-cover v-if="review.libraryItem" :library-item="review.libraryItem" :width="40" />
</div>
<!-- Main content -->
<div class="flex-grow min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<nuxt-link :to="`/item/${review.libraryItemId}`" class="text-sm font-semibold truncate hover:underline text-gray-100 leading-tight">
{{ getTitle(review) }}
</nuxt-link>
<span class="text-[11px] text-gray-500">by {{ getAuthor(review) }}</span>
</div>
<!-- Review text inline -->
<p v-if="review.reviewText" class="text-xs text-gray-400 italic mt-0.5 line-clamp-1 hover:line-clamp-none cursor-default transition-all duration-200">
"{{ review.reviewText }}"
</p>
</div>
<!-- Stars -->
<div class="flex-shrink-0 flex items-center">
<ui-star-rating :value="review.rating" readonly :size="14" />
</div>
<!-- Username -->
<div class="hidden md:block flex-shrink-0 w-24 text-xs text-gray-400 truncate text-right">
{{ review.user ? review.user.username : 'Unknown' }}
</div>
<!-- Date -->
<div class="hidden md:block flex-shrink-0 w-20 text-[10px] text-gray-500 text-right leading-tight">
{{ $formatDate(review.createdAt, dateFormat) }}
</div>
<!-- Edit button -->
<div class="flex-shrink-0 w-7 flex flex-col gap-1">
<button v-if="isReviewAuthor(review)" class="p-0.5 rounded hover:bg-white/10 text-gray-400 hover:text-gray-200" @click.stop="editReview(review)">
<span class="material-symbols text-base">edit</span>
</button>
<button v-if="isAdmin && !isReviewAuthor(review)" class="p-0.5 rounded hover:bg-white/10 text-gray-400 hover:text-error transition-colors" title="Delete Review" @click.stop="deleteReviewAdmin(review)">
<span class="material-symbols text-base">delete</span>
</button>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="totalReviews > limit" class="mt-6 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 + 1 }} / {{ 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>
<modals-review-modal @review-updated="fetchReviews" @review-deleted="fetchReviews" />
</div>
</template>
<script>
export default {
asyncData({ params, redirect, store }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=/library/${params.library}/ratings`)
}
},
data() {
return {
reviews: [],
reviewers: [],
ratingCounts: {},
totalReviews: 0,
loading: true,
selectedSort: 'newest',
sortDesc: true,
selectedUserFilter: null,
selectedRatingFilter: null,
searchQuery: '',
page: 0,
limit: 50,
showUserMenu: false,
showRatingMenu: false
}
},
computed: {
libraryId() {
return this.$route.params.library
},
dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat')
},
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 },
{ value: 'oldest', text: this.$strings.LabelSortOldestFirst },
{ value: 'highest', text: this.$strings.LabelSortHighestRated },
{ value: 'lowest', text: this.$strings.LabelSortLowestRated }
]
},
selectedUserText() {
if (!this.selectedUserFilter) return this.$strings.LabelFilterByUser
const u = this.reviewers.find((r) => r.id === this.selectedUserFilter)
return u ? u.username : this.$strings.LabelFilterByUser
},
selectedRatingText() {
if (!this.selectedRatingFilter) return this.$strings.LabelFilterByRating
return `${this.selectedRatingFilter}`
},
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: {
closeUserMenu() {
this.showUserMenu = false
},
closeRatingMenu() {
this.showRatingMenu = false
},
async fetchReviews() {
this.loading = true
try {
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 || 0
if (data.reviewers) {
this.reviewers = data.reviewers
}
if (data.ratingCounts) {
this.ratingCounts = data.ratingCounts
}
} catch (error) {
console.error('Failed to fetch library reviews', error)
this.$toast.error('Failed to fetch reviews')
} finally {
this.loading = false
}
},
onSortChange(val) {
this.selectedSort = val
this.page = 0
this.fetchReviews()
},
setUserFilter(val) {
this.selectedUserFilter = val
this.selectedRatingFilter = null
this.showUserMenu = false
this.page = 0
this.fetchReviews()
},
setRatingFilter(val) {
this.selectedRatingFilter = val
this.selectedUserFilter = null
this.showRatingMenu = false
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) {
if (item) this.$router.push(`/item/${item.id}`)
},
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,
review: review
})
}
},
mounted() {
this.fetchReviews()
}
}
</script>
<style>
#ratings-toolbar {
box-shadow: 0px 8px 6px #111111aa;
}
</style>

View file

@ -43,6 +43,26 @@
</div>
</template>
</div>
<div v-if="isBookLibrary && enableReviews && top10RatedItems.length" class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTopRated }}</h1>
<template v-for="(ab, index) in top10RatedItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm text-white/70 w-36 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="grow rounded-full h-2.5 bg-primary/0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: (ab.avgRating * 20) + '%' }" />
</div>
<div class="w-12 ml-3 flex items-center">
<p class="text-sm font-bold">{{ ab.avgRating.toFixed(1) }}</p>
<span class="material-symbols text-sm text-yellow-400 ml-0.5">star</span>
</div>
</div>
<p class="text-[10px] text-gray-400 ml-8 -mt-1">{{ ab.numReviews }} {{ $strings.LabelReviews.toLowerCase() }}</p>
</div>
</template>
</div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLongestItems }}</h1>
<p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
@ -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
},
@ -165,6 +188,9 @@ export default {
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
enableReviews() {
return this.$store.getters['getServerSetting']('enableReviews')
}
},
methods: {

View file

@ -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 = []
},

View file

@ -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",
@ -237,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.",
@ -255,6 +268,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 +495,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 +565,7 @@
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRecentSeries": "Recent Series",
"LabelRating": "Rating",
"LabelRecentlyAdded": "Recently Added",
"LabelRecommended": "Recommended",
"LabelRedo": "Redo",
@ -561,6 +577,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",
@ -591,6 +609,10 @@
"LabelSettingsEnableWatcher": "Automatically watch libraries for changes",
"LabelSettingsEnableWatcherForLibrary": "Automatically watch library for changes",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEnableReviews": "Enable Reviews",
"LabelSettingsEnableReviewsHelp": "Allow users to rate and review books",
"LabelSettingsShowRatingsPage": "Show Ratings Page in Sidebar",
"LabelSettingsShowRatingsPageHelp": "Display the Ratings page link in the library sidebar",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsExperimentalFeatures": "Experimental features",
@ -816,6 +838,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": "<strong>{0} listening</strong> on {1}",
"MessageHeatmapNoListeningSessions": "No listening sessions on {0}",
"MessageImportantNotice": "Important Notice!",
@ -963,6 +986,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…",