Created Rating and Review Feature as well as added a Top Rated books list to the Stats page

This commit is contained in:
fannta1990 2026-02-09 10:04:11 +01:00
parent b01facc034
commit 3a8075a077
15 changed files with 861 additions and 2 deletions

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 :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 />
</div>
</template>
@ -434,6 +437,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,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>
<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>
</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>
<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>
<div class="mt-4 flex justify-end">
<ui-btn small outlined @click="editReview(review)">
{{ $strings.ButtonEdit }}
</ui-btn>
</div>
</div>
</div>
</div>
</div>
</div>
<modals-review-modal @review-updated="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: [],
loading: true
}
},
computed: {
libraryId() {
return this.$route.params.library
},
dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat')
}
},
methods: {
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)
} catch (error) {
console.error('Failed to fetch user reviews', error)
this.$toast.error('Failed to fetch reviews')
} finally {
this.loading = false
}
},
goToItem(item) {
this.$router.push(`/item/${item.id}`)
},
editReview(review) {
this.$store.commit('globals/setReviewModal', {
libraryItem: review.libraryItem,
review: review
})
}
},
mounted() {
this.fetchReviews()
}
}
</script>

View file

@ -43,6 +43,26 @@
</div>
</template>
</div>
<div v-if="isBookLibrary && 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
},