Files
hnh-map/frontend-nuxt/pages/profile.vue
Nikolay Tatarinov adfdfd01c4 Update frontend components for accessibility and functionality improvements
- Modified docker-compose.dev.yml to conditionally run npm install based on the presence of package-lock.json.
- Upgraded Nuxt version in package-lock.json from 3.21.1 to 4.3.1 for enhanced features.
- Enhanced ConfirmModal component with aria-modal attribute for better accessibility.
- Updated MapErrorBoundary component's error message for clarity.
- Added role and aria-label attributes to MapView and MapSearch components for improved screen reader support.
- Refactored various components to manage focus behavior on modal close, enhancing user experience.
- Improved ToastContainer styling for better responsiveness and visibility.
- Updated layout components to include skip navigation links for improved accessibility.
2026-03-04 01:00:56 +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 @submit.prevent="changePass" class="flex flex-col gap-2">
<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>