mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
d9ea13ba45
Define AGPLv3 additional terms and expose attribution, source, and license details through unobtrusive About surfaces.
329 lines
12 KiB
Vue
329 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { Moon, Sun, Wallpaper } from '@lucide/vue'
|
|
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
|
|
|
|
const { t } = useI18n()
|
|
const themeStore = useThemeStore()
|
|
const accentOpen = ref(false)
|
|
const radiusOpen = ref(false)
|
|
const backgroundOpen = ref(false)
|
|
const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeStore.accent))
|
|
|
|
function radiusPreview(id: string): string {
|
|
const map: Record<string, string> = {
|
|
sharp: '0px',
|
|
default: '4px',
|
|
rounded: '8px',
|
|
pill: '999px',
|
|
}
|
|
return map[id] ?? '4px'
|
|
}
|
|
|
|
function openRadius() {
|
|
radiusOpen.value = !radiusOpen.value
|
|
accentOpen.value = false
|
|
backgroundOpen.value = false
|
|
}
|
|
|
|
function openBackground() {
|
|
backgroundOpen.value = !backgroundOpen.value
|
|
accentOpen.value = false
|
|
radiusOpen.value = false
|
|
}
|
|
|
|
function openAccent() {
|
|
accentOpen.value = !accentOpen.value
|
|
radiusOpen.value = false
|
|
backgroundOpen.value = false
|
|
}
|
|
|
|
function closeAll() {
|
|
accentOpen.value = false
|
|
radiusOpen.value = false
|
|
backgroundOpen.value = false
|
|
}
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const token = ref('')
|
|
const newPassword = ref('')
|
|
const confirmPassword = ref('')
|
|
const error = ref<string | null>(null)
|
|
const loading = ref(false)
|
|
|
|
onMounted(() => {
|
|
token.value = (route.query.token as string) ?? ''
|
|
if (!token.value) {
|
|
error.value = t('auth.resetPassword.errors.invalidLink')
|
|
}
|
|
})
|
|
|
|
async function handleSubmit() {
|
|
error.value = null
|
|
|
|
if (newPassword.value !== confirmPassword.value) {
|
|
error.value = t('auth.errors.passwordsDoNotMatch')
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
try {
|
|
const res = await fetch('/api/v1/auth/reset-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token: token.value, newPassword: newPassword.value }),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}))
|
|
error.value = err.message ?? t('auth.resetPassword.errors.invalidOrExpired')
|
|
return
|
|
}
|
|
|
|
router.push({ path: '/login', query: { reset: '1' } })
|
|
} catch {
|
|
error.value = t('auth.errors.generic')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
|
|
<!-- Compact theme picker -->
|
|
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
|
|
<!-- Dark / light toggle -->
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="theme-btn" @click="themeStore.toggleTheme()">
|
|
<Sun v-if="themeStore.resolvedTheme === 'dark'" :size="14" />
|
|
<Moon v-else :size="14" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{
|
|
themeStore.resolvedTheme === 'dark' ? t('auth.themePicker.switchToLight') : t('auth.themePicker.switchToDark')
|
|
}}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<!-- Radius picker -->
|
|
<div class="relative">
|
|
<Transition name="popover">
|
|
<div v-if="radiusOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg">
|
|
<div class="flex items-center gap-1.5">
|
|
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all focus:outline-none"
|
|
:class="
|
|
themeStore.radius === opt.id
|
|
? 'text-primary bg-primary/10'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
|
|
"
|
|
@click="themeStore.setRadius(opt.id)"
|
|
>
|
|
<span class="w-4 h-4 border-2 border-current block" :style="{ borderRadius: radiusPreview(opt.id) }" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ opt.label }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="theme-btn" @click="openRadius()">
|
|
<span class="w-3.5 h-3.5 border-2 border-current block" :style="{ borderRadius: radiusPreview(themeStore.radius) }" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('auth.themePicker.changeRadius') }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<!-- Background picker -->
|
|
<div class="relative">
|
|
<Transition name="popover">
|
|
<div v-if="backgroundOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg w-64 max-h-72 overflow-y-auto">
|
|
<div class="grid grid-cols-5 gap-1.5">
|
|
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
class="w-full h-9 rounded overflow-hidden ring-2 transition-all focus:outline-none shrink-0"
|
|
:class="
|
|
themeStore.background === opt.id ? 'ring-primary shadow-sm shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
|
|
"
|
|
@click="themeStore.setBackground(opt.id)"
|
|
>
|
|
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ opt.label }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="theme-btn" @click="openBackground()">
|
|
<Wallpaper :size="14" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('auth.themePicker.changeBackground') }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<!-- Accent picker -->
|
|
<div class="relative">
|
|
<Transition name="popover">
|
|
<div v-if="accentOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-3 rounded-lg space-y-2 w-96 max-w-[calc(100vw-2rem)]">
|
|
<div class="overflow-x-auto no-scrollbar px-1 py-0.5">
|
|
<div class="space-y-2 w-max">
|
|
<div v-for="(row, rowIndex) in ACCENT_ROWS" :key="rowIndex" class="flex items-center gap-1.5">
|
|
<Tooltip v-for="opt in row" :key="opt.id">
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card shrink-0"
|
|
:class="opt.swatchClass"
|
|
:aria-label="t(opt.labelKey)"
|
|
:style="{
|
|
backgroundColor: opt.color,
|
|
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
|
|
outlineOffset: '2px',
|
|
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
|
|
}"
|
|
@click="themeStore.setAccent(opt.id)"
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t(opt.labelKey) }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="theme-btn" @click="openAccent()">
|
|
<span class="w-3.5 h-3.5 rounded-full block" :class="currentAccent?.swatchClass" :style="{ backgroundColor: currentAccent?.color }" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('auth.themePicker.changeAccent') }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Click-outside backdrop -->
|
|
<div v-if="accentOpen || radiusOpen || backgroundOpen" class="fixed inset-0 z-10" @click="closeAll()" />
|
|
|
|
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
|
|
<div class="text-center mb-8">
|
|
<h1 class="text-2xl font-serif font-semibold text-foreground">Book<span class="text-primary"> Orbit</span></h1>
|
|
<p class="text-sm text-muted-foreground mt-1">{{ t('auth.resetPassword.subtitle') }}</p>
|
|
</div>
|
|
|
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
|
<div class="space-y-1.5">
|
|
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('auth.fields.newPassword') }}</label>
|
|
<input
|
|
id="newPassword"
|
|
v-model="newPassword"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
required
|
|
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
|
|
/>
|
|
<p class="text-xs text-muted-foreground">{{ t('auth.fields.passwordHint') }}</p>
|
|
</div>
|
|
|
|
<div class="space-y-1.5">
|
|
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('auth.fields.confirmPassword') }}</label>
|
|
<input
|
|
id="confirmPassword"
|
|
v-model="confirmPassword"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
required
|
|
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
|
|
|
|
<button
|
|
type="submit"
|
|
:disabled="loading || !token"
|
|
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
>
|
|
{{ loading ? t('auth.resetPassword.saving') : t('auth.resetPassword.setNewPassword') }}
|
|
</button>
|
|
</form>
|
|
|
|
<p class="mt-4 text-center text-sm text-muted-foreground">
|
|
<RouterLink to="/login" class="text-primary hover:underline">{{ t('auth.backToSignIn') }}</RouterLink>
|
|
</p>
|
|
</div>
|
|
<PublicLegalNotices />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.login-bg {
|
|
position: relative;
|
|
}
|
|
|
|
.theme-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border-radius: 9999px;
|
|
background: color-mix(in oklch, var(--card) 72%, transparent);
|
|
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
box-shadow: var(--elevation-md);
|
|
color: var(--muted-foreground);
|
|
transition: color 0.15s ease;
|
|
}
|
|
|
|
.theme-btn:hover {
|
|
color: var(--foreground);
|
|
}
|
|
|
|
.accent-popover {
|
|
background: color-mix(in oklch, var(--card) 80%, transparent);
|
|
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
|
backdrop-filter: blur(24px);
|
|
-webkit-backdrop-filter: blur(24px);
|
|
box-shadow: var(--elevation-lg);
|
|
}
|
|
|
|
.popover-enter-active,
|
|
.popover-leave-active {
|
|
transition:
|
|
opacity 0.15s ease,
|
|
transform 0.15s ease;
|
|
}
|
|
|
|
.popover-enter-from,
|
|
.popover-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(4px) scale(0.97);
|
|
}
|
|
|
|
.login-card {
|
|
background: color-mix(in oklch, var(--card) 72%, transparent);
|
|
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
|
backdrop-filter: blur(24px);
|
|
-webkit-backdrop-filter: blur(24px);
|
|
box-shadow: var(--elevation-xl);
|
|
}
|
|
</style>
|