mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-08 10:21:38 +00:00
Fixes after review: add tests, tighten schema and errors
This commit is contained in:
parent
19497add98
commit
c6e804bcae
12 changed files with 725 additions and 0 deletions
|
|
@ -36,6 +36,18 @@
|
|||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/me/recommendations/inbox" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||
<ui-tooltip text="Recommendations Inbox" direction="bottom" class="flex items-center">
|
||||
<span class="material-symbols text-2xl" aria-label="Recommendations Inbox" role="button">inbox</span>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/me/recommendations/sent" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||
<ui-tooltip text="Recommendations Sent" direction="bottom" class="flex items-center">
|
||||
<span class="material-symbols text-2xl" aria-label="Recommendations Sent" role="button">send</span>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
||||
<span class="material-symbols text-2xl" aria-label="System Settings" role="button"></span>
|
||||
|
|
|
|||
130
client/components/recommendations/RecommendButton.vue
Normal file
130
client/components/recommendations/RecommendButton.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<template>
|
||||
<div v-if="enabled">
|
||||
<button class="rounded px-3 py-1 text-sm bg-primary text-white border border-primary hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/40" @click="onClick">Recommend</button>
|
||||
|
||||
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/60" @click="close"></div>
|
||||
|
||||
<div class="relative z-10 w-full max-w-md rounded-md border border-gray-700 bg-bg p-4">
|
||||
<div class="flex items-center mb-3">
|
||||
<h3 class="text-lg font-semibold">Recommend this</h3>
|
||||
<button class="ml-auto text-gray-400 hover:text-gray-200" @click="close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tagsLoading" class="text-gray-400">Loading tags…</div>
|
||||
<div v-else>
|
||||
<label class="block text-sm mb-1">Tag</label>
|
||||
<select v-model="form.tagId" class="w-full mb-3 rounded border px-2 py-1 bg-[#111827] text-gray-100 border-gray-600" style="color-scheme: dark">
|
||||
<option disabled value="">Select a tag…</option>
|
||||
<option v-for="t in tags" :key="t.id" :value="t.id" class="bg-[#111827] text-gray-100">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<label class="block text-sm mb-1">Visibility</label>
|
||||
<select v-model="form.visibility" class="w-full mb-3 rounded border px-2 py-1 bg-[#111827] text-gray-100 border-gray-600" style="color-scheme: dark">
|
||||
<option value="public">public</option>
|
||||
<option value="recipient-only">recipient-only</option>
|
||||
</select>
|
||||
|
||||
<label class="block text-sm mb-1">Note <span class="opacity-60">(optional)</span></label>
|
||||
<textarea v-model="form.note" rows="3" maxlength="1000" class="w-full rounded border border-gray-600 bg-transparent px-2 py-1" placeholder="Why should someone read/listen to this?"></textarea>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button :disabled="submitting || !form.tagId" class="rounded px-3 py-1 text-sm bg-primary text-white border border-primary hover:bg-primary/90 disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-primary/40" @click="submit">
|
||||
{{ submitting ? 'Posting…' : 'Post recommendation' }}
|
||||
</button>
|
||||
<span v-if="error" class="text-sm text-error">{{ error }}</span>
|
||||
<span v-if="ok" class="text-sm text-success">Posted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RecommendButton',
|
||||
props: { bookId: { type: String, required: true } },
|
||||
data: () => ({
|
||||
enabled: false,
|
||||
user: null,
|
||||
open: false,
|
||||
tags: [],
|
||||
tagsLoading: false,
|
||||
submitting: false,
|
||||
ok: false,
|
||||
error: '',
|
||||
form: { tagId: '', visibility: 'public', note: '' }
|
||||
}),
|
||||
mounted() {
|
||||
const flag = this.$store.getters['getServerSetting']?.('recommendationsEnabled')
|
||||
this.enabled = !!flag
|
||||
this.user = this.$store?.state?.user?.user || null
|
||||
|
||||
if (!this.enabled) {
|
||||
this.$axios
|
||||
.$get('/api/recommendations/tags')
|
||||
.then(() => {
|
||||
this.enabled = true
|
||||
})
|
||||
.catch(() => {
|
||||
this.enabled = false
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async onClick() {
|
||||
if (!this.enabled) return
|
||||
|
||||
if (!this.user) {
|
||||
const back = encodeURIComponent(this.$route.fullPath)
|
||||
return this.$router.push(`/login?redirect=${back}`)
|
||||
}
|
||||
this.open = true
|
||||
this.ok = false
|
||||
this.error = ''
|
||||
if (!this.tags.length) {
|
||||
this.tagsLoading = true
|
||||
try {
|
||||
this.tags = await this.$axios.$get('/api/recommendations/tags')
|
||||
} catch (e) {
|
||||
this.error = e?.response?.data?.message || 'Failed to load tags'
|
||||
} finally {
|
||||
this.tagsLoading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
close() {
|
||||
this.open = false
|
||||
this.form = { tagId: '', visibility: 'public', note: '' }
|
||||
this.error = ''
|
||||
this.ok = false
|
||||
},
|
||||
async submit() {
|
||||
if (!this.form.tagId) return
|
||||
this.submitting = true
|
||||
this.error = ''
|
||||
this.ok = false
|
||||
try {
|
||||
const payload = {
|
||||
bookId: this.bookId,
|
||||
tagId: this.form.tagId,
|
||||
note: this.form.note || '',
|
||||
visibility: this.form.visibility
|
||||
}
|
||||
const created = await this.$axios.$post('/api/recommendations', payload)
|
||||
this.$emit('recommended', created)
|
||||
this.ok = true
|
||||
this.$toast?.success?.('Recommendation posted')
|
||||
setTimeout(() => this.close(), 700)
|
||||
} catch (e) {
|
||||
this.error = e?.response?.data?.message || 'Failed to post'
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -120,6 +120,8 @@
|
|||
</button>
|
||||
</template>
|
||||
</ui-context-menu-dropdown>
|
||||
|
||||
<recommend-button v-if="isBook && libraryItem?.id" :book-id="libraryItem.id" class="ml-2" />
|
||||
</div>
|
||||
|
||||
<div class="my-4 w-full">
|
||||
|
|
@ -137,6 +139,8 @@
|
|||
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
<tables-library-files-table v-if="libraryFiles.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
<book-recommendations v-if="libraryItem?.id" :book-id="libraryItem.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -147,7 +151,9 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import RecommendButton from '@/components/recommendations/RecommendButton.vue'
|
||||
export default {
|
||||
components: { RecommendButton },
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
|
|
|
|||
64
client/pages/me/recommendations/inbox.vue
Normal file
64
client/pages/me/recommendations/inbox.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-semibold mb-4">Recommendations · Inbox</h1>
|
||||
|
||||
<div v-if="loading" class="text-gray-300">Loading…</div>
|
||||
<div v-else-if="!items.length" class="text-gray-400">You have no incoming recommendations yet.</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="r in items" :key="r.id" class="border border-gray-700 rounded-md p-3">
|
||||
<div class="text-sm text-gray-400 mb-1 flex items-center">
|
||||
<div>
|
||||
from <span class="text-gray-200">{{ r.recommender?.username || 'Someone' }}</span>
|
||||
<span class="text-gray-500"> • {{ fmt(r.createdAt) }}</span>
|
||||
</div>
|
||||
<nuxt-link class="ml-auto text-xs px-2 py-1 rounded bg-primary text-white border border-primary hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/40" :to="itemLink(r)"> Go to </nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border border-gray-500 mr-2">
|
||||
{{ r.tag?.label || 'Recommended' }}
|
||||
</span>
|
||||
<div class="text-sm text-gray-200">
|
||||
<span>{{ r.bookTitle || `Book #${r.bookId}` }}</span>
|
||||
<span v-if="r.note" class="block text-gray-300 mt-1">{{ r.note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
middleware: 'authenticated',
|
||||
async asyncData({ store, app, route, redirect }) {
|
||||
if (!store.state.user.user) return redirect(`/login?redirect=${route.path}`)
|
||||
try {
|
||||
const items = await app.$axios.$get('/api/recommendations/inbox?include=tag,recommender,item')
|
||||
return { items, loading: false }
|
||||
} catch (_) {
|
||||
return { items: [], loading: false }
|
||||
}
|
||||
},
|
||||
data: () => ({ loading: true, items: [] }),
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat') || 'yyyy-MM-dd'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fmt(v) {
|
||||
try {
|
||||
return this.$formatDate(v, this.dateFormat)
|
||||
} catch {
|
||||
return new Date(v).toLocaleString()
|
||||
}
|
||||
},
|
||||
itemLink(r) {
|
||||
const id = r?.item?.id || r.bookId
|
||||
return `/item/${id}`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
82
client/pages/me/recommendations/sent.vue
Normal file
82
client/pages/me/recommendations/sent.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-semibold mb-4">Recommendations · Sent</h1>
|
||||
|
||||
<div v-if="loading" class="text-gray-300">Loading…</div>
|
||||
<div v-else-if="!items.length" class="text-gray-400">You haven’t sent any recommendations yet.</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="r in items" :key="r.id" class="border border-gray-700 rounded-md p-3">
|
||||
<div class="flex items-center text-sm text-gray-400 mb-1">
|
||||
<div class="truncate">
|
||||
to <span class="text-gray-200">{{ r.recipient?.username || 'Public' }}</span>
|
||||
<span class="text-gray-500"> • {{ fmt(r.createdAt) }}</span>
|
||||
<span
|
||||
class="ml-2 text-xs px-2 py-0.5 border rounded-full"
|
||||
:class="r.visibility === 'public' ? 'border-primary text-primary' : 'border-gray-500 text-gray-400'"
|
||||
>{{ r.visibility }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="ml-auto text-xs px-2 py-1 rounded border border-error/60 text-error hover:bg-error/10 disabled:opacity-50"
|
||||
:disabled="!!deleting[r.id]"
|
||||
@click="confirmDelete(r)"
|
||||
>{{ deleting[r.id] ? 'Deleting…' : 'Delete' }}</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border border-gray-500 mr-2">
|
||||
{{ r.tag?.label || 'Recommended' }}
|
||||
</span>
|
||||
<div class="text-sm text-gray-200">
|
||||
<span class="block">{{ r.bookTitle || `Book #${r.bookId}` }}</span>
|
||||
<span v-if="r.note" class="block text-gray-300 mt-1">{{ r.note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
middleware: 'authenticated',
|
||||
async asyncData({ store, app, route, redirect }) {
|
||||
if (!store.state.user.user) return redirect(`/login?redirect=${route.path}`)
|
||||
try {
|
||||
const items = await app.$axios.$get('/api/recommendations/sent?include=tag,recipient,item')
|
||||
return { items, loading: false }
|
||||
} catch {
|
||||
return { items: [], loading: false }
|
||||
}
|
||||
},
|
||||
data: () => ({ loading: true, items: [], deleting: {} }),
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat') || 'yyyy-MM-dd'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fmt(v) {
|
||||
try { return this.$formatDate(v, this.dateFormat) }
|
||||
catch { return new Date(v).toLocaleString() }
|
||||
},
|
||||
async confirmDelete(r) {
|
||||
if (!window.confirm('Delete this recommendation?')) return
|
||||
await this.del(r.id)
|
||||
},
|
||||
async del(id) {
|
||||
if (this.deleting[id]) return
|
||||
this.$set(this.deleting, id, true)
|
||||
try {
|
||||
await this.$axios.$delete(`/api/recommendations/${id}`)
|
||||
this.items = this.items.filter((i) => i.id !== id)
|
||||
this.$toast?.success('Recommendation deleted')
|
||||
} catch (e) {
|
||||
this.$toast?.error(e?.response?.data?.message || 'Failed to delete recommendation')
|
||||
} finally {
|
||||
this.$delete(this.deleting, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue