Files
Dungeons-Ground/apps/web/pages/profile/index.vue
pavel444-byte fdf02446f5
Some checks failed
CI / validate (push) Has been cancelled
CI / validate (pull_request) Failing after 11s
feat(ui): add site context menu
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 16:56:14 +05:00

231 lines
14 KiB
Vue

<script setup lang="ts">
interface ProfileView {
id: string
display_name: string
description: string
avatar_url: string | null
created_at: string
updated_at: string
}
interface SharedCampaign {
id: string
title: string
status: string
role: string
joined_at: string
}
const { api } = useDngApi()
const auth = useDngAuth()
const profile = ref<ProfileView | null>(null)
const campaigns = ref<SharedCampaign[]>([])
const loading = ref(true)
const saving = ref(false)
const changingPassword = ref(false)
const removingAvatar = ref(false)
const error = ref('')
const success = ref('')
const avatarFile = ref<File | null>(null)
const avatarPreview = ref('')
const displayName = ref('')
const description = ref('')
const password = ref('')
const passwordConfirmation = ref('')
const initials = computed(() => (displayName.value || 'Adventurer')
.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase())
const currentAvatar = computed(() => avatarPreview.value || profile.value?.avatar_url || '')
function messageFrom(cause: unknown, fallback: string) {
const value = cause as { data?: { statusMessage?: string; message?: string }; message?: string }
return value.data?.statusMessage ?? value.data?.message ?? value.message ?? fallback
}
function clearPreview() {
if (avatarPreview.value) URL.revokeObjectURL(avatarPreview.value)
avatarPreview.value = ''
avatarFile.value = null
}
function chooseAvatar(event: Event) {
error.value = ''
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
error.value = 'Choose a JPG, PNG, or WebP image.'
input.value = ''
return
}
if (file.size > 2 * 1024 * 1024) {
error.value = 'Profile pictures must be 2 MB or smaller.'
input.value = ''
return
}
clearPreview()
avatarFile.value = file
avatarPreview.value = URL.createObjectURL(file)
}
async function loadProfile() {
await auth.restore()
const userId = auth.session.value?.user.id
if (!userId) {
await navigateTo('/auth/sign-in')
return
}
const result = await api<{ profile: ProfileView; sharedCampaigns: SharedCampaign[] }>(`/api/v1/profiles/${userId}`)
profile.value = result.profile
campaigns.value = result.sharedCampaigns
displayName.value = result.profile.display_name
description.value = result.profile.description
}
async function saveProfile() {
if (saving.value) return
saving.value = true
error.value = ''
success.value = ''
try {
let avatarPath: string | undefined
if (avatarFile.value) avatarPath = await auth.uploadAvatar(avatarFile.value)
const body: Record<string, unknown> = {
displayName: displayName.value.trim(),
description: description.value.trim(),
}
if (avatarPath) body.avatarPath = avatarPath
const result = await api<{ profile: ProfileView }>('/api/v1/profile', { method: 'PATCH', body })
profile.value = result.profile
displayName.value = result.profile.display_name
description.value = result.profile.description
await auth.updateUserMetadata({
display_name: result.profile.display_name,
avatar_url: result.profile.avatar_url,
})
clearPreview()
success.value = 'Profile changes saved.'
} catch (cause) {
error.value = messageFrom(cause, 'Could not save your profile.')
} finally {
saving.value = false
}
}
async function removePicture() {
if (!profile.value?.avatar_url || removingAvatar.value) return
removingAvatar.value = true
error.value = ''
success.value = ''
try {
await auth.removeAvatar()
const result = await api<{ profile: ProfileView }>('/api/v1/profile', {
method: 'PATCH', body: { avatarPath: null },
})
profile.value = result.profile
await auth.updateUserMetadata({ avatar_url: null })
clearPreview()
success.value = 'Profile picture removed.'
} catch (cause) {
error.value = messageFrom(cause, 'Could not remove your profile picture.')
} finally {
removingAvatar.value = false
}
}
async function changePassword() {
error.value = ''
success.value = ''
if (password.value.length < 8) {
error.value = 'Your new password must contain at least 8 characters.'
return
}
if (password.value !== passwordConfirmation.value) {
error.value = 'The password confirmation does not match.'
return
}
changingPassword.value = true
try {
await auth.updatePassword(password.value)
password.value = ''
passwordConfirmation.value = ''
success.value = 'Password updated securely.'
} catch (cause) {
error.value = messageFrom(cause, 'Could not update your password.')
} finally {
changingPassword.value = false
}
}
async function leave() {
await auth.signOut()
await navigateTo('/')
}
onMounted(async () => {
try {
await loadProfile()
} catch (cause) {
error.value = messageFrom(cause, 'Could not load your profile.')
} finally {
loading.value = false
}
})
onBeforeUnmount(clearPreview)
</script>
<template>
<AppShell section="ACCOUNT">
<div v-if="loading" class="profile-state noise"><i class="profile-spinner" /><p>OPENING YOUR ACCOUNT</p></div>
<div v-else-if="!profile" class="profile-state noise"><p class="eyebrow">PROFILE UNAVAILABLE</p><h1>THE RECORD IS MISSING.</h1><p>{{ error }}</p><NuxtLink to="/dashboard">BACK TO DASHBOARD</NuxtLink></div>
<div v-else class="profile-page noise">
<header class="profile-head">
<div><p class="eyebrow">PLAYER RECORD / PRIVATE CONTROLS</p><h1>YOUR PROFILE<span>.</span></h1><p>Shape how your party sees you and keep your account secure.</p></div>
<NuxtLink :to="`/profile/${profile.id}`">VIEW PUBLIC PROFILE </NuxtLink>
</header>
<div v-if="error || success" class="profile-notice" :class="{success}" role="status">{{ error || success }}</div>
<div class="profile-grid">
<aside class="identity-card">
<div class="avatar-stage">
<span>{{ initials }}</span><img v-if="currentAvatar" :src="currentAvatar" alt="Current profile picture">
<i>PLAYER<br>PORTRAIT</i>
</div>
<label class="upload-button">CHOOSE PICTURE<input type="file" accept="image/jpeg,image/png,image/webp" @change="chooseAvatar"></label>
<button v-if="profile.avatar_url" class="remove-button" :disabled="removingAvatar" @click="removePicture">{{ removingAvatar ? 'REMOVING' : 'REMOVE PICTURE' }}</button>
<p>JPG, PNG, or WebP · 2 MB maximum.</p>
<dl>
<div><dt>REGISTERED</dt><dd>{{ new Date(profile.created_at).toLocaleDateString(undefined, { dateStyle: 'long' }) }}</dd></div>
<div><dt>UNIVERSES</dt><dd>{{ campaigns.length }}</dd></div>
<div><dt>ACCOUNT EMAIL</dt><dd>{{ auth.session.value?.user.email }}</dd></div>
</dl>
</aside>
<div class="profile-forms">
<form id="identity" class="profile-panel" @submit.prevent="saveProfile">
<header><span>01</span><div><small>PUBLIC IDENTITY</small><h2>NAME & DESCRIPTION</h2></div></header>
<label>DISPLAY NAME<input v-model="displayName" required minlength="1" maxlength="80" autocomplete="name" placeholder="How your party knows you"></label>
<label>PROFILE DESCRIPTION<textarea v-model="description" maxlength="500" rows="7" placeholder="Tell your party who you are, what you enjoy playing, or what kind of stories you seek." /><span>{{ description.length }} / 500</span></label>
<button class="acid-button" :disabled="saving || !displayName.trim()">{{ saving ? 'SAVING PROFILE…' : avatarFile ? 'SAVE PROFILE & PICTURE →' : 'SAVE PROFILE →' }}</button>
</form>
<form id="settings" class="profile-panel" @submit.prevent="changePassword">
<header><span>02</span><div><small>SECURITY</small><h2>PASSWORD MANAGER</h2></div></header>
<p class="panel-copy">Choose a new password for this account. Your current signed-in session authorizes the change.</p>
<div class="password-grid"><label>NEW PASSWORD<input v-model="password" type="password" minlength="8" autocomplete="new-password" placeholder="At least 8 characters"></label><label>CONFIRM PASSWORD<input v-model="passwordConfirmation" type="password" minlength="8" autocomplete="new-password" placeholder="Repeat new password"></label></div>
<button class="ghost-button" :disabled="changingPassword || !password">{{ changingPassword ? 'UPDATING…' : 'UPDATE PASSWORD' }}</button>
</form>
<section class="signout-panel"><div><small>SESSION CONTROL</small><h2>LEAVE THE GROUND</h2><p>Sign out on this device. Your universes and profile remain safely stored.</p></div><button @click="leave">SIGN OUT </button></section>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.profile-page{min-height:calc(100vh - 76px);padding:clamp(38px,6vw,84px) clamp(20px,6vw,88px) 90px}.profile-head{display:flex;align-items:end;justify-content:space-between;gap:30px;margin-bottom:42px}.eyebrow{margin:0;color:var(--acid);font:500 8px var(--mono);letter-spacing:.18em}.profile-head h1,.profile-state h1{margin:15px 0 10px;font:600 clamp(42px,7vw,82px)/1 var(--display);letter-spacing:-.07em}.profile-head h1 span{color:var(--acid)}.profile-head>div>p:last-child,.profile-state>p{color:var(--muted)}.profile-head>a,.profile-state>a{padding-bottom:6px;border-bottom:1px solid var(--acid);color:var(--ink);text-decoration:none;font:600 8px var(--mono);letter-spacing:.1em}.profile-notice{margin-bottom:22px;padding:13px 16px;border:1px solid #6b372e;background:#221310;color:#ffab98;font:500 9px var(--mono)}.profile-notice.success{border-color:#526425;background:#151a0d;color:var(--acid)}.profile-grid{display:grid;grid-template-columns:minmax(240px,320px) minmax(0,1fr);gap:22px;align-items:start}.identity-card,.profile-panel,.signout-panel{border:1px solid var(--line);background:#0e0e0d}.identity-card{position:sticky;top:98px;padding:22px}.avatar-stage{position:relative;aspect-ratio:1;overflow:hidden;display:grid;place-items:center;background:radial-gradient(circle at 50% 45%,#3a411d,#11110e 52%,#080808);border:1px solid #37372f;color:var(--acid);font:600 48px var(--display)}.avatar-stage::after{content:"";position:absolute;inset:12px;border:1px solid rgba(217,247,95,.18);pointer-events:none}.avatar-stage img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.avatar-stage>i{position:absolute;z-index:2;left:22px;bottom:20px;color:var(--ink);font:500 7px/1.5 var(--mono);letter-spacing:.16em;font-style:normal}.upload-button,.remove-button{width:100%;min-height:42px;display:grid;place-items:center;margin-top:10px;border:0;background:var(--acid);color:#090909;font:700 8px var(--mono);letter-spacing:.11em}.upload-button input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}.remove-button{border:1px solid var(--line);background:transparent;color:var(--muted)}.identity-card>p{margin:12px 0 25px;color:var(--muted);font-size:9px}.identity-card dl{margin:0;border-top:1px solid var(--line)}.identity-card dl div{padding:14px 0;border-bottom:1px solid var(--line)}.identity-card dt{font:500 7px var(--mono);letter-spacing:.12em;color:var(--muted)}.identity-card dd{margin:6px 0 0;overflow-wrap:anywhere;font-size:11px}.profile-forms{display:grid;gap:22px}.profile-panel{padding:clamp(24px,4vw,46px)}.profile-panel header{display:flex;gap:18px;align-items:start;margin-bottom:34px}.profile-panel header>span{color:var(--acid);font:500 9px var(--mono)}.profile-panel small,.signout-panel small{color:var(--acid);font:500 7px var(--mono);letter-spacing:.15em}.profile-panel h2,.signout-panel h2{margin:8px 0 0;font:600 clamp(21px,3vw,33px) var(--display);letter-spacing:-.04em}.profile-panel>label,.password-grid label{display:grid;gap:9px;margin-top:18px;color:#b5b5ae;font:600 8px var(--mono);letter-spacing:.11em}.profile-panel input,.profile-panel textarea{width:100%;padding:14px;border:1px solid #383833;background:#090909;color:var(--ink);outline:none;font:12px/1.6 var(--body)}.profile-panel textarea{resize:vertical;min-height:150px}.profile-panel input:focus,.profile-panel textarea:focus{border-color:var(--acid)}.profile-panel label>span{justify-self:end;margin-top:-4px;color:var(--muted);font:500 7px var(--mono)}.acid-button,.ghost-button{min-height:46px;margin-top:22px;padding:0 20px;border:0;background:var(--acid);color:#090909;font:700 8px var(--mono);letter-spacing:.1em}.ghost-button{border:1px solid var(--line);background:transparent;color:var(--ink)}button:disabled{opacity:.45}.panel-copy{max-width:650px;color:var(--muted);font-size:11px;line-height:1.7}.password-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.signout-panel{display:flex;align-items:center;justify-content:space-between;gap:30px;padding:28px 34px}.signout-panel p{margin:9px 0 0;color:var(--muted);font-size:10px}.signout-panel button{flex:0 0 auto;min-height:42px;padding:0 18px;border:1px solid #61352e;background:transparent;color:#ff9e88;font:600 8px var(--mono);letter-spacing:.1em}.profile-state{min-height:calc(100vh - 76px);display:grid;place-content:center;justify-items:center;text-align:center;padding:30px}.profile-spinner{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--acid);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:850px){.profile-head{align-items:start;flex-direction:column}.profile-grid{grid-template-columns:1fr}.identity-card{position:static;display:grid;grid-template-columns:160px 1fr;column-gap:18px}.avatar-stage{grid-row:1/5}.identity-card dl{grid-column:1/-1;margin-top:12px}.password-grid{grid-template-columns:1fr}}@media(max-width:520px){.identity-card{display:block}.avatar-stage{margin-bottom:14px}.signout-panel{align-items:flex-start;flex-direction:column}.signout-panel button{width:100%}}
</style>