feat(web): add profile management
Some checks failed
CI / validate (push) Successful in 19m25s
CI / validate (pull_request) Failing after 9m54s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-20 19:24:50 +05:00
parent fc205c232f
commit 41f4633dca
12 changed files with 687 additions and 15 deletions

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
interface ProfileView {
id: string
display_name: string
description: string
avatar_url: string | null
created_at: string
}
interface SharedCampaign {
id: string
title: string
status: string
role: string
joined_at: string
}
const route = useRoute()
const { api } = useDngApi()
const auth = useDngAuth()
const profile = ref<ProfileView | null>(null)
const campaigns = ref<SharedCampaign[]>([])
const loading = ref(true)
const error = ref('')
const profileId = computed(() => String(route.params.id ?? ''))
const returnCampaign = computed(() => typeof route.query.campaign === 'string' ? route.query.campaign : '')
const initials = computed(() => (profile.value?.display_name || 'Adventurer')
.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase())
function messageFrom(cause: unknown) {
const value = cause as { data?: { statusMessage?: string }; message?: string }
return value.data?.statusMessage ?? value.message ?? 'This player profile is not available.'
}
onMounted(async () => {
try {
await auth.restore()
const result = await api<{ profile: ProfileView; sharedCampaigns: SharedCampaign[] }>(`/api/v1/profiles/${profileId.value}`)
profile.value = result.profile
campaigns.value = result.sharedCampaigns
} catch (cause) {
error.value = messageFrom(cause)
} finally {
loading.value = false
}
})
</script>
<template>
<AppShell section="PLAYER RECORD">
<div v-if="loading" class="public-state noise"><i /><p>LOCATING PLAYER RECORD</p></div>
<div v-else-if="!profile" class="public-state noise"><small>PRIVATE RECORD</small><h1>PLAYER NOT FOUND.</h1><p>{{ error }}</p><NuxtLink :to="returnCampaign ? `/campaign/${returnCampaign}` : '/dashboard'">GO BACK</NuxtLink></div>
<div v-else class="public-profile noise">
<nav><NuxtLink :to="returnCampaign ? `/campaign/${returnCampaign}` : '/dashboard'"> {{ returnCampaign ? 'BACK TO CAMPAIGN' : 'BACK TO DASHBOARD' }}</NuxtLink><NuxtLink v-if="profile.id === auth.session.value?.user.id" to="/profile">MANAGE PROFILE </NuxtLink></nav>
<main>
<div class="public-avatar"><span>{{ initials }}</span><img v-if="profile.avatar_url" :src="profile.avatar_url" :alt="`${profile.display_name}'s profile picture`"><i>VERIFIED<br>PARTY MEMBER</i></div>
<section class="public-copy">
<p class="eyebrow">PLAYER PROFILE / DUNGEONS & GROUND</p>
<h1>{{ profile.display_name }}<span>.</span></h1>
<blockquote>{{ profile.description || 'This adventurer has not written a profile description yet.' }}</blockquote>
<dl>
<div><dt>REGISTERED</dt><dd>{{ new Date(profile.created_at).toLocaleDateString(undefined, { dateStyle: 'long' }) }}</dd></div>
<div><dt>SHARED UNIVERSES</dt><dd>{{ campaigns.length }}</dd></div>
</dl>
<div v-if="campaigns.length" class="shared-worlds"><small>YOUR SHARED UNIVERSES</small><NuxtLink v-for="campaign in campaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`"><span><b>{{ campaign.title }}</b><em>{{ campaign.role }} · joined {{ new Date(campaign.joined_at).toLocaleDateString() }}</em></span><i></i></NuxtLink></div>
</section>
</main>
</div>
</AppShell>
</template>
<style scoped>
.public-profile{min-height:calc(100vh - 76px);padding:32px clamp(20px,6vw,88px) 80px}.public-profile nav{display:flex;justify-content:space-between;gap:20px;padding:0 0 28px;border-bottom:1px solid var(--line)}.public-profile nav a,.public-state a{color:var(--muted);text-decoration:none;font:600 8px var(--mono);letter-spacing:.1em}.public-profile main{display:grid;grid-template-columns:minmax(300px,42%) minmax(0,1fr);min-height:650px;border:1px solid var(--line);border-top:0;background:#0e0e0d}.public-avatar{position:relative;overflow:hidden;display:grid;place-items:center;min-height:650px;border-right:1px solid var(--line);background:radial-gradient(circle at 50% 42%,#4e5724,#17170f 33%,#080808 70%);color:var(--acid);font:600 clamp(50px,8vw,110px) var(--display)}.public-avatar::after{content:"";position:absolute;inset:28px;border:1px solid rgba(217,247,95,.2)}.public-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.public-avatar i{position:absolute;z-index:2;left:50px;bottom:45px;color:var(--ink);font:500 8px/1.6 var(--mono);letter-spacing:.16em;font-style:normal}.public-copy{padding:clamp(35px,6vw,80px);align-self:center}.eyebrow{color:var(--acid);font:500 8px var(--mono);letter-spacing:.18em}.public-copy h1,.public-state h1{margin:18px 0 28px;font:600 clamp(42px,6vw,78px)/1.04 var(--display);letter-spacing:-.07em;overflow-wrap:anywhere}.public-copy h1 span{color:var(--acid)}blockquote{margin:0;padding-left:22px;border-left:2px solid var(--acid);color:#c9c9c2;font:400 clamp(14px,2vw,19px)/1.8 var(--body)}dl{display:grid;grid-template-columns:1fr 1fr;margin:42px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}dl div{padding:18px 0}dl div+div{padding-left:24px;border-left:1px solid var(--line)}dt{color:var(--muted);font:500 7px var(--mono);letter-spacing:.13em}dd{margin:7px 0 0;font-size:11px}.shared-worlds>small{color:var(--acid);font:500 7px var(--mono);letter-spacing:.14em}.shared-worlds>a{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:16px 0;border-bottom:1px solid var(--line);color:var(--ink);text-decoration:none}.shared-worlds span{display:flex;flex-direction:column;gap:6px}.shared-worlds b{font:600 11px var(--display)}.shared-worlds em{color:var(--muted);font:500 7px var(--mono);text-transform:uppercase;font-style:normal}.shared-worlds>a>i{color:var(--acid);font-style:normal}.public-state{min-height:calc(100vh - 76px);display:grid;place-content:center;justify-items:center;text-align:center;padding:30px}.public-state>i{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--acid);border-radius:50%;animation:spin .8s linear infinite}.public-state small{color:var(--acid);font:500 8px var(--mono);letter-spacing:.15em}.public-state p{color:var(--muted)}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:820px){.public-profile main{grid-template-columns:1fr}.public-avatar{min-height:380px;border-right:0;border-bottom:1px solid var(--line)}.public-avatar i{left:28px;bottom:26px}.public-copy{padding:34px 26px}}@media(max-width:480px){dl{grid-template-columns:1fr}dl div+div{padding-left:0;border-left:0;border-top:1px solid var(--line)}}
</style>

File diff suppressed because one or more lines are too long