mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-04 06:59:41 +00:00
Created Rating and Review Feature as well as added a Top Rated books list to the Stats page
This commit is contained in:
parent
b01facc034
commit
3a8075a077
15 changed files with 861 additions and 2 deletions
|
|
@ -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"></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'
|
||||
},
|
||||
|
|
|
|||
99
client/components/modals/ReviewModal.vue
Normal file
99
client/components/modals/ReviewModal.vue
Normal 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>
|
||||
116
client/components/tables/ReviewsTable.vue
Normal file
116
client/components/tables/ReviewsTable.vue
Normal 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"></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>
|
||||
55
client/components/ui/StarRating.vue
Normal file
55
client/components/ui/StarRating.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue