mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 05:29: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>
|
||||
|
|
@ -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' : ''" /></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) {
|
||||
|
|
|
|||
109
client/pages/library/_library/ratings.vue
Normal file
109
client/pages/library/_library/ratings.vue
Normal 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>
|
||||
|
|
@ -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 }}. <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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -255,6 +259,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 +486,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 +556,7 @@
|
|||
"LabelReadAgain": "Read Again",
|
||||
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRating": "Rating",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecommended": "Recommended",
|
||||
"LabelRedo": "Redo",
|
||||
|
|
@ -561,6 +568,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",
|
||||
|
|
@ -816,6 +825,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 +973,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…",
|
||||
|
|
|
|||
|
|
@ -162,6 +162,11 @@ class Database {
|
|||
return this.models.device
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Review')} */
|
||||
get reviewModel() {
|
||||
return this.models.review
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if db file exists
|
||||
* @returns {boolean}
|
||||
|
|
@ -345,6 +350,7 @@ class Database {
|
|||
require('./models/Setting').init(this.sequelize)
|
||||
require('./models/CustomMetadataProvider').init(this.sequelize)
|
||||
require('./models/MediaItemShare').init(this.sequelize)
|
||||
require('./models/Review').init(this.sequelize)
|
||||
|
||||
return this.sequelize.sync({ force, alter: false })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -995,6 +995,48 @@ class LibraryController {
|
|||
stats.totalSize = bookStats.totalSize
|
||||
stats.totalDuration = bookStats.totalDuration
|
||||
stats.numAudioTracks = bookStats.numAudioFiles
|
||||
|
||||
// Get top 10 rated items
|
||||
const topRatedReviews = await Database.reviewModel.findAll({
|
||||
attributes: [
|
||||
'libraryItemId',
|
||||
[Sequelize.fn('AVG', Sequelize.col('rating')), 'avgRating'],
|
||||
[Sequelize.fn('COUNT', Sequelize.col('id')), 'numReviews']
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['id'],
|
||||
where: { libraryId: req.library.id },
|
||||
include: [
|
||||
{
|
||||
model: Database.bookModel,
|
||||
attributes: ['id'],
|
||||
include: [
|
||||
{
|
||||
model: Database.bookMetadataModel,
|
||||
attributes: ['title']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
group: ['libraryItemId', 'libraryItem.id', 'libraryItem.book.id', 'libraryItem.book.bookMetadata.id'],
|
||||
order: [
|
||||
[Sequelize.literal('avgRating'), 'DESC'],
|
||||
[Sequelize.literal('numReviews'), 'DESC']
|
||||
],
|
||||
limit: 10
|
||||
})
|
||||
stats.topRatedItems = topRatedReviews.map((r) => {
|
||||
return {
|
||||
id: r.libraryItemId,
|
||||
title: r.libraryItem?.book?.bookMetadata?.title || 'Unknown',
|
||||
avgRating: parseFloat(r.getDataValue('avgRating')),
|
||||
numReviews: parseInt(r.getDataValue('numReviews'))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id)
|
||||
const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id)
|
||||
|
|
|
|||
171
server/controllers/ReviewController.js
Normal file
171
server/controllers/ReviewController.js
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class ReviewController {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* POST: /api/items/:id/review
|
||||
* Create or update the current user's review for a library item.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async createUpdate(req, res) {
|
||||
const { rating, reviewText } = req.body
|
||||
const libraryItemId = req.params.id
|
||||
|
||||
if (isNaN(rating) || rating < 1 || rating > 5) {
|
||||
return res.status(400).send('Invalid rating. Must be an integer between 1 and 5.')
|
||||
}
|
||||
|
||||
const cleanReviewText = reviewText ? String(reviewText).trim().substring(0, 5000) : null
|
||||
|
||||
try {
|
||||
const [review, created] = await Database.reviewModel.findOrCreate({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
libraryItemId
|
||||
},
|
||||
defaults: {
|
||||
rating,
|
||||
reviewText: cleanReviewText
|
||||
}
|
||||
})
|
||||
|
||||
if (!created) {
|
||||
review.rating = rating
|
||||
review.reviewText = cleanReviewText
|
||||
await review.save()
|
||||
}
|
||||
|
||||
// Load user for toOldJSON
|
||||
review.user = req.user
|
||||
|
||||
res.json(review.toOldJSON())
|
||||
} catch (error) {
|
||||
Logger.error(`[ReviewController] Failed to create/update review`, error)
|
||||
res.status(500).send('Failed to save review')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/items/:id/reviews
|
||||
* Get all reviews for a library item.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async findAllForItem(req, res) {
|
||||
const libraryItemId = req.params.id
|
||||
|
||||
try {
|
||||
const reviews = await Database.reviewModel.findAll({
|
||||
where: { libraryItemId },
|
||||
include: [
|
||||
{
|
||||
model: Database.userModel,
|
||||
attributes: ['id', 'username']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
})
|
||||
|
||||
res.json(reviews.map((r) => r.toOldJSON()))
|
||||
} catch (error) {
|
||||
Logger.error(`[ReviewController] Failed to fetch reviews for item ${libraryItemId}`, error)
|
||||
res.status(500).send('Failed to fetch reviews')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/items/:id/review
|
||||
* Delete the current user's review for a library item.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const libraryItemId = req.params.id
|
||||
|
||||
try {
|
||||
const review = await Database.reviewModel.findOne({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
libraryItemId
|
||||
}
|
||||
})
|
||||
|
||||
if (!review) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
await review.destroy()
|
||||
res.sendStatus(200)
|
||||
} catch (error) {
|
||||
Logger.error(`[ReviewController] Failed to delete review for item ${libraryItemId}`, error)
|
||||
res.status(500).send('Failed to delete review')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/reviews
|
||||
* Get all reviews by the current user.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async findAllForUser(req, res) {
|
||||
try {
|
||||
const reviews = await Database.reviewModel.findAll({
|
||||
where: { userId: req.user.id },
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel,
|
||||
include: [
|
||||
{
|
||||
model: Database.bookModel
|
||||
},
|
||||
{
|
||||
model: Database.podcastModel
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
})
|
||||
|
||||
res.json(reviews.map((r) => {
|
||||
const json = r.toOldJSON()
|
||||
if (r.libraryItem) {
|
||||
json.libraryItem = r.libraryItem.toOldJSONMinified()
|
||||
}
|
||||
return json
|
||||
}))
|
||||
} catch (error) {
|
||||
Logger.error(`[ReviewController] Failed to fetch reviews for user ${req.user.id}`, error)
|
||||
res.status(500).send('Failed to fetch reviews')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware for review routes.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
async middleware(req, res, next) {
|
||||
// Basic library item access check
|
||||
req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
|
||||
if (!req.libraryItem?.media) return res.sendStatus(404)
|
||||
|
||||
if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ReviewController()
|
||||
110
server/migrations/v2.33.0-create-reviews-table.js
Normal file
110
server/migrations/v2.33.0-create-reviews-table.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.33.0'
|
||||
const migrationName = `${migrationVersion}-create-reviews-table`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This upward migration creates a reviews table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Check if table exists
|
||||
if (await queryInterface.tableExists('reviews')) {
|
||||
logger.info(`${loggerPrefix} table "reviews" already exists`)
|
||||
} else {
|
||||
// Create table
|
||||
logger.info(`${loggerPrefix} creating table "reviews"`)
|
||||
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||
await queryInterface.createTable('reviews', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
rating: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
reviewText: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
allowNull: false,
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
libraryItemId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'libraryItems'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
allowNull: false,
|
||||
onDelete: 'CASCADE'
|
||||
}
|
||||
})
|
||||
|
||||
// Add unique constraint on (userId, libraryItemId)
|
||||
await queryInterface.addIndex('reviews', ['userId', 'libraryItemId'], {
|
||||
unique: true,
|
||||
name: 'reviews_user_id_library_item_id_unique'
|
||||
})
|
||||
|
||||
logger.info(`${loggerPrefix} created table "reviews"`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script removes the reviews table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('reviews')) {
|
||||
logger.info(`${loggerPrefix} dropping table "reviews"`)
|
||||
await queryInterface.dropTable('reviews')
|
||||
logger.info(`${loggerPrefix} dropped table "reviews"`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} table "reviews" does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
82
server/models/Review.js
Normal file
82
server/models/Review.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
class Review extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {number} */
|
||||
this.rating
|
||||
/** @type {string} */
|
||||
this.reviewText
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {UUIDV4} */
|
||||
this.libraryItemId
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
}
|
||||
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
rating: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
validate: {
|
||||
min: 1,
|
||||
max: 5
|
||||
}
|
||||
},
|
||||
reviewText: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'review',
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['userId', 'libraryItemId']
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
const { user, libraryItem } = sequelize.models
|
||||
|
||||
user.hasMany(Review, { onDelete: 'CASCADE' })
|
||||
Review.belongsTo(user)
|
||||
|
||||
libraryItem.hasMany(Review, { onDelete: 'CASCADE' })
|
||||
Review.belongsTo(libraryItem)
|
||||
}
|
||||
|
||||
toOldJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
rating: this.rating,
|
||||
reviewText: this.reviewText,
|
||||
userId: this.userId,
|
||||
libraryItemId: this.libraryItemId,
|
||||
updatedAt: this.updatedAt.valueOf(),
|
||||
createdAt: this.createdAt.valueOf(),
|
||||
user: this.user ? {
|
||||
id: this.user.id,
|
||||
username: this.user.username
|
||||
} : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Review
|
||||
|
|
@ -35,6 +35,7 @@ const MiscController = require('../controllers/MiscController')
|
|||
const ShareController = require('../controllers/ShareController')
|
||||
const StatsController = require('../controllers/StatsController')
|
||||
const ApiKeyController = require('../controllers/ApiKeyController')
|
||||
const ReviewController = require('../controllers/ReviewController')
|
||||
|
||||
class ApiRouter {
|
||||
constructor(Server) {
|
||||
|
|
@ -127,6 +128,10 @@ class ApiRouter {
|
|||
this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))
|
||||
this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this))
|
||||
|
||||
this.router.get('/items/:id/reviews', ReviewController.middleware.bind(this), ReviewController.findAllForItem.bind(this))
|
||||
this.router.post('/items/:id/review', ReviewController.middleware.bind(this), ReviewController.createUpdate.bind(this))
|
||||
this.router.delete('/items/:id/review', ReviewController.middleware.bind(this), ReviewController.delete.bind(this))
|
||||
|
||||
//
|
||||
// User Routes
|
||||
//
|
||||
|
|
@ -188,6 +193,7 @@ class ApiRouter {
|
|||
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
|
||||
this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))
|
||||
this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this))
|
||||
this.router.get('/me/reviews', ReviewController.findAllForUser.bind(this))
|
||||
|
||||
//
|
||||
// Backup Routes
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue