Merge branch 'master' into feat/all-stats-page

This commit is contained in:
Finn Dittmar 2025-07-07 17:17:01 +02:00 committed by GitHub
commit eddd4d7d0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
355 changed files with 15354 additions and 4795 deletions

View file

@ -26,8 +26,8 @@
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" />
<div class="flex items-center py-2">
<p v-if="isRoot" class="text-error py-2 text-xs">* {{ $strings.NoteChangeRootPassword }}</p>
<div class="flex-grow" />
<ui-btn v-show="(password && newPassword && confirmPassword) || isRoot" type="submit" :loading="changingPassword" color="success">{{ $strings.ButtonSubmit }}</ui-btn>
<div class="grow" />
<ui-btn v-show="(password && newPassword && confirmPassword) || isRoot" type="submit" :loading="changingPassword" color="bg-success">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</form>
</div>
@ -37,9 +37,9 @@
<app-settings-content :header-text="$strings.HeaderEreaderDevices">
<template #header-items>
<div class="flex-grow" />
<div class="grow" />
<ui-btn color="primary" small @click="addNewDeviceClick">{{ $strings.ButtonAddDevice }}</ui-btn>
<ui-btn color="bg-primary" small @click="addNewDeviceClick">{{ $strings.ButtonAddDevice }}</ui-btn>
</template>
<table v-if="ereaderDevices.length" class="tracksTable mt-4">
@ -70,7 +70,7 @@
</div>
<div class="py-4 mt-8 flex">
<ui-btn color="primary flex items-center text-lg" @click="logout"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
<ui-btn color="bg-primary flex items-center text-lg" @click="logout"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
</div>
<modals-emails-user-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="revisedEreaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />

View file

