231 lines
14 KiB
Vue
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 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 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>
|