mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat: add guided onboarding tour
- install driver.js and add 9-step tour covering libraries, lenses, collections, search, statistics, appearance, upload, and book bucket - first step context-aware: points to welcome card on first run, falls back to settings nav when libraries already exist - tour state persisted in user.settings JSONB, survives across devices - skip on mobile; any dismissal (skip or done) marks tour complete - theme the tour popover with app design tokens (card, primary, radius) - add "Take the tour again" button in Settings > Account (desktop only)
This commit is contained in:
+5
-4
@@ -32,12 +32,13 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"pdfjs-dist": "^5.6.205",
|
"pdfjs-dist": "^5.6.205",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"reka-ui": "^2.9.5",
|
"reka-ui": "^2.9.6",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vitest/coverage-v8": "^4.1.4",
|
"@vitest/coverage-v8": "^4.1.4",
|
||||||
"@vitest/eslint-plugin": "^1.6.15",
|
"@vitest/eslint-plugin": "^1.6.15",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
@@ -61,12 +62,12 @@
|
|||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"eslint": "^10.2.0",
|
"eslint": "^10.2.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.59.0",
|
"eslint-plugin-oxlint": "~1.60.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"oxlint": "~1.59.0",
|
"oxlint": "~1.60.0",
|
||||||
"prettier": "3.8.2",
|
"prettier": "3.8.2",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
|
|||||||
@@ -382,3 +382,125 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Driver.js tour ─────────────────────────────────────────── */
|
||||||
|
.driver-popover {
|
||||||
|
background-color: var(--card) !important;
|
||||||
|
color: var(--card-foreground) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
border-radius: var(--radius) !important;
|
||||||
|
box-shadow: var(--elevation-xl) !important;
|
||||||
|
padding: 1rem 1.125rem !important;
|
||||||
|
min-width: 260px !important;
|
||||||
|
max-width: 310px !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-title {
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: var(--card-foreground) !important;
|
||||||
|
margin-bottom: 0.25rem !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
letter-spacing: -0.01em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-description {
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
line-height: 1.55 !important;
|
||||||
|
color: var(--muted-foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-footer {
|
||||||
|
margin-top: 0.875rem !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
padding-top: 0.625rem !important;
|
||||||
|
border-top: 1px solid var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-progress-text {
|
||||||
|
font-size: 0.6875rem !important;
|
||||||
|
color: var(--muted-foreground) !important;
|
||||||
|
opacity: 0.65 !important;
|
||||||
|
font-variant-numeric: tabular-nums !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-prev-btn,
|
||||||
|
.driver-popover-prev-btn:focus {
|
||||||
|
all: unset !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
padding: 0.3125rem 0.625rem !important;
|
||||||
|
border-radius: calc(var(--radius) - 0.125rem) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--foreground) !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: background-color 0.15s ease !important;
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-prev-btn:hover {
|
||||||
|
background: var(--muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-next-btn,
|
||||||
|
.driver-popover-next-btn:focus {
|
||||||
|
all: unset !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
padding: 0.3125rem 0.75rem !important;
|
||||||
|
border-radius: calc(var(--radius) - 0.125rem) !important;
|
||||||
|
background: var(--primary) !important;
|
||||||
|
color: var(--primary-foreground) !important;
|
||||||
|
border: none !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: opacity 0.15s ease !important;
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-next-btn:hover {
|
||||||
|
opacity: 0.88 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-close-btn,
|
||||||
|
.driver-popover-close-btn:focus {
|
||||||
|
all: unset !important;
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0.625rem !important;
|
||||||
|
right: 0.75rem !important;
|
||||||
|
color: var(--muted-foreground) !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: color 0.15s ease !important;
|
||||||
|
opacity: 0.7 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-close-btn:hover {
|
||||||
|
color: var(--foreground) !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arrow: match popover card background on all sides */
|
||||||
|
.driver-popover-arrow-side-left.driver-popover-arrow {
|
||||||
|
border-right-color: var(--card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-arrow-side-right.driver-popover-arrow {
|
||||||
|
border-left-color: var(--card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-arrow-side-top.driver-popover-arrow {
|
||||||
|
border-bottom-color: var(--card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-popover-arrow-side-bottom.driver-popover-arrow {
|
||||||
|
border-top-color: var(--card) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ function formatBadgeClass(fmt: string): string {
|
|||||||
|
|
||||||
<!-- Center: desktop global search -->
|
<!-- Center: desktop global search -->
|
||||||
<div
|
<div
|
||||||
|
data-tour="global-search"
|
||||||
class="hidden md:flex flex-1 mx-4 relative items-center transition-all duration-300"
|
class="hidden md:flex flex-1 mx-4 relative items-center transition-all duration-300"
|
||||||
:class="searchFocused || globalSearchQuery ? 'max-w-xl' : 'max-w-sm'"
|
:class="searchFocused || globalSearchQuery ? 'max-w-xl' : 'max-w-sm'"
|
||||||
>
|
>
|
||||||
@@ -504,6 +505,7 @@ function formatBadgeClass(fmt: string): string {
|
|||||||
<Tooltip v-if="hasPermission('book_bucket_access')">
|
<Tooltip v-if="hasPermission('book_bucket_access')">
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
|
data-tour="book-bucket-btn"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="relative h-8 w-8 border transition-colors"
|
class="relative h-8 w-8 border transition-colors"
|
||||||
@@ -531,6 +533,7 @@ function formatBadgeClass(fmt: string): string {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
|
data-tour="statistics-btn"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 border transition-colors"
|
class="h-8 w-8 border transition-colors"
|
||||||
@@ -552,6 +555,7 @@ function formatBadgeClass(fmt: string): string {
|
|||||||
<Tooltip v-if="hasPermission('library_upload')">
|
<Tooltip v-if="hasPermission('library_upload')">
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
|
data-tour="upload-button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
:class="[
|
:class="[
|
||||||
@@ -575,6 +579,7 @@ function formatBadgeClass(fmt: string): string {
|
|||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
|
data-tour="appearance-picker"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
:class="['h-8 w-8 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors', iconRadiusClass]"
|
:class="['h-8 w-8 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors', iconRadiusClass]"
|
||||||
@@ -611,6 +616,7 @@ function formatBadgeClass(fmt: string): string {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
|
data-tour="settings-nav"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
:class="[
|
:class="[
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ onUnmounted(() => stopLibraryUploadListener())
|
|||||||
<!-- Libraries -->
|
<!-- Libraries -->
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarSectionHeader
|
<SidebarSectionHeader
|
||||||
|
data-tour="sidebar-libraries"
|
||||||
label="Libraries"
|
label="Libraries"
|
||||||
:is-open="librariesOpen"
|
:is-open="librariesOpen"
|
||||||
:collapsed-count="libraries.length"
|
:collapsed-count="libraries.length"
|
||||||
@@ -300,6 +301,7 @@ onUnmounted(() => stopLibraryUploadListener())
|
|||||||
<!-- Lenses -->
|
<!-- Lenses -->
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarSectionHeader
|
<SidebarSectionHeader
|
||||||
|
data-tour="sidebar-lenses"
|
||||||
label="Lenses"
|
label="Lenses"
|
||||||
:is-open="lensesOpen"
|
:is-open="lensesOpen"
|
||||||
:collapsed-count="lenses.length"
|
:collapsed-count="lenses.length"
|
||||||
@@ -359,6 +361,7 @@ onUnmounted(() => stopLibraryUploadListener())
|
|||||||
<!-- Collections -->
|
<!-- Collections -->
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarSectionHeader
|
<SidebarSectionHeader
|
||||||
|
data-tour="sidebar-collections"
|
||||||
label="Collections"
|
label="Collections"
|
||||||
:is-open="collectionsOpen"
|
:is-open="collectionsOpen"
|
||||||
:collapsed-count="collections.length"
|
:collapsed-count="collections.length"
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ async function handleSaved(library: Library) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-center py-16 px-4">
|
<div class="flex items-center justify-center py-16 px-4">
|
||||||
<div class="relative w-full max-w-md overflow-hidden rounded-2xl border border-primary/40 bg-card/30 shadow-sm backdrop-blur-[1px]">
|
<div
|
||||||
|
data-tour="welcome-card"
|
||||||
|
class="relative w-full max-w-md overflow-hidden rounded-2xl border border-primary/40 bg-card/30 shadow-sm backdrop-blur-[1px]"
|
||||||
|
>
|
||||||
<!-- Glow backdrop -->
|
<!-- Glow backdrop -->
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-0"
|
class="pointer-events-none absolute inset-0"
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { driver } from 'driver.js'
|
||||||
|
import type { DriveStep } from 'driver.js'
|
||||||
|
import 'driver.js/dist/driver.css'
|
||||||
|
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
|
export function useOnboardingTour() {
|
||||||
|
const { user, me } = useAuth()
|
||||||
|
|
||||||
|
function isMobileViewport(): boolean {
|
||||||
|
return typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTourCompleted(): boolean {
|
||||||
|
return user.value?.settings?.onboarding?.tourCompleted === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteps(): DriveStep[] {
|
||||||
|
const hasWelcomeCard = document.querySelector('[data-tour="welcome-card"]') !== null
|
||||||
|
|
||||||
|
const firstStep: DriveStep = hasWelcomeCard
|
||||||
|
? {
|
||||||
|
element: '[data-tour="welcome-card"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Your library is empty',
|
||||||
|
description: 'Create a library to start organizing and reading your books. Point it to a folder and it will do the rest.',
|
||||||
|
side: 'top',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
element: '[data-tour="settings-nav"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Create your first library',
|
||||||
|
description: 'Head to Settings to create your first library and start scanning your books.',
|
||||||
|
side: 'right',
|
||||||
|
align: 'start',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: DriveStep[] = [
|
||||||
|
firstStep,
|
||||||
|
{
|
||||||
|
element: '[data-tour="sidebar-libraries"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Your libraries live here',
|
||||||
|
description: 'Once created, your libraries appear here. Click any library to browse its books.',
|
||||||
|
side: 'right',
|
||||||
|
align: 'start',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="sidebar-lenses"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Lenses - smart filters',
|
||||||
|
description:
|
||||||
|
'Lenses are saved filter rules that always stay up to date. Define criteria once - like "unread sci-fi" - and browse that slice of your library instantly.',
|
||||||
|
side: 'right',
|
||||||
|
align: 'start',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="sidebar-collections"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Collections - curated lists',
|
||||||
|
description:
|
||||||
|
'Collections are manual lists you build yourself - great for reading orders, recommendations, or syncing a specific set of books to your Kobo.',
|
||||||
|
side: 'right',
|
||||||
|
align: 'start',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="global-search"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Search your collection',
|
||||||
|
description: 'Use the search bar to instantly find any book, author, or series across your library.',
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="statistics-btn"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Reading statistics',
|
||||||
|
description:
|
||||||
|
'Explore 33+ charts covering your reading pace, genre breakdown, session patterns, top authors, and more. Your full reading history at a glance.',
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'end',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="appearance-picker"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Make it yours',
|
||||||
|
description: 'Customize your theme, accent color, and background from the appearance menu.',
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'end',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="upload-button"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Upload books',
|
||||||
|
description: 'Upload books directly from your browser. They land in the Book Bucket for metadata review before being added to a library.',
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'end',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="book-bucket-btn"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Book Bucket',
|
||||||
|
description: 'Uploaded files wait here. Review metadata, set the target library, then finalize to add them to your collection.',
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'end',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return candidates.filter((step) => document.querySelector(step.element as string) !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCompletedLocally(): void {
|
||||||
|
if (!user.value) return
|
||||||
|
user.value = {
|
||||||
|
...user.value,
|
||||||
|
settings: {
|
||||||
|
...user.value.settings,
|
||||||
|
onboarding: { tourCompleted: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistCompletion(): void {
|
||||||
|
api('/api/v1/users/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ settings: { onboarding: { tourCompleted: true } } }),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTour(): void {
|
||||||
|
const steps = buildSteps()
|
||||||
|
if (steps.length === 0) return
|
||||||
|
|
||||||
|
const driverObj = driver({
|
||||||
|
showProgress: true,
|
||||||
|
progressText: '{{current}} of {{total}}',
|
||||||
|
nextBtnText: 'Next',
|
||||||
|
prevBtnText: 'Back',
|
||||||
|
doneBtnText: 'Done',
|
||||||
|
disableActiveInteraction: true,
|
||||||
|
onDestroyed: () => {
|
||||||
|
markCompletedLocally()
|
||||||
|
persistCompletion()
|
||||||
|
},
|
||||||
|
steps,
|
||||||
|
})
|
||||||
|
|
||||||
|
driverObj.drive()
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeStartTour(): void {
|
||||||
|
if (isMobileViewport()) return
|
||||||
|
if (isTourCompleted()) return
|
||||||
|
startTour()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetTour(): Promise<void> {
|
||||||
|
await api('/api/v1/users/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ settings: { onboarding: { tourCompleted: false } } }),
|
||||||
|
})
|
||||||
|
await me()
|
||||||
|
startTour()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { maybeStartTour, startTour, resetTour }
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import type { OidcProviderPublic } from '@projectx/types'
|
import type { OidcProviderPublic } from '@projectx/types'
|
||||||
import { ChevronDown, ChevronUp, KeyRound, Link, LinkIcon, Save, Trash2, Upload } from 'lucide-vue-next'
|
import { ChevronDown, ChevronUp, KeyRound, Link, LinkIcon, MapPin, Save, Trash2, Upload } from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@@ -11,10 +11,12 @@ import { MAX_PROFILE_AVATAR_BYTES, useProfileAvatar } from '@/features/auth/comp
|
|||||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||||
import { useMediaQuery } from '@vueuse/core'
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
|
import { useOnboardingTour } from '@/features/onboarding/composables/useOnboardingTour'
|
||||||
|
|
||||||
const { user, me } = useAuth()
|
const { user, me } = useAuth()
|
||||||
const { open: openChangePassword } = useChangePasswordDialog()
|
const { open: openChangePassword } = useChangePasswordDialog()
|
||||||
const { uploading, removing, uploadAvatar, removeAvatar } = useProfileAvatar()
|
const { uploading, removing, uploadAvatar, removeAvatar } = useProfileAvatar()
|
||||||
|
const { resetTour } = useOnboardingTour()
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const savingProfile = ref(false)
|
const savingProfile = ref(false)
|
||||||
@@ -450,6 +452,22 @@ function closeUnlinkDialog() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Guided Tour (desktop only) -->
|
||||||
|
<div class="hidden md:block mt-4">
|
||||||
|
<section class="rounded-xl border border-border bg-card p-4 md:p-5">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<MapPin class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-foreground">Guided Tour</p>
|
||||||
|
<p class="text-xs text-muted-foreground mt-0.5">Replay the walkthrough that highlights key features of the app.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="settings-btn-outline shrink-0" @click="resetTour">Take the tour again</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Unlink OIDC identity confirmation dialog -->
|
<!-- Unlink OIDC identity confirmation dialog -->
|
||||||
<div v-if="unlinkDialogOpen" class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4" @click.self="closeUnlinkDialog">
|
<div v-if="unlinkDialogOpen" class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4" @click.self="closeUnlinkDialog">
|
||||||
<button class="absolute inset-0 bg-black/45" @click="closeUnlinkDialog" />
|
<button class="absolute inset-0 bg-black/45" @click="closeUnlinkDialog" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
import { Settings2 } from 'lucide-vue-next'
|
import { Settings2 } from 'lucide-vue-next'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
@@ -9,10 +9,12 @@ import DashboardScroller from '@/features/dashboard/components/DashboardScroller
|
|||||||
import DashboardSettingsSheet from '@/features/dashboard/components/DashboardSettingsSheet.vue'
|
import DashboardSettingsSheet from '@/features/dashboard/components/DashboardSettingsSheet.vue'
|
||||||
import DashboardWelcome from '@/features/dashboard/components/DashboardWelcome.vue'
|
import DashboardWelcome from '@/features/dashboard/components/DashboardWelcome.vue'
|
||||||
import { useDashboardConfig } from '@/features/dashboard/composables/useDashboardConfig'
|
import { useDashboardConfig } from '@/features/dashboard/composables/useDashboardConfig'
|
||||||
|
import { useOnboardingTour } from '@/features/onboarding/composables/useOnboardingTour'
|
||||||
|
|
||||||
const { hasPermission } = usePermissions()
|
const { hasPermission } = usePermissions()
|
||||||
const { libraries, loading: librariesLoading, fetchLibraries } = useLibraries()
|
const { libraries, loading: librariesLoading, fetchLibraries } = useLibraries()
|
||||||
const { scrollers } = useDashboardConfig()
|
const { scrollers } = useDashboardConfig()
|
||||||
|
const { maybeStartTour } = useOnboardingTour()
|
||||||
|
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
|
|
||||||
@@ -22,6 +24,9 @@ const hasNoLibraries = computed(() => !librariesLoading.value && libraries.value
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchLibraries()
|
fetchLibraries()
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(maybeStartTour, 500)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+14
-11
@@ -1,15 +1,15 @@
|
|||||||
export enum OidcErrorCode {
|
export enum OidcErrorCode {
|
||||||
STATE_EXPIRED = 'oidc_state_expired',
|
STATE_EXPIRED = "oidc_state_expired",
|
||||||
TOKEN_EXCHANGE_FAILED = 'oidc_token_exchange_failed',
|
TOKEN_EXCHANGE_FAILED = "oidc_token_exchange_failed",
|
||||||
USER_NOT_PROVISIONED = 'oidc_user_not_provisioned',
|
USER_NOT_PROVISIONED = "oidc_user_not_provisioned",
|
||||||
USER_INACTIVE = 'oidc_user_inactive',
|
USER_INACTIVE = "oidc_user_inactive",
|
||||||
PROVIDER_ERROR = 'oidc_provider_error',
|
PROVIDER_ERROR = "oidc_provider_error",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProvisioningMethod = {
|
export const ProvisioningMethod = {
|
||||||
Local: 'local',
|
Local: "local",
|
||||||
Manual: 'manual',
|
Manual: "manual",
|
||||||
Oidc: 'oidc',
|
Oidc: "oidc",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof ProvisioningMethod];
|
export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof ProvisioningMethod];
|
||||||
@@ -17,6 +17,9 @@ export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof Provis
|
|||||||
export interface UserSettings {
|
export interface UserSettings {
|
||||||
syncReaderPreferences?: boolean;
|
syncReaderPreferences?: boolean;
|
||||||
statisticsConfig?: import("./statistics").StatisticsSettings;
|
statisticsConfig?: import("./statistics").StatisticsSettings;
|
||||||
|
onboarding?: {
|
||||||
|
tourCompleted?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
@@ -102,18 +105,18 @@ export interface OidcBaseConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OidcCallbackResult {
|
export interface OidcCallbackResult {
|
||||||
mode: 'login';
|
mode: "login";
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OidcLinkResult {
|
export interface OidcLinkResult {
|
||||||
mode: 'link';
|
mode: "link";
|
||||||
linked: true;
|
linked: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OidcPreviewResult {
|
export interface OidcPreviewResult {
|
||||||
mode: 'preview';
|
mode: "preview";
|
||||||
claims: {
|
claims: {
|
||||||
raw: Record<string, unknown>;
|
raw: Record<string, unknown>;
|
||||||
mapped: { username: string; name: string; email?: string; groups: string[] };
|
mapped: { username: string; name: string; email?: string; groups: string[] };
|
||||||
|
|||||||
Generated
+11
@@ -53,6 +53,9 @@ importers:
|
|||||||
dompurify:
|
dompurify:
|
||||||
specifier: ^3.4.1
|
specifier: ^3.4.1
|
||||||
version: 3.4.1
|
version: 3.4.1
|
||||||
|
driver.js:
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0
|
||||||
echarts:
|
echarts:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@@ -5575,6 +5578,12 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=12" }
|
engines: { node: ">=12" }
|
||||||
|
|
||||||
|
driver.js@1.4.0:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==,
|
||||||
|
}
|
||||||
|
|
||||||
drizzle-kit@0.31.10:
|
drizzle-kit@0.31.10:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -13166,6 +13175,8 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.4.1: {}
|
dotenv@17.4.1: {}
|
||||||
|
|
||||||
|
driver.js@1.4.0: {}
|
||||||
|
|
||||||
drizzle-kit@0.31.10:
|
drizzle-kit@0.31.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@drizzle-team/brocli": 0.10.2
|
"@drizzle-team/brocli": 0.10.2
|
||||||
|
|||||||
Reference in New Issue
Block a user