@ -1,34 +1,34 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center py-4 px-2 md:px-0 max-w-7xl mx-auto">
<div class="flex items-center py-4 px-4 max-w-7xl mx-auto">
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
<h1 class="text-lg lg:text-xl">{{ title }}</h1>
</nuxt-link>
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
<span class="material-symbols text-base">edit</span>
</button>
<div class="flex-grow hidden md:block" />
<div class="grow hidden md:block" />
<p class="text-base hidden md:block">{{ $strings.LabelDuration }}:</p>
<p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
</div>
<div class="flex flex-wrap-reverse justify-center py-4 px-2">
<div class="flex flex-wrap-reverse lg:flex-nowrap justify-center py-4 px-4">
<div class="w-full max-w-3xl py-4">
<div class="flex items-center">
<div class="w-12 hidden lg:block" />
<p class="text-lg mb-4 font-semibold">{{ $strings.HeaderChapters }}</p>
<div class="flex-grow" />
<div class="grow" />
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" :label="$strings.LabelShowSeconds" class="mx-2" />
<div class="w-32 hidden lg:block" />
</div>
<div class="flex items-center mb-3 py-1 -mx-1">
<div class="w-12 hidden lg:block" />
<ui-btn v-if="chapters.length" color="primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" class="mx-1" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<div class="flex-grow" />
<ui-btn v-if="chapters.length" color="bg-primary" small class="mx-1 whitespace-nowrap" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg-bg' : 'bg-primary'" class="mx-1 whitespace-nowrap" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="bg-primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<div class="grow" />
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-if="hasChanges" color="success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn v-if="hasChanges" color="bg-success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-32 hidden lg:block" />
</div>
@ -36,12 +36,12 @@
<transition name="slide">
<div v-if="showShiftTimes" class="flex mb-4">
<div class="w-12 hidden lg:block" />
<div class="flex-grow">
<div class="grow">
<div class="flex items-center">
<p class="text-sm mb-1 font-semibold pr-2">{{ $strings.LabelTimeToShift }}</p>
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" />
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn>
<div class="flex-grow" />
<ui-btn color="bg-primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn>
<div class="grow" />
<span class="material-symbols text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">expand_less</span>
</div>
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
@ -54,7 +54,7 @@
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="w-8 min-w-8 md:w-12 md:min-w-12"></div>
<div class="w-24 min-w-24 md:w-32 md:min-w-32 px-2">{{ $strings.LabelStart }}</div>
<div class="flex-grow px-2">{{ $strings.LabelTitle }}</div>
<div class="grow px-2">{{ $strings.LabelTitle }}</div>
<div class="w-32"></div>
</div>
<template v-for="chapter in newChapters">
@ -64,8 +64,8 @@
<ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
</div>
<div class="flex-grow px-1">
<ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs" />
<div class="grow px-1">
<ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs min-w-52" />
</div>
<div class="w-32 min-w-32 px-2 py-1">
<div class="flex items-center">
@ -103,20 +103,20 @@
<div class="w-full max-w-xl py-4 px-2">
<div class="flex items-center mb-4 py-1">
<p class="text-lg font-semibold">{{ $strings.HeaderAudioTracks }}</p>
<div class="flex-grow" />
<div class="grow" />
<ui-btn small @click="setChaptersFromTracks">{{ $strings.ButtonSetChaptersFromTracks }}</ui-btn>
<ui-tooltip :text="$strings.MessageSetChaptersFromTracksDescription" direction="top" class="flex items-center mx-1 cursor-default">
<span class="material-symbols text-xl text-gray-200">info</span>
</ui-tooltip>
</div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="flex-grow">{{ $strings.LabelFilename }}</div>
<div class="grow">{{ $strings.LabelFilename }}</div>
<div class="w-20">{{ $strings.LabelDuration }}</div>
<div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div>
</div>
<template v-for="track in audioTracks">
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
<div class="flex-grow max-w-[calc(100%-80px)] pr-2">
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success/10' : ''">
<div class="grow max-w-[calc(100%-80px)] pr-2">
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
</div>
<div class="w-20" style="min-width: 80px">
@ -130,7 +130,7 @@
</div>
</div>
<div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black bg-opacity-25 flex items-center justify-center">
<div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black/25 flex items-center justify-center">
<ui-loading-indicator />
</div>
@ -141,10 +141,22 @@
</div>
</template>
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
<div v-if="!chapterData" class="flex p-20">
<ui-text-input-with-label v-model.trim="asinInput" label="ASIN" />
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-32 mx-1" />
<ui-btn small color="primary" class="mt-5" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
<div v-if="!chapterData" class="flex flex-col items-center justify-center p-20">
<div class="relative">
<div class="flex items-end space-x-2">
<ui-text-input-with-label v-model.trim="asinInput" label="ASIN" class="flex-grow" />
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-20 max-w-20" />
<ui-btn color="bg-primary" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
</div>
<div class="mt-4">
<ui-checkbox v-model="removeBranding" :label="$strings.LabelRemoveAudibleBranding" small checkbox-bg="bg" label-class="pl-2 text-base text-sm" @click="toggleRemoveBranding" />
</div>
<div class="absolute left-0 mt-1.5 text-error text-s h-5">
<p v-if="asinError">{{ asinError }}</p>
<p v-if="asinError">{{ $strings.MessageAsinCheck }}</p>
</div>
<div class="invisible mt-1 text-xs"></div>
</div>
</div>
<div v-else class="w-full p-4">
<div class="flex justify-between mb-4">
@ -164,35 +176,35 @@
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
<div class="w-24 px-2">{{ $strings.LabelStart }}</div>
<div class="flex-grow px-2">{{ $strings.LabelTitle }}</div>
<div class="grow px-2">{{ $strings.LabelTitle }}</div>
</div>
<div class="w-full max-h-80 overflow-y-auto my-2">
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''">
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error/20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning/20' : index % 2 === 0 ? 'bg-primary/30' : ''">
<div class="w-24 min-w-24 px-2">
<p class="font-mono">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p>
</div>
<div class="flex-grow px-2">
<div class="grow px-2">
<p class="truncate max-w-sm">{{ chapter.title }}</p>
</div>
</div>
</div>
<div v-if="chapterData.runtimeLengthSec > mediaDurationRounded" class="w-full pt-2">
<div class="flex items-center">
<div class="w-2 h-2 bg-warning bg-opacity-50" />
<div class="w-2 h-2 bg-warning/50" />
<p class="pl-2">{{ $strings.MessageChapterEndIsAfter }}</p>
</div>
<div class="flex items-center">
<div class="w-2 h-2 bg-error bg-opacity-50" />
<div class="w-2 h-2 bg-error/50" />
<p class="pl-2">{{ $strings.MessageChapterStartIsAfter }}</p>
</div>
</div>
<div class="flex items-center pt-2">
<ui-btn small color="primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn>
<ui-btn small color="bg-primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn>
<ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center">
<span class="material-symbols text-xl text-gray-200">info</span>
</ui-tooltip>
<div class="flex-grow" />
<ui-btn small color="success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn>
<div class="grow" />
<ui-btn small color="bg-success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn>
</div>
</div>
</div>
@ -221,6 +233,11 @@ export default {
return redirect('/')
}
// Fetch and set library if this items library does not match the current
if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) {
await store.dispatch('libraries/fetch', libraryItem.libraryId)
}
var previousRoute = from ? from.fullPath : null
if (from && from.path === '/login') previousRoute = null
return {
@ -244,6 +261,8 @@ export default {
findingChapters: false,
showFindChaptersModal: false,
chapterData: null,
asinError: null,
removeBranding: false,
showSecondInputs: false,
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'],
hasChanges: false
@ -305,6 +324,9 @@ export default {
this.checkChapters()
},
toggleRemoveBranding() {
this.removeBranding = !this.removeBranding
},
shiftChapterTimes() {
if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) {
return
@ -314,12 +336,12 @@ export default {
const lastChapter = this.newChapters[this.newChapters.length - 1]
if (lastChapter.start + amount > this.mediaDurationRounded) {
this.$toast.error('Invalid shift amount. Last chapter start time would extend beyond the duration of this audiobook.')
this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountLast)
return
}
if (this.newChapters[0].end + amount <= 0) {
this.$toast.error('Invalid shift amount. First chapter would have zero or negative length.')
if (this.newChapters[1].start + amount <= 0) {
this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountStart)
return
}
@ -414,11 +436,8 @@ export default {
const audioEl = this.audioEl || document.createElement('audio')
var src = audioTrack.contentUrl + `?token=${this.userToken}`
if (this.$isDev) {
src = `${process.env.serverUrl}${src}`
}
audioEl.src = src
audioEl.src = `${process.env.serverUrl}${src}`
audioEl.id = 'chapter-audio'
document.body.appendChild(audioEl)
@ -544,17 +563,17 @@ export default {
this.findingChapters = true
this.chapterData = null
this.asinError = null // used to show warning about audible vs amazon ASIN
this.$axios
.$get(`/api/search/chapters?asin=${this.asinInput}&region=${this.regionInput}`)
.then((data) => {
this.findingChapters = false
if (data.error) {
this.$toast.error(data.error)
this.showFindChaptersModal = false
this.asinError = this.$getString(data.stringKey)
} else {
console.log('Chapter data', data)
this.chapterData = data
this.chapterData = this.removeBranding ? this.removeBrandingFromData(data) : data
}
})
.catch((error) => {
@ -564,6 +583,37 @@ export default {
this.showFindChaptersModal = false
})
},
removeBrandingFromData(data) {
if (!data) return data
try {
const introDuration = data.brandIntroDurationMs
const outroDuration = data.brandOutroDurationMs
for (let i = 0; i < data.chapters.length; i++) {
const chapter = data.chapters[i]
if (chapter.startOffsetMs < introDuration) {
// This should never happen, as the intro is not longer than the first chapter
// If this happens set to the next second
// Will be 0 for the first chapter anayways
chapter.startOffsetMs = i * 1000
chapter.startOffsetSec = i
} else {
chapter.startOffsetMs -= introDuration
chapter.startOffsetSec = Math.floor(chapter.startOffsetMs / 1000)
}
}
const lastChapter = data.chapters[data.chapters.length - 1]
// If there is an outro that's in the outro duration, remove it
if (lastChapter && lastChapter.lengthMs <= outroDuration) {
data.chapters.pop()
}
return data
} catch {
return data
}
},
resetChapters() {
const payload = {
message: this.$strings.MessageResetChaptersConfirm,

View file

@ -6,27 +6,27 @@
<div class="w-full h-full overflow-y-auto p-8">
<div class="w-full flex justify-between items-center pb-6 pt-2">
<p class="text-lg">{{ $strings.MessageDragFilesIntoTrackOrder }}</p>
<ui-btn color="success" @click="saveTracklist">{{ $strings.ButtonSaveTracklist }}</ui-btn>
<ui-btn color="bg-success" @click="saveTracklist">{{ $strings.ButtonSaveTracklist }}</ui-btn>
</div>
<div class="w-full flex items-center text-sm py-4 bg-primary border-l border-r border-t border-gray-600">
<div class="text-center px-4 w-12">{{ $strings.LabelNew }}</div>
<div class="text-center px-4 w-24 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByCurrent" @mousedown.prevent>
<div class="text-center px-4 w-24 flex items-center cursor-pointer text-white/40 hover:text-white/100" @click="sortByCurrent" @mousedown.prevent>
<span class="text-white">{{ $strings.LabelCurrent }}</span>
<span class="material-symbols ml-1" :class="currentSort === 'current' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'current' ? 'expand_more' : 'unfold_more' }}</span>
<span class="material-symbols ml-1" :class="currentSort === 'current' ? 'text-white/100 text-lg' : 'text-sm'">{{ currentSort === 'current' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilenameTrack" @mousedown.prevent>
<div class="text-center px-4 w-32 flex items-center cursor-pointer text-white/40 hover:text-white/100" @click="sortByFilenameTrack" @mousedown.prevent>
<span class="text-white">{{ $strings.LabelTrackFromFilename }}</span>
<span class="material-symbols ml-1" :class="currentSort === 'track-filename' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'track-filename' ? 'expand_more' : 'unfold_more' }}</span>
<span class="material-symbols ml-1" :class="currentSort === 'track-filename' ? 'text-white/100 text-lg' : 'text-sm'">{{ currentSort === 'track-filename' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByMetadataTrack" @mousedown.prevent>
<div class="text-center px-4 w-32 flex items-center cursor-pointer text-white/40 hover:text-white/100" @click="sortByMetadataTrack" @mousedown.prevent>
<span class="text-white">{{ $strings.LabelTrackFromMetadata }}</span>
<span class="material-symbols ml-1" :class="currentSort === 'metadata' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'metadata' ? 'expand_more' : 'unfold_more' }}</span>
<span class="material-symbols ml-1" :class="currentSort === 'metadata' ? 'text-white/100 text-lg' : 'text-sm'">{{ currentSort === 'metadata' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="w-20 text-center">{{ $strings.LabelDiscFromFilename }}</div>
<div class="w-20 text-center">{{ $strings.LabelDiscFromMetadata }}</div>
<div class="text-center px-4 flex-grow flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilename" @mousedown.prevent>
<div class="text-center px-4 grow flex items-center cursor-pointer text-white/40 hover:text-white/100" @click="sortByFilename" @mousedown.prevent>
<span class="text-white">{{ $strings.LabelFilename }}</span>
<span class="material-symbols ml-1" :class="currentSort === 'filename' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'filename' ? 'expand_more' : 'unfold_more' }}</span>
<span class="material-symbols ml-1" :class="currentSort === 'filename' ? 'text-white/100 text-lg' : 'text-sm'">{{ currentSort === 'filename' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="w-20 text-center">{{ $strings.LabelSize }}</div>
@ -53,7 +53,7 @@
<div class="truncate px-4 w-20 min-w-20">
{{ audio.discNumFromMeta }}
</div>
<div class="truncate px-4 flex-grow">
<div class="truncate px-4 grow">
{{ audio.metadata.filename }}
</div>
@ -103,6 +103,12 @@ export default {
console.error('No need to edit library item that is 1 file...')
return redirect('/')
}
// Fetch and set library if this items library does not match the current
if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) {
await store.dispatch('libraries/fetch', libraryItem.libraryId)
}
return {
libraryItem,
files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []

View file

@ -2,7 +2,14 @@
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center justify-center mb-6">
<div class="w-full max-w-2xl">
<p class="text-2xl mb-2">{{ $strings.HeaderAudiobookTools }}</p>
<div class="flex items-center mb-4">
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
<h1 class="text-lg lg:text-xl">{{ mediaMetadata.title }}</h1>
</nuxt-link>
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
<span class="material-symbols text-base">edit</span>
</button>
</div>
</div>
<div class="w-full max-w-2xl">
<div class="flex justify-end">
@ -13,43 +20,43 @@
<div class="flex justify-center mb-2">
<div class="w-full max-w-2xl">
<p class="text-xl">{{ $strings.HeaderMetadataToEmbed }}</p>
<p class="text-lg">{{ $strings.HeaderMetadataToEmbed }}</p>
</div>
<div class="w-full max-w-2xl"></div>
</div>
<div class="flex justify-center flex-wrap">
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex justify-center flex-wrap lg:flex-nowrap gap-4">
<div class="w-full max-w-2xl border border-white/10 bg-bg">
<div class="flex py-2 px-4">
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
<div class="w-28 min-w-28 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<template v-for="(value, key, index) in metadataObject">
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-1/3 font-semibold">{{ key }}</div>
<div class="w-2/3">
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary/25' : ''">
<div class="w-28 min-w-28 font-semibold">{{ key }}</div>
<div class="grow">
{{ value }}
</div>
</div>
</template>
</div>
</div>
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
<div class="w-full max-w-2xl border border-white/10 bg-bg">
<div class="flex py-2 px-4 bg-primary/25">
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">{{ $strings.MessageNoChapters }}</p>
<template v-for="(chapter, index) in metadataChapters">
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary bg-opacity-25' : ''">
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
<div class="w-24">
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary/25' : ''">
<div class="grow font-semibold">{{ chapter.title }}</div>
<div class="w-16 min-w-16">
{{ $secondsToTimestamp(chapter.start) }}
</div>
<div class="w-24">
<div class="w-16 min-w-16">
{{ $secondsToTimestamp(chapter.end) }}
</div>
</div>
@ -58,7 +65,7 @@
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full h-px bg-white/10 my-8" />
<div class="w-full max-w-4xl mx-auto">
<!-- queued alert -->
@ -69,38 +76,32 @@
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" :label="$strings.LabelBackupAudioFiles" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
<div class="grow" />
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" :progress="progress" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<ui-btn v-if="!isTaskFinished" color="bg-primary" :loading="processing" :progress="progress" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">{{ $strings.MessageEmbedFailed }} {{ taskError }}</p>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div>
<!-- m4b embed action buttons -->
<div v-else class="w-full flex items-center mb-4">
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
<span class="material-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">{{ $strings.LabelUseAdvancedOptions }}</span>
</button>
<div class="grow" />
<div class="flex-grow" />
<ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn>
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" :progress="progress" @click.stop="encodeM4bClick">{{ $strings.ButtonStartM4BEncode }}</ui-btn>
<ui-btn v-if="!isTaskFinished && processing" color="bg-error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn>
<ui-btn v-if="!isTaskFinished" color="bg-primary" :loading="processing" :progress="progress" @click.stop="encodeM4bClick">{{ $strings.ButtonStartM4BEncode }}</ui-btn>
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">{{ $strings.MessageM4BFailed }} {{ taskError }}</p>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div>
<!-- advanced encoding options -->
<div v-if="isM4BTool" class="overflow-hidden">
<transition name="slide">
<div v-if="showEncodeOptions || usingCustomEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
<div class="flex flex-wrap -mx-2">
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
</div>
<p class="text-sm text-warning">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>
</div>
</transition>
<!-- show encoding options for running task -->
<div v-if="encodeTaskHasEncodingOptions" class="mb-4 pb-4 border-b border-white/10">
<div class="flex flex-wrap -mx-2">
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" readonly :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" readonly :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" readonly :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
</div>
</div>
<div v-else-if="isM4BTool" class="mb-4">
<widgets-encoder-options-card ref="encoderOptionsCard" :audio-tracks="audioFiles" :disabled="processing || isTaskFinished" />
</div>
<div class="mb-4">
@ -142,23 +143,33 @@
<div class="w-full max-w-4xl mx-auto">
<p class="mb-2 font-semibold">{{ $strings.HeaderAudioTracks }}</p>
<div class="w-full mx-auto border border-white border-opacity-10 bg-bg">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<div class="w-full mx-auto border border-white/10 bg-bg">
<div class="flex py-2 px-4 bg-primary/25">
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelFilename }}</div>
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelFilename }}</div>
<div class="w-20 text-xs font-semibold uppercase text-gray-200 hidden lg:block">{{ $strings.LabelChannels }}</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200 hidden md:block">{{ $strings.LabelCodec }}</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200 hidden md:block">{{ $strings.LabelBitrate }}</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelSize }}</div>
<div class="w-24"></div>
</div>
<template v-for="file in audioFiles">
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-10">{{ file.index }}</div>
<div class="flex-grow">
<div :key="file.index" class="flex py-2 px-4 text-xs sm:text-sm" :class="file.index % 2 === 0 ? 'bg-primary/25' : ''">
<div class="w-10 min-w-10">{{ file.index }}</div>
<div class="grow">
{{ file.metadata.filename }}
</div>
<div class="w-16 font-mono text-gray-200">
<div class="w-20 min-w-20 text-gray-200 hidden lg:block">{{ file.channels || 'unknown' }} ({{ file.channelLayout || 'unknown' }})</div>
<div class="w-16 min-w-16 text-gray-200 hidden md:block">
{{ file.codec || 'unknown' }}
</div>
<div class="w-16 min-w-16 text-gray-200 hidden md:block">
{{ $bytesPretty(file.bitRate || 0, 0) }}
</div>
<div class="w-16 min-w-16 text-gray-200">
{{ $bytesPretty(file.metadata.size) }}
</div>
<div class="w-24">
<div class="w-24 min-w-24">
<div class="flex justify-center">
<span v-if="audioFilesFinished[file.ino]" class="material-symbols text-xl text-success leading-none">check_circle</span>
<div v-else-if="audioFilesEncoding[file.ino]">
@ -195,10 +206,15 @@ export default {
return redirect('/?error=invalid media type')
}
if (!libraryItem.media.audioFiles.length) {
cnosole.error('No audio files')
console.error('No audio files')
return redirect('/?error=no audio files')
}
// Fetch and set library if this items library does not match the current
if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) {
await store.dispatch('libraries/fetch', libraryItem.libraryId)
}
return {
libraryItem
}
@ -209,7 +225,6 @@ export default {
metadataObject: null,
selectedTool: 'embed',
isCancelingEncode: false,
showEncodeOptions: false,
shouldBackupAudioFiles: true,
encodingOptions: {
bitrate: '128k',
@ -258,9 +273,6 @@ export default {
audioFiles() {
return (this.media.audioFiles || []).filter((af) => !af.exclude)
},
isSingleM4b() {
return this.audioFiles.length === 1 && this.audioFiles[0].metadata.ext.toLowerCase() === '.m4b'
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
@ -268,14 +280,10 @@ export default {
return this.media.chapters || []
},
availableTools() {
if (this.isSingleM4b) {
return [{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata }]
} else {
return [
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
]
}
return [
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
]
},
taskFailed() {
return this.isTaskFinished && this.task.isFailed
@ -309,8 +317,8 @@ export default {
isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
},
usingCustomEncodeOptions() {
return this.isM4BTool && this.encodeTask && this.encodeTask.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0
encodeTaskHasEncodingOptions() {
return this.isM4BTool && !!this.encodeTask?.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0
}
},
methods: {
@ -346,19 +354,15 @@ export default {
if (this.$refs.channelsInput) this.$refs.channelsInput.blur()
if (this.$refs.codecInput) this.$refs.codecInput.blur()
let queryStr = ''
if (this.showEncodeOptions) {
const options = []
if (this.encodingOptions.bitrate) options.push(`bitrate=${this.encodingOptions.bitrate}`)
if (this.encodingOptions.channels) options.push(`channels=${this.encodingOptions.channels}`)
if (this.encodingOptions.codec) options.push(`codec=${this.encodingOptions.codec}`)
if (options.length) {
queryStr = `?${options.join('&')}`
}
}
const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()
this.encodingOptions = encodeOptions
const queryParams = new URLSearchParams(encodeOptions)
this.processing = true
this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b${queryStr}`)
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b?${queryParams.toString()}`)
.then(() => {
console.log('Ab m4b merge started')
})
@ -411,14 +415,10 @@ export default {
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
if (this.usingCustomEncodeOptions) {
if (this.encodeTaskHasEncodingOptions) {
if (this.encodeTask.data.encodeOptions.bitrate) this.encodingOptions.bitrate = this.encodeTask.data.encodeOptions.bitrate
if (this.encodeTask.data.encodeOptions.channels) this.encodingOptions.channels = this.encodeTask.data.encodeOptions.channels
if (this.encodeTask.data.encodeOptions.codec) this.encodingOptions.codec = this.encodeTask.data.encodeOptions.codec
} else {
this.encodingOptions.bitrate = localStorage.getItem('embedMetadataBitrate') || '128k'
this.encodingOptions.channels = localStorage.getItem('embedMetadataChannels') || '2'
this.encodingOptions.codec = localStorage.getItem('embedMetadataCodec') || 'aac'
}
},
fetchMetadataEmbedObject() {
@ -433,10 +433,24 @@ export default {
},
taskUpdated(task) {
this.processing = !task.isFinished
},
editItem() {
this.$store.commit('showEditModal', this.libraryItem)
},
libraryItemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItem.id) {
this.libraryItem = libraryItem
this.fetchMetadataEmbedObject()
}
}
},
mounted() {
this.init()
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
},
beforeDestroy() {
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
}
}
</script>

View file

@ -4,10 +4,10 @@
<div class="flex flex-wrap sm:flex-nowrap justify-center mb-6">
<div class="w-48 min-w-48">
<div class="w-full h-60">
<covers-author-image :author="author" rounded="0" />
<covers-author-image :author="author" rounded-sm="0" />
</div>
</div>
<div class="flex-grow py-4 sm:py-0 px-4 md:px-8">
<div class="grow py-4 sm:py-0 px-4 md:px-8">
<div class="flex items-center mb-8">
<h1 class="text-2xl">{{ author.name }}</h1>
@ -16,7 +16,7 @@
</button>
</div>
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
<p v-if="author.description" class="text-white/60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
<p ref="description" id="author-description" class="text-white max-w-3xl text-base whitespace-pre-wrap" :class="{ 'show-full': showFullDescription }">{{ author.description }}</p>
<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">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
@ -37,7 +37,7 @@
<nuxt-link :to="`/library/${currentLibraryId}/series/${series.id}`" class="hover:underline">
<h2 class="text-lg">{{ series.name }}</h2>
</nuxt-link>
<p class="text-white text-opacity-40 text-base px-2">{{ $strings.LabelSeries }}</p>
<p class="text-white/40 text-base px-2">{{ $strings.LabelSeries }}</p>
</widgets-item-slider>
</div>
</div>

View file

@ -1,12 +1,12 @@
<template>
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
<div class="border border-white border-opacity-10 max-w-7xl mx-auto mb-10 mt-5">
<div class="border border-white/10 max-w-7xl mx-auto mb-10 mt-5">
<div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent>
<span class="material-symbols text-2xl">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
<p class="ml-4 text-gray-200 text-lg">{{ $strings.HeaderMapDetails }}</p>
<div class="flex-grow" />
<div class="grow" />
<div class="w-64 flex">
<button class="w-32 h-8 rounded-l-md shadow-md border border-gray-600" :class="!isMapOverwrite ? 'bg-bg text-white/30' : 'bg-primary'" @click.stop.prevent="mapDetailsType = 'overwrite'">
@ -22,7 +22,7 @@
<div v-if="openMapOptions" class="flex flex-wrap">
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.subtitle" />
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-5 ml-4" />
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" trim-whitespace class="mb-5 ml-4" />
</div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.authors" />
@ -31,7 +31,7 @@
</div>
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publishedYear" />
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-5 ml-4" />
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" trim-whitespace class="mb-5 ml-4" />
</div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.series" />
@ -51,11 +51,11 @@
</div>
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publisher" />
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-5 ml-4" />
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" trim-whitespace class="mb-5 ml-4" />
</div>
<div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.language" />
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-5 ml-4" />
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" trim-whitespace class="mb-5 ml-4" />
</div>
<div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.explicit" />
@ -86,8 +86,13 @@
</div>
</div>
<div class="w-full flex items-center justify-end p-4">
<ui-btn color="success" :disabled="!hasSelectedBatchUsage" :padding-x="8" small class="text-base" :loading="isProcessing" @click="mapBatchDetails">{{ $strings.ButtonApply }}</ui-btn>
<div class="w-full flex items-center p-4 space-x-2">
<ui-btn small @click.stop="resetMapDetails">{{ $strings.ButtonReset }}</ui-btn>
<ui-tooltip direction="bottom" :text="$strings.MessageBatchEditPopulateMapDetailsAllHelp">
<ui-btn small :disabled="!hasSelectedBatchUsage" @click.stop="populateFromExisting()">{{ $strings.ButtonBatchEditPopulateFromExisting }}</ui-btn>
</ui-tooltip>
<div class="grow" />
<ui-btn color="bg-success" :disabled="!hasSelectedBatchUsage" :padding-x="8" small class="text-base" :loading="isProcessing" @click="mapBatchDetails">{{ $strings.ButtonApply }}</ui-btn>
</div>
</div>
</transition>
@ -97,18 +102,23 @@
<div class="flex justify-center flex-wrap">
<template v-for="libraryItem in libraryItemCopies">
<div :key="libraryItem.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px">
<div class="flex items-center justify-end">
<ui-tooltip direction="bottom" :text="$strings.MessageBatchEditPopulateMapDetailsItemHelp">
<ui-btn small :disabled="!hasSelectedBatchUsage" @click="populateFromExisting(libraryItem.id)">{{ $strings.ButtonBatchEditPopulateMapDetails }}</ui-btn>
</ui-tooltip>
</div>
<widgets-book-details-edit v-if="libraryItem.mediaType === 'book'" :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" @change="handleItemChange" />
<widgets-podcast-details-edit v-else :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" @change="handleItemChange" />
</div>
</template>
</div>
<div v-show="isProcessing" class="fixed top-0 left-0 z-50 w-full h-full flex items-center justify-center bg-black bg-opacity-60">
<div v-show="isProcessing" class="fixed top-0 left-0 z-50 w-full h-full flex items-center justify-center bg-black/60">
<ui-loading-indicator />
</div>
<div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamLibraryItem ? '165px' : '0px' }">
<div class="flex-grow" />
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" :disabled="!hasChanges" @click.prevent="saveClick">{{ $strings.ButtonSave }}</ui-btn>
<div class="grow" />
<ui-btn color="bg-success" :padding-x="8" class="text-lg" :loading="isProcessing" :disabled="!hasChanges" @click.prevent="saveClick">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
</template>
@ -228,6 +238,88 @@ export default {
}
},
methods: {
resetMapDetails() {
this.blurBatchForm()
this.batchDetails = {
subtitle: null,
authors: null,
publishedYear: null,
series: [],
genres: [],
tags: [],
narrators: [],
publisher: null,
language: null,
explicit: false,
abridged: false
}
this.selectedBatchUsage = {
subtitle: false,
authors: false,
publishedYear: false,
series: false,
genres: false,
tags: false,
narrators: false,
publisher: false,
language: false,
explicit: false,
abridged: false
}
},
populateFromExisting(libraryItemId) {
this.blurBatchForm()
let libraryItemsToMap = this.libraryItemCopies
if (libraryItemId) {
libraryItemsToMap = this.libraryItemCopies.filter((li) => li.id === libraryItemId)
}
for (const key in this.selectedBatchUsage) {
if (!this.selectedBatchUsage[key]) continue
if (this.isMapAppend && !this.appendableKeys.includes(key)) continue
let existingValues = undefined
libraryItemsToMap.forEach((li) => {
if (key === 'tags') {
if (!existingValues) existingValues = []
li.media.tags.forEach((tag) => {
if (!existingValues.includes(tag)) {
existingValues.push(tag)
}
})
} else if (key === 'authors') {
if (!existingValues) existingValues = []
li.media.metadata[key].forEach((entity) => {
if (!existingValues.some((au) => au.id === entity.id)) {
existingValues.push({
id: entity.id,
name: entity.name
})
}
})
} else if (key === 'series') {
if (!existingValues) existingValues = []
li.media.metadata[key].forEach((entity) => {
if (!existingValues.includes(entity.name)) {
existingValues.push(entity.name)
}
})
} else if (key === 'genres' || key === 'narrators') {
if (!existingValues) existingValues = []
li.media.metadata[key].forEach((item) => {
if (!existingValues.includes(item)) {
existingValues.push(item)
}
})
} else if (existingValues === undefined) {
existingValues = li.media.metadata[key]
}
})
this.batchDetails[key] = existingValues
}
},
handleItemChange(itemChange) {
if (!itemChange.hasChanges) {
this.itemsWithChanges = this.itemsWithChanges.filter((id) => id !== itemChange.libraryItemId)
@ -288,7 +380,7 @@ export default {
const ref = this.getEditFormRef(li.id)
ref.mapBatchDetails(batchMapPayload, this.mapDetailsType)
})
this.$toast.success('Details mapped')
this.$toast.success(this.$strings.ToastBatchApplyDetailsToItemsSuccess)
},
newSeriesItem(item) {},
removedSeriesItem(item) {},
@ -360,7 +452,7 @@ export default {
this.isProcessing = false
if (data.updates) {
this.itemsWithChanges = []
this.$toast.success(`Successfully updated ${data.updates} items`)
this.$toast.success(this.$getString('MessageItemsUpdated', [data.updates]))
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
} else {
this.$toast.warning(this.$strings.MessageNoUpdatesWereNecessary)
@ -368,7 +460,7 @@ export default {
})
.catch((error) => {
console.error('failed to batch update', error)
this.$toast.error('Failed to batch update')
this.$toast.error(this.$strings.ToastFailedToUpdate)
this.isProcessing = false
})
},

View file

@ -7,24 +7,24 @@
<covers-collection-cover :book-items="bookItems" :width="240" :height="120 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="grow px-2 py-6 md:py-0 md:px-10">
<div class="flex items-end flex-row flex-wrap md:flex-nowrap">
<h1 class="text-2xl md:text-3xl font-sans w-full md:w-fit mb-4 md:mb-0">
{{ collectionName }}
</h1>
<div class="flex-grow" />
<div class="grow" />
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay">
<ui-btn v-if="showPlayButton" :disabled="streaming" color="bg-success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay">
<span v-show="!streaming" class="material-symbols fill text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlayAll }}
</ui-btn>
<!-- RSS feed -->
<ui-tooltip v-if="rssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="showRSSFeedModal" />
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'bg-success' : 'bg-primary'" outlined @click="showRSSFeedModal" />
</ui-tooltip>
<button type="button" class="h-9 w-9 flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5 mx-px" @click.stop.prevent="editClick">
<button type="button" class="h-9 w-9 flex items-center justify-center shadow-xs pl-3 pr-3 text-left focus:outline-hidden cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5 mx-px" @click.stop.prevent="editClick">
<span class="material-symbols text-xl">edit</span>
</button>
@ -39,7 +39,7 @@
</div>
</div>
</div>
<div v-show="processing" class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-40 flex items-center justify-center">
<div v-show="processing" class="absolute top-0 left-0 w-full h-full z-10 bg-black/40 flex items-center justify-center">
<ui-loading-indicator />
</div>
</div>
@ -176,21 +176,31 @@ export default {
this.$store.commit('globals/setEditCollection', this.collection)
},
removeClick() {
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
this.processing = true
this.$axios
.$delete(`/api/collections/${this.collection.id}`)
.then(() => {
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove collection', error)
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
})
.finally(() => {
this.processing = false
})
const payload = {
message: this.$getString('MessageConfirmRemoveCollection', [this.collectionName]),
callback: (confirmed) => {
if (confirmed) {
this.deleteCollection()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteCollection() {
this.processing = true
this.$axios
.$delete(`/api/collections/${this.collection.id}`)
.then(() => {
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove collection', error)
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
})
.finally(() => {
this.processing = false
})
},
clickPlay() {
const queueItems = []

View file

@ -2,7 +2,7 @@
<div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<app-config-side-nav :is-open.sync="sideDrawerOpen" />
<div class="configContent" :class="`page-${currentPage}`">
<div v-show="isMobilePortrait" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2 cursor-pointer" @click.stop.prevent="toggleShowMore">
<div v-show="isMobilePortrait" class="w-full pb-4 px-2 flex border-b border-white/10 mb-2 cursor-pointer" @click.stop.prevent="toggleShowMore">
<span class="material-symbols text-2xl cursor-pointer">arrow_forward</span>
<p class="pl-3 capitalize">{{ currentPage }}</p>
</div>

View file

@ -33,7 +33,7 @@
<transition name="slide">
<div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4">
<div class="w-full flex items-center mb-2">
<div class="flex-grow">
<div class="grow">
<ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" />
</div>
<div class="w-36 mx-1 mt-[1.375rem]">
@ -122,8 +122,9 @@
</div>
</transition>
</div>
<div class="w-full flex items-center justify-end p-4">
<ui-btn color="success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-full flex items-center justify-between p-4">
<p v-if="enableOpenIDAuth" class="text-sm text-warning">{{ $strings.MessageAuthenticationOIDCChangesRestart }}</p>
<ui-btn color="bg-success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
</div>
</app-settings-content>
</div>

View file

@ -4,7 +4,7 @@
<div v-if="backupLocation" class="mb-4 max-w-full overflow-hidden">
<div class="flex items-center mb-0.5">
<span class="material-symbols text-2xl text-black-50 mr-2">folder</span>
<span class="text-white text-opacity-60 uppercase text-sm whitespace-nowrap">{{ $strings.LabelBackupLocation }}:</span>
<span class="text-white/60 uppercase text-sm whitespace-nowrap">{{ $strings.LabelBackupLocation }}:</span>
</div>
<div v-if="!showEditBackupPath" class="inline-flex items-center w-full overflow-hidden">
<p class="text-gray-100 max-w-[calc(100%-40px)] text-sm sm:text-base break-words">{{ backupLocation }}</p>
@ -17,7 +17,7 @@
<div v-else>
<form class="flex items-center w-full space-x-1" @submit.prevent="saveBackupPath">
<ui-text-input v-model="newBackupLocation" :disabled="savingBackupPath || !canEditBackup" class="w-full max-w-[calc(100%-50px)] text-sm h-8" />
<ui-btn v-if="canEditBackup" small :loading="savingBackupPath" color="success" type="submit" class="h-8">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn v-if="canEditBackup" small :loading="savingBackupPath" color="bg-success" type="submit" class="h-8">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn small :disabled="savingBackupPath" type="button" class="h-8" @click="cancelEditBackupPath">{{ $strings.ButtonCancel }}</ui-btn>
</form>
<p class="text-sm text-warning/80 pt-1">{{ canEditBackup ? $strings.MessageBackupsLocationEditNote : $strings.MessageBackupsLocationNoEditNote }}</p>
@ -35,7 +35,7 @@
<div class="flex items-center pl-0 sm:pl-6 mb-2">
<span class="material-symbols text-xl sm:text-2xl text-black-50 mr-2">schedule</span>
<div class="w-32 min-w-32 sm:w-40 sm:min-w-40">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span>
<span class="text-white/60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span>
</div>
<div class="text-gray-100 text-sm sm:text-base">{{ scheduleDescription }}</div>
<button class="ml-2 text-black-50 hover:text-yellow-500 inline-flex" type="button" @click="showCronBuilder = !showCronBuilder">
@ -46,7 +46,7 @@
<div v-if="nextBackupDate" class="flex items-center pl-0 sm:pl-6 py-0.5">
<span class="material-symbols text-xl sm:text-2xl text-black-50 mr-2">event</span>
<div class="w-32 min-w-32 sm:w-40 sm:min-w-40">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span>
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span>
</div>
<div class="text-gray-100 text-sm sm:text-base">{{ nextBackupDate }}</div>
</div>
@ -122,7 +122,7 @@ export default {
},
scheduleDescription() {
if (!this.cronExpression) return ''
const parsed = this.$parseCronExpression(this.cronExpression)
const parsed = this.$parseCronExpression(this.cronExpression, this)
return parsed ? parsed.description : `${this.$strings.LabelCustomCronExpression} ${this.cronExpression}`
},
nextBackupDate() {

View file

@ -71,16 +71,16 @@
</div>
</form>
<div v-show="loading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-25 flex items-center justify-center">
<div v-show="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 flex items-center justify-center">
<ui-loading-indicator />
</div>
</app-settings-content>
<app-settings-content :header-text="$strings.HeaderEreaderDevices" :description="$strings.MessageEreaderDevices">
<template #header-items>
<div class="flex-grow" />
<div class="grow" />
<ui-btn color="primary" small @click="addNewDeviceClick">{{ $strings.ButtonAddDevice }}</ui-btn>
<ui-btn color="bg-primary" small @click="addNewDeviceClick">{{ $strings.ButtonAddDevice }}</ui-btn>
</template>
<table v-if="existingEReaderDevices.length" class="tracksTable mt-4">

View file

@ -38,7 +38,7 @@
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="sortingPrefixesUpdated" :disabled="savingPrefixes" />
<div class="flex justify-end py-1">
<ui-btn v-if="hasPrefixesChanged" color="success" :loading="savingPrefixes" small @click="updateSortingPrefixes">Save</ui-btn>
<ui-btn v-if="hasPrefixesChanged" color="bg-success" :loading="savingPrefixes" small @click="updateSortingPrefixes">Save</ui-btn>
</div>
</div>
@ -64,10 +64,10 @@
<span class="material-symbols icon-text">info</span>
</p>
</ui-tooltip>
<div class="flex-grow" />
<div class="grow" />
</div>
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" :label="$strings.LabelCoverProvider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
</div>
<div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">
@ -130,14 +130,14 @@
</ui-tooltip>
</div>
<div class="flex-grow py-2">
<div class="grow py-2">
<ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
<p class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelExample }}: {{ dateExample }}</p>
<p class="text-xs ml-1 text-white/60">{{ $strings.LabelExample }}: {{ dateExample }}</p>
</div>
<div class="flex-grow py-2">
<div class="grow py-2">
<ui-dropdown :label="$strings.LabelSettingsTimeFormat" v-model="newServerSettings.timeFormat" :items="timeFormats" small class="max-w-52" @input="(val) => updateSettingsKey('timeFormat', val)" />
<p class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelExample }}: {{ timeExample }}</p>
<p class="text-xs ml-1 text-white/60">{{ $strings.LabelExample }}: {{ timeExample }}</p>
</div>
<div class="py-2">
@ -164,16 +164,16 @@
</div>
</app-settings-content>
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
<div class="h-0.5 bg-primary/30 w-full" />
<div class="flex items-center py-4">
<div class="flex-grow" />
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>
<div class="grow" />
<ui-btn color="bg-bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn>
<ui-btn color="bg-bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>
</div>
<div class="flex items-center py-4">
<div class="flex-grow" />
<div class="grow" />
<p class="pr-2 text-sm text-yellow-400">
{{ $strings.MessageReportBugsAndContribute }}
<a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>
@ -206,7 +206,7 @@
</a>
</div>
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
<div class="h-0.5 bg-primary/30 w-full" />
<!-- confirm cache purge dialog -->
<prompt-dialog v-model="showConfirmPurgeCache" :width="675">
@ -214,9 +214,9 @@
<p class="text-error font-semibold">{{ $strings.MessageImportantNotice }}</p>
<p class="my-8 text-center" v-html="$strings.MessageConfirmPurgeCache" />
<div class="flex px-1 items-center">
<ui-btn color="primary" @click="showConfirmPurgeCache = false">{{ $strings.ButtonNevermind }}</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" @click="confirmPurge">{{ $strings.ButtonYes }}</ui-btn>
<ui-btn color="bg-primary" @click="showConfirmPurgeCache = false">{{ $strings.ButtonNevermind }}</ui-btn>
<div class="grow" />
<ui-btn color="bg-success" @click="confirmPurge">{{ $strings.ButtonYes }}</ui-btn>
</div>
</div>
</prompt-dialog>

View file

@ -2,7 +2,7 @@
<div class="relative">
<app-settings-content :header-text="$strings.HeaderCustomMetadataProviders">
<template #header-prefix>
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center mr-2">
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white/10 text-center mr-2">
<span class="material-symbols text-2xl">arrow_back</span>
</nuxt-link>
</template>
@ -12,9 +12,9 @@
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
<div class="flex-grow" />
<div class="grow" />
<ui-btn color="primary" small @click="setShowAddModal">{{ $strings.ButtonAdd }}</ui-btn>
<ui-btn color="bg-primary" small @click="setShowAddModal">{{ $strings.ButtonAdd }}</ui-btn>
</template>
<tables-custom-metadata-provider-table :providers="providers" :processing.sync="processing" class="pt-2" @removed="providerRemoved" />

View file

@ -1,7 +1,7 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8 relative" style="min-height: 200px">
<div class="bg-bg rounded-md shadow-lg border border-white/5 p-4 mb-8 relative" style="min-height: 200px">
<div class="flex items-center mb-4">
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center">
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white/10 text-center">
<span class="material-symbols text-2xl">arrow_back</span>
</nuxt-link>
@ -15,13 +15,13 @@
<div :key="genre" class="w-full p-2 flex items-center text-gray-400 hover:text-white" :class="{ 'bg-primary/20': index % 2 === 0 }">
<p v-if="editingGenre !== genre" class="text-sm md:text-base text-gray-100">{{ genre }}</p>
<ui-text-input v-else v-model="newGenreName" />
<div class="flex-grow" />
<div class="grow" />
<template v-if="editingGenre !== genre">
<ui-icon-btn v-if="editingGenre !== genre" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editClick(genre)" />
<ui-icon-btn v-if="editingGenre !== genre" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeClick(genre)" />
</template>
<template v-else>
<ui-btn color="success" small class="mx-2" @click.stop="saveClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn color="bg-success" small class="mx-2" @click.stop="saveClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
</template>
</div>

View file

@ -1,19 +1,19 @@
<template>
<div>
<app-settings-content :header-text="$strings.HeaderItemMetadataUtils">
<nuxt-link to="/config/item-metadata-utils/tags" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 mt-6 mb-2">
<nuxt-link to="/config/item-metadata-utils/tags" class="block w-full rounded-sm bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 mt-6 mb-2">
<div class="flex justify-between">
<p>{{ $strings.HeaderManageTags }}</p>
<span class="material-symbols">arrow_forward</span>
</div>
</nuxt-link>
<nuxt-link to="/config/item-metadata-utils/genres" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
<nuxt-link to="/config/item-metadata-utils/genres" class="block w-full rounded-sm bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
<div class="flex justify-between">
<p>{{ $strings.HeaderManageGenres }}</p>
<span class="material-symbols">arrow_forward</span>
</div>
</nuxt-link>
<nuxt-link to="/config/item-metadata-utils/custom-metadata-providers" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
<nuxt-link to="/config/item-metadata-utils/custom-metadata-providers" class="block w-full rounded-sm bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
<div class="flex justify-between">
<p>{{ $strings.HeaderCustomMetadataProviders }}</p>
<span class="material-symbols">arrow_forward</span>

View file

@ -1,7 +1,7 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8 relative" style="min-height: 200px">
<div class="bg-bg rounded-md shadow-lg border border-white/5 p-4 mb-8 relative" style="min-height: 200px">
<div class="flex items-center mb-4">
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center">
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white/10 text-center">
<span class="material-symbols text-2xl">arrow_back</span>
</nuxt-link>
@ -15,13 +15,13 @@
<div :key="tag" class="w-full p-2 flex items-center text-gray-400 hover:text-white" :class="{ 'bg-primary/20': index % 2 === 0 }">
<p v-if="editingTag !== tag" class="text-sm md:text-base text-gray-100">{{ tag }}</p>
<ui-text-input v-else v-model="newTagName" />
<div class="flex-grow" />
<div class="grow" />
<template v-if="editingTag !== tag">
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
</template>
<template v-else>
<ui-btn color="success" small class="mx-2" @click.stop="saveTagClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn color="bg-success" small class="mx-2" @click.stop="saveTagClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
</template>
</div>

View file

@ -8,9 +8,9 @@
</a>
</ui-tooltip>
<div class="flex-grow" />
<div class="grow" />
<ui-btn color="primary" small @click="setShowLibraryModal()">{{ $strings.ButtonAddLibrary }}</ui-btn>
<ui-btn color="bg-primary" small @click="setShowLibraryModal()">{{ $strings.ButtonAddLibrary }}</ui-btn>
</template>
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" class="pt-2" />
</app-settings-content>
@ -40,4 +40,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View file

@ -18,9 +18,9 @@
<div class="relative">
<div ref="container" id="log-container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="min-height: 550px">
<template v-for="(log, index) in logs">
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm" :class="`${bgColors[log.level]}`">
<p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
<p class="font-semibold w-12 text-right text-sm" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
<p class="font-semibold w-12 text-right text-sm" :class="`${textColors[log.level]}`">{{ log.levelName }}</p>
<p class="px-4 logmessage">{{ log.message }}</p>
</div>
</template>
@ -47,7 +47,8 @@ export default {
searchTimeout: null,
searchText: null,
newServerSettings: {},
logColors: ['yellow-200', 'gray-400', 'info', 'warning', 'error', 'red-800', 'blue-400'],
textColors: ['text-yellow-200', 'text-gray-400', 'text-info', 'text-warning', 'text-error', 'text-red-800', 'text-blue-400'],
bgColors: ['bg-yellow-200/10', 'bg-gray-400/10', 'bg-info/10', 'bg-warning/10', 'bg-error/10', 'bg-red-800/10', 'bg-blue-400/10'],
loadedLogs: []
}
},

View file

@ -25,11 +25,11 @@
</div>
</form>
<div class="w-full h-px bg-white bg-opacity-10 my-6" />
<div class="w-full h-px bg-white/10 my-6" />
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold">{{ $strings.HeaderNotifications }}</h2>
<ui-btn small color="success" class="flex items-center" @click="clickCreate">{{ $strings.ButtonCreate }} <span class="material-symbols text-lg pl-2">add</span></ui-btn>
<ui-btn small color="bg-success" class="flex items-center" @click="clickCreate">{{ $strings.ButtonCreate }} <span class="material-symbols text-lg pl-2">add</span></ui-btn>
</div>
<div v-if="!notifications.length" class="flex justify-center text-center">

View file

@ -11,14 +11,14 @@
<div v-if="feeds.length" class="block max-w-full pt-2">
<table class="rssFeedsTable text-xs">
<tr class="bg-primary bg-opacity-40 h-12">
<tr class="bg-primary/40 h-12">
<th class="w-16 min-w-16"></th>
<th class="w-48 max-w-64 min-w-24 text-left truncate">{{ $strings.LabelTitle }}</th>
<th class="w-48 min-w-24 text-left hidden xl:table-cell">{{ $strings.LabelSlug }}</th>
<th class="w-24 min-w-16 text-left hidden md:table-cell">{{ $strings.LabelType }}</th>
<th class="w-16 min-w-16 text-center">{{ $strings.HeaderEpisodes }}</th>
<th class="w-16 min-w-16 text-center hidden lg:table-cell">{{ $strings.LabelRSSFeedPreventIndexing }}</th>
<th class="w-48 min-w-24 flex-grow hidden md:table-cell">{{ $strings.LabelLastUpdate }}</th>
<th class="w-48 min-w-24 grow hidden md:table-cell">{{ $strings.LabelLastUpdate }}</th>
<th class="w-16 text-left"></th>
</tr>
@ -32,7 +32,7 @@
<p class="truncate">{{ feed.meta.title }}</p>
</td>
<!-- -->
<td class="hidden xl:table-cell">
<td class="hidden xl:table-cell max-w-48">
<p class="truncate">{{ feed.slug }}</p>
</td>
<!-- -->
@ -57,7 +57,7 @@
</td>
<!-- -->
<td class="text-center">
<ui-icon-btn icon="delete" class="mx-0.5" :size="7" bg-color="error" outlined @click.stop="deleteFeedClick(feed)" />
<ui-icon-btn icon="delete" class="mx-0.5 text-white/70" borderless :size="7" iconFontSize="1.25rem" outlined @click.stop="deleteFeedClick(feed)" />
</td>
</tr>
</table>
@ -78,10 +78,10 @@ export default {
},
computed: {
dateFormat() {
return this.$store.state.serverSettings.dateFormat
return this.$store.getters['getServerSetting']('dateFormat')
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
return this.$store.getters['getServerSetting']('timeFormat')
}
},
methods: {
@ -137,7 +137,16 @@ export default {
this.$toast.error(this.$strings.ToastFailedToLoadData)
return
}
this.feeds = data.feeds
this.feeds = data.feeds.map((feed) => ({
...feed,
episodes: [...feed.episodes].sort((a, b) => {
if (!a.pubDate) return 1 // null dates sort to end
if (!b.pubDate) return -1
const dateA = new Date(a.pubDate)
const dateB = new Date(b.pubDate)
return dateA - dateB
})
}))
},
init() {
this.loadFeeds()

View file

@ -7,18 +7,18 @@
<div v-if="listeningSessions.length" class="block max-w-full relative">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<tr class="bg-primary/40">
<th class="w-6 min-w-6 text-left hidden md:table-cell h-11">
<ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" />
</th>
<th v-if="numSelected" class="flex-grow text-left" :colspan="7">
<th v-if="numSelected" class="grow text-left" :colspan="7">
<div class="flex items-center">
<p>{{ $getString('MessageSelected', [numSelected]) }}</p>
<div class="flex-grow" />
<ui-btn small color="error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
<div class="grow" />
<ui-btn small color="bg-error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
</div>
</th>
<th v-if="!numSelected" class="flex-grow sm:flex-grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
<th v-if="!numSelected" class="grow sm:grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
<div class="inline-flex items-center">
{{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
@ -40,7 +40,7 @@
{{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
</th>
<th v-if="!numSelected" class="flex-grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
<th v-if="!numSelected" class="grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
<div class="inline-flex items-center">
{{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
@ -53,7 +53,7 @@
<!-- overlay of the checkbox so that the entire box is clickable -->
<div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" />
</td>
<td class="py-1 flex-grow sm:flex-grow-0 sm:w-48 sm:max-w-48">
<td class="py-1 grow sm:grow-0 sm:w-48 sm:max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td>
@ -82,7 +82,7 @@
</table>
<!-- table bottom options -->
<div class="flex items-center my-2">
<div class="flex-grow" />
<div class="grow" />
<div class="hidden sm:inline-flex items-center">
<p class="text-sm whitespace-nowrap">{{ $strings.LabelRowsPerPage }}</p>
<ui-dropdown v-model="itemsPerPage" :items="itemsPerPageOptions" small class="w-24 mx-2" @input="updatedItemsPerPage" />
@ -98,7 +98,7 @@
<ui-loading-indicator />
</div>
</div>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
<p v-else class="text-white/50">{{ $strings.MessageNoListeningSessions }}</p>
<div v-if="openListeningSessions.length" class="w-full my-8 h-px bg-white/10" />
@ -106,14 +106,14 @@
<p v-if="openListeningSessions.length" class="text-lg my-4">{{ $strings.HeaderOpenListeningSessions }}</p>
<div v-if="openListeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<tr class="bg-primary/40">
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
<th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
</tr>
<tr v-for="session in openListeningSessions" :key="`open-${session.id}`" class="cursor-pointer" @click="showSession(session)">
@ -151,13 +151,13 @@
<p v-if="openShareListeningSessions.length" class="text-lg my-4">Open Share Listening Sessions</p>
<div v-if="openShareListeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<tr class="bg-primary/40">
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
<th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
</tr>
<tr v-for="session in openShareListeningSessions" :key="`open-${session.id}`" class="cursor-pointer" @click="showSession(session)">
@ -250,10 +250,10 @@ export default {
return user?.username || null
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
return this.$store.getters['getServerSetting']('dateFormat')
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
return this.$store.getters['getServerSetting']('timeFormat')
},
numSelected() {
return this.listeningSessions.filter((s) => s.selected).length

View file

@ -3,18 +3,15 @@
<!-- Year in review banner shown at the top in December and January -->
<stats-year-in-review-banner v-if="showYearInReviewBanner" />
<app-settings-content :header-text="$strings.HeaderYourStats" class="!mb-4">
<app-settings-content :header-text="$strings.HeaderYourStats" class="mb-4!">
<div class="flex justify-center">
<div class="flex p-2">
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
/>
</svg>
<div class="hidden sm:block">
<span class="hidden sm:block material-symbols text-5xl lg:text-6xl">auto_stories</span>
</div>
<div class="px-3">
<p class="text-4xl md:text-5xl font-bold">{{ $formatNumber(userItemsFinished.length) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsFinished }}</p>
<p class="text-xs md:text-sm text-white/80">{{ $strings.LabelStatsItemsFinished }}</p>
</div>
</div>
@ -24,7 +21,7 @@
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ $formatNumber(totalDaysListened) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsDaysListened }}</p>
<p class="text-xs md:text-sm text-white/80">{{ $strings.LabelStatsDaysListened }}</p>
</div>
</div>
@ -34,7 +31,7 @@
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ $formatNumber(totalMinutesListening) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsMinutesListening }}</p>
<p class="text-xs md:text-sm text-white/80">{{ $strings.LabelStatsMinutesListening }}</p>
</div>
</div>
</div>
@ -43,19 +40,19 @@
<div class="w-80 my-6 mx-auto">
<div class="flex mb-4 items-center">
<h1 class="text-2xl">{{ $strings.HeaderStatsRecentSessions }}</h1>
<div class="flex-grow" />
<div class="grow" />
<ui-btn v-if="isAdminOrUp" :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
</div>
<p v-if="!mostRecentListeningSessions.length">{{ $strings.MessageNoListeningSessions }}</p>
<template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5">
<div class="flex items-center mb-1">
<p class="text-sm text-white text-opacity-70 w-8">{{ index + 1 }}.&nbsp;</p>
<p class="text-sm text-white/70 w-8">{{ index + 1 }}.&nbsp;</p>
<div class="w-56">
<p class="text-sm text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
<p class="text-sm text-white/80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
<p class="text-xs text-white/50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
</div>
<div class="flex-grow" />
<div class="grow" />
<div class="w-18 text-right">
<p class="text-sm font-bold">{{ $elapsedPretty(item.timeListening) }}</p>
</div>

View file

@ -1,7 +1,7 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
<nuxt-link to="/config/users" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full px-2 sm:px-0">
<div class="bg-bg rounded-md shadow-lg border border-white/5 p-0 sm:p-4 mb-8">
<nuxt-link to="/config/users" class="text-white/70 hover:text-white/100 hover:bg-white/5 cursor-pointer rounded-full px-2 sm:px-0">
<div class="flex items-center">
<div class="h-10 w-10 flex items-center justify-center">
<span class="material-symbols text-2xl">arrow_back</span>
@ -14,15 +14,11 @@
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div v-if="userToken" class="flex text-xs mt-4">
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly />
<div class="px-1 mt-8 cursor-pointer" @click="copyToClipboard(userToken)">
<span class="material-symbols pl-2 text-base">content_copy</span>
</div>
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly show-copy />
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="w-full h-px bg-white/10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderListeningStats }}</h1>
<h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningStats }}</h1>
<div class="flex items-center">
<p class="text-sm text-gray-300">{{ listeningSessions.total }} {{ $strings.HeaderListeningSessions }}</p>
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs mx-2" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
@ -37,18 +33,18 @@
</p>
<div v-if="latestSession" class="mt-4">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderLastListeningSession }}</h1>
<h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderLastListeningSession }}</h1>
<p class="text-sm text-gray-300">
<strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class="font-mono text-base">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span>
</p>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="w-full h-px bg-white/10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1>
<h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1>
<table v-if="mediaProgress.length" class="userAudiobooksTable">
<tr class="bg-primary bg-opacity-40">
<tr class="bg-primary/40">
<th class="w-16 text-left">{{ $strings.LabelItem }}</th>
<th class="text-left"></th>
<th class="w-32">{{ $strings.LabelProgress }}</th>
@ -62,7 +58,7 @@
</td>
<td>
<p>{{ item.displayTitle || 'Unknown' }}</p>
<p v-if="item.displaySubtitle" class="text-white text-opacity-50 text-sm font-sans">{{ item.displaySubtitle }}</p>
<p v-if="item.displaySubtitle" class="text-white/50 text-sm font-sans">{{ item.displaySubtitle }}</p>
</td>
<td class="text-center">
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
@ -79,7 +75,7 @@
</td>
</tr>
</table>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoMediaProgress }}</p>
<p v-else class="text-white/50">{{ $strings.MessageNoMediaProgress }}</p>
</div>
</div>
</div>
@ -133,16 +129,13 @@ export default {
return this.listeningSessions.sessions[0]
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
return this.$store.getters['getServerSetting']('dateFormat')
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
return this.$store.getters['getServerSetting']('timeFormat')
}
},
methods: {
copyToClipboard(str) {
this.$copyToClipboard(str, this)
},
async init() {
this.listeningSessions = await this.$axios
.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`)

View file

@ -1,7 +1,7 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
<nuxt-link :to="`/config/users/${user.id}`" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full px-2 sm:px-0">
<div class="bg-bg rounded-md shadow-lg border border-white/5 p-0 sm:p-4 mb-8">
<nuxt-link :to="`/config/users/${user.id}`" class="text-white/70 hover:text-white/100 hover:bg-white/5 cursor-pointer rounded-full px-2 sm:px-0">
<div class="flex items-center">
<div class="h-10 w-10 flex items-center justify-center">
<span class="material-symbols text-2xl">arrow_back</span>
@ -14,19 +14,19 @@
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="w-full h-px bg-white/10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderListeningSessions }}</h1>
<h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningSessions }}</h1>
<div v-if="listeningSessions.length">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<tr class="bg-primary/40">
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
<th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1 max-w-48">
@ -58,7 +58,7 @@
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
<p v-else class="text-white/50">No sessions yet...</p>
</div>
</div>
@ -98,10 +98,10 @@ export default {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
return this.$store.getters['getServerSetting']('dateFormat')
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
return this.$store.getters['getServerSetting']('timeFormat')
}
},
methods: {

View file

@ -12,9 +12,9 @@
</a>
</ui-tooltip>
<div class="flex-grow" />
<div class="grow" />
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
<ui-btn color="bg-primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
</template>
<tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />

View file

@ -7,7 +7,7 @@
<covers-book-cover class="relative group-hover:brightness-75 transition cursor-pointer" expand-on-click :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- Item Progress Bar -->
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-xs z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
<!-- Item Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
@ -17,11 +17,11 @@
</button>
</div>
<button class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" :aria-label="$strings.ButtonEdit" @click="showEditCover">edit</button>
<button class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white/75 hover:text-white/100 hover:scale-110 transform duration-200 pointer-events-auto" :aria-label="$strings.ButtonEdit" @click="showEditCover">edit</button>
</div>
</div>
</div>
<div class="flex-grow px-2 py-6 lg:py-0 md:px-10">
<div class="grow px-2 py-6 lg:py-0 md:px-10">
<div class="flex justify-center">
<div class="mb-4">
<h1 class="text-2xl md:text-3xl font-semibold">
@ -40,18 +40,18 @@
</template>
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden text-ellipsis">
{{ $getString('LabelByAuthor', ['']) }}<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
<content-library-item-details :library-item="libraryItem" />
</div>
<div class="hidden md:block flex-grow" />
<div class="hidden md:block grow" />
</div>
<!-- Podcast episode downloads queue -->
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info/40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div class="flex items-center">
<p class="text-sm py-1">{{ $getString('MessageEpisodesQueuedForDownload', [episodeDownloadsQueued.length]) }}</p>
@ -60,7 +60,7 @@
</div>
<!-- Podcast episodes currently downloading -->
<div v-if="episodesDownloading.length" class="px-4 py-2 mt-4 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div v-if="episodesDownloading.length" class="px-4 py-2 mt-4 bg-success/20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
<widgets-loading-spinner />
<p class="text-sm py-1 pl-4">{{ $strings.MessageDownloadingEpisode }} "{{ episode.episodeDisplayTitle }}"</p>
@ -81,25 +81,25 @@
<!-- Icon buttons -->
<div class="flex items-center justify-center md:justify-start pt-4">
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem">
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="bg-success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem">
<span v-show="!isStreaming" class="material-symbols fill text-2xl -ml-2 pr-1 text-white">&#xe037;</span>
{{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<ui-btn v-else-if="isMissing || isInvalid" color="bg-error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
</ui-btn>
<ui-tooltip v-if="showQueueBtn" :text="isQueued ? $strings.ButtonQueueRemoveItem : $strings.ButtonQueueAddItem" direction="top">
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" :bg-color="isQueued ? 'primary' : 'success bg-opacity-60'" class="mx-0.5" :class="isQueued ? 'text-success' : ''" @click="queueBtnClick" />
</ui-tooltip>
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<ui-btn v-if="showReadButton" color="bg-info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-symbols text-2xl -ml-2 pr-2 text-white" aria-hidden="true">auto_stories</span>
{{ $strings.ButtonRead }}
</ui-btn>
<ui-tooltip v-if="showQueueBtn" :text="isQueued ? $strings.ButtonQueueRemoveItem : $strings.ButtonQueueAddItem" direction="top">
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" :bg-color="isQueued ? 'bg-primary' : 'bg-success/60'" class="mx-0.5" :class="isQueued ? 'text-success' : ''" @click="queueBtnClick" />
</ui-tooltip>
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
<ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" :aria-label="$strings.LabelEdit" @click="editClick" />
</ui-tooltip>
@ -123,7 +123,8 @@
</div>
<div class="my-4 w-full">
<p ref="description" id="item-description" dir="auto" class="text-base text-gray-100 whitespace-pre-line mb-1" :class="{ 'show-full': showFullDescription }">{{ description }}</p>
<div ref="description" id="item-description" dir="auto" role="paragraph" class="default-style less-spacing text-base text-gray-100 whitespace-pre-line mb-1" :class="{ 'show-full': showFullDescription }" v-html="description" />
<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' : '&#xe313;'" /></button>
</div>
@ -131,7 +132,7 @@
<tables-tracks-table v-if="tracks.length" :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
<tables-podcast-lazy-episodes-table v-if="isPodcast" :library-item="libraryItem" />
<tables-podcast-lazy-episodes-table ref="episodesTable" v-if="isPodcast" :library-item="libraryItem" />
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
@ -140,8 +141,8 @@
</div>
</div>
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
<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" />
</div>
</template>
@ -192,7 +193,7 @@ export default {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
return this.$store.getters['getServerSetting']('dateFormat')
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
@ -502,7 +503,7 @@ export default {
toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.progressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${this.title}" as finished?`,
message: this.$getString('MessageConfirmMarkItemFinished', [this.title]),
callback: (confirmed) => {
if (confirmed) {
this.toggleFinished(true)
@ -533,13 +534,15 @@ export default {
let episodeId = null
const queueItems = []
if (this.isPodcast) {
const episodesInListeningOrder = this.podcastEpisodes.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
// Uses the sorting and filtering from the episode table component
const episodesInListeningOrder = this.$refs.episodesTable?.episodesList || []
// Find most recent episode unplayed
let episodeIndex = episodesInListeningOrder.findLastIndex((ep) => {
// 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
@ -598,19 +601,31 @@ export default {
},
clearProgressClick() {
if (!this.userMediaProgress) return
if (confirm(this.$strings.MessageConfirmResetProgress)) {
this.resettingProgress = true
this.$axios
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
.then(() => {
console.log('Progress reset complete')
this.resettingProgress = false
})
.catch((error) => {
console.error('Progress reset failed', error)
this.resettingProgress = false
})
const payload = {
message: this.$strings.MessageConfirmResetProgress,
callback: (confirmed) => {
if (confirmed) {
this.clearProgress()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
clearProgress() {
this.resettingProgress = true
this.$axios
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
.then(() => {
console.log('Progress reset complete')
})
.catch((error) => {
console.error('Progress reset failed', error)
})
.finally(() => {
this.resettingProgress = false
})
},
clickRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
@ -645,13 +660,11 @@ export default {
},
rssFeedOpen(data) {
if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Opened', data)
this.rssFeed = data
}
},
rssFeedClosed(data) {
if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Closed', data)
this.rssFeed = null
}
},
@ -804,9 +817,19 @@ export default {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
max-height: 6.25rem;
transition: all 0.3s ease-in-out;
max-height: calc(6 * 1lh);
}
/* Safari-specific fix for the description clamping */
@supports (-webkit-touch-callout: none) {
#item-description {
position: relative;
display: block;
overflow: hidden;
max-height: calc(6 * 1lh);
}
}
#item-description.show-full {
-webkit-line-clamp: unset;
max-height: 999rem;

View file

@ -10,7 +10,7 @@
</tr>
<tr v-for="narrator in narrators" :key="narrator.id">
<td>
<p v-if="selectedNarrator?.id !== narrator.id" class="text-sm md:text-base text-gray-100">{{ narrator.name }}</p>
<nuxt-link v-if="selectedNarrator?.id !== narrator.id" :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${narrator.id}`" class="text-sm md:text-base text-gray-100 hover:underline">{{ narrator.name }}</nuxt-link>
<form v-else @submit.prevent="saveClick">
<ui-text-input v-model="newNarratorName" />
</form>
@ -25,7 +25,7 @@
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeClick(narrator)" />
</template>
<template v-else>
<ui-btn color="success" small class="mr-2" @click.stop="saveClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn color="bg-success" small class="mr-2" @click.stop="saveClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
</template>
</div>

View file

@ -9,11 +9,11 @@
<template v-for="episode in episodesDownloading">
<div :key="episode.id" class="flex py-5 relative">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="hidden md:block" />
<div class="flex-grow pl-4 max-w-2xl">
<div class="grow pl-4 max-w-2xl">
<!-- mobile -->
<div class="flex md:hidden mb-2">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
<div class="flex-grow px-2">
<div class="grow px-2">
<div class="flex items-center">
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
<widgets-explicit-indicator v-if="episode.podcastExplicit" />

View file

@ -9,11 +9,11 @@
<template v-for="(episode, index) in episodesMapped">
<div :key="episode.id" class="flex py-5 cursor-pointer relative" @click.stop="clickEpisode(episode)">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId, episode.updatedAt)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="hidden md:block" />
<div class="flex-grow pl-4 max-w-2xl">
<div class="grow pl-4 max-w-2xl">
<!-- mobile -->
<div class="flex md:hidden mb-2">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId, episode.updatedAt)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
<div class="flex-grow px-2">
<div class="grow px-2">
<div class="flex items-center">
<div class="flex" @click.stop>
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
@ -48,7 +48,7 @@
<p dir="auto" class="text-sm text-gray-200 mb-4 line-clamp-4" v-html="episode.subtitle || episode.description" />
<div class="flex items-center">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress?.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
<button class="h-8 px-4 border border-white/20 hover:bg-white/10 rounded-full flex items-center justify-center cursor-pointer focus:outline-hidden" :class="episode.progress?.isFinished ? 'text-white/40' : ''" @click.stop="playClick(episode)">
<span v-if="episodeIdStreaming === episode.id" class="material-symbols text-2xl" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<span v-else class="material-symbols fill text-2xl text-success">play_arrow</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p>
@ -70,7 +70,7 @@
<div v-if="episode.progress" class="absolute bottom-0 left-0 h-0.5 pointer-events-none bg-warning" :style="{ width: episode.progress.progress * 100 + '%' }" />
</div>
<div :key="index" v-if="index !== recentEpisodes.length" class="w-full h-px bg-white bg-opacity-10" />
<div :key="index" v-if="index !== recentEpisodes.length" class="w-full h-px bg-white/10" />
</template>
</div>
</div>
@ -141,7 +141,7 @@ export default {
return episodeIds
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
return this.$store.getters['getServerSetting']('dateFormat')
}
},
methods: {
@ -155,7 +155,7 @@ export default {
const itemProgressPercent = episode.progress?.progress || 0
if (!isFinished && itemProgressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${episode.title}" as finished?`,
message: this.$getString('MessageConfirmMarkItemFinished', [episode.title]),
callback: (confirmed) => {
if (confirmed) {
this.toggleEpisodeFinished(episode, true)
@ -249,7 +249,7 @@ export default {
},
async loadRecentEpisodes(page = 0) {
this.processing = true
const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=50&page=${page}`).catch((error) => {
console.error('Failed to get recent episodes', error)
this.$toast.error(this.$strings.ToastFailedToLoadData)
return null

View file

@ -4,8 +4,8 @@
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
<div class="w-full max-w-4xl mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="searchInput" type="search" :disabled="processing" :placeholder="$strings.MessagePodcastSearchField" class="flex-grow mr-2 text-sm md:text-base" />
<form @submit.prevent="submit" class="flex grow">
<ui-text-input v-model="searchInput" type="search" :disabled="processing" :placeholder="$strings.MessagePodcastSearchField" class="grow mr-2 text-sm md:text-base" />
<ui-btn type="submit" :disabled="processing" class="hidden md:block">{{ $strings.ButtonSubmit }}</ui-btn>
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>{{ $strings.ButtonSubmit }}</ui-btn>
</form>
@ -14,11 +14,11 @@
<div class="w-full max-w-3xl mx-auto py-4">
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">{{ $strings.MessageNoPodcastsFound }}</p>
<template v-for="podcast in results">
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
<div :key="podcast.id" class="flex p-1 hover:bg-primary/25 cursor-pointer" @click="selectPodcast(podcast)">
<div class="w-20 min-w-20 h-20 md:w-24 md:min-w-24 md:h-24 bg-primary">
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
</div>
<div class="flex-grow pl-4 max-w-2xl">
<div class="grow pl-4 max-w-2xl">
<div class="flex items-center">
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<widgets-explicit-indicator v-if="podcast.explicit" />
@ -32,7 +32,7 @@
</template>
</div>
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black/25 z-40">
<ui-loading-indicator />
</div>
</div>

View file

@ -22,6 +22,7 @@ export default {
})
results = {
podcasts: results?.podcast || [],
episodes: results?.episodes || [],
books: results?.book || [],
authors: results?.authors || [],
series: results?.series || [],
@ -61,6 +62,7 @@ export default {
})
this.results = {
podcasts: results?.podcast || [],
episodes: results?.episodes || [],
books: results?.book || [],
authors: results?.authors || [],
series: results?.series || [],

View file

@ -13,12 +13,12 @@
<div :key="genre.genre" class="w-full py-2">
<div class="flex items-end mb-1">
<p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }}&nbsp;%</p>
<div class="flex-grow" />
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base text-white text-opacity-70 hover:underline">
<div class="grow" />
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base text-white/70 hover:underline">
{{ genre.genre }}
</nuxt-link>
</div>
<div class="w-full rounded-full h-3 bg-primary bg-opacity-50 overflow-hidden">
<div class="w-full rounded-full h-3 bg-primary/50 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * genre.count) / totalItems) + '%' }" />
</div>
</div>
@ -30,10 +30,10 @@
<template v-for="(author, index) in top10Authors">
<div :key="author.id" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm text-white text-opacity-70 w-36 pr-2 truncate">
<p class="text-sm text-white/70 w-36 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="grow rounded-full h-2.5 bg-primary/0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
</div>
<div class="w-4 ml-3">
@ -49,10 +49,10 @@
<template v-for="(ab, index) in top10LongestItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
<p class="text-sm text-white/70 w-44 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="grow rounded-full h-2.5 bg-primary/0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }" />
</div>
<div class="w-4 ml-3">
@ -68,10 +68,10 @@
<template v-for="(ab, index) in top10LargestItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
<p class="text-sm text-white/70 w-44 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="grow rounded-full h-2.5 bg-primary/0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }" />
</div>
<div class="w-4 ml-3">
@ -89,14 +89,16 @@
<script>
export default {
asyncData({ redirect, store }) {
async asyncData({ redirect, store, params }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
return
}
if (!store.state.libraries.currentLibraryId) {
return redirect('/config')
const libraryId = params.library
const library = await store.dispatch('libraries/fetch', libraryId)
if (!library) {
return redirect(`/oops?message=Library "${libraryId}" not found`)
}
return {}
},

View file

@ -8,12 +8,12 @@
</div>
<div class="relative z-10 w-full flex h-full items-center justify-center">
<div v-if="criticalError" class="w-full max-w-md rounded border border-error border-opacity-25 bg-error bg-opacity-10 p-4">
<div v-if="criticalError" class="w-full max-w-md rounded-sm border border-error/25 bg-error/10 p-4">
<p class="text-center text-lg font-semibold">{{ $strings.MessageServerCouldNotBeReached }}</p>
</div>
<div v-else-if="showInitScreen" class="w-full max-w-lg px-4 md:px-8 pb-8 pt-4">
<p class="text-3xl text-white text-center mb-4">Initial Server Setup</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<div class="w-full h-px bg-white/10 my-4" />
<form @submit.prevent="submitServerSetup">
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
@ -26,15 +26,15 @@
<ui-text-input-with-label v-model="MetadataPath" label="Metadata Path" disabled class="w-full mb-3 text-sm" />
<div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Initializing...' : $strings.ButtonSubmit }}</ui-btn>
<ui-btn type="submit" :disabled="processing" color="bg-primary" class="leading-none">{{ processing ? 'Initializing...' : $strings.ButtonSubmit }}</ui-btn>
</div>
</form>
</div>
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 lg:-mt-40">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4">
<div class="bg-bg rounded-md shadow-lg border border-white/5 p-4">
<p class="text-2xl font-semibold text-center text-white mb-4">{{ $strings.HeaderLogin }}</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<div class="w-full h-px bg-white/10 my-4" />
<p v-if="loginCustomMessage" class="py-2 default-style mb-2" v-html="loginCustomMessage"></p>
@ -47,14 +47,14 @@
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label>
<ui-text-input v-model.trim="password" type="password" :disabled="processing" class="w-full mb-3" inputName="password" />
<div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
<ui-btn type="submit" :disabled="processing" color="bg-primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
</div>
</form>
<div v-if="login_local && login_openid" class="w-full h-px bg-white bg-opacity-10 my-4" />
<div v-if="login_local && login_openid" class="w-full h-px bg-white/10 my-4" />
<div class="w-full flex py-3">
<a v-if="login_openid" :href="openidAuthUri" class="w-full abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none">
<a v-if="login_openid" :href="openidAuthUri" class="w-full abs-btn outline-hidden rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none">
{{ openIDButtonText }}
</a>
</div>

View file

@ -7,14 +7,14 @@
<covers-playlist-cover :items="playlistItems" :width="200" :height="200" />
</div>
</div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="grow px-2 py-6 md:py-0 md:px-10">
<div class="flex items-end flex-row flex-wrap md:flex-nowrap">
<h1 class="text-2xl md:text-3xl font-sans w-full md:w-fit mb-4 md:mb-0">
{{ playlistName }}
</h1>
<div class="flex-grow" />
<div class="grow" />
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay">
<ui-btn v-if="showPlayButton" :disabled="streaming" color="bg-success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay">
<span v-show="!streaming" class="material-symbols fill text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlayAll }}
</ui-btn>
@ -32,7 +32,7 @@
</div>
</div>
</div>
<div v-show="processingRemove" class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-40 flex items-center justify-center">
<div v-show="processingRemove" class="absolute top-0 left-0 w-full h-full z-10 bg-black/40 flex items-center justify-center">
<ui-loading-indicator />
</div>
</div>
@ -109,21 +109,31 @@ export default {
this.$store.commit('globals/setEditPlaylist', this.playlist)
},
removeClick() {
if (confirm(`Are you sure you want to remove playlist "${this.playlistName}"?`)) {
this.processingRemove = true
var playlistName = this.playlistName
this.$axios
.$delete(`/api/playlists/${this.playlist.id}`)
.then(() => {
this.processingRemove = false
this.$toast.success(`Playlist "${playlistName}" Removed`)
})
.catch((error) => {
console.error('Failed to remove playlist', error)
this.processingRemove = false
this.$toast.error(`Failed to remove playlist`)
})
const payload = {
message: this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]),
callback: (confirmed) => {
if (confirmed) {
this.removePlaylist()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
removePlaylist() {
this.processingRemove = true
this.$axios
.$delete(`/api/playlists/${this.playlist.id}`)
.then(() => {
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove playlist', error)
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.processingRemove = false
})
},
clickPlay() {
const queueItems = []

View file

@ -1,5 +1,5 @@
<template>
<div class="w-full h-dvh max-h-dvh overflow-hidden" :style="{ backgroundColor: coverRgb }">
<div class="w-full max-w-full h-dvh max-h-dvh overflow-hidden" :style="{ backgroundColor: coverRgb }">
<div class="w-screen h-screen absolute inset-0 pointer-events-none" style="background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)"></div>
<div class="absolute inset-0 w-screen h-dvh flex items-center justify-center z-10">
<div class="w-full p-2 sm:p-4 md:p-8">
@ -64,7 +64,7 @@ export default {
return this.mediaItemShare.playbackSession
},
coverUrl() {
if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
if (!this.playbackSession.coverPath) return this.$store.getters['globals/getPlaceholderCoverSrc']
return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`
},
downloadUrl() {
@ -335,8 +335,11 @@ export default {
}
},
resize() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
setTimeout(() => {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
}, 100)
},
playerError(error) {
console.error('Player error', error)

View file

@ -23,7 +23,7 @@
<span class="pl-1 material-symbols icon-text text-sm cursor-pointer">info</span>
</ui-tooltip>
<div class="flex-grow ml-4">
<div class="grow ml-4">
<ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" />
</div>
</div>
@ -33,7 +33,7 @@
</widgets-alert>
<!-- Picker display -->
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-4 md:px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white/20 px-4 md:px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary/40' : 'border-dashed'">
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : isIOS ? $strings.LabelUploaderDragAndDropFilesOnly : $strings.LabelUploaderDragAndDrop }}</p>
<p class="text-center text-sm my-5">{{ $strings.MessageOr }}</p>
<div class="w-full max-w-xl mx-auto">
@ -43,20 +43,19 @@
</div>
</div>
<div class="pt-8 text-center">
<p class="text-xs text-white text-opacity-50 font-mono mb-4">
<p class="text-xs text-white/50 font-mono mb-4">
<strong>{{ $strings.LabelSupportedFileTypes }}: </strong>{{ inputAccept.join(', ') }}
</p>
<p class="text-sm text-white text-opacity-70">
<p class="text-sm text-white/70">
<span v-if="!isIOS">{{ $strings.NoteUploaderFoldersWithMediaFiles }}</span> <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
</p>
</div>
</div>
<!-- Item list header -->
<div v-else class="w-full flex items-center pb-4 border-b border-white border-opacity-10">
<p class="text-lg">{{ items.length }} item{{ items.length === 1 ? '' : 's' }}</p>
<p v-if="ignoredFiles.length" class="text-lg">&nbsp;|&nbsp;{{ ignoredFiles.length }} file{{ ignoredFiles.length === 1 ? '' : 's' }} ignored</p>
<div class="flex-grow" />
<div v-else class="w-full flex items-center pb-4 border-b border-white/10">
<p class="text-lg lowercase">{{ items.length === 1 ? `1 ${$strings.LabelItem}` : $getString('LabelXItems', [items.length]) }}</p>
<div class="grow" />
<ui-btn :disabled="processing" small @click="reset">{{ $strings.ButtonReset }}</ui-btn>
</div>
@ -68,7 +67,7 @@
<div class="w-full pr-12">
<p class="text-base mb-1">{{ $strings.NoteUploaderUnsupportedFiles }}</p>
<tables-uploaded-files-table :files="ignoredFiles" :title="$strings.HeaderIgnoredFiles" class="text-white" />
<p class="text-xs text-white text-opacity-50 font-mono pt-1">
<p class="text-xs text-white/50 font-mono pt-1">
<strong>{{ $strings.LabelSupportedFileTypes }}: </strong>{{ inputAccept.join(', ') }}
</p>
</div>
@ -79,7 +78,7 @@
<!-- Upload/Reset btns -->
<div v-show="items.length" class="flex justify-end pb-8 pt-4">
<ui-btn v-if="!uploadFinished" color="success" :loading="processing" @click="submit">{{ $strings.ButtonUpload }}</ui-btn>
<ui-btn v-if="!uploadFinished" color="bg-success" :loading="processing" @click="submit">{{ $strings.ButtonUpload }}</ui-btn>
<ui-btn v-else @click="reset">{{ $strings.ButtonReset }}</ui-btn>
</div>
</div>
@ -317,9 +316,8 @@ export default {
.$post('/api/upload', form)
.then(() => true)
.catch((error) => {
console.error('Failed', error)
var errorMessage = error.response && error.response.data ? error.response.data : 'Oops, something went wrong...'
this.$toast.error(errorMessage)
console.error('Failed to upload item', error)
this.$toast.error(error.response?.data || 'Oops, something went wrong...')
return false
})
},
@ -361,12 +359,15 @@ export default {
// Check if path already exists before starting upload
// uploading fails if path already exists
for (const item of items) {
const filepath = Path.join(this.selectedFolder.fullPath, item.directory)
const exists = await this.$axios
.$post(`/api/filesystem/pathexists`, { filepath })
.$post(`/api/filesystem/pathexists`, { directory: item.directory, folderPath: this.selectedFolder.fullPath })
.then((data) => {
if (data.exists) {
this.$toast.error(`Filepath "${filepath}" already exists on server`)
if (data.libraryItemTitle) {
this.$toast.error(this.$getString('ToastUploaderItemExistsInSubdirectoryError', [data.libraryItemTitle]))
} else {
this.$toast.error(this.$getString('ToastUploaderFilepathExistsError', [Path.join(this.selectedFolder.fullPath, item.directory)]))
}
}
return data.exists
})
@ -379,13 +380,9 @@ export default {
}
}
let itemsUploaded = 0
let itemsFailed = 0
for (const item of itemsToUpload) {
this.updateItemCardStatus(item.index, 'uploading')
const result = await this.uploadItem(item)
if (result) itemsUploaded++
else itemsFailed++
this.updateItemCardStatus(item.index, result ? 'success' : 'failed')
}
this.processing = false