Files
PicList/src/renderer/components/FirstTimeGuide.vue
2026-01-25 12:58:38 +08:00

378 lines
11 KiB
Vue

<template>
<TransitionRoot appear :show="isVisible" as="template">
<div class="pointer-events-auto fixed inset-0 z-9999">
<div class="absolute inset-0 bg-black/15 transition-all duration-300 ease-apple" @click="handleClose" />
<div
v-if="currentStepConfig.target"
class="pointer-events-none absolute z-10000 rounded-xl border-2 border-dashed border-accent shadow-sm transition-all duration-300 ease-apple"
:style="spotlightStyle"
/>
<!-- Guide Card -->
<div
class="absolute z-10001 max-h-[80vh] w-[420px] max-w-[90vw] overflow-auto rounded-lg border border-border bg-bg-tertiary shadow-2xl transition-all duration-300 ease-apple max-sm:w-[calc(100vw-32px)]"
:style="cardStyle"
>
<div class="flex items-center justify-between border-b border-b-border px-4 py-3">
<div class="flex flex-col gap-0.5">
<h3 class="m-0 text-[16px] font-semibold text-main">{{ t('guide.title') }}</h3>
<span class="text-sm text-tertiary">
{{ t('guide.stepIndicator', { current: currentStep + 1, total: steps.length }) }}
</span>
</div>
<button
class="flex cursor-pointer items-center justify-center rounded-sm border-none bg-transparent p-[4px] text-secondary transition-all duration-200 ease-apple hover:bg-accent-hover hover:text-main"
:title="t('guide.close')"
@click="handleClose"
>
<XIcon :size="20" />
</button>
</div>
<div class="flex items-start gap-4 px-5 py-4">
<div
class="border-lg flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-lg bg-accent text-white"
>
<component :is="currentStepConfig.icon" :size="24" />
</div>
<div class="min-w-0 flex-1">
<h4 class="mb-1 text-base font-semibold text-main">{{ t(currentStepConfig.title) }}</h4>
<p class="m-0 text-sm leading-[1.5] text-secondary">{{ t(currentStepConfig.description) }}</p>
</div>
</div>
<div class="border-t border-t-border p-3">
<div class="mb-2.5 flex justify-center gap-1.5">
<div
v-for="(_, index) in steps"
:key="index"
class="h-[6px] w-[6px] rounded-full bg-border transition-all duration-300 ease-apple [.active]:w-5 [.active]:rounded-xs [.active]:bg-accent [.completed]:bg-success"
:class="{ active: index === currentStep, completed: index < currentStep }"
/>
</div>
<div class="flex justify-end gap-1.5">
<CustomButton
v-if="currentStep > 0"
type="secondary"
:icon="ChevronLeftIcon"
:text="t('guide.previous')"
class="p-2!"
@click="handlePrevious"
/>
<CustomButton class="p-2!" type="secondary" :text="t('guide.skip')" @click="handleSkip" />
<CustomButton
v-if="currentStep < steps.length - 1"
type="primary"
class="p-2!"
:icon="ChevronRightIcon"
:text="t('guide.next')"
@click="handleNext"
/>
<CustomButton
v-else
type="primary"
class="p-2!"
:icon="CheckCircleIcon"
:text="t('guide.finish')"
@click="handleFinish"
/>
</div>
</div>
</div>
</div>
</TransitionRoot>
</template>
<script setup lang="ts">
import { TransitionRoot } from '@headlessui/vue'
import { useStorage } from '@vueuse/core'
import {
ArrowLeftRightIcon,
CheckCircleIcon,
ChevronLeftIcon,
ChevronRightIcon,
HelpCircleIcon,
ImageIcon,
PaletteIcon,
UploadCloudIcon,
XIcon,
} from 'lucide-vue-next'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import CustomButton from '@/components/common/CustomButton.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const hasSeenGuide = useStorage('has-seen-first-time-guide', false)
const isVisible = ref(false)
const currentStep = ref(0)
const spotlightRect = ref<DOMRect | null>(null)
interface GuideStep {
id: string
title: string
description: string
additionalInfo?: string[]
target?: string
position?: 'top' | 'bottom' | 'left' | 'right' | 'center'
icon: any
action?: () => void
}
const steps: GuideStep[] = [
{
id: 'welcome',
title: 'guide.steps.welcome.title',
description: 'guide.steps.welcome.description',
position: 'center',
icon: HelpCircleIcon,
},
{
id: 'upload',
title: 'guide.steps.upload.title',
description: 'guide.steps.upload.description',
target: '#upload-area',
position: 'bottom',
icon: UploadCloudIcon,
},
{
id: 'picbed',
title: 'guide.steps.picbed.title',
description: 'guide.steps.picbed.description',
target: '.provider-button',
position: 'bottom',
icon: ArrowLeftRightIcon,
},
{
id: 'theme',
title: 'guide.steps.theme.title',
description: 'guide.steps.theme.description',
target: '.theme-switcher',
position: 'right',
icon: PaletteIcon,
},
{
id: 'themeSelection',
title: 'guide.steps.themeSelection.title',
description: 'guide.steps.themeSelection.description',
target: '.theme-dropdown',
position: 'bottom',
icon: PaletteIcon,
},
{
id: 'gallery',
title: 'guide.steps.gallery.title',
description: 'guide.steps.gallery.description',
target: 'nav .nav-item:nth-child(3)',
position: 'right',
icon: ImageIcon,
},
{
id: 'finish',
title: 'guide.steps.finish.title',
description: 'guide.steps.finish.description',
position: 'center',
icon: CheckCircleIcon,
},
]
const currentStepConfig = computed(() => steps[currentStep.value])
const updateSpotlight = async () => {
await nextTick()
const target = currentStepConfig.value.target
if (!target) {
spotlightRect.value = null
return
}
const element = document.querySelector(target)
if (element) {
spotlightRect.value = element.getBoundingClientRect()
} else {
spotlightRect.value = null
}
}
const spotlightStyle = computed(() => {
if (!spotlightRect.value) return {}
const padding = 8
return {
top: `${spotlightRect.value.top - padding}px`,
left: `${spotlightRect.value.left - padding}px`,
width: `${spotlightRect.value.width + padding * 2}px`,
height: `${spotlightRect.value.height + padding * 2}px`,
}
})
const cardStyle = computed(() => {
if (!spotlightRect.value || currentStepConfig.value.position === 'center') {
return {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}
}
const rect = spotlightRect.value
const position = currentStepConfig.value.position || 'bottom'
const offset = 16
const cardWidth = 420
const estimatedCardHeight = 260
const padding = 12
const style: Record<string, string> = {}
const centerX = rect.left + rect.width / 2
const halfCardWidth = cardWidth / 2
let adjustedCenterX = centerX
if (centerX - halfCardWidth < padding) {
adjustedCenterX = halfCardWidth + padding
} else if (centerX + halfCardWidth > window.innerWidth - padding) {
adjustedCenterX = window.innerWidth - halfCardWidth - padding
}
if (position === 'bottom') {
const spaceBelow = window.innerHeight - rect.bottom - offset - padding
const spaceAbove = rect.top - offset - padding
if (spaceBelow >= estimatedCardHeight || spaceBelow > spaceAbove) {
style.top = `${rect.bottom + offset}px`
style.left = `${adjustedCenterX}px`
style.transform = 'translateX(-50%)'
} else {
style.bottom = `${window.innerHeight - rect.top + offset}px`
style.left = `${adjustedCenterX}px`
style.transform = 'translateX(-50%)'
}
} else if (position === 'top') {
const spaceAbove = rect.top - offset - padding
const spaceBelow = window.innerHeight - rect.bottom - offset - padding
if (spaceAbove >= estimatedCardHeight || spaceAbove > spaceBelow) {
style.bottom = `${window.innerHeight - rect.top + offset}px`
style.left = `${adjustedCenterX}px`
style.transform = 'translateX(-50%)'
} else {
style.top = `${rect.bottom + offset}px`
style.left = `${adjustedCenterX}px`
style.transform = 'translateX(-50%)'
}
} else if (position === 'right') {
const spaceRight = window.innerWidth - rect.right - offset - padding
const spaceLeft = rect.left - offset - padding
const centerY = rect.top + rect.height / 2
const adjustedCenterY = Math.max(
estimatedCardHeight / 2 + padding,
Math.min(centerY, window.innerHeight - estimatedCardHeight / 2 - padding),
)
if (spaceRight >= cardWidth || spaceRight > spaceLeft) {
style.left = `${rect.right + offset}px`
style.top = `${adjustedCenterY}px`
style.transform = 'translateY(-50%)'
} else {
style.right = `${window.innerWidth - rect.left + offset}px`
style.top = `${adjustedCenterY}px`
style.transform = 'translateY(-50%)'
}
} else if (position === 'left') {
const spaceLeft = rect.left - offset - padding
const spaceRight = window.innerWidth - rect.right - offset - padding
const centerY = rect.top + rect.height / 2
const adjustedCenterY = Math.max(
estimatedCardHeight / 2 + padding,
Math.min(centerY, window.innerHeight - estimatedCardHeight / 2 - padding),
)
if (spaceLeft >= cardWidth || spaceLeft > spaceRight) {
style.right = `${window.innerWidth - rect.left + offset}px`
style.top = `${adjustedCenterY}px`
style.transform = 'translateY(-50%)'
} else {
style.left = `${rect.right + offset}px`
style.top = `${adjustedCenterY}px`
style.transform = 'translateY(-50%)'
}
}
return style
})
watch(currentStep, () => {
updateSpotlight()
})
watch(
() => route.path,
() => {
if (isVisible.value) {
updateSpotlight()
}
},
)
const handleNext = async () => {
if (currentStep.value < steps.length - 1) {
currentStep.value++
if (currentStep.value === 4) {
await router.push('/main-page/settings')
await new Promise(resolve => setTimeout(resolve, 400))
await updateSpotlight()
}
}
}
const handlePrevious = () => {
if (currentStep.value > 0) {
currentStep.value--
}
}
const handleSkip = () => {
isVisible.value = false
hasSeenGuide.value = true
}
const handleClose = () => {
isVisible.value = false
hasSeenGuide.value = true
}
const handleFinish = () => {
isVisible.value = false
hasSeenGuide.value = true
}
const restartGuide = () => {
currentStep.value = 0
isVisible.value = true
}
defineExpose({
restartGuide,
})
onMounted(async () => {
if (!hasSeenGuide.value) {
setTimeout(() => {
isVisible.value = true
updateSpotlight()
}, 500)
}
window.addEventListener('resize', updateSpotlight)
})
</script>