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

@ -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" :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'
},

View file

@ -0,0 +1,99 @@
<template>
<modals-modal v-model="show" name="review-modal" :width="500">
<div class="px-6 py-8 w-full rounded-lg bg-bg shadow-lg border border-black-300" 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"
></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 @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>
export default {
data() {
return {
rating: 0,
reviewText: '',
processing: 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 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.$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,116 @@
<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="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>
<p class="text-xs text-gray-400">{{ $formatDate(review.createdAt, dateFormat) }}</p>
</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>
export default {
props: {
libraryItem: {
type: Object,
required: true
}
},
data() {
return {
showReviews: false,
reviews: [],
loading: false
}
},
computed: {
user() {
return this.$store.state.user.user
},
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
})
},
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)
}
})
},
beforeDestroy() {
this.$root.$off('review-updated')
}
}
</script>

View file

@ -0,0 +1,55 @@
<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>
export default {
props: {
value: {
type: Number,
default: 0
},
readonly: {
type: Boolean,
default: false
},
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>