Compare commits

...

10 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
c1e6b4b61f Style dashboard world cards and responsive layout
Some checks failed
CI / validate (push) Failing after 34m57s
2026-08-20 14:53:11 +05:00
139c0d4fd2 Merge pull request 'DUNGEONS-9: fix black screen caused by asset failures' (#7) from agent/codex-chatgpt/75fff14e into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #7
2026-08-20 09:11:13 +00:00
8ce9314887 fix(web): serve Nitro assets reliably
Some checks failed
CI / validate (push) Failing after 8m18s
CI / validate (pull_request) Successful in 29m51s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 13:38:32 +05:00
40100cccab Merge pull request 'DUNGEONS-8: test Coolify PR webhook (recreated #2)' (#6) from test/coolify-webhook-3 into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #6
2026-08-19 14:50:41 +00:00
a079598f9c test(coolify): trigger preview deployment webhook
Some checks failed
CI / validate (pull_request) Has been cancelled
CI / validate (push) Has been cancelled
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 19:44:34 +05:00
18 changed files with 757 additions and 24 deletions

View File

@@ -61,11 +61,6 @@ COPY --from=build /app/apps/worker apps/worker
COPY --from=build /app/packages/shared packages/shared
COPY --from=build /app/packages/game-engine packages/game-engine
# Nitro 2.10 resolves file-backed public assets from the directory containing
# its split runtime chunk. Keep the generated output in its standard location
# and expose it at that runtime path without storing a second copy.
RUN ln -s ../../public apps/web/.output/server/chunks/public
EXPOSE 3000
# Coolify (and other hosts) override PORT at runtime; probe that port, not a

View File

@@ -190,5 +190,3 @@ Desktopверсия позднее создаётся через Tauri и и
- MVP полностью текстовый.
- Не входят: публичный каталог, marketplace, карты, изображения, TTS, видеогенерация, полноценный 5e combat, нативный desktop/mobile и пользовательские ruleset.
- После альфы приоритет определяется метриками: удержание кампаний, завершённые раунды, стоимость AIхода, частота ручных исправлений и качество памяти.

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}}
/* 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">
defineProps<{ section?: string }>()
const { session, restore, signOut } = useDngAuth()
const { session, restore } = useDngAuth()
onMounted(() => void restore())
const initials = computed(() => {
@@ -8,11 +8,10 @@ const initials = computed(() => {
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'
})
async function leave() {
await signOut()
await navigateTo('/')
}
const avatarUrl = computed(() => {
const value = session.value?.user.user_metadata?.avatar_url
return typeof value === 'string' && value ? value : ''
})
</script>
<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-actions">
<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>
</header>
@@ -31,5 +32,5 @@ async function leave() {
</template>
<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>

View File

@@ -211,6 +211,64 @@ export function useDngAuth() {
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() {
await restore()
if (!session.value) throw new Error('Enter the alpha to continue.')
@@ -238,5 +296,20 @@ export function useDngAuth() {
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

@@ -3,6 +3,13 @@ export default defineNuxtConfig({
devtools: { enabled: false },
css: ['~/assets/css/main.css'],
modules: [],
nitro: {
// Nitro 2.10 resolves file-backed assets relative to its split runtime
// chunk, which makes every /_nuxt request fail outside the Docker-only
// symlink workaround. Embedding them keeps every production launcher
// (preview, Docker, and managed hosts) on the same reliable code path.
serveStatic: 'inline',
},
runtimeConfig: {
aiProvider: process.env.AI_PROVIDER ?? 'openrouter',
openrouterApiKey: process.env.OPENROUTER_API_KEY,

View File

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

View File

@@ -94,5 +94,43 @@ onMounted(async () => {
</template>
<style scoped>
.draft-world .world-art{background:radial-gradient(circle at 50% 60%,#3f4720 0 2%,#1c2011 9%,#080808 58%)}.draft-world .world-art span{background:transparent;color:var(--acid);border:1px solid var(--acid-dim)}
.dash-wrap{min-height:calc(100vh - 76px);padding:clamp(42px,6vw,86px) clamp(20px,6vw,88px)}
.dash-head{display:flex;align-items:end;justify-content:space-between;gap:30px}
.kicker{font:500 9px var(--mono);letter-spacing:.2em;color:var(--acid)}
.dash-head h1{margin:14px 0 10px;font:600 clamp(44px,6vw,82px)/1 var(--display);letter-spacing:-.06em}
.dash-head h1 span{color:var(--acid)}
.dash-head p{color:var(--muted)}
.create-button{display:flex;align-items:center;gap:18px;padding:18px 22px;background:var(--acid);color:#090909;text-decoration:none;font:600 10px var(--mono);letter-spacing:.12em}
.create-button span{font-size:20px}
.filter-row{display:flex;align-items:center;gap:10px;margin:58px 0 24px;border-bottom:1px solid var(--line)}
.filter-row button{padding:0 4px 16px;margin-right:18px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em}
.filter-row button.active{color:var(--ink);border-bottom:2px solid var(--acid)}
.filter-row>span{margin-left:auto;padding-bottom:16px;font:500 8px var(--mono);color:var(--muted)}
.world-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}
.load-state{grid-column:1/-1;padding:34px;border:1px solid var(--line);font:500 9px var(--mono);color:var(--muted)}
.load-state.error{color:#ff9d85}
.load-state a{color:var(--acid)}
.world-card{min-height:420px;border:1px solid var(--line);color:var(--ink);text-decoration:none;background:#0e0e0d;transition:border-color .25s ease,transform .25s ease}
.world-card:hover{border-color:#65655e;transform:translateY(-3px)}
.active-world{display:grid;grid-template-columns:.75fr 1.25fr}
.world-art{position:relative;overflow:hidden;display:grid;place-items:center;background:radial-gradient(circle at 50% 60%,#7d8240 0 2%,#303018 4%,#0b0b0a 36%,#030303 72%)}
.world-art::before{content:"";position:absolute;width:320px;height:320px;border:1px solid #38382d;border-radius:50%;box-shadow:0 0 0 34px #111,0 0 0 35px #26261d}
.eclipse{position:absolute;width:126px;height:126px;border-radius:50%;background:#020202;box-shadow:0 0 50px var(--acid-dim)}
.world-art span{position:absolute;left:20px;top:20px;padding:9px 11px;background:var(--acid);color:#0a0a0a;font:600 8px var(--mono);letter-spacing:.12em}
.draft-world .world-art{background:radial-gradient(circle at 50% 60%,#3f4720 0 2%,#1c2011 9%,#080808 58%)}
.draft-world .world-art span{background:transparent;color:var(--acid);border:1px solid var(--acid-dim)}
.world-info{padding:42px;display:flex;flex-direction:column;min-width:0}
.world-info small{font:500 8px var(--mono);letter-spacing:.14em;color:var(--acid);text-transform:uppercase}
.world-info h2,.new-card h2{font:600 clamp(25px,3vw,42px)/1.05 var(--display);letter-spacing:-.05em;margin:22px 0;overflow-wrap:anywhere}
.world-info p,.new-card p{color:var(--muted);font-size:13px;line-height:1.7;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}
.world-info div{margin-top:auto;padding-top:26px;border-top:1px solid var(--line);display:flex;justify-content:space-between;gap:16px;font:500 8px var(--mono);color:var(--muted)}
.world-info b{color:var(--acid)}
.new-card{padding:48px;display:flex;flex-direction:column;justify-content:flex-end;background:linear-gradient(145deg,#121211,#090909)}
.new-card .plus{margin-bottom:auto;font:300 42px var(--body);color:var(--acid)}
.system-strip{margin-top:32px;padding:18px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.12em;color:var(--muted)}
.system-strip i{display:inline-block;width:6px;height:6px;background:var(--acid);border-radius:50%;margin-right:8px}
.system-strip b{color:var(--acid)}
@media(max-width:1050px){.world-grid{grid-template-columns:1fr}}
@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}
@media(max-width:520px){.dash-head h1{font-size:clamp(34px,12vw,52px)}.create-button{width:100%;justify-content:center}.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}.world-info div{align-items:flex-start;flex-direction:column}.system-strip{flex-direction:column}}
</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 { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
export default defineEventHandler(async (event) => {
try {
@@ -30,11 +31,12 @@ export default defineEventHandler(async (event) => {
const round = openRounds[0] ?? fallbackRounds[0] ?? null
const userIds = [...new Set(members.map(member => String(member.user_id)))]
const profiles = userIds.length
? await stageTwoDatabase<Array<{ id: string; display_name: string }>>(
`profiles?select=id,display_name&id=in.(${userIds.join(',')})`,
? await stageTwoDatabase<ProfileRecord[]>(
`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 visibleRounds = rounds.slice().reverse()
const visibleRoundIds = visibleRounds.map(item => String(item.id))

View File

@@ -31,7 +31,7 @@ export default defineEventHandler(async (event) => {
}
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,
schemaName: 'coauthor_question',
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';
-- ============================================================================
-- 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 $$
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
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
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';
end if;
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';