Compare commits
2 Commits
agent/code
...
fb4b06711d
| Author | SHA1 | Date | |
|---|---|---|---|
| fb4b06711d | |||
| 41f4633dca |
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{ section?: string }>()
|
defineProps<{ section?: string }>()
|
||||||
const { session, restore, signOut } = useDngAuth()
|
const { session, restore } = useDngAuth()
|
||||||
onMounted(() => void restore())
|
onMounted(() => void restore())
|
||||||
|
|
||||||
const initials = computed(() => {
|
const initials = computed(() => {
|
||||||
@@ -8,11 +8,10 @@ const initials = computed(() => {
|
|||||||
const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? ''
|
const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? ''
|
||||||
return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G'
|
return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G'
|
||||||
})
|
})
|
||||||
|
const avatarUrl = computed(() => {
|
||||||
async function leave() {
|
const value = session.value?.user.user_metadata?.avatar_url
|
||||||
await signOut()
|
return typeof value === 'string' && value ? value : ''
|
||||||
await navigateTo('/')
|
})
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -22,7 +21,9 @@ async function leave() {
|
|||||||
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard">⌂</NuxtLink>
|
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard">⌂</NuxtLink>
|
||||||
<button v-if="session" class="avatar" :title="`Sign out ${session.user.email ?? ''}`" @click="leave">{{ initials }}</button>
|
<NuxtLink v-if="session" to="/profile" class="avatar" :title="`Open profile for ${session.user.email ?? ''}`" aria-label="Open profile">
|
||||||
|
<span>{{ initials }}</span><img v-if="avatarUrl" :src="avatarUrl" alt="">
|
||||||
|
</NuxtLink>
|
||||||
<div v-else class="avatar">D&G</div>
|
<div v-else class="avatar">D&G</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -31,5 +32,5 @@ async function leave() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;font:700 10px var(--mono)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{position:relative;overflow:hidden;display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;text-decoration:none;font:700 10px var(--mono)}.avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.avatar span{position:relative}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -211,6 +211,64 @@ export function useDngAuth() {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateUserMetadata(data: Record<string, unknown>) {
|
||||||
|
const token = await accessToken()
|
||||||
|
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: authHeaders(token),
|
||||||
|
body: { data },
|
||||||
|
}))
|
||||||
|
if (session.value) persist({ ...session.value, user })
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
async function avatarStorageRequest(path: string, options: RequestInit) {
|
||||||
|
const token = await accessToken()
|
||||||
|
const baseUrl = String(config.public.supabaseUrl).replace(/\/$/, '')
|
||||||
|
const response = await fetch(`${baseUrl}/storage/v1/${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
apikey: config.public.supabaseAnonKey,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null) as { message?: string; error?: string } | null
|
||||||
|
throw new Error(payload?.message || payload?.error || `Profile picture request failed (${response.status}).`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatar(file: File) {
|
||||||
|
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||||
|
throw new Error('Choose a JPG, PNG, or WebP image.')
|
||||||
|
}
|
||||||
|
if (file.size > 2 * 1024 * 1024) throw new Error('Profile pictures must be 2 MB or smaller.')
|
||||||
|
await restore()
|
||||||
|
if (!session.value) throw new Error('Sign in to update your profile picture.')
|
||||||
|
const path = `${session.value.user.id}/avatar`
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('cacheControl', '3600')
|
||||||
|
body.append('', file)
|
||||||
|
await avatarStorageRequest(`object/profile-avatars/${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-upsert': 'true' },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAvatar() {
|
||||||
|
await restore()
|
||||||
|
if (!session.value) throw new Error('Sign in to update your profile picture.')
|
||||||
|
const path = `${session.value.user.id}/avatar`
|
||||||
|
await avatarStorageRequest('object/profile-avatars', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prefixes: [path] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function accessToken() {
|
async function accessToken() {
|
||||||
await restore()
|
await restore()
|
||||||
if (!session.value) throw new Error('Enter the alpha to continue.')
|
if (!session.value) throw new Error('Enter the alpha to continue.')
|
||||||
@@ -238,5 +296,20 @@ export function useDngAuth() {
|
|||||||
hydrated.value = true
|
hydrated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, accessToken, signOut, invalidate }
|
return {
|
||||||
|
session,
|
||||||
|
hydrated,
|
||||||
|
restore,
|
||||||
|
refresh,
|
||||||
|
signUp,
|
||||||
|
signIn,
|
||||||
|
requestPasswordReset,
|
||||||
|
updatePassword,
|
||||||
|
updateUserMetadata,
|
||||||
|
uploadAvatar,
|
||||||
|
removeAvatar,
|
||||||
|
accessToken,
|
||||||
|
signOut,
|
||||||
|
invalidate,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type Controller = 'human' | 'ai' | 'delegated'
|
|||||||
interface CampaignPayload {
|
interface CampaignPayload {
|
||||||
campaign: Record<string, any>
|
campaign: Record<string, any>
|
||||||
world: 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>>
|
characters: Array<Record<string, any>>
|
||||||
round: Record<string, any> | null
|
round: Record<string, any> | null
|
||||||
rounds: Array<Record<string, any>>
|
rounds: Array<Record<string, any>>
|
||||||
@@ -311,7 +311,7 @@ onBeforeUnmount(() => {
|
|||||||
<aside class="control-panel">
|
<aside class="control-panel">
|
||||||
<header class="panel-title"><span>ROUND CONTROL</span><i :class="{live:!refreshing}" /></header>
|
<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="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 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>
|
<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>
|
<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{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)}
|
.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}
|
.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>
|
</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
@@ -1,4 +1,5 @@
|
|||||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||||
|
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -30,11 +31,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
const round = openRounds[0] ?? fallbackRounds[0] ?? null
|
const round = openRounds[0] ?? fallbackRounds[0] ?? null
|
||||||
const userIds = [...new Set(members.map(member => String(member.user_id)))]
|
const userIds = [...new Set(members.map(member => String(member.user_id)))]
|
||||||
const profiles = userIds.length
|
const profiles = userIds.length
|
||||||
? await stageTwoDatabase<Array<{ id: string; display_name: string }>>(
|
? await stageTwoDatabase<ProfileRecord[]>(
|
||||||
`profiles?select=id,display_name&id=in.(${userIds.join(',')})`,
|
`profiles?select=id,display_name,description,avatar_path,created_at,updated_at&id=in.(${userIds.join(',')})`,
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
const profileById = new Map(profiles.map(profile => [profile.id, profile]))
|
const supabaseUrl = useRuntimeConfig().supabaseUrl
|
||||||
|
const profileById = new Map(profiles.map(profile => [profile.id, visibleProfile(profile, supabaseUrl)]))
|
||||||
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
||||||
const visibleRounds = rounds.slice().reverse()
|
const visibleRounds = rounds.slice().reverse()
|
||||||
const visibleRoundIds = visibleRounds.map(item => String(item.id))
|
const visibleRoundIds = visibleRounds.map(item => String(item.id))
|
||||||
|
|||||||
37
apps/web/server/api/v1/profile.patch.ts
Normal file
37
apps/web/server/api/v1/profile.patch.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
||||||
|
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
|
||||||
|
|
||||||
|
const ProfileUpdateSchema = z.object({
|
||||||
|
displayName: z.string().trim().min(1).max(80).optional(),
|
||||||
|
description: z.string().trim().max(500).optional(),
|
||||||
|
avatarPath: z.string().nullable().optional(),
|
||||||
|
}).strict().refine(value => Object.keys(value).length > 0, 'At least one profile field is required.')
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const user = await requireStageTwoUser(event)
|
||||||
|
const update = ProfileUpdateSchema.parse(await readBody(event))
|
||||||
|
const expectedAvatarPath = `${user.id}/avatar`
|
||||||
|
if (update.avatarPath !== undefined && update.avatarPath !== null && update.avatarPath !== expectedAvatarPath) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Invalid profile picture path.' })
|
||||||
|
}
|
||||||
|
if (update.displayName !== undefined) requireStageTwoSafeText(update.displayName)
|
||||||
|
if (update.description !== undefined) requireStageTwoSafeText(update.description)
|
||||||
|
|
||||||
|
const changes: Record<string, unknown> = { updated_at: new Date().toISOString() }
|
||||||
|
if (update.displayName !== undefined) changes.display_name = update.displayName
|
||||||
|
if (update.description !== undefined) changes.description = update.description
|
||||||
|
if (update.avatarPath !== undefined) changes.avatar_path = update.avatarPath
|
||||||
|
|
||||||
|
const profiles = await stageTwoDatabase<ProfileRecord[]>(`profiles?id=eq.${user.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(changes),
|
||||||
|
prefer: 'return=representation',
|
||||||
|
})
|
||||||
|
if (!profiles[0]) throw createError({ statusCode: 404, statusMessage: 'Profile not found.' })
|
||||||
|
return { profile: visibleProfile(profiles[0], useRuntimeConfig().supabaseUrl) }
|
||||||
|
} catch (error) {
|
||||||
|
stageTwoApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
65
apps/web/server/api/v1/profiles/[id].get.ts
Normal file
65
apps/web/server/api/v1/profiles/[id].get.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||||
|
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
|
||||||
|
|
||||||
|
interface MembershipRow {
|
||||||
|
campaign_id: string
|
||||||
|
role: string
|
||||||
|
joined_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const user = await requireStageTwoUser(event)
|
||||||
|
const profileId = stageTwoUuid(getRouterParam(event, 'id'), 'profile id')
|
||||||
|
const viewerMemberships = await stageTwoDatabase<Array<{ campaign_id: string }>>(
|
||||||
|
`campaign_members?select=campaign_id&user_id=eq.${user.id}&active=eq.true`,
|
||||||
|
)
|
||||||
|
const viewerCampaignIds = [...new Set(viewerMemberships.map(member => member.campaign_id))]
|
||||||
|
|
||||||
|
let sharedMemberships: MembershipRow[]
|
||||||
|
if (profileId === user.id) {
|
||||||
|
sharedMemberships = await stageTwoDatabase<MembershipRow[]>(
|
||||||
|
`campaign_members?select=campaign_id,role,joined_at&user_id=eq.${profileId}&active=eq.true&order=joined_at.desc`,
|
||||||
|
)
|
||||||
|
} else if (viewerCampaignIds.length) {
|
||||||
|
sharedMemberships = await stageTwoDatabase<MembershipRow[]>(
|
||||||
|
`campaign_members?select=campaign_id,role,joined_at&user_id=eq.${profileId}&active=eq.true&campaign_id=in.(${viewerCampaignIds.join(',')})&order=joined_at.desc`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sharedMemberships = []
|
||||||
|
}
|
||||||
|
if (profileId !== user.id && !sharedMemberships.length) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Profile not found in your campaigns.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = await stageTwoDatabase<ProfileRecord[]>(
|
||||||
|
`profiles?select=id,display_name,description,avatar_path,created_at,updated_at&id=eq.${profileId}&limit=1`,
|
||||||
|
)
|
||||||
|
if (!profiles[0]) throw createError({ statusCode: 404, statusMessage: 'Profile not found.' })
|
||||||
|
|
||||||
|
const campaignIds = sharedMemberships.map(member => member.campaign_id)
|
||||||
|
const campaigns = campaignIds.length
|
||||||
|
? await stageTwoDatabase<Array<{ id: string; title: string; status: string }>>(
|
||||||
|
`campaigns?select=id,title,status&id=in.(${campaignIds.join(',')})`,
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
const campaignById = new Map(campaigns.map(campaign => [campaign.id, campaign]))
|
||||||
|
const sharedCampaigns = sharedMemberships.flatMap((membership) => {
|
||||||
|
const campaign = campaignById.get(membership.campaign_id)
|
||||||
|
return campaign ? [{
|
||||||
|
id: campaign.id,
|
||||||
|
title: campaign.title,
|
||||||
|
status: campaign.status,
|
||||||
|
role: membership.role,
|
||||||
|
joined_at: membership.joined_at,
|
||||||
|
}] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: visibleProfile(profiles[0], useRuntimeConfig().supabaseUrl),
|
||||||
|
sharedCampaigns,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
stageTwoApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
29
apps/web/server/utils/profile.test.ts
Normal file
29
apps/web/server/utils/profile.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { profileAvatarUrl, visibleProfile } from './profile'
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
id: '00000000-0000-0000-0000-000000000001',
|
||||||
|
display_name: 'Aster Vale',
|
||||||
|
description: 'Keeper of impossible maps.',
|
||||||
|
avatar_path: '00000000-0000-0000-0000-000000000001/avatar',
|
||||||
|
created_at: '2026-08-20T10:00:00.000Z',
|
||||||
|
updated_at: '2026-08-20T11:00:00.000Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('profile presentation', () => {
|
||||||
|
it('builds a cache-busted public avatar URL', () => {
|
||||||
|
expect(profileAvatarUrl(profile, 'https://example.supabase.co/')).toBe(
|
||||||
|
'https://example.supabase.co/storage/v1/object/public/profile-avatars/00000000-0000-0000-0000-000000000001/avatar?v=2026-08-20T11%3A00%3A00.000Z',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expose the storage path in a visible profile', () => {
|
||||||
|
const visible = visibleProfile(profile, 'https://example.supabase.co')
|
||||||
|
expect(visible).not.toHaveProperty('avatar_path')
|
||||||
|
expect(visible.avatar_url).toContain('/profile-avatars/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no URL when the profile has no avatar', () => {
|
||||||
|
expect(profileAvatarUrl({ ...profile, avatar_path: null }, 'https://example.supabase.co')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
26
apps/web/server/utils/profile.ts
Normal file
26
apps/web/server/utils/profile.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export interface ProfileRecord {
|
||||||
|
id: string
|
||||||
|
display_name: string
|
||||||
|
description: string
|
||||||
|
avatar_path: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileAvatarUrl(profile: Pick<ProfileRecord, 'avatar_path' | 'updated_at'>, supabaseUrl: string): string | null {
|
||||||
|
if (!profile.avatar_path) return null
|
||||||
|
const encodedPath = profile.avatar_path.split('/').map(encodeURIComponent).join('/')
|
||||||
|
const version = encodeURIComponent(profile.updated_at)
|
||||||
|
return `${supabaseUrl.replace(/\/$/, '')}/storage/v1/object/public/profile-avatars/${encodedPath}?v=${version}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleProfile(profile: ProfileRecord, supabaseUrl: string) {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
display_name: profile.display_name,
|
||||||
|
description: profile.description,
|
||||||
|
avatar_url: profileAvatarUrl(profile, supabaseUrl),
|
||||||
|
created_at: profile.created_at,
|
||||||
|
updated_at: profile.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2258,6 +2258,72 @@ grant execute on function public.dng_schema_version() to service_role;
|
|||||||
|
|
||||||
notify pgrst, 'reload schema';
|
notify pgrst, 'reload schema';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 0009_profile_management.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
alter table public.profiles
|
||||||
|
add column description text not null default '' check (char_length(description) <= 500),
|
||||||
|
add column avatar_path text check (avatar_path is null or avatar_path = id::text || '/avatar'),
|
||||||
|
add column updated_at timestamptz not null default now();
|
||||||
|
|
||||||
|
-- Avatars are public profile media, but only the owning authenticated user may
|
||||||
|
-- create, replace, or remove the one deterministic object in their folder.
|
||||||
|
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
values (
|
||||||
|
'profile-avatars',
|
||||||
|
'profile-avatars',
|
||||||
|
true,
|
||||||
|
2097152,
|
||||||
|
array['image/jpeg', 'image/png', 'image/webp']
|
||||||
|
)
|
||||||
|
on conflict (id) do update
|
||||||
|
set public = excluded.public,
|
||||||
|
file_size_limit = excluded.file_size_limit,
|
||||||
|
allowed_mime_types = excluded.allowed_mime_types;
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_insert
|
||||||
|
on storage.objects for insert to authenticated
|
||||||
|
with check (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_select_own
|
||||||
|
on storage.objects for select to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_update
|
||||||
|
on storage.objects for update to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_delete
|
||||||
|
on storage.objects for delete to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.dng_schema_version()
|
||||||
|
returns integer language sql stable security definer set search_path = '' as $$
|
||||||
|
select 9;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||||
|
grant execute on function public.dng_schema_version() to service_role;
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
|
|
||||||
|
|
||||||
do $$
|
do $$
|
||||||
begin
|
begin
|
||||||
@@ -2282,7 +2348,7 @@ begin
|
|||||||
if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then
|
if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then
|
||||||
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
||||||
end if;
|
end if;
|
||||||
if public.dng_schema_version() <> 8 then
|
if public.dng_schema_version() <> 9 then
|
||||||
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||||
end if;
|
end if;
|
||||||
end;
|
end;
|
||||||
|
|||||||
61
supabase/migrations/0009_profile_management.sql
Normal file
61
supabase/migrations/0009_profile_management.sql
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
alter table public.profiles
|
||||||
|
add column description text not null default '' check (char_length(description) <= 500),
|
||||||
|
add column avatar_path text check (avatar_path is null or avatar_path = id::text || '/avatar'),
|
||||||
|
add column updated_at timestamptz not null default now();
|
||||||
|
|
||||||
|
-- Avatars are public profile media, but only the owning authenticated user may
|
||||||
|
-- create, replace, or remove the one deterministic object in their folder.
|
||||||
|
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
values (
|
||||||
|
'profile-avatars',
|
||||||
|
'profile-avatars',
|
||||||
|
true,
|
||||||
|
2097152,
|
||||||
|
array['image/jpeg', 'image/png', 'image/webp']
|
||||||
|
)
|
||||||
|
on conflict (id) do update
|
||||||
|
set public = excluded.public,
|
||||||
|
file_size_limit = excluded.file_size_limit,
|
||||||
|
allowed_mime_types = excluded.allowed_mime_types;
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_insert
|
||||||
|
on storage.objects for insert to authenticated
|
||||||
|
with check (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_select_own
|
||||||
|
on storage.objects for select to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_update
|
||||||
|
on storage.objects for update to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_delete
|
||||||
|
on storage.objects for delete to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.dng_schema_version()
|
||||||
|
returns integer language sql stable security definer set search_path = '' as $$
|
||||||
|
select 9;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||||
|
grant execute on function public.dng_schema_version() to service_role;
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
Reference in New Issue
Block a user