mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-04-19 13:39:42 +00:00
Merge 633bc4805e into 6e0da3bf7a
This commit is contained in:
commit
c2062cdb0d
21 changed files with 1671 additions and 2 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -23,5 +23,8 @@
|
||||||
},
|
},
|
||||||
"[vue]": {
|
"[vue]": {
|
||||||
"editor.defaultFormatter": "octref.vetur"
|
"editor.defaultFormatter": "octref.vetur"
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +68,14 @@
|
||||||
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</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'">
|
<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>
|
<span class="material-symbols text-2xl"></span>
|
||||||
|
|
||||||
|
|
@ -174,6 +182,9 @@ export default {
|
||||||
isNarratorsPage() {
|
isNarratorsPage() {
|
||||||
return this.$route.name === 'library-library-narrators'
|
return this.$route.name === 'library-library-narrators'
|
||||||
},
|
},
|
||||||
|
isRatingsPage() {
|
||||||
|
return this.$route.name === 'library-library-ratings'
|
||||||
|
},
|
||||||
isPlaylistsPage() {
|
isPlaylistsPage() {
|
||||||
return this.paramId === 'playlists'
|
return this.paramId === 'playlists'
|
||||||
},
|
},
|
||||||
|
|
@ -196,6 +207,12 @@ export default {
|
||||||
numIssues() {
|
numIssues() {
|
||||||
return this.$store.state.libraries.issues || 0
|
return this.$store.state.libraries.issues || 0
|
||||||
},
|
},
|
||||||
|
enableReviews() {
|
||||||
|
return this.$store.getters['getServerSetting']('enableReviews')
|
||||||
|
},
|
||||||
|
showReviewsInSidebar() {
|
||||||
|
return this.$store.getters['getServerSetting']('showReviewsInSidebar')
|
||||||
|
},
|
||||||
versionData() {
|
versionData() {
|
||||||
return this.$store.state.versionData || {}
|
return this.$store.state.versionData || {}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
128
client/components/modals/ReviewModal.vue
Normal file
128
client/components/modals/ReviewModal.vue
Normal 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>
|
||||||
147
client/components/tables/ReviewsTable.vue
Normal file
147
client/components/tables/ReviewsTable.vue
Normal 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"></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>
|
||||||
64
client/components/ui/StarRating.vue
Normal file
64
client/components/ui/StarRating.vue
Normal 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>
|
||||||
|
|
@ -103,6 +103,26 @@
|
||||||
<ui-toggle-switch v-model="newServerSettings.allowIframe" :label="$strings.LabelSettingsAllowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
|
<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>
|
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsAllowIframe }}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
</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-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" />
|
<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-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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -343,6 +346,9 @@ export default {
|
||||||
isQueued() {
|
isQueued() {
|
||||||
return this.$store.getters['getIsMediaQueued'](this.libraryItemId)
|
return this.$store.getters['getIsMediaQueued'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
|
enableReviews() {
|
||||||
|
return this.$store.getters['getServerSetting']('enableReviews')
|
||||||
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
|
|
@ -434,6 +440,9 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
onReviewUpdated(review) {
|
||||||
|
this.$root.$emit('review-updated', review)
|
||||||
|
},
|
||||||
selectBookmark(bookmark) {
|
selectBookmark(bookmark) {
|
||||||
if (!bookmark) return
|
if (!bookmark) return
|
||||||
if (this.isStreaming) {
|
if (this.isStreaming) {
|
||||||
|
|
|
||||||
297
client/pages/library/_library/ratings.vue
Normal file
297
client/pages/library/_library/ratings.vue
Normal 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>
|
||||||
|
|
@ -43,6 +43,26 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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 }}. <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">
|
<div class="w-80 my-6 mx-auto">
|
||||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLongestItems }}</h1>
|
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLongestItems }}</h1>
|
||||||
<p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
|
<p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
|
||||||
|
|
@ -154,6 +174,9 @@ export default {
|
||||||
top10Authors() {
|
top10Authors() {
|
||||||
return this.authorsWithCount?.slice(0, 10) || []
|
return this.authorsWithCount?.slice(0, 10) || []
|
||||||
},
|
},
|
||||||
|
top10RatedItems() {
|
||||||
|
return this.libraryStats?.topRatedItems || []
|
||||||
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
|
@ -165,6 +188,9 @@ export default {
|
||||||
},
|
},
|
||||||
isBookLibrary() {
|
isBookLibrary() {
|
||||||
return this.currentLibraryMediaType === 'book'
|
return this.currentLibraryMediaType === 'book'
|
||||||
|
},
|
||||||
|
enableReviews() {
|
||||||
|
return this.$store.getters['getServerSetting']('enableReviews')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ export const state = () => ({
|
||||||
selectedRawCoverUrl: null,
|
selectedRawCoverUrl: null,
|
||||||
selectedMediaItemShare: null,
|
selectedMediaItemShare: null,
|
||||||
isCasting: false, // Actively casting
|
isCasting: false, // Actively casting
|
||||||
isChromecastInitialized: false, // Script loadeds
|
isChromecastInitialized: false, // Script loadeds
|
||||||
showBatchQuickMatchModal: false,
|
showReviewModal: false,
|
||||||
|
selectedReviewItem: null,
|
||||||
|
showBatchQuickMatchModal: false,
|
||||||
dateFormats: [
|
dateFormats: [
|
||||||
{
|
{
|
||||||
text: 'MM/DD/YYYY',
|
text: 'MM/DD/YYYY',
|
||||||
|
|
@ -204,6 +206,16 @@ export const mutations = {
|
||||||
setShowBatchQuickMatchModal(state, val) {
|
setShowBatchQuickMatchModal(state, val) {
|
||||||
state.showBatchQuickMatchModal = val
|
state.showBatchQuickMatchModal = val
|
||||||
},
|
},
|
||||||
|
setShowReviewModal(state, val) {
|
||||||
|
state.showReviewModal = val
|
||||||
|
},
|
||||||
|
setReviewModal(state, { libraryItem, review }) {
|
||||||
|
state.selectedReviewItem = {
|
||||||
|
libraryItem,
|
||||||
|
review
|
||||||
|
}
|
||||||
|
state.showReviewModal = true
|
||||||
|
},
|
||||||
resetSelectedMediaItems(state) {
|
resetSelectedMediaItems(state) {
|
||||||
state.selectedMediaItems = []
|
state.selectedMediaItems = []
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,13 @@
|
||||||
"ButtonQuickEmbed": "Quick Embed",
|
"ButtonQuickEmbed": "Quick Embed",
|
||||||
"ButtonQuickEmbedMetadata": "Quick Embed Metadata",
|
"ButtonQuickEmbedMetadata": "Quick Embed Metadata",
|
||||||
"ButtonQuickMatch": "Quick Match",
|
"ButtonQuickMatch": "Quick Match",
|
||||||
|
"ButtonRatings": "Ratings",
|
||||||
"ButtonReScan": "Re-Scan",
|
"ButtonReScan": "Re-Scan",
|
||||||
"ButtonRead": "Read",
|
"ButtonRead": "Read",
|
||||||
"ButtonReadLess": "Read less",
|
"ButtonReadLess": "Read less",
|
||||||
"ButtonReadMore": "Read more",
|
"ButtonReadMore": "Read more",
|
||||||
|
"ButtonReviewEdit": "Edit Review",
|
||||||
|
"ButtonReviewWrite": "Write a Review",
|
||||||
"ButtonRefresh": "Refresh",
|
"ButtonRefresh": "Refresh",
|
||||||
"ButtonRemove": "Remove",
|
"ButtonRemove": "Remove",
|
||||||
"ButtonRemoveAll": "Remove All",
|
"ButtonRemoveAll": "Remove All",
|
||||||
|
|
@ -208,6 +211,7 @@
|
||||||
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
|
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
|
||||||
"HeaderStatsRecentSessions": "Recent Sessions",
|
"HeaderStatsRecentSessions": "Recent Sessions",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Authors",
|
"HeaderStatsTop10Authors": "Top 10 Authors",
|
||||||
|
"HeaderStatsTopRated": "Top 10 Best Rated",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Genres",
|
"HeaderStatsTop5Genres": "Top 5 Genres",
|
||||||
"HeaderTableOfContents": "Table of Contents",
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Tools",
|
"HeaderTools": "Tools",
|
||||||
|
|
@ -237,10 +241,19 @@
|
||||||
"LabelAddedDate": "Added {0}",
|
"LabelAddedDate": "Added {0}",
|
||||||
"LabelAdminUsersOnly": "Admin users only",
|
"LabelAdminUsersOnly": "Admin users only",
|
||||||
"LabelAll": "All",
|
"LabelAll": "All",
|
||||||
|
"LabelAllReviews": "All Reviews",
|
||||||
"LabelAllEpisodesDownloaded": "All episodes downloaded",
|
"LabelAllEpisodesDownloaded": "All episodes downloaded",
|
||||||
"LabelAllUsers": "All Users",
|
"LabelAllUsers": "All Users",
|
||||||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
||||||
"LabelAllUsersIncludingGuests": "All users including 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",
|
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||||
"LabelApiKeyCreated": "API Key \"{0}\" created successfully.",
|
"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.",
|
"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)",
|
"LabelAuthorFirstLast": "Author (First Last)",
|
||||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||||
"LabelAuthors": "Authors",
|
"LabelAuthors": "Authors",
|
||||||
|
"LabelAverageRating": "Average Rating",
|
||||||
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
||||||
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
"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",
|
"LabelNextScheduledRun": "Next scheduled run",
|
||||||
"LabelNoApiKeys": "No API keys",
|
"LabelNoApiKeys": "No API keys",
|
||||||
"LabelNoCustomMetadataProviders": "No custom metadata providers",
|
"LabelNoCustomMetadataProviders": "No custom metadata providers",
|
||||||
|
"LabelNoReviews": "No reviews yet.",
|
||||||
"LabelNoEpisodesSelected": "No episodes selected",
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotFinished": "Not Finished",
|
"LabelNotFinished": "Not Finished",
|
||||||
"LabelNotStarted": "Not Started",
|
"LabelNotStarted": "Not Started",
|
||||||
|
|
@ -550,6 +565,7 @@
|
||||||
"LabelReadAgain": "Read Again",
|
"LabelReadAgain": "Read Again",
|
||||||
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
|
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
|
||||||
"LabelRecentSeries": "Recent Series",
|
"LabelRecentSeries": "Recent Series",
|
||||||
|
"LabelRating": "Rating",
|
||||||
"LabelRecentlyAdded": "Recently Added",
|
"LabelRecentlyAdded": "Recently Added",
|
||||||
"LabelRecommended": "Recommended",
|
"LabelRecommended": "Recommended",
|
||||||
"LabelRedo": "Redo",
|
"LabelRedo": "Redo",
|
||||||
|
|
@ -561,6 +577,8 @@
|
||||||
"LabelRemoveCover": "Remove cover",
|
"LabelRemoveCover": "Remove cover",
|
||||||
"LabelRemoveMetadataFile": "Remove metadata files in library item folders",
|
"LabelRemoveMetadataFile": "Remove metadata files in library item folders",
|
||||||
"LabelRemoveMetadataFileHelp": "Remove all metadata.json and metadata.abs files in your {0} folders.",
|
"LabelRemoveMetadataFileHelp": "Remove all metadata.json and metadata.abs files in your {0} folders.",
|
||||||
|
"LabelReviewComment": "Comment",
|
||||||
|
"LabelReviews": "Reviews",
|
||||||
"LabelRowsPerPage": "Rows per page",
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelSearchTerm": "Search Term",
|
"LabelSearchTerm": "Search Term",
|
||||||
"LabelSearchTitle": "Search Title",
|
"LabelSearchTitle": "Search Title",
|
||||||
|
|
@ -591,6 +609,10 @@
|
||||||
"LabelSettingsEnableWatcher": "Automatically watch libraries for changes",
|
"LabelSettingsEnableWatcher": "Automatically watch libraries for changes",
|
||||||
"LabelSettingsEnableWatcherForLibrary": "Automatically watch library for changes",
|
"LabelSettingsEnableWatcherForLibrary": "Automatically watch library for changes",
|
||||||
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
"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",
|
"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.",
|
"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",
|
"LabelSettingsExperimentalFeatures": "Experimental features",
|
||||||
|
|
@ -816,6 +838,7 @@
|
||||||
"MessageFeedURLWillBe": "Feed URL will be {0}",
|
"MessageFeedURLWillBe": "Feed URL will be {0}",
|
||||||
"MessageFetching": "Fetching...",
|
"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.",
|
"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}",
|
"MessageHeatmapListeningTimeTooltip": "<strong>{0} listening</strong> on {1}",
|
||||||
"MessageHeatmapNoListeningSessions": "No listening sessions on {0}",
|
"MessageHeatmapNoListeningSessions": "No listening sessions on {0}",
|
||||||
"MessageImportantNotice": "Important Notice!",
|
"MessageImportantNotice": "Important Notice!",
|
||||||
|
|
@ -963,6 +986,7 @@
|
||||||
"PlaceholderNewPlaylist": "New playlist name",
|
"PlaceholderNewPlaylist": "New playlist name",
|
||||||
"PlaceholderSearch": "Search..",
|
"PlaceholderSearch": "Search..",
|
||||||
"PlaceholderSearchEpisode": "Search episode..",
|
"PlaceholderSearchEpisode": "Search episode..",
|
||||||
|
"PlaceholderReviewWrite": "Write a personal comment...",
|
||||||
"StatsAuthorsAdded": "authors added",
|
"StatsAuthorsAdded": "authors added",
|
||||||
"StatsBooksAdded": "books added",
|
"StatsBooksAdded": "books added",
|
||||||
"StatsBooksAdditional": "Some additions include…",
|
"StatsBooksAdditional": "Some additions include…",
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,11 @@ class Database {
|
||||||
return this.models.device
|
return this.models.device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Review')} */
|
||||||
|
get reviewModel() {
|
||||||
|
return this.models.review
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if db file exists
|
* Check if db file exists
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
|
|
@ -345,6 +350,7 @@ class Database {
|
||||||
require('./models/Setting').init(this.sequelize)
|
require('./models/Setting').init(this.sequelize)
|
||||||
require('./models/CustomMetadataProvider').init(this.sequelize)
|
require('./models/CustomMetadataProvider').init(this.sequelize)
|
||||||
require('./models/MediaItemShare').init(this.sequelize)
|
require('./models/MediaItemShare').init(this.sequelize)
|
||||||
|
require('./models/Review').init(this.sequelize)
|
||||||
|
|
||||||
return this.sequelize.sync({ force, alter: false })
|
return this.sequelize.sync({ force, alter: false })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,7 @@ class Server {
|
||||||
'/library/:library/authors',
|
'/library/:library/authors',
|
||||||
'/library/:library/narrators',
|
'/library/:library/narrators',
|
||||||
'/library/:library/stats',
|
'/library/:library/stats',
|
||||||
|
'/library/:library/ratings',
|
||||||
'/library/:library/series/:id?',
|
'/library/:library/series/:id?',
|
||||||
'/library/:library/podcast/search',
|
'/library/:library/podcast/search',
|
||||||
'/library/:library/podcast/latest',
|
'/library/:library/podcast/latest',
|
||||||
|
|
|
||||||
|
|
@ -995,6 +995,47 @@ class LibraryController {
|
||||||
stats.totalSize = bookStats.totalSize
|
stats.totalSize = bookStats.totalSize
|
||||||
stats.totalDuration = bookStats.totalDuration
|
stats.totalDuration = bookStats.totalDuration
|
||||||
stats.numAudioTracks = bookStats.numAudioFiles
|
stats.numAudioTracks = bookStats.numAudioFiles
|
||||||
|
|
||||||
|
// Get top 10 rated items
|
||||||
|
try {
|
||||||
|
const topRatedReviews = await Database.reviewModel.findAll({
|
||||||
|
attributes: [
|
||||||
|
'libraryItemId',
|
||||||
|
[Sequelize.fn('AVG', Sequelize.col('review.rating')), 'avgRating'],
|
||||||
|
[Sequelize.fn('COUNT', Sequelize.col('review.id')), 'numReviews']
|
||||||
|
],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.libraryItemModel,
|
||||||
|
attributes: ['id'],
|
||||||
|
where: { libraryId: req.library.id },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.bookModel,
|
||||||
|
attributes: ['id', 'title']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
group: ['libraryItemId', 'libraryItem.id', 'libraryItem.book.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?.title || 'Unknown',
|
||||||
|
avgRating: parseFloat(r.getDataValue('avgRating')),
|
||||||
|
numReviews: parseInt(r.getDataValue('numReviews'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[LibraryController] Failed to get top rated items for stats', error)
|
||||||
|
stats.topRatedItems = []
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id)
|
const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id)
|
||||||
const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id)
|
const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id)
|
||||||
|
|
|
||||||
388
server/controllers/ReviewController.js
Normal file
388
server/controllers/ReviewController.js
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: /api/reviews/:id
|
||||||
|
* Delete a review by ID.
|
||||||
|
* Admin or the owner of the review can delete it.
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async deleteById(req, res) {
|
||||||
|
if (!Database.serverSettings.enableReviews) {
|
||||||
|
return res.status(403).send('Review feature is disabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const review = await Database.reviewModel.findByPk(id)
|
||||||
|
if (!review) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is owner or admin
|
||||||
|
if (review.userId !== req.user.id && req.user.type !== 'admin' && req.user.type !== 'root') {
|
||||||
|
return res.status(403).send('Not authorized to delete this review')
|
||||||
|
}
|
||||||
|
|
||||||
|
await review.destroy()
|
||||||
|
res.sendStatus(200)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[ReviewController] Failed to delete review ${id}`, 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) {
|
||||||
|
if (!Database.serverSettings.enableReviews) {
|
||||||
|
return res.status(403).send('Review feature is disabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reviews = await Database.reviewModel.findAll({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.libraryItemModel,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.bookModel,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.authorModel,
|
||||||
|
through: { attributes: [] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.seriesModel,
|
||||||
|
through: { attributes: ['id', 'sequence'] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.podcastModel,
|
||||||
|
include: {
|
||||||
|
model: Database.podcastEpisodeModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(reviews.map((r) => {
|
||||||
|
const json = r.toOldJSON()
|
||||||
|
if (r.libraryItem) {
|
||||||
|
// Manually set media if missing (Sequelize hooks don't run on nested includes)
|
||||||
|
if (!r.libraryItem.media) {
|
||||||
|
if (r.libraryItem.mediaType === 'book' && r.libraryItem.book) {
|
||||||
|
r.libraryItem.media = r.libraryItem.book
|
||||||
|
} else if (r.libraryItem.mediaType === 'podcast' && r.libraryItem.podcast) {
|
||||||
|
r.libraryItem.media = r.libraryItem.podcast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.libraryItem.media) {
|
||||||
|
try {
|
||||||
|
json.libraryItem = r.libraryItem.toOldJSONMinified()
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error(`[ReviewController] Failed to minify library item ${r.libraryItem.id}`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/libraries/:id/reviews
|
||||||
|
* Get all reviews for items in a library.
|
||||||
|
* Supports sorting by newest, oldest, highest, lowest.
|
||||||
|
* Supports filtering by user or rating.
|
||||||
|
* Supports pagination with limit and page.
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async findAllForLibrary(req, res) {
|
||||||
|
if (!Database.serverSettings.enableReviews) {
|
||||||
|
return res.status(403).send('Review feature is disabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = req.params.id
|
||||||
|
const { sort, filter, limit, page } = req.query
|
||||||
|
|
||||||
|
try {
|
||||||
|
const where = {}
|
||||||
|
const include = [
|
||||||
|
{
|
||||||
|
model: Database.userModel,
|
||||||
|
attributes: ['id', 'username']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.libraryItemModel,
|
||||||
|
where: { libraryId },
|
||||||
|
required: true,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.bookModel,
|
||||||
|
include: [
|
||||||
|
{ model: Database.authorModel, through: { attributes: [] } },
|
||||||
|
{ model: Database.seriesModel, through: { attributes: ['id', 'sequence'] } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.podcastModel,
|
||||||
|
include: { model: Database.podcastEpisodeModel }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
const [filterType, filterValue] = filter.split('.')
|
||||||
|
if (filterType === 'user' && filterValue) {
|
||||||
|
where.userId = filterValue
|
||||||
|
} else if (filterType === 'rating' && filterValue) {
|
||||||
|
where.rating = filterValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let order = [['createdAt', 'DESC']]
|
||||||
|
if (sort === 'oldest') order = [['createdAt', 'ASC']]
|
||||||
|
else if (sort === 'highest') order = [['rating', 'DESC'], ['createdAt', 'DESC']]
|
||||||
|
else if (sort === 'lowest') order = [['rating', 'ASC'], ['createdAt', 'DESC']]
|
||||||
|
|
||||||
|
const limitNum = limit ? parseInt(limit) : 50
|
||||||
|
const pageNum = page ? parseInt(page) : 0
|
||||||
|
const offset = pageNum * limitNum
|
||||||
|
|
||||||
|
const { count, rows: reviews } = await Database.reviewModel.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
order,
|
||||||
|
limit: limitNum,
|
||||||
|
offset
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = reviews.map((r) => {
|
||||||
|
const json = r.toOldJSON()
|
||||||
|
if (r.libraryItem) {
|
||||||
|
if (!r.libraryItem.media) {
|
||||||
|
if (r.libraryItem.mediaType === 'book' && r.libraryItem.book) {
|
||||||
|
r.libraryItem.media = r.libraryItem.book
|
||||||
|
} else if (r.libraryItem.mediaType === 'podcast' && r.libraryItem.podcast) {
|
||||||
|
r.libraryItem.media = r.libraryItem.podcast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (r.libraryItem.media) {
|
||||||
|
try {
|
||||||
|
json.libraryItem = r.libraryItem.toOldJSONMinified()
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error(`[ReviewController] Failed to minify library item ${r.libraryItem.id}`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
})
|
||||||
|
|
||||||
|
// Collect unique reviewers for the filter dropdown
|
||||||
|
const allReviewers = await Database.reviewModel.findAll({
|
||||||
|
attributes: ['userId'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.libraryItemModel,
|
||||||
|
where: { libraryId },
|
||||||
|
required: true,
|
||||||
|
attributes: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.userModel,
|
||||||
|
attributes: ['id', 'username']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
group: ['review.userId']
|
||||||
|
})
|
||||||
|
const reviewers = allReviewers
|
||||||
|
.filter((r) => r.user)
|
||||||
|
.map((r) => ({ id: r.user.id, username: r.user.username }))
|
||||||
|
.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
||||||
|
|
||||||
|
// Get counts for each rating level
|
||||||
|
const ratingCountsResults = await Database.reviewModel.findAll({
|
||||||
|
attributes: ['rating', [Database.sequelize.fn('COUNT', Database.sequelize.col('review.id')), 'count']],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.libraryItemModel,
|
||||||
|
where: { libraryId },
|
||||||
|
required: true,
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
group: ['rating']
|
||||||
|
})
|
||||||
|
const ratingCounts = {}
|
||||||
|
for (let i = 1; i <= 5; i++) ratingCounts[i] = 0
|
||||||
|
ratingCountsResults.forEach((r) => {
|
||||||
|
ratingCounts[r.rating] = parseInt(r.get('count'))
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
reviews: results,
|
||||||
|
total: count,
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
reviewers,
|
||||||
|
ratingCounts
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[ReviewController] Failed to fetch reviews for library ${libraryId}`, 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) {
|
||||||
|
if (!Database.serverSettings.enableReviews) {
|
||||||
|
return res.status(403).send('Review feature is disabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
107
server/models/Review.js
Normal file
107
server/models/Review.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
const { DataTypes, Model } = require('sequelize')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef ReviewJSON
|
||||||
|
* @property {string} id
|
||||||
|
* @property {number} rating
|
||||||
|
* @property {string} reviewText
|
||||||
|
* @property {string} userId
|
||||||
|
* @property {string} libraryItemId
|
||||||
|
* @property {number} updatedAt
|
||||||
|
* @property {number} createdAt
|
||||||
|
* @property {Object} [user]
|
||||||
|
* @property {string} user.id
|
||||||
|
* @property {string} user.username
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Review extends Model {
|
||||||
|
constructor(values, options) {
|
||||||
|
super(values, options)
|
||||||
|
|
||||||
|
/** @type {string} */
|
||||||
|
this.id
|
||||||
|
/** @type {number} */
|
||||||
|
this.rating
|
||||||
|
/** @type {string} */
|
||||||
|
this.reviewText
|
||||||
|
/** @type {string} */
|
||||||
|
this.userId
|
||||||
|
/** @type {string} */
|
||||||
|
this.libraryItemId
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the Review model and associations.
|
||||||
|
* A user can have only one review per library item.
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').Sequelize} sequelize
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to the old JSON format for the browser.
|
||||||
|
*
|
||||||
|
* @returns {ReviewJSON}
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|
@ -55,6 +55,11 @@ class ServerSettings {
|
||||||
this.language = 'en-us'
|
this.language = 'en-us'
|
||||||
this.allowedOrigins = []
|
this.allowedOrigins = []
|
||||||
|
|
||||||
|
/** @type {boolean} If true, users can rate and review library items */
|
||||||
|
this.enableReviews = true
|
||||||
|
/** @type {boolean} If true, the Ratings page link is shown in the library sidebar */
|
||||||
|
this.showReviewsInSidebar = true
|
||||||
|
|
||||||
this.logLevel = Logger.logLevel
|
this.logLevel = Logger.logLevel
|
||||||
|
|
||||||
this.version = packageJson.version
|
this.version = packageJson.version
|
||||||
|
|
@ -122,6 +127,9 @@ class ServerSettings {
|
||||||
this.timeFormat = settings.timeFormat || 'HH:mm'
|
this.timeFormat = settings.timeFormat || 'HH:mm'
|
||||||
this.language = settings.language || 'en-us'
|
this.language = settings.language || 'en-us'
|
||||||
this.allowedOrigins = settings.allowedOrigins || []
|
this.allowedOrigins = settings.allowedOrigins || []
|
||||||
|
|
||||||
|
this.enableReviews = settings.enableReviews !== false
|
||||||
|
this.showReviewsInSidebar = settings.showReviewsInSidebar !== false
|
||||||
this.logLevel = settings.logLevel || Logger.logLevel
|
this.logLevel = settings.logLevel || Logger.logLevel
|
||||||
this.version = settings.version || null
|
this.version = settings.version || null
|
||||||
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
|
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
|
||||||
|
|
@ -234,6 +242,8 @@ class ServerSettings {
|
||||||
timeFormat: this.timeFormat,
|
timeFormat: this.timeFormat,
|
||||||
language: this.language,
|
language: this.language,
|
||||||
allowedOrigins: this.allowedOrigins,
|
allowedOrigins: this.allowedOrigins,
|
||||||
|
enableReviews: this.enableReviews,
|
||||||
|
showReviewsInSidebar: this.showReviewsInSidebar,
|
||||||
logLevel: this.logLevel,
|
logLevel: this.logLevel,
|
||||||
version: this.version,
|
version: this.version,
|
||||||
buildNumber: this.buildNumber,
|
buildNumber: this.buildNumber,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ const MiscController = require('../controllers/MiscController')
|
||||||
const ShareController = require('../controllers/ShareController')
|
const ShareController = require('../controllers/ShareController')
|
||||||
const StatsController = require('../controllers/StatsController')
|
const StatsController = require('../controllers/StatsController')
|
||||||
const ApiKeyController = require('../controllers/ApiKeyController')
|
const ApiKeyController = require('../controllers/ApiKeyController')
|
||||||
|
const ReviewController = require('../controllers/ReviewController')
|
||||||
|
|
||||||
class ApiRouter {
|
class ApiRouter {
|
||||||
constructor(Server) {
|
constructor(Server) {
|
||||||
|
|
@ -83,6 +84,7 @@ class ApiRouter {
|
||||||
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
|
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
|
||||||
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
||||||
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
||||||
|
this.router.get('/libraries/:id/reviews', LibraryController.middleware.bind(this), ReviewController.findAllForLibrary.bind(this))
|
||||||
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
||||||
this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))
|
this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))
|
||||||
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))
|
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))
|
||||||
|
|
@ -127,6 +129,11 @@ class ApiRouter {
|
||||||
this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))
|
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.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))
|
||||||
|
this.router.delete('/reviews/:id', ReviewController.deleteById.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// User Routes
|
// User Routes
|
||||||
//
|
//
|
||||||
|
|
@ -188,6 +195,7 @@ class ApiRouter {
|
||||||
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
|
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.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))
|
||||||
this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this))
|
this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this))
|
||||||
|
this.router.get('/me/reviews', ReviewController.findAllForUser.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// Backup Routes
|
// Backup Routes
|
||||||
|
|
|
||||||
154
test/server/controllers/ReviewController.test.js
Normal file
154
test/server/controllers/ReviewController.test.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
const ApiRouter = require('../../../server/routers/ApiRouter')
|
||||||
|
const ReviewController = require('../../../server/controllers/ReviewController')
|
||||||
|
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
||||||
|
const Auth = require('../../../server/Auth')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
describe('ReviewController', () => {
|
||||||
|
/** @type {ApiRouter} */
|
||||||
|
let apiRouter
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
|
await Database.buildModels()
|
||||||
|
|
||||||
|
Database.serverSettings = {
|
||||||
|
enableReviews: true
|
||||||
|
}
|
||||||
|
|
||||||
|
apiRouter = new ApiRouter({
|
||||||
|
auth: new Auth(),
|
||||||
|
apiCacheManager: new ApiCacheManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
sinon.stub(Logger, 'info')
|
||||||
|
sinon.stub(Logger, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
sinon.restore()
|
||||||
|
await Database.sequelize.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function createTestLibraryItem() {
|
||||||
|
const library = await Database.libraryModel.create({ name: 'Test', mediaType: 'book' })
|
||||||
|
const book = await Database.bookModel.create({ title: 'Test Book' })
|
||||||
|
return await Database.libraryItemModel.create({ mediaId: book.id, mediaType: 'book', libraryId: library.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createUpdate', () => {
|
||||||
|
it('should create a new review', async () => {
|
||||||
|
const user = await Database.userModel.create({ username: 'testuser', type: 'root' })
|
||||||
|
const libraryItem = await createTestLibraryItem()
|
||||||
|
|
||||||
|
const fakeReq = {
|
||||||
|
params: { id: libraryItem.id },
|
||||||
|
body: { rating: 5, reviewText: 'Great book!' },
|
||||||
|
user
|
||||||
|
}
|
||||||
|
const fakeRes = {
|
||||||
|
json: sinon.spy(),
|
||||||
|
status: sinon.stub().returns({ send: sinon.spy() })
|
||||||
|
}
|
||||||
|
|
||||||
|
await ReviewController.createUpdate(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.json.calledOnce).to.be.true
|
||||||
|
const review = fakeRes.json.firstCall.args[0]
|
||||||
|
expect(review.rating).to.equal(5)
|
||||||
|
expect(review.reviewText).to.equal('Great book!')
|
||||||
|
expect(review.userId).to.equal(user.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update an existing review', async () => {
|
||||||
|
const user = await Database.userModel.create({ username: 'testuser', type: 'root' })
|
||||||
|
const libraryItem = await createTestLibraryItem()
|
||||||
|
|
||||||
|
await Database.reviewModel.create({ userId: user.id, libraryItemId: libraryItem.id, rating: 3 })
|
||||||
|
|
||||||
|
const fakeReq = {
|
||||||
|
params: { id: libraryItem.id },
|
||||||
|
body: { rating: 4, reviewText: 'Actually better' },
|
||||||
|
user
|
||||||
|
}
|
||||||
|
const fakeRes = {
|
||||||
|
json: sinon.spy()
|
||||||
|
}
|
||||||
|
|
||||||
|
await ReviewController.createUpdate(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
const review = fakeRes.json.firstCall.args[0]
|
||||||
|
expect(review.rating).to.equal(4)
|
||||||
|
expect(review.reviewText).to.equal('Actually better')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400 for invalid rating', async () => {
|
||||||
|
const fakeReq = {
|
||||||
|
params: { id: 'some-id' },
|
||||||
|
body: { rating: 6 },
|
||||||
|
user: { id: 'u1' }
|
||||||
|
}
|
||||||
|
const fakeRes = {
|
||||||
|
status: sinon.stub().returns({ send: sinon.spy() })
|
||||||
|
}
|
||||||
|
|
||||||
|
await ReviewController.createUpdate(fakeReq, fakeRes)
|
||||||
|
expect(fakeRes.status.calledWith(400)).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findAllForItem', () => {
|
||||||
|
it('should return all reviews for an item', async () => {
|
||||||
|
const user1 = await Database.userModel.create({ username: 'u1', type: 'user' })
|
||||||
|
const user2 = await Database.userModel.create({ username: 'u2', type: 'user' })
|
||||||
|
const libraryItem = await createTestLibraryItem()
|
||||||
|
|
||||||
|
await Database.reviewModel.create({ userId: user1.id, libraryItemId: libraryItem.id, rating: 5 })
|
||||||
|
await Database.reviewModel.create({ userId: user2.id, libraryItemId: libraryItem.id, rating: 4 })
|
||||||
|
|
||||||
|
const fakeReq = { params: { id: libraryItem.id } }
|
||||||
|
const fakeRes = { json: sinon.spy() }
|
||||||
|
|
||||||
|
await ReviewController.findAllForItem(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.json.calledOnce).to.be.true
|
||||||
|
expect(fakeRes.json.firstCall.args[0]).to.have.lengthOf(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete a review', async () => {
|
||||||
|
const user = await Database.userModel.create({ username: 'u1', type: 'user' })
|
||||||
|
const libraryItem = await createTestLibraryItem()
|
||||||
|
await Database.reviewModel.create({ userId: user.id, libraryItemId: libraryItem.id, rating: 5 })
|
||||||
|
|
||||||
|
const fakeReq = { params: { id: libraryItem.id }, user }
|
||||||
|
const fakeRes = { sendStatus: sinon.spy() }
|
||||||
|
|
||||||
|
await ReviewController.delete(fakeReq, fakeRes)
|
||||||
|
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
||||||
|
|
||||||
|
const count = await Database.reviewModel.count()
|
||||||
|
expect(count).to.equal(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('middleware', () => {
|
||||||
|
it('should block when enableReviews is false', async () => {
|
||||||
|
Database.serverSettings.enableReviews = false
|
||||||
|
const fakeReq = {}
|
||||||
|
const fakeRes = { status: sinon.stub().returns({ send: sinon.spy() }) }
|
||||||
|
const next = sinon.spy()
|
||||||
|
|
||||||
|
await ReviewController.middleware(fakeReq, fakeRes, next)
|
||||||
|
expect(fakeRes.status.calledWith(403)).to.be.true
|
||||||
|
expect(next.called).to.be.false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
97
test/server/models/Review.test.js
Normal file
97
test/server/models/Review.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
|
||||||
|
describe('Review Model', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
await Database.buildModels()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Database.sequelize.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function createTestLibraryItem(id = 'li1') {
|
||||||
|
const library = await Database.libraryModel.create({ name: 'Test', mediaType: 'book' })
|
||||||
|
const book = await Database.bookModel.create({ title: 'Test Book' })
|
||||||
|
return await Database.libraryItemModel.create({ id, mediaId: book.id, mediaType: 'book', libraryId: library.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should validate rating between 1 and 5', async () => {
|
||||||
|
const user = await Database.userModel.create({ username: 'u1' })
|
||||||
|
const item = await createTestLibraryItem('li1')
|
||||||
|
const item2 = await createTestLibraryItem('li2')
|
||||||
|
const item3 = await createTestLibraryItem('li3')
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 5 })
|
||||||
|
|
||||||
|
// Invalid - too high
|
||||||
|
let error
|
||||||
|
try {
|
||||||
|
await Database.reviewModel.create({ userId: user.id, libraryItemId: item2.id, rating: 6 })
|
||||||
|
} catch (err) {
|
||||||
|
error = err
|
||||||
|
}
|
||||||
|
expect(error).to.exist
|
||||||
|
expect(error.name).to.equal('SequelizeValidationError')
|
||||||
|
|
||||||
|
// Invalid - too low
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
await Database.reviewModel.create({ userId: user.id, libraryItemId: item3.id, rating: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
error = err
|
||||||
|
}
|
||||||
|
expect(error).to.exist
|
||||||
|
expect(error.name).to.equal('SequelizeValidationError')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enforce unique constraint on userId and libraryItemId', async () => {
|
||||||
|
const user = await Database.userModel.create({ username: 'u1' })
|
||||||
|
const item = await createTestLibraryItem('li1')
|
||||||
|
|
||||||
|
await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 5 })
|
||||||
|
|
||||||
|
let error
|
||||||
|
try {
|
||||||
|
await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 4 })
|
||||||
|
} catch (err) {
|
||||||
|
error = err
|
||||||
|
}
|
||||||
|
expect(error).to.exist
|
||||||
|
expect(error.name).to.equal('SequelizeUniqueConstraintError')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cascade delete when user is deleted', async () => {
|
||||||
|
const user = await Database.userModel.create({ username: 'u1' })
|
||||||
|
const item = await createTestLibraryItem('li1')
|
||||||
|
await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 5 })
|
||||||
|
|
||||||
|
await user.destroy()
|
||||||
|
const count = await Database.reviewModel.count()
|
||||||
|
expect(count).to.equal(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return correct format in toOldJSON', async () => {
|
||||||
|
const user = await Database.userModel.create({ username: 'testuser' })
|
||||||
|
const item = await createTestLibraryItem('li1')
|
||||||
|
const review = await Database.reviewModel.create({
|
||||||
|
userId: user.id,
|
||||||
|
libraryItemId: item.id,
|
||||||
|
rating: 4,
|
||||||
|
reviewText: 'Nice'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Manually associate user for the test
|
||||||
|
review.user = user
|
||||||
|
|
||||||
|
const json = review.toOldJSON()
|
||||||
|
expect(json.rating).to.equal(4)
|
||||||
|
expect(json.reviewText).to.equal('Nice')
|
||||||
|
expect(json.user.username).to.equal('testuser')
|
||||||
|
expect(json.createdAt).to.be.a('number')
|
||||||
|
expect(json.updatedAt).to.be.a('number')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue