Enhance frontend components and introduce new features
- Updated PasswordInput component with improved styling and touch manipulation support. - Added new IconMenu component for consistent icon representation in the UI. - Refactored MapControls and introduced MapControlsContent for better organization and usability. - Implemented suppress-leaflet-deprecation plugin to handle known warnings in Firefox. - Enhanced default layout with a responsive drawer for mobile navigation and improved user experience.
This commit is contained in:
@@ -1,190 +1,190 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-4 max-w-2xl">
|
||||
<h1 class="text-2xl font-bold mb-6">Profile</h1>
|
||||
|
||||
<!-- User info card -->
|
||||
<div class="card bg-base-200 shadow-xl 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">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-14">
|
||||
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload tokens -->
|
||||
<div class="card bg-base-200 shadow-xl 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"
|
||||
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 transition-all duration-200 hover:scale-[1.02]" :disabled="loadingTokens" @click="generateToken">
|
||||
{{ loadingTokens ? '…' : 'Generate token' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl 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 transition-all duration-200 hover:scale-[1.02]" :disabled="loadingPass">Save password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const api = useMapApi()
|
||||
const toast = useToast()
|
||||
const initialLoad = ref(true)
|
||||
const me = ref<{ username?: string; auths?: string[] } | null>(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)
|
||||
let copiedTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
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 = { username: data.username, auths: data.auths }
|
||||
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 me = await api.me()
|
||||
tokens.value = me.tokens ?? []
|
||||
uploadPrefix.value = me.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>
|
||||
<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 bg-base-200 shadow-xl 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">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-14">
|
||||
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload tokens -->
|
||||
<div class="card bg-base-200 shadow-xl 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 transition-all duration-200 hover:scale-[1.02]" :disabled="loadingTokens" @click="generateToken">
|
||||
{{ loadingTokens ? '…' : 'Generate token' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl 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 transition-all duration-200 hover:scale-[1.02]" :disabled="loadingPass">Save password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const api = useMapApi()
|
||||
const toast = useToast()
|
||||
const initialLoad = ref(true)
|
||||
const me = ref<{ username?: string; auths?: string[] } | null>(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)
|
||||
let copiedTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
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 = { username: data.username, auths: data.auths }
|
||||
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 me = await api.me()
|
||||
tokens.value = me.tokens ?? []
|
||||
uploadPrefix.value = me.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>
|
||||
|
||||
Reference in New Issue
Block a user