Compare commits

...

5 Commits

Author SHA1 Message Date
a630b3f3c8 fix(ai): keep coauthor questions in user language
Some checks failed
CI / validate (push) Has been cancelled
CI / validate (pull_request) Has been cancelled
Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 13:31:00 +05:00
fb4b06711d Merge pull request 'DUNGEONS-12: add profile management' (#9) from agent/codex-chatgpt/24f3d9f6 into main
Some checks failed
CI / validate (push) Failing after 10m19s
Reviewed-on: #9
2026-08-20 15:18:20 +00:00
41f4633dca 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>
2026-08-20 19:24:50 +05:00
fc205c232f Merge pull request 'DUNGEONS-11: keep auth pages styled reliably' (#8) from agent/codex-chatgpt/d97b9d5e into main
Some checks failed
CI / validate (push) Failing after 59s
Reviewed-on: #8
2026-08-20 13:51:41 +00:00
744749bc54 fix(web): keep auth styles globally available
All checks were successful
CI / validate (push) Successful in 17m4s
CI / validate (pull_request) Successful in 16m32s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 18:28:12 +05:00
14 changed files with 711 additions and 16 deletions

View File

@@ -1 +1,24 @@
:root{--acid:#d9f75f;--acid-dim:#596522;--ink:#eeeeE7;--muted:#888881;--line:#272725;--display:'Unbounded',sans-serif;--body:'Manrope',sans-serif;--mono:'DM Mono',monospace;color-scheme:dark}*{box-sizing:border-box}html{background:#0a0a0a;color:var(--ink);font-family:var(--body);scroll-behavior:smooth}body{margin:0;background:#0a0a0a}button,input,textarea{font:inherit}button,a{transition:filter .2s ease,opacity .2s ease}button:not(:disabled),a{cursor:pointer}button:hover:not(:disabled),a:hover{filter:brightness(1.12)}::selection{background:var(--acid);color:#070707}.noise{background-color:#0a0a0a;background-image:linear-gradient(rgba(255,255,255,.013) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.013) 1px,transparent 1px);background-size:34px 34px}textarea:focus,input:focus,button:focus-visible,a:focus-visible{outline:1px solid var(--acid);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}} :root{--acid:#d9f75f;--acid-dim:#596522;--ink:#eeeeE7;--muted:#888881;--line:#272725;--display:'Unbounded',sans-serif;--body:'Manrope',sans-serif;--mono:'DM Mono',monospace;color-scheme:dark}*{box-sizing:border-box}html{background:#0a0a0a;color:var(--ink);font-family:var(--body);scroll-behavior:smooth}body{margin:0;background:#0a0a0a}button,input,textarea{font:inherit}button,a{transition:filter .2s ease,opacity .2s ease}button:not(:disabled),a{cursor:pointer}button:hover:not(:disabled),a:hover{filter:brightness(1.12)}::selection{background:var(--acid);color:#070707}.noise{background-color:#0a0a0a;background-image:linear-gradient(rgba(255,255,255,.013) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.013) 1px,transparent 1px);background-size:34px 34px}textarea:focus,input:focus,button:focus-visible,a:focus-visible{outline:1px solid var(--acid);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}}
/* Keep account pages usable even when a route-level component stylesheet is delayed or missed. */
.auth-page{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:28px;padding:42px 20px}
.auth-page .auth-card{width:min(480px,calc(100vw - 40px));padding:clamp(28px,5vw,48px);border:1px solid var(--line);background:linear-gradient(145deg,#11110f,#090909);box-shadow:0 35px 100px rgba(0,0,0,.48)}
.auth-page .auth-card header small{font:600 8px var(--mono);letter-spacing:.18em;color:var(--acid)}
.auth-page .auth-card header h1{margin:14px 0 12px;font:600 clamp(32px,6vw,52px)/1 var(--display);letter-spacing:-.06em}
.auth-page .auth-card header p{margin:0 0 30px;color:var(--muted);font-size:12px;line-height:1.7}
.auth-page .auth-card footer{margin-top:25px;padding-top:22px;border-top:1px solid var(--line);text-align:center;color:var(--muted);font-size:11px}
.auth-page .security-note{font:500 7px var(--mono);letter-spacing:.13em;color:var(--muted)}
.auth-page .security-note i{display:inline-block;width:5px;height:5px;margin-right:8px;border-radius:50%;background:var(--acid)}
.auth-page .auth-form{display:grid;gap:15px}
.auth-page .auth-form label{display:grid;gap:8px;font:600 8px var(--mono);letter-spacing:.11em;color:#b5b5ae}
.auth-page .auth-form input{box-sizing:border-box;width:100%;min-height:48px;padding:0 14px;border:1px solid #373732;background:#0a0a09;color:var(--ink);font:12px var(--body);outline:none}
.auth-page .auth-form input:focus{border-color:var(--acid)}
.auth-page .auth-form button{min-height:50px;border:0;background:var(--acid);color:#080808;font:700 9px var(--mono);letter-spacing:.12em}
.auth-page .auth-form button:disabled{opacity:.45;cursor:wait}
.auth-page .form-error,.auth-page .form-success{margin:0;padding:12px 14px;border:1px solid #6b372e;background:#221310;color:#ffab98;font:500 9px/1.55 var(--mono)}
.auth-page .form-success{border-color:#526425;background:#151a0d;color:var(--acid)}
.auth-page .form-link{color:var(--acid);text-decoration:none;font-weight:600}
.auth-page .field-row{display:flex;align-items:center;justify-content:space-between}
.auth-page .field-row a{color:var(--muted);font:500 8px var(--mono)}
.auth-page .auth-form .consent{grid-template-columns:18px 1fr!important;align-items:start!important;font:400 10px/1.5 var(--body)!important;letter-spacing:0!important;color:var(--muted)!important}
.auth-page .auth-form .consent input{width:16px!important;min-height:16px!important;margin:1px 0 0;padding:0!important;accent-color:var(--acid)}

View File

@@ -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>

View File

@@ -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,
}
} }

View File

@@ -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>

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

View File

@@ -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))

View File

@@ -31,7 +31,7 @@ export default defineEventHandler(async (event) => {
} }
const completion = buildJsonCompletion(useRuntimeConfig(), { const completion = buildJsonCompletion(useRuntimeConfig(), {
system: `You are the Dungeons & Ground coauthor. Based only on this conversation, ask one focused question that makes the original TTRPG world more playable. This is clarification ${answerCount + 1} of 4. Do not repeat a topic already answered. Adapt to the requested genre. Provide exactly three concise, mutually distinct answer options. Keep everything suitable for ages 13+.`, system: `You are the Dungeons & Ground coauthor. Based only on this conversation, ask one focused question that makes the original TTRPG world more playable. Write the question and all answer options in the same language as the user's original world description; never switch languages unless the user explicitly requests a different one. This is clarification ${answerCount + 1} of 4. Do not repeat a topic already answered. Adapt to the requested genre. Provide exactly three concise, mutually distinct answer options. Keep everything suitable for ages 13+.`,
messages, messages,
schemaName: 'coauthor_question', schemaName: 'coauthor_question',
jsonSchema: QuestionSchema.toJSONSchema(), jsonSchema: QuestionSchema.toJSONSchema(),

View 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)
}
})

View 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)
}
})

View 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()
})
})

View 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,
}
}

View File

@@ -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;

View 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';