Files
hnh-map/frontend-nuxt/pages/profile.vue
Nikolay Tatarinov fd624c2357 Refactor frontend components for improved functionality and accessibility
- Consolidated global error handling in app.vue to redirect users to the login page on API authentication failure.
- Enhanced MapView component by reintroducing event listeners for selected map and marker updates, improving interactivity.
- Updated PasswordInput and various modal components to ensure proper input handling and accessibility compliance.
- Refactored MapControls and MapControlsContent to streamline prop management and enhance user experience.
- Improved error handling in local storage operations within useMapBookmarks and useRecentLocations composables.
- Standardized input elements across forms for consistency in user interaction.
2026-03-04 14:06:27 +03:00

253 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="container mx-auto p-4 max-w-2xl min-w-0">
<h1 class="text-2xl font-bold mb-6">Profile</h1>
<!-- User info card -->
<div class="card card-app card-bg-base-200 mb-6 transition-all duration-200">
<div class="card-body">
<template v-if="initialLoad">
<div class="flex items-center gap-4">
<Skeleton class="size-14 rounded-full shrink-0" />
<div class="flex flex-col gap-2">
<Skeleton class="h-6 w-32" />
<Skeleton class="h-4 w-48" />
</div>
</div>
</template>
<template v-else-if="me">
<div class="flex flex-wrap items-center gap-4">
<UserAvatar :username="me.username" :email="me.email" :size="56" />
<div class="flex flex-col gap-1">
<h2 class="text-lg font-semibold">{{ me.username }}</h2>
<div class="flex flex-wrap gap-1.5">
<span
v-for="auth in (me.auths ?? [])"
:key="auth"
class="badge badge-sm badge-outline"
>
{{ auth }}
</span>
<span v-if="!me.auths?.length" class="text-sm text-base-content/60">No roles</span>
</div>
<p class="text-sm text-base-content/80 mt-1">
Email: {{ me.email || 'Not set' }}
</p>
<div v-if="!emailEditing" class="flex items-center gap-2 mt-1">
<button
type="button"
class="btn btn-ghost btn-xs"
@click="startEditEmail"
>
{{ me.email ? 'Edit' : 'Set' }} email
</button>
</div>
<form v-else class="flex flex-wrap items-center gap-2 mt-2" @submit.prevent="saveEmail">
<input
v-model="emailEdit"
type="email"
placeholder="email@example.com"
class="input input-bordered input-sm w-full max-w-xs"
autocomplete="email"
:aria-describedby="emailError ? 'profile-email-error' : undefined"
:aria-invalid="!!emailError"
>
<button type="submit" class="btn btn-primary btn-sm" :disabled="loadingEmail">
{{ loadingEmail ? '…' : 'Save' }}
</button>
<button type="button" class="btn btn-ghost btn-sm" :disabled="loadingEmail" @click="cancelEditEmail">
Cancel
</button>
<p v-if="emailError" id="profile-email-error" class="text-error text-sm w-full" role="alert">{{ emailError }}</p>
</form>
</div>
</div>
</template>
</div>
</div>
<!-- Upload tokens -->
<div class="card card-app card-bg-base-200 mb-6 transition-all duration-200">
<div class="card-body">
<template v-if="initialLoad">
<Skeleton class="h-6 w-32 mb-2" />
<Skeleton class="h-4 w-full mb-4" />
<Skeleton class="h-8 w-full mb-2" />
<Skeleton class="h-9 w-36 mt-2" />
</template>
<template v-else>
<h2 class="card-title gap-2">
<icons-icon-key />
Upload tokens
</h2>
<p class="text-sm opacity-80">Tokens for upload API. Generate and copy as needed.</p>
<ul v-if="tokens?.length" class="list-none mt-2 space-y-2">
<li
v-for="(t, idx) in tokens"
:key="t"
class="font-mono text-sm flex flex-wrap items-center gap-2 p-2 rounded-lg bg-base-300/50"
>
<span class="break-all flex-1 min-w-0">{{ uploadTokenDisplay(t) }}</span>
<span class="text-xs text-base-content/60 shrink-0">Token {{ idx + 1 }}</span>
<button
type="button"
class="btn btn-ghost btn-xs shrink-0 gap-1 min-h-9 min-w-[4rem] touch-manipulation"
aria-label="Copy token"
:class="copiedToken === t ? 'btn-success' : ''"
@click="copyToken(t)"
>
<template v-if="copiedToken === t">Copied!</template>
<template v-else>Copy</template>
</button>
</li>
</ul>
<p v-else class="text-sm mt-2">No tokens yet.</p>
<p v-if="tokenError" class="text-error text-sm mt-2">{{ tokenError }}</p>
<button class="btn btn-primary btn-sm mt-2 min-h-11 touch-manipulation" :disabled="loadingTokens" @click="generateToken">
{{ loadingTokens ? '' : 'Generate token' }}
</button>
</template>
</div>
</div>
<div class="card card-app card-bg-base-200 mb-6 transition-all duration-200">
<div class="card-body">
<h2 class="card-title gap-2">
<icons-icon-settings />
Change password
</h2>
<form class="flex flex-col gap-2" @submit.prevent="changePass">
<PasswordInput
v-model="newPass"
placeholder="New password"
autocomplete="new-password"
/>
<p v-if="passMsg" class="text-sm" :class="passOk ? 'text-success' : 'text-error'">{{ passMsg }}</p>
<button type="submit" class="btn btn-sm min-h-11 touch-manipulation" :disabled="loadingPass">Save password</button>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { MeResponse } from '~/types/api'
useHead({ title: 'Profile HnH Map' })
const api = useMapApi()
const toast = useToast()
const initialLoad = ref(true)
const me = useState<MeResponse | null>('me', () => null)
const tokens = ref<string[]>([])
const uploadPrefix = ref('')
const newPass = ref('')
const loadingTokens = ref(false)
const loadingPass = ref(false)
const passMsg = ref('')
const passOk = ref(false)
const tokenError = ref('')
const copiedToken = ref<string | null>(null)
const emailEditing = ref(false)
const emailEdit = ref('')
const loadingEmail = ref(false)
const emailError = ref('')
let copiedTimeout: ReturnType<typeof setTimeout> | null = null
function startEditEmail() {
emailError.value = ''
emailEdit.value = me.value?.email ?? ''
emailEditing.value = true
}
function cancelEditEmail() {
emailEditing.value = false
emailError.value = ''
}
async function saveEmail() {
emailError.value = ''
loadingEmail.value = true
try {
await api.meUpdate({ email: emailEdit.value.trim() || undefined })
const data = await api.me()
me.value = data
emailEditing.value = false
toast.success('Email updated')
} catch (e: unknown) {
emailError.value = e instanceof Error ? e.message : 'Failed to update email'
} finally {
loadingEmail.value = false
}
}
function uploadTokenDisplay(token: string): string {
const base = (uploadPrefix.value ?? '').replace(/\/+$/, '')
return base ? `${base}/client/${token}` : `client/${token}`
}
async function copyToken(token: string) {
const text = uploadTokenDisplay(token)
try {
await navigator.clipboard.writeText(text)
copiedToken.value = token
toast.success('Copied to clipboard', 2000)
if (copiedTimeout) clearTimeout(copiedTimeout)
copiedTimeout = setTimeout(() => {
copiedToken.value = null
}, 2000)
} catch {
toast.error('Failed to copy')
}
}
onMounted(async () => {
try {
const data = await api.me()
me.value = data
tokens.value = data.tokens ?? []
uploadPrefix.value = data.prefix ?? ''
} catch {
me.value = null
tokens.value = []
uploadPrefix.value = ''
} finally {
initialLoad.value = false
}
})
async function generateToken() {
tokenError.value = ''
loadingTokens.value = true
try {
await api.meTokens()
const data = await api.me()
me.value = data
tokens.value = data.tokens ?? []
uploadPrefix.value = data.prefix ?? ''
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : ''
tokenError.value = msg === 'Forbidden'
? 'You need "upload" permission to generate tokens. Ask an admin to add it to your account.'
: (msg || 'Failed to generate token')
} finally {
loadingTokens.value = false
}
}
async function changePass() {
passMsg.value = ''
loadingPass.value = true
try {
await api.mePassword(newPass.value)
passMsg.value = 'Password updated.'
passOk.value = true
newPass.value = ''
} catch (e: unknown) {
passMsg.value = e instanceof Error ? e.message : 'Failed'
passOk.value = false
} finally {
loadingPass.value = false
}
}
</script>