feat(web): add profile management
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -7,7 +7,7 @@ type Controller = 'human' | 'ai' | 'delegated'
|
||||
interface CampaignPayload {
|
||||
campaign: Record<string, any>
|
||||
world: Record<string, any>
|
||||
members: Array<Record<string, any> & { profile?: { display_name?: string } | null }>
|
||||
members: Array<Record<string, any> & { profile?: { id?: string; display_name?: string; avatar_url?: string | null } | null }>
|
||||
characters: Array<Record<string, any>>
|
||||
round: Record<string, any> | null
|
||||
rounds: Array<Record<string, any>>
|
||||
@@ -311,7 +311,7 @@ onBeforeUnmount(() => {
|
||||
<aside class="control-panel">
|
||||
<header class="panel-title"><span>ROUND CONTROL</span><i :class="{live:!refreshing}" /></header>
|
||||
<section class="readiness"><p><b>{{ readyCount }} / {{ activeHumanMembers.length }}</b> HUMANS READY</p><div><i :style="{width:`${activeHumanMembers.length ? readyCount/activeHumanMembers.length*100 : 0}%`} " /></div></section>
|
||||
<section class="members"><small>PLAYERS</small><article v-for="member in payload.members" :key="member.id"><i :class="{ready:payload.intents.some(intent=>intent.member_id===member.id && intent.ready)}" /><span><b>{{ member.profile?.display_name || 'Adventurer' }}</b><em>{{ member.role }} · {{ payload.intents.some(intent=>intent.member_id===member.id && intent.ready) ? 'ready' : 'waiting' }}</em><button v-if="isOwner" class="takeover-toggle" :class="{enabled:member.ai_takeover_allowed}" :disabled="mutating" @click="toggleTakeover(member)">AI TAKEOVER {{ member.ai_takeover_allowed ? 'ON' : 'OFF' }}</button></span></article></section>
|
||||
<section class="members"><small>PLAYERS</small><article v-for="member in payload.members" :key="member.id"><i :class="{ready:payload.intents.some(intent=>intent.member_id===member.id && intent.ready)}" /><NuxtLink class="member-profile" :to="`/profile/${member.user_id}?campaign=${campaignId}`"><span class="member-avatar"><b>{{ initials(member.profile?.display_name || 'Adventurer') }}</b><img v-if="member.profile?.avatar_url" :src="member.profile.avatar_url" alt=""></span><span><b>{{ member.profile?.display_name || 'Adventurer' }}</b><em>{{ member.role }} · {{ payload.intents.some(intent=>intent.member_id===member.id && intent.ready) ? 'ready' : 'waiting' }}</em></span></NuxtLink><button v-if="isOwner" class="takeover-toggle" :class="{enabled:member.ai_takeover_allowed}" :disabled="mutating" @click="toggleTakeover(member)">AI TAKEOVER {{ member.ai_takeover_allowed ? 'ON' : 'OFF' }}</button></article></section>
|
||||
<section v-if="isOwner" class="invite-box"><small>INVITE PLAYERS</small><p>Private link · up to 8 joins · expires in 72 hours.</p><button v-if="!inviteLink" class="ghost-button" :disabled="mutating" @click="createInvite">CREATE INVITE LINK</button><template v-else><input :value="inviteLink" readonly aria-label="Campaign invite link"><button class="acid-button" @click="copyInvite">{{ inviteCopied ? 'COPIED' : 'COPY LINK' }}</button></template></section>
|
||||
<section class="ai-order"><small>AI TURN ORDER</small><p v-if="!payload.characters.some(character=>character.controller!=='human')">No AI heroes in this party.</p><div v-for="(character,index) in payload.characters.filter(character=>character.controller!=='human')" :key="character.id"><b>{{ String(index+1).padStart(2,'0') }}</b><span>{{ character.name }}<small>{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}</small></span></div></section>
|
||||
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span>↗</span></button>
|
||||
@@ -334,4 +334,12 @@ onBeforeUnmount(() => {
|
||||
.takeover-toggle{margin-top:7px;padding:4px 6px;border:1px solid var(--line);background:transparent;color:var(--muted);font:500 6px var(--mono);text-align:left}
|
||||
.takeover-toggle.enabled{border-color:var(--acid-dim);color:var(--acid)}
|
||||
.ai-draft{min-height:34px;border:1px solid var(--acid-dim);background:rgba(207,255,70,.04);color:var(--acid);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor{border:1px solid var(--line);padding:9px}.persona-editor summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor input{margin-top:7px}
|
||||
.members article{display:grid;grid-template-columns:6px minmax(0,1fr);align-items:center}
|
||||
.member-profile{display:grid;grid-template-columns:30px minmax(0,1fr);gap:9px;align-items:center;color:var(--ink);text-decoration:none}
|
||||
.member-profile>span:last-child{display:flex;min-width:0;flex-direction:column}
|
||||
.member-avatar{position:relative;overflow:hidden;display:grid!important;place-items:center;width:30px;height:30px;border:1px solid var(--line);background:#171714;color:var(--acid)}
|
||||
.member-avatar>b{font:600 7px var(--mono)!important}
|
||||
.member-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||
.members .member-profile b{overflow-wrap:anywhere}
|
||||
.members .takeover-toggle{grid-column:2;margin-left:39px}
|
||||
</style>
|
||||
|
||||
74
apps/web/pages/profile/[id].vue
Normal file
74
apps/web/pages/profile/[id].vue
Normal 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>
|
||||
230
apps/web/pages/profile/index.vue
Normal file
230
apps/web/pages/profile/index.vue
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user