diff --git a/client/components/AbsSelect.vue b/client/components/AbsSelect.vue
new file mode 100644
index 000000000..5c0e1cd50
--- /dev/null
+++ b/client/components/AbsSelect.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ -
+ {{ opt.label }}
+
+ - No options
+
+
+
+
+
+
+
+
diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue
index f74134041..98d1904e7 100644
--- a/client/components/app/Appbar.vue
+++ b/client/components/app/Appbar.vue
@@ -42,15 +42,31 @@
+
+
+
+ inbox
+
+
+
+
+
+ send
+
+
+
+
{{ username }}
+
+
{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}
@@ -58,17 +74,20 @@
play_arrow
{{ $strings.ButtonPlay }}
+
+
+
@@ -160,26 +179,22 @@ export default {
},
contextMenuItems() {
if (!this.userIsAdminOrUp) return []
-
const options = [
{
text: this.$strings.ButtonQuickMatch,
action: 'quick-match'
}
]
-
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
options.push({
text: this.$strings.ButtonQuickEmbedMetadata,
action: 'quick-embed'
})
}
-
options.push({
text: this.$strings.ButtonReScan,
action: 'rescan'
})
-
// The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440
// + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains
if (this.selectedMediaItems.length <= 40) {
@@ -188,7 +203,6 @@ export default {
action: 'download'
})
}
-
return options
}
},
@@ -259,7 +273,6 @@ export default {
},
async playSelectedItems() {
this.$store.commit('setProcessingBatch', true)
-
const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
const libraryItems = await this.$axios
.$post(`/api/items/batch/get`, { libraryItemIds })
@@ -308,6 +321,7 @@ export default {
toggleBatchRead() {
this.$store.commit('setProcessingBatch', true)
const newIsFinished = !this.selectedIsFinished
+
const updateProgressPayloads = this.selectedMediaItems.map((item) => {
return {
libraryItemId: item.id,
@@ -339,9 +353,7 @@ export default {
callback: (confirmed, hardDelete) => {
if (confirmed) {
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
-
this.$store.commit('setProcessingBatch', true)
-
this.$axios
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue
index 1d8f0f20b..47c6b7ae6 100644
--- a/client/pages/item/_id/index.vue
+++ b/client/pages/item/_id/index.vue
@@ -21,6 +21,7 @@
+
@@ -41,7 +42,8 @@
{{ $getString('LabelByAuthor', [podcastAuthor]) }}
- {{ $getString('LabelByAuthor', ['']) }}{{ author.name }},
+ {{ $getString('LabelByAuthor', ['']) }}
+ {{ author.name }},
by Unknown
@@ -54,7 +56,6 @@
{{ $getString('MessageEpisodesQueuedForDownload', [episodeDownloadsQueued.length]) }}
-
close
@@ -70,9 +71,11 @@
{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%
-
{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt, dateFormat) }}
-
{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}
-
{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}
+
{{ $strings.LabelFinished }} {{ fmtDate(userProgressFinishedAt) }}
+
+ {{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}
+
+
{{ $strings.LabelStarted }} {{ fmtDate(userProgressStartedAt) }}
@@ -96,6 +99,12 @@
{{ $strings.ButtonRead }}
+
+
+ sell
+ Recommend
+
+
@@ -124,25 +133,70 @@
-
-
+
+
+
-
-
-
-
+
+
+
+
+
+
+
Recommend this book
+
+
+
+
+
+
+
+
+
+
If left empty, this will be a public recommendation.
+
+
+
+
+
+
{{ recForm.note.length }}/1000
+
+
+
+
+
+ Cancel
+
+ hourglass_empty
+ Submit
+
+
+
+
@@ -153,8 +207,7 @@ export default {
return redirect(`/login?redirect=${route.path}`)
}
- // Include episode downloads for podcasts
- var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed,share`).catch((error) => {
+ const item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed,share`).catch((error) => {
console.error('Failed', error)
return false
})
@@ -182,7 +235,18 @@ export default {
episodeDownloadsQueued: [],
showBookmarksModal: false,
isDescriptionClamped: false,
- showFullDescription: false
+ showFullDescription: false,
+
+ // Recommend modal state
+ showRecModal: false,
+ postingRec: false,
+ recTags: [],
+ recForm: {
+ tagId: '',
+ recipientUserId: '',
+ note: '',
+ visibility: 'public'
+ }
}
},
computed: {
@@ -287,10 +351,7 @@ export default {
return this.series.map((se) => {
let text = se.name
if (se.sequence) text += ` #${se.sequence}`
- return {
- ...se,
- text
- }
+ return { ...se, text }
})
},
duration() {
@@ -353,9 +414,7 @@ export default {
return this.$store.getters['user/getUserCanDownload']
},
showRssFeedBtn() {
- if (!this.rssFeed && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
-
- // If rss feed is open then show feed url to users otherwise just show to admins
+ if (!this.rssFeed && !this.podcastEpisodes.length && !this.tracks.length) return false
return this.userIsAdminOrUp || this.rssFeed
},
showQueueBtn() {
@@ -367,86 +426,86 @@ export default {
},
contextMenuItems() {
const items = []
-
- if (this.showCollectionsButton) {
- items.push({
- text: this.$strings.LabelCollections,
- action: 'collections'
- })
- }
-
- if (!this.isPodcast && this.tracks.length) {
- items.push({
- text: this.$strings.LabelYourPlaylists,
- action: 'playlists'
- })
- }
-
- if (this.bookmarks.length) {
- items.push({
- text: this.$strings.LabelYourBookmarks,
- action: 'bookmarks'
- })
- }
-
- if (this.showRssFeedBtn) {
- items.push({
- text: this.$strings.LabelOpenRSSFeed,
- action: 'rss-feeds'
- })
- }
-
- if (this.userCanDownload) {
- items.push({
- text: this.$strings.LabelDownload,
- action: 'download'
- })
- }
-
+ if (this.showCollectionsButton) items.push({ text: this.$strings.LabelCollections, action: 'collections' })
+ if (!this.isPodcast && this.tracks.length) items.push({ text: this.$strings.LabelYourPlaylists, action: 'playlists' })
+ if (this.bookmarks.length) items.push({ text: this.$strings.LabelYourBookmarks, action: 'bookmarks' })
+ if (this.showRssFeedBtn) items.push({ text: this.$strings.LabelOpenRSSFeed, action: 'rss-feeds' })
+ if (this.userCanDownload) items.push({ text: this.$strings.LabelDownload, action: 'download' })
if (this.ebookFile && this.$store.state.libraries.ereaderDevices?.length) {
items.push({
text: this.$strings.LabelSendEbookToDevice,
- subitems: this.$store.state.libraries.ereaderDevices.map((d) => {
- return {
- text: d.name,
- action: 'sendToDevice',
- data: d.name
- }
- })
+ subitems: this.$store.state.libraries.ereaderDevices.map((d) => ({ text: d.name, action: 'sendToDevice', data: d.name }))
})
}
-
- if (this.userIsAdminOrUp && !this.isPodcast && this.tracks.length) {
- items.push({
- text: this.$strings.LabelShare,
- action: 'share'
- })
- }
-
- if (this.userCanDelete) {
- items.push({
- text: this.$strings.ButtonDelete,
- action: 'delete'
- })
- }
-
+ if (this.userIsAdminOrUp && !this.isPodcast && this.tracks.length) items.push({ text: this.$strings.LabelShare, action: 'share' })
+ if (this.userCanDelete) items.push({ text: this.$strings.ButtonDelete, action: 'delete' })
return items
}
},
methods: {
+ // ---- Safe date formatter ----
+ fmtDate(value) {
+ if (!value) return ''
+ if (typeof value === 'number') {
+ const ms = value < 1e12 ? value * 1000 : value
+ return this.$formatDate(ms, this.dateFormat)
+ }
+ const ts = Date.parse(value)
+ if (!Number.isNaN(ts)) return this.$formatDate(ts, this.dateFormat)
+ return ''
+ },
+
+ // --- Recommendations helpers ---
+ async openRecommendModal() {
+ this.showRecModal = true
+ if (!this.recTags.length) {
+ try {
+ this.recTags = await this.$axios.$get('/api/recommendations/tags')
+ } catch (e) {
+ console.error('Fetch tags failed', e)
+ this.$toast?.error?.('Failed to load tags')
+ }
+ }
+ },
+ closeRecommendModal() {
+ if (this.postingRec) return
+ this.showRecModal = false
+ this.recForm = { tagId: '', recipientUserId: '', note: '', visibility: 'public' }
+ },
+ async submitRecommendation() {
+ if (!this.recForm.tagId) return
+ this.postingRec = true
+ try {
+ const payload = {
+ bookId: this.libraryItemId,
+ tagId: this.recForm.tagId,
+ note: this.recForm.note || undefined,
+ visibility: this.recForm.visibility,
+ recipientUserId: this.recForm.recipientUserId || undefined
+ }
+ await this.$axios.$post('/api/recommendations', payload)
+ this.$toast?.success?.('Recommendation saved')
+ this.closeRecommendModal()
+ } catch (e) {
+ const msg = e?.response?.data?.message || 'Failed to save recommendation'
+ this.$toast?.error?.(msg)
+ console.error('Create recommendation failed', e)
+ } finally {
+ this.postingRec = false
+ }
+ },
+
+ // --- existing methods below ---
selectBookmark(bookmark) {
if (!bookmark) return
if (this.isStreaming) {
this.$eventBus.$emit('playback-seek', bookmark.time)
} else if (this.streamLibraryItem) {
this.showBookmarksModal = false
- console.log('Already streaming library item so ask about it')
const payload = {
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(bookmark.time)}?`,
callback: (confirmed) => {
- if (confirmed) {
- this.playItem(bookmark.time)
- }
+ if (confirmed) this.playItem(bookmark.time)
},
type: 'yesNo'
}
@@ -475,7 +534,7 @@ export default {
return this.$toast.error(this.$strings.ToastNoRSSFeed)
}
this.fetchingRSSFeed = true
- var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
+ const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
console.error('Failed to get feed', error)
this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)
return null
@@ -483,7 +542,6 @@ export default {
this.fetchingRSSFeed = false
if (!payload) return
- console.log('Podcast feed', payload)
const podcastfeed = payload.podcast
if (!podcastfeed.episodes || !podcastfeed.episodes.length) {
this.$toast.info(this.$strings.ToastPodcastNoEpisodesInFeed)
@@ -504,10 +562,8 @@ export default {
if (!this.userIsFinished && this.progressPercent > 0 && !confirmed) {
const payload = {
message: this.$getString('MessageConfirmMarkItemFinished', [this.title]),
- callback: (confirmed) => {
- if (confirmed) {
- this.toggleFinished(true)
- }
+ callback: (c) => {
+ if (c) this.toggleFinished(true)
},
type: 'yesNo'
}
@@ -515,9 +571,7 @@ export default {
return
}
- var updatePayload = {
- isFinished: !this.userIsFinished
- }
+ const updatePayload = { isFinished: !this.userIsFinished }
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
@@ -534,15 +588,12 @@ export default {
let episodeId = null
const queueItems = []
if (this.isPodcast) {
- // Uses the sorting and filtering from the episode table component
const episodesInListeningOrder = this.$refs.episodesTable?.episodesList || []
- // Find the first unplayed episode from the table
let episodeIndex = episodesInListeningOrder.findIndex((ep) => {
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
return !podcastProgress || !podcastProgress.isFinished
})
- // If all episodes are played, use the first episode
if (episodeIndex < 0) episodeIndex = 0
episodeId = episodesInListeningOrder[episodeIndex].id
@@ -557,14 +608,14 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: this.title,
- caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
+ caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.fmtDate(episode.publishedAt)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: this.libraryItem.media.coverPath || null
})
}
}
} else {
- const queueItem = {
+ queueItems.push({
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: null,
@@ -573,8 +624,7 @@ export default {
caption: '',
duration: this.duration || null,
coverPath: this.media.coverPath || null
- }
- queueItems.push(queueItem)
+ })
}
this.$eventBus.$emit('play-item', {
@@ -594,20 +644,16 @@ export default {
},
libraryItemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItemId) {
- console.log('Item was updated', libraryItem)
this.libraryItem = libraryItem
this.$nextTick(this.checkDescriptionClamped)
}
},
clearProgressClick() {
if (!this.userMediaProgress) return
-
const payload = {
message: this.$strings.MessageConfirmResetProgress,
callback: (confirmed) => {
- if (confirmed) {
- this.clearProgress()
- }
+ if (confirmed) this.clearProgress()
},
type: 'yesNo'
}
@@ -680,11 +726,8 @@ export default {
},
queueBtnClick() {
if (this.isQueued) {
- // Remove from queue
this.$store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId })
} else {
- // Add to queue
-
const queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
@@ -711,7 +754,6 @@ export default {
callback: (confirmed, hardDelete) => {
if (confirmed) {
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
-
this.$axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => {
@@ -733,10 +775,7 @@ export default {
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFile.ebookFormat, this.title, deviceName]),
callback: (confirmed) => {
if (confirmed) {
- const payload = {
- libraryItemId: this.libraryItemId,
- deviceName
- }
+ const payload = { libraryItemId: this.libraryItemId, deviceName }
this.processing = true
this.$axios
.$post(`/api/emails/send-ebook-to-device`, payload)
diff --git a/client/pages/me/recommendations/inbox.vue b/client/pages/me/recommendations/inbox.vue
new file mode 100644
index 000000000..0c19f4894
--- /dev/null
+++ b/client/pages/me/recommendations/inbox.vue
@@ -0,0 +1,70 @@
+
+
+
Recommendations · Inbox
+
+
Loading…
+
You have no incoming recommendations yet.
+
+
+
+
+
+
+ from {{ r.recommender?.username || 'Someone' }}
+ • {{ fmt(r.createdAt) }}
+
+
+
+
+ Go to
+
+
+
+
+
+ {{ r.tag?.label || 'Recommended' }}
+
+
+ {{ r.bookTitle || `Book #${r.bookId}` }}
+ {{ r.note }}
+
+
+
+
+
+
+
+
diff --git a/client/pages/me/recommendations/sent.vue b/client/pages/me/recommendations/sent.vue
new file mode 100644
index 000000000..2bf5c9cf2
--- /dev/null
+++ b/client/pages/me/recommendations/sent.vue
@@ -0,0 +1,85 @@
+
+
+
Recommendations · Sent
+
+
Loading…
+
You haven’t sent any recommendations yet.
+
+
+
+
+
+ to {{ r.recipient?.username || 'Public' }}
+ • {{ fmt(r.createdAt) }}
+ {{ r.visibility }}
+
+
+
+
+
+
+ {{ r.tag?.label || 'Recommended' }}
+
+
+ {{ r.bookTitle || `Book #${r.bookId}` }}
+ {{ r.note }}
+
+
+
+
+
+
+
+