Add tour guide for home page and library page.create enable feature in setting file.

This commit is contained in:
Laknima 2025-10-01 20:58:37 +00:00
parent ee3fa2f427
commit 7fc572a3a2
12 changed files with 4292 additions and 4842 deletions

View file

@ -66,3 +66,20 @@
margin-block-start: 0;
}
.shepherd-progress-bar {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.2);
margin-top: 5px;
border-radius: 2px;
overflow: hidden;
}
.shepherd-progress-bar span {
display: block;
height: 100%;
background: #4cafef;
/* Change to match theme */
width: 0%;
transition: width 0.3s ease;
}

View file

@ -42,14 +42,6 @@
</ui-tooltip>
</nuxt-link>
<!-- Tour Tooltip icon -->
<ui-tooltip text="Start Tour" direction="bottom">
<button @click="startTour" class="hover:text-gray-200 w-8 h-8 flex items-center justify-center mx-1">
<span class="material-symbols text-2xl">info</span>
</button>
</ui-tooltip>
<!-- Tour Tooltip icon -->
<nuxt-link to="/account" id="appbar-account-link" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded-sm shadow-xs ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg/40" aria-haspopup="listbox" aria-expanded="true">
<span class="items-center hidden md:flex">
<span class="block truncate">{{ username }}</span>
@ -390,106 +382,8 @@ export default {
batchAutoMatchClick() {
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
},
startTour() {
const tour = new Shepherd.Tour({
useModalOverlay: true,
defaultStepOptions: {
scrollTo: true,
cancelIcon: { enabled: true },
classes: 'shadow-md bg-primary text-white',
highlightClass: 'bg-secondary'
}
})
tour.addStep({
id: 'home-button',
text: 'Click here to return to the homepage.',
attachTo: {
element: '#appbar-home-icon',
on: 'bottom'
},
buttons: [{ text: 'Next', action: tour.next }]
})
tour.addStep({
id: 'library_icon',
text: 'Click to select the library',
attachTo: {
element: '#appbar-library-icon',
on: 'bottom'
},
buttons: [
{ text: 'Back', action: tour.back },
{ text: 'Next', action: tour.next }
]
})
tour.addStep({
id: 'search-bar',
text: 'Use this search bar to find any book.',
attachTo: {
element: '#appbar-search-bar',
on: 'bottom'
},
buttons: [
{ text: 'Back', action: tour.back },
{ text: 'Next', action: tour.next }
]
})
tour.addStep({
id: 'stats-icon',
text: 'View your stats here.',
attachTo: {
element: '#appbar-stats',
on: 'bottom'
},
buttons: [
{ text: 'Back', action: tour.back },
{ text: 'Next', action: tour.next }
]
})
tour.addStep({
id: 'upload-button',
text: 'Click here to upload a new audiobook.',
attachTo: {
element: '#appbar-upload-button',
on: 'bottom'
},
buttons: [
{ text: 'Back', action: tour.back },
{ text: 'Next', action: tour.next }
]
})
tour.addStep({
id: 'settings-icon',
text: 'Access your app settings here.',
attachTo: {
element: '#appbar-settings-icon',
on: 'bottom'
},
buttons: [
{ text: 'Back', action: tour.back },
{ text: 'Next', action: tour.next }
]
})
tour.addStep({
id: 'account-link',
text: 'Click here to access your account settings.',
attachTo: {
element: '#appbar-account-link',
on: 'bottom'
},
buttons: [
{ text: 'Back', action: tour.back },
{ text: 'Done', action: tour.complete }
]
})
tour.start()
startTour(tourId) {
startTour(tourId)
}
},
mounted() {
@ -503,6 +397,59 @@ export default {
<style>
#appbar {
box-shadow: 0px 5px 5px #11111155;
box-shadow: 0px 5px 5px #bab4b4b6;
}
.shepherd-content {
padding: 10px;
background: #f36f3a;
}
.shepherd-header {
background: #fbeedc;
}
.shepherd-cancel-icon {
background: #4a2b1d;
font-size: 30px;
height: 30px;
width: 30px;
line-height: 0;
border-radius: 10px;
}
.shepherd-extra-wrapper {
background: #fbeedc;
margin: 0 !important;
padding: 10px;
}
.shepherd-text {
color: #4a2b1d;
background: #fbeedc;
}
.shepherd-footer {
justify-content: center;
background: #fbeedc;
}
.shepherd-button {
background: #f36f3a;
border-radius: 10px;
color: #ffffff;
}
.shepherd-button:not(:disabled):hover {
background: #e4572e;
}
.shepherd-progress-bar {
width: 100%;
height: 5px;
background: #f36f3a;
margin-top: 5px;
border-radius: 2px;
overflow: hidden;
}
.shepherd-progress-bar span {
display: block;
height: 100%;
background: #0ad93d;
/* Change to match theme */
width: 0%;
transition: width 0.3s ease;
}
</style>

View file

@ -4,7 +4,7 @@
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary/80' : 'bg-bg/60'">
<nuxt-link :to="`/library/${currentLibraryId}`" id="sidebar-home" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary/80' : 'bg-bg/60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
@ -22,7 +22,7 @@
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary/80' : 'bg-bg/60'">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" id="sidebar-library" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary/80' : 'bg-bg/60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
@ -32,7 +32,7 @@
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary/80' : 'bg-bg/60'">
<nuxt-link v-if="isBookLibrary" id="sidebar-series" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary/80' : 'bg-bg/60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
@ -42,7 +42,7 @@
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary/80' : 'bg-bg/60'">
<nuxt-link v-if="isBookLibrary" id="sidebar-collection" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">&#xe431;</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
@ -50,7 +50,7 @@
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary/80' : 'bg-bg/60'">
<nuxt-link v-if="showPlaylists" id="sidebar-playlist" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2.5xl">&#xe03d;</span>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
@ -58,7 +58,7 @@
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary/80' : 'bg-bg/60'">
<nuxt-link v-if="isBookLibrary" id="sidebar-authors" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary/80' : 'bg-bg/60'">
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path
fill="currentColor"
@ -71,7 +71,7 @@
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary/80' : 'bg-bg/60'">
<nuxt-link v-if="isBookLibrary" id="sidebar-narrators" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">&#xe91f;</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
@ -79,7 +79,7 @@
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/stats`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isStatsPage ? 'bg-primary/80' : 'bg-bg/60'">
<nuxt-link v-if="isBookLibrary && userIsAdminOrUp" id="sidebar-stats" :to="`/library/${currentLibraryId}/stats`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isStatsPage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">&#xf190;</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonStats }}</p>
@ -87,6 +87,38 @@
<div v-show="isStatsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<!-- cus -->
<!-- Floating Action Button -->
<div v-if="enableTourGuide">
<button v-if="!showChild" @click="showChild = true" class="fixed bottom-32 right-4 w-12 h-12 rounded-full bg-tourGuide text-white flex items-center justify-center shadow-lg hover:bg-primary-dark z-50" title="Tour Guide">
<span class="material-symbols">tour</span>
</button>
<!-- Popup with Child Links -->
<transition name="fade">
<div v-if="showChild" class="fixed bottom-36 right-4 flex flex-col bg-bg/95 border border-primary rounded-xl shadow-lg z-50 w-40">
<!-- Home Tour -->
<div class="child-link flex items-center gap-2 px-4 py-2 border-b border-primary/40 cursor-pointer" :class="{ active: activeChild === 'home' }" @click="startAppBarTour">
<span class="material-symbols">home</span>
<p>Home Tour</p>
</div>
<!-- Library Tour -->
<div class="child-link flex items-center gap-2 px-4 py-2 border-b border-primary/40 cursor-pointer" :class="{ active: activeChild === 'library' }" @click="startLibraryTour">
<span class="material-symbols">menu_book</span>
<p>Library Tour</p>
</div>
<!-- Close -->
<button @click="showChild = false" class="px-4 py-2 text-sm text-white bg-red-600 hover:bg-red-700 rounded-b-xl">Close</button>
</div>
</transition>
</div>
<button @click="resetFirstVisit" class="fixed bottom-16 right-4 bg-red-500 text-white px-3 py-1 rounded shadow-md hover:bg-red-600 z-50">Reset Tour</button>
<!-- cus -->
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="abs-icons icon-podcast text-xl"></span>
@ -125,11 +157,19 @@
</div>
</template>
<script>
import { startTour } from '~/plugins/tour'
export default {
data() {
return {
showChangelogModal: false
showChangelogModal: false,
showChild: false,
activeParent: null,
activeChild: null,
isFirstVisit: false
}
},
computed: {
@ -221,14 +261,76 @@ export default {
},
showPlaylists() {
return this.$store.state.libraries.numUserPlaylists > 0
},
enableTourGuide() {
return this.$store.state.serverSettings?.enableTourGuide
},
activeParent() {
// Parent is active if menu is open OR it's first visit
return this.activeChild || this.showChild || this.isFirstVisit ? 'tour' : null
}
},
methods: {
clickChangelog() {
this.showChangelogModal = true
},
toggleChild() {
this.showChild = !this.showChild
this.activeParent = this.showChild ? 'tour' : null
this.activeChild = null
this.isFirstVisit = false
},
startAppBarTour() {
this.activeChild = 'home'
startTour('appBar', {
onComplete: () => {
this.showChild = false
this.activeParent = 'tour'
}
})
},
startLibraryTour() {
this.activeChild = 'library'
// Use Vue 2 / Nuxt 2 $nextTick
this.$nextTick(() => {
startTour('libraryItem', {
onComplete: () => {
this.showChild = false
this.activeParent = 'tour'
}
})
})
},
resetFirstVisit() {
localStorage.removeItem('tourGuideVisited')
this.isFirstVisit = true
this.showChild = false
this.activeParent = 'tour'
}
},
mounted() {}
mounted() {
// Listen to the event from the button in template
this.$eventBus.$on('start-library-tour', () => {
this.startLibraryTour()
})
// Existing first visit logic
const hasVisited = localStorage.getItem('tourGuideVisited')
if (!hasVisited) {
this.showChild = true
this.activeParent = 'tour'
this.isFirstVisit = true
this.startAppBarTour()
localStorage.setItem('tourGuideVisited', 'true')
}
},
beforeUnmount() {
// Clean up the event listener
this.$eventBus.$off('start-library-tour')
}
}
</script>
@ -239,4 +341,49 @@ export default {
#siderail-buttons-container.player-open {
max-height: calc(100vh - 64px - 48px - 160px);
}
.child-link {
font-size: 12px;
}
.child-link:hover {
background-color: #92622a;
}
.child-link.active {
background-color: #92622a;
}
.bg-tourGuide {
background-color: #92622a;
}
.bg-tourGuide .text-tg {
font-size: 10px;
}
.parent-link.first-time {
animation: pulse-highlight 1.5s ease-in-out 3;
}
/* Pulse keyframes */
@keyframes pulse-highlight {
0%,
100% {
box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.5);
}
50% {
box-shadow: 0 0 10px 5px rgba(255, 165, 0, 0.7);
}
}
/* Transition for tour guide open/close */
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
height: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
height: auto;
}
</style>

5657
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -130,6 +130,18 @@
</ui-tooltip>
</div>
<!-- Tour Guide -->
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-enable-tour-guide" v-model="newServerSettings.enableTourGuide" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableTourGuide', val)" />
<ui-tooltip :text="'Enable or disable the in-app tour guide feature'">
<p class="pl-4">
<span id="settings-enable-tour-guide">Enable Tour Guide</span>
<span class="material-symbols icon-text">info</span>
</p>
</ui-tooltip>
</div>
<!-- Tour Guide -->
<div class="grow py-2">
<ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-72" @input="(val) => updateSettingsKey('dateFormat', val)" />
<p class="text-xs ml-1 text-white/60">{{ $strings.LabelExample }}: {{ dateExample }}</p>
@ -369,6 +381,11 @@ export default {
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
// Initialize Tour Guide toggle
if (this.newServerSettings.enableTourGuide === undefined) {
this.newServerSettings.enableTourGuide = false
}
},
purgeCache() {
this.showConfirmPurgeCache = true

View file

@ -4,8 +4,9 @@
<div class="flex flex-col lg:flex-row max-w-6xl mx-auto">
<div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px">
<div class="relative group" style="height: fit-content">
<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" />
<div id="covers-book-cover">
<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" />
</div>
<!-- Item Progress Bar -->
<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>
@ -24,7 +25,7 @@
<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">
<h1 class="text-2xl md:text-3xl font-semibold" id="book-title">
<div class="flex items-center">
{{ title }}
<widgets-explicit-indicator v-if="isExplicit" />
@ -32,7 +33,7 @@
</div>
</h1>
<p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p>
<p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl" id="book-sub-title">{{ bookSubtitle }}</p>
<template v-for="(_series, index) in seriesList">
<nuxt-link :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7">{{ _series.text }}</nuxt-link
@ -43,7 +44,7 @@
<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>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl" id="item-author">by Unknown</p>
<content-library-item-details :library-item="libraryItem" />
</div>
@ -123,7 +124,7 @@
</div>
<div class="my-4 w-full">
<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" />
<div ref="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>
@ -147,6 +148,8 @@
</template>
<script>
import { startTour } from '~/plugins/tour'
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
@ -795,6 +798,22 @@ export default {
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
// Existing mounted code...
this.checkDescriptionClamped()
this.$eventBus.$on('start-library-tour', () => {
if (this.libraryItem && this.libraryItem.id) {
this.$nextTick(() => startTour('libraryItem'))
} else {
// Wait until libraryItem is loaded
const unwatch = this.$watch('libraryItem', (newVal) => {
if (newVal && newVal.id) {
this.$nextTick(() => startTour('libraryItem'))
unwatch()
}
})
}
})
},
beforeDestroy() {
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
@ -807,6 +826,7 @@ export default {
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
this.$eventBus.$off('start-library-tour')
}
}
</script>

View file

@ -1,24 +1,190 @@
// client/plugins/tour.js
import Shepherd from 'shepherd.js'
import 'shepherd.js/dist/css/shepherd.css'
export default (context, inject) => {
const defaultTourOptions = {
defaultStepOptions: {
cancelIcon: {
enabled: true
},
scrollTo: { behavior: 'smooth', block: 'center' },
classes: 'shadow-md bg-purple-dark text-white rounded p-3'
let tour = null
const tours = {
appBar: [
{ id: 'home-button', text: 'Click here to return to the homepage.', attachTo: { element: '#appbar-home-icon', on: 'bottom' } },
{ id: 'library-icon', text: 'Click to select the library.', attachTo: { element: '#appbar-library-icon', on: 'bottom' } },
{ id: 'search-bar', text: 'Use this search bar to find any book.', attachTo: { element: '#appbar-search-bar', on: 'bottom' } },
{ id: 'stats-icon', text: 'View your stats here.', attachTo: { element: '#appbar-stats', on: 'bottom' } },
{ id: 'upload-button', text: 'Click here to upload a new audiobook.', attachTo: { element: '#appbar-upload-button', on: 'bottom' } },
{ id: 'settings-icon', text: 'Access your app settings here.', attachTo: { element: '#appbar-settings-icon', on: 'bottom' } },
{ id: 'account-link', text: 'Click here to access your account settings.', attachTo: { element: '#appbar-account-link', on: 'bottom' } },
{ id: 'sidebar-home', text: 'This is your homepage where you can see your recent activity.', attachTo: { element: '#sidebar-home', on: 'right' } },
{ id: 'sidebar-library', text: 'Access your entire audiobook library here.', attachTo: { element: '#sidebar-library', on: 'right' } },
{ id: 'sidebar-series', text: 'Quick access to your series.', attachTo: { element: '#sidebar-series', on: 'right' } },
{ id: 'sidebar-collection', text: 'View your books collection.', attachTo: { element: '#sidebar-collection', on: 'right' } },
{ id: 'sidebar-playlists', text: 'Manage your playlists from this section.', attachTo: { element: '#sidebar-playlists', on: 'right' } },
{ id: 'sidebar-authors', text: 'Manage your authors from this section.', attachTo: { element: '#sidebar-authors', on: 'right' } },
{ id: 'sidebar-narrators', text: 'View narrators and their books from this section.', attachTo: { element: '#sidebar-narrators', on: 'right' } },
{ id: 'sidebar-stats', text: 'This section will provide all the stats in audio book shelf.', attachTo: { element: '#sidebar-stats', on: 'right' } }
],
libraryItem: [
{
id: 'item-cover',
text: 'This is the book cover. Click to expand or edit.',
attachTo: { element: '#covers-book-cover', on: 'right' },
canClickTarget: false // stops Shepherd from triggering the click
},
useModalOverlay: true
}
const createTour = (steps = []) => {
const tour = new Shepherd.Tour(defaultTourOptions)
steps.forEach((step) => tour.addStep(step))
return tour
}
inject('createTour', createTour)
{
id: 'item-title',
text: 'Here is the title and subtitle of the item.',
attachTo: { element: '#book-title', on: 'right' }
}
]
}
export function startTour(tourId = 'appBar') {
if (!tours[tourId]) return console.warn('Tour not found:', tourId)
tour = new Shepherd.Tour({
useModalOverlay: true,
defaultStepOptions: {
scrollTo: true,
cancelIcon: { enabled: true },
popperOptions: { modifiers: [{ name: 'preventOverflow', options: { boundary: 'viewport' } }] },
classes: 'shadow-md bg-primary text-white',
highlightClass: 'bg-secondary',
container: 'body',
when: {
show() {
requestAnimationFrame(() => {
const content = this.el.querySelector('.shepherd-content') || this.el.querySelector('.shepherd-body')
if (!content) return
const existingWrapper = content.querySelector('.shepherd-extra-wrapper')
if (existingWrapper) existingWrapper.remove()
const wrapper = document.createElement('div')
wrapper.className = 'shepherd-extra-wrapper'
wrapper.style.display = 'flex'
wrapper.style.flexDirection = 'column'
wrapper.style.alignItems = 'center'
wrapper.style.margin = '10px 0'
// Progress bar
const progress = document.createElement('div')
progress.style.height = '6px'
progress.style.width = '100%'
progress.style.background = '#eee'
progress.style.borderRadius = '3px'
progress.style.marginBottom = '10px'
const innerBar = document.createElement('span')
innerBar.style.display = 'block'
innerBar.style.height = '100%'
innerBar.style.background = '#E4572E'
innerBar.style.borderRadius = '3px'
const currentStepIndex = tour.steps.indexOf(this) + 1
innerBar.style.width = `${(currentStepIndex / tour.steps.length) * 100}%`
progress.appendChild(innerBar)
// Image slider
const slider = document.createElement('div')
slider.className = 'tour-image-slider'
slider.style.position = 'relative'
slider.style.width = '200px'
slider.style.height = '120px'
slider.style.overflow = 'hidden'
slider.style.marginTop = '10px'
// Correct public paths
const images = ['/book_placeholder.jpg', '/images/step2.png', '/images/step3.png']
let currentIndex = 0
const imgEl = document.createElement('img')
imgEl.src = images[currentIndex]
imgEl.alt = 'Tour Image'
imgEl.style.width = '100%'
imgEl.style.height = '100%'
imgEl.style.objectFit = 'contain'
slider.appendChild(imgEl)
// Prev button
const prevBtn = document.createElement('button')
prevBtn.textContent = '<'
prevBtn.style.position = 'absolute'
prevBtn.style.left = '5px'
prevBtn.style.top = '50%'
prevBtn.style.transform = 'translateY(-50%)'
prevBtn.style.background = 'rgba(0,0,0,0.5)'
prevBtn.style.color = '#fff'
prevBtn.style.border = 'none'
prevBtn.style.borderRadius = '50%'
prevBtn.style.width = '25px'
prevBtn.style.height = '25px'
prevBtn.onclick = () => {
currentIndex = (currentIndex - 1 + images.length) % images.length
imgEl.src = images[currentIndex]
}
// Next button
const nextBtn = document.createElement('button')
nextBtn.textContent = '>'
nextBtn.style.position = 'absolute'
nextBtn.style.right = '5px'
nextBtn.style.top = '50%'
nextBtn.style.transform = 'translateY(-50%)'
nextBtn.style.background = 'rgba(0,0,0,0.5)'
nextBtn.style.color = '#fff'
nextBtn.style.border = 'none'
nextBtn.style.borderRadius = '50%'
nextBtn.style.width = '25px'
nextBtn.style.height = '25px'
nextBtn.onclick = () => {
currentIndex = (currentIndex + 1) % images.length
imgEl.src = images[currentIndex]
}
slider.appendChild(prevBtn)
slider.appendChild(nextBtn)
// Add to wrapper
wrapper.appendChild(progress)
wrapper.appendChild(slider)
const footer = content.querySelector('.shepherd-footer')
if (footer) content.insertBefore(wrapper, footer)
else content.appendChild(wrapper)
})
}
}
}
})
const addStepSafely = (step) => {
const el = document.querySelector(step.attachTo?.element)
if (!el) return false
tour.addStep(step)
return true
}
const steps = tours[tourId].map((step, idx) => {
const stepWithButtons = { ...step, buttons: [] }
if (idx > 0) stepWithButtons.buttons.push({ text: 'Back', action: () => tour.back() })
stepWithButtons.buttons.push({
text: idx === tours[tourId].length - 1 ? 'Done' : 'Next',
action: idx === tours[tourId].length - 1 ? () => tour.complete() : () => tour.next()
})
return stepWithButtons
})
if (tourId === 'libraryItem') {
// Wait for all elements to exist before adding steps and starting tour
const interval = setInterval(() => {
const allExist = steps.every((step) => document.querySelector(step.attachTo?.element))
if (allExist) {
steps.forEach((step) => tour.addStep(step))
clearInterval(interval)
tour.start()
}
}, 200)
} else {
// AppBar tour: add steps immediately
steps.forEach((step) => tour.addStep(step))
tour.start()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

2866
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -83,6 +83,9 @@ class ServerSettings {
this.authOpenIDAdvancedPermsClaim = ''
this.authOpenIDSubfolderForRedirectURLs = undefined
//Tour Guide - be initialized with default false
this.enableTourGuide = settings.enableTourGuide !== undefined ? !!settings.enableTourGuide : true
if (settings) {
this.construct(settings)
}
@ -147,6 +150,9 @@ class ServerSettings {
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
//Tour Guide - update when saving
this.enableTourGuide = !!settings.enableTourGuide
if (!Array.isArray(this.authActiveAuthMethods)) {
this.authActiveAuthMethods = ['local']
}
@ -255,7 +261,8 @@ class ServerSettings {
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
enableTourGuide: this.enableTourGuide // return to frontend via API
}
}