Compare commits

..

16 Commits

Author SHA1 Message Date
98d65bcde9 fix(web): route avatar to profile management
All checks were successful
CI / validate (push) Successful in 19m38s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 20:11:43 +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
263bee12dd Merge pull request 'DUNGEONS-8: test Coolify PR webhook (recreated)' (#5) from test/coolify-webhook-2 into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #5
2026-08-19 14:42:43 +00:00
de815cb9c1 test(coolify): trigger preview deployment webhook
Some checks failed
CI / validate (pull_request) Has been cancelled
CI / validate (push) Successful in 14m54s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 19:21:18 +05:00
bf48924644 Merge pull request 'DUNGEONS-8: test Coolify PR webhook' (#4) from test/coolify-webhook into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #4
2026-08-19 14:19:40 +00:00
e90dccd8bb test(coolify): trigger preview deployment webhook
All checks were successful
CI / validate (push) Successful in 15m15s
CI / validate (pull_request) Successful in 16m0s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 19:15:46 +05:00
9c08cabcf7 Merge pull request 'DUNGEONS-6: Fix Nitro public assets in Coolify image' (#3) from perfect_python500/dungeons-6-coolify-assets into main
All checks were successful
CI / validate (push) Successful in 14m28s
Reviewed-on: #3
2026-08-19 13:28:05 +00:00
8651a9aad1 Fix Nitro public assets in Coolify image
All checks were successful
CI / validate (push) Successful in 20m23s
CI / validate (pull_request) Successful in 17m14s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 18:19:59 +05:00
860a5383b0 Merge pull request 'Fix Dockerfile healthcheck for Coolify PORT override' (#2) from agent/opencode-deepseek/1fa9d179 into main
All checks were successful
CI / validate (push) Successful in 23m18s
Reviewed-on: #2
2026-08-19 12:06:45 +00:00
8f42a6664b Merge pull request 'Add Dockerfile for containerized build and run' (#1) from agent/opencode-deepseek/1fa9d179 into main
All checks were successful
CI / validate (push) Successful in 14m54s
Reviewed-on: #1
2026-08-19 09:47:20 +00:00
7 changed files with 314 additions and 9 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(() => {
@@ -9,10 +9,6 @@ const initials = computed(() => {
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'
}) })
async function leave() {
await signOut()
await navigateTo('/')
}
</script> </script>
<template> <template>
@@ -22,7 +18,7 @@ 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="`Manage profile for ${session.user.email ?? ''}`" aria-label="Manage profile">{{ initials }}</NuxtLink>
<div v-else class="avatar">D&G</div> <div v-else class="avatar">D&G</div>
</div> </div>
</header> </header>
@@ -31,5 +27,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{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);text-decoration:none}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
</style> </style>

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDngAuth } from './useDngAuth'
const states = new Map<string, { value: unknown }>()
function activeSession() {
return {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 600_000,
user: {
id: 'user-123',
email: 'old-name@example.com',
user_metadata: { display_name: 'Old Name' },
},
}
}
describe('useDngAuth profile updates', () => {
beforeEach(() => {
states.clear()
vi.restoreAllMocks()
vi.stubGlobal('useRuntimeConfig', () => ({
public: {
supabaseUrl: 'https://supabase.example',
supabaseAnonKey: 'anon-key',
},
}))
vi.stubGlobal('useState', (key: string, factory: () => unknown) => {
if (!states.has(key)) states.set(key, { value: factory() })
return states.get(key)
})
})
it('updates both the public profile and authenticated user metadata', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
id: 'user-123',
email: 'old-name@example.com',
user_metadata: { display_name: 'New Name' },
})
vi.stubGlobal('$fetch', fetchMock)
const auth = useDngAuth()
auth.session.value = activeSession()
await auth.updateProfile(' New Name ')
expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://supabase.example/rest/v1/profiles?id=eq.user-123', expect.objectContaining({
method: 'PATCH',
body: { display_name: 'New Name' },
}))
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://supabase.example/auth/v1/user', expect.objectContaining({
method: 'PUT',
body: { data: { display_name: 'New Name' } },
}))
expect(auth.session.value?.user.user_metadata?.display_name).toBe('New Name')
})
it('rolls the public profile back when the auth metadata update fails', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('Auth update failed'))
.mockResolvedValueOnce(undefined)
vi.stubGlobal('$fetch', fetchMock)
const auth = useDngAuth()
auth.session.value = activeSession()
await expect(auth.updateProfile('New Name')).rejects.toThrow('Auth update failed')
expect(fetchMock).toHaveBeenNthCalledWith(3, 'https://supabase.example/rest/v1/profiles?id=eq.user-123', expect.objectContaining({
body: { display_name: 'Old Name' },
}))
expect(auth.session.value?.user.user_metadata?.display_name).toBe('Old Name')
})
it('rejects invalid display names before making a request', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('$fetch', fetchMock)
const auth = useDngAuth()
auth.session.value = activeSession()
await expect(auth.updateProfile(' ')).rejects.toThrow('between 2 and 80 characters')
expect(fetchMock).not.toHaveBeenCalled()
})
})

View File

@@ -211,6 +211,44 @@ export function useDngAuth() {
return user return user
} }
async function updateProfile(displayName: string) {
const nextDisplayName = displayName.trim()
if (nextDisplayName.length < 2 || nextDisplayName.length > 80) {
throw new Error('Display name must contain between 2 and 80 characters.')
}
const token = await accessToken()
if (!session.value) throw new Error('Enter the alpha to continue.')
const activeSession = session.value
await $fetch(`${config.public.supabaseUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(activeSession.user.id)}`, {
method: 'PATCH',
headers: { ...authHeaders(token), Prefer: 'return=minimal' },
body: { display_name: nextDisplayName },
})
try {
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
method: 'PUT',
headers: authHeaders(token),
body: { data: { display_name: nextDisplayName } },
}))
persist({ ...activeSession, user })
return user
} catch (cause) {
const previousDisplayName = activeSession.user.user_metadata?.display_name
const rollbackDisplayName = typeof previousDisplayName === 'string' && previousDisplayName.trim()
? previousDisplayName.trim()
: activeSession.user.email?.split('@')[0] || 'Adventurer'
await $fetch(`${config.public.supabaseUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(activeSession.user.id)}`, {
method: 'PATCH',
headers: { ...authHeaders(token), Prefer: 'return=minimal' },
body: { display_name: rollbackDisplayName },
}).catch(() => undefined)
throw cause
}
}
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 +276,5 @@ 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, updateProfile, accessToken, signOut, invalidate }
} }

View File

@@ -3,6 +3,13 @@ export default defineNuxtConfig({
devtools: { enabled: false }, devtools: { enabled: false },
css: ['~/assets/css/main.css'], css: ['~/assets/css/main.css'],
modules: [], 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: { runtimeConfig: {
aiProvider: process.env.AI_PROVIDER ?? 'openrouter', aiProvider: process.env.AI_PROVIDER ?? 'openrouter',
openrouterApiKey: process.env.OPENROUTER_API_KEY, openrouterApiKey: process.env.OPENROUTER_API_KEY,

View File

@@ -94,5 +94,43 @@ onMounted(async () => {
</template> </template>
<style scoped> <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> </style>

115
apps/web/pages/profile.vue Normal file
View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
const auth = useDngAuth()
const displayName = ref('')
const savedDisplayName = ref('')
const loading = ref(true)
const saving = ref(false)
const signingOut = ref(false)
const error = ref('')
const success = ref('')
const email = computed(() => auth.session.value?.user.email ?? '')
const hasChanges = computed(() => displayName.value.trim() !== savedDisplayName.value)
function messageFrom(cause: unknown, fallback: string) {
const value = cause as { data?: { msg?: string; message?: string; error_description?: string }; message?: string }
return value.data?.msg ?? value.data?.message ?? value.data?.error_description ?? value.message ?? fallback
}
onMounted(async () => {
try {
const session = await auth.restore()
if (!session) {
await navigateTo('/auth/sign-in', { replace: true })
return
}
const value = session.user.user_metadata?.display_name
savedDisplayName.value = typeof value === 'string' && value.trim() ? value.trim() : session.user.email?.split('@')[0] ?? 'Adventurer'
displayName.value = savedDisplayName.value
} catch (cause) {
error.value = messageFrom(cause, 'Could not load your profile.')
} finally {
loading.value = false
}
})
async function saveProfile() {
error.value = ''
success.value = ''
const nextDisplayName = displayName.value.trim()
if (nextDisplayName.length < 2 || nextDisplayName.length > 80) {
error.value = 'Display name must contain between 2 and 80 characters.'
return
}
saving.value = true
try {
await auth.updateProfile(nextDisplayName)
displayName.value = nextDisplayName
savedDisplayName.value = nextDisplayName
success.value = 'PROFILE UPDATED.'
} catch (cause) {
error.value = messageFrom(cause, 'Could not update your profile.')
} finally {
saving.value = false
}
}
async function leave() {
signingOut.value = true
error.value = ''
try {
await auth.signOut()
await navigateTo('/', { replace: true })
} catch (cause) {
error.value = messageFrom(cause, 'Could not sign out.')
signingOut.value = false
}
}
</script>
<template>
<AppShell section="ACCOUNT">
<div class="profile-page noise">
<section class="profile-intro">
<p class="kicker">PLAYER IDENTITY</p>
<h1>YOUR PROFILE<span>.</span></h1>
<p>Manage how your party sees you and control access to your account.</p>
</section>
<section class="profile-card">
<div class="identity-mark" aria-hidden="true">{{ savedDisplayName.slice(0, 2).toUpperCase() || 'D&G' }}</div>
<div class="profile-content">
<small>ACCOUNT DETAILS</small>
<form v-if="!loading" class="profile-form" @submit.prevent="saveProfile">
<label>DISPLAY NAME<input v-model="displayName" name="name" autocomplete="name" minlength="2" maxlength="80" required placeholder="How your party sees you"></label>
<label>EMAIL<input :value="email" name="email" type="email" autocomplete="email" readonly></label>
<p class="field-note">Your email is fixed to protect campaign ownership. Use password recovery if you need new credentials.</p>
<p v-if="error" class="form-message error" role="alert">{{ error }}</p>
<p v-if="success" class="form-message success" role="status">{{ success }}</p>
<div class="profile-actions">
<button class="save-button" :disabled="saving || signingOut || !hasChanges">{{ saving ? 'SAVING…' : 'SAVE PROFILE →' }}</button>
<NuxtLink to="/auth/forgot-password">RESET PASSWORD</NuxtLink>
</div>
</form>
<p v-else class="loading-state">RECEIVING ACCOUNT DETAILS</p>
</div>
</section>
<section class="session-card">
<div><small>SESSION CONTROL</small><h2>LEAVE THE TABLE.</h2><p>Sign out on this device. Your worlds and campaign progress stay saved.</p></div>
<button :disabled="signingOut || loading" @click="leave">{{ signingOut ? 'SIGNING OUT' : 'SIGN OUT' }}</button>
</section>
</div>
</AppShell>
</template>
<style scoped>
.profile-page{min-height:calc(100vh - 76px);padding:clamp(42px,6vw,86px) clamp(20px,6vw,88px)}
.profile-intro{max-width:760px}.kicker{margin:0;font:500 9px var(--mono);letter-spacing:.2em;color:var(--acid)}.profile-intro h1{margin:14px 0 12px;font:600 clamp(44px,6vw,82px)/1 var(--display);letter-spacing:-.06em}.profile-intro h1 span{color:var(--acid)}.profile-intro>p:last-child{color:var(--muted);line-height:1.7}
.profile-card{display:grid;grid-template-columns:minmax(220px,.7fr) minmax(0,1.3fr);max-width:1050px;margin-top:52px;border:1px solid var(--line);background:#0e0e0d}.identity-mark{min-height:460px;display:grid;place-items:center;border-right:1px solid var(--line);background:radial-gradient(circle at 50% 50%,#303018 0 3%,#15170c 17%,#090909 64%);color:var(--acid);font:600 clamp(48px,7vw,92px) var(--display);letter-spacing:-.08em}.profile-content{padding:clamp(30px,5vw,58px)}.profile-content>small,.session-card small{font:600 8px var(--mono);letter-spacing:.17em;color:var(--acid)}
.profile-form{display:grid;gap:18px;margin-top:30px}.profile-form label{display:grid;gap:9px;font:600 8px var(--mono);letter-spacing:.13em;color:#b5b5ae}.profile-form input{width:100%;min-height:50px;padding:0 15px;border:1px solid #373732;background:#090909;color:var(--ink);font:12px var(--body);outline:none}.profile-form input:focus{border-color:var(--acid)}.profile-form input[readonly]{color:var(--muted);cursor:not-allowed}.field-note{margin:0;color:var(--muted);font-size:11px;line-height:1.65}.form-message{margin:0;padding:12px 14px;font:500 9px/1.55 var(--mono)}.form-message.error{border:1px solid #6b372e;background:#221310;color:#ffab98}.form-message.success{border:1px solid #526425;background:#151a0d;color:var(--acid)}.profile-actions{display:flex;align-items:center;gap:20px;margin-top:8px}.profile-actions button{min-height:50px;padding:0 22px;border:0;background:var(--acid);color:#080808;font:700 9px var(--mono);letter-spacing:.12em}.profile-actions button:disabled{opacity:.45;cursor:not-allowed}.profile-actions a{color:var(--muted);font:600 8px var(--mono);letter-spacing:.1em}.loading-state{margin-top:30px;color:var(--muted);font:500 9px var(--mono);letter-spacing:.12em}
.session-card{display:flex;align-items:center;justify-content:space-between;gap:30px;max-width:1050px;margin-top:18px;padding:30px clamp(28px,4vw,46px);border:1px solid var(--line);background:#0c0c0b}.session-card h2{margin:12px 0 8px;font:600 clamp(20px,3vw,30px) var(--display);letter-spacing:-.04em}.session-card p{margin:0;color:var(--muted);font-size:12px;line-height:1.6}.session-card button{min-width:150px;min-height:46px;border:1px solid #6b372e;background:transparent;color:#ffab98;font:600 9px var(--mono);letter-spacing:.12em}.session-card button:disabled{opacity:.45;cursor:wait}
@media(max-width:760px){.profile-card{grid-template-columns:1fr}.identity-mark{min-height:210px;border-right:0;border-bottom:1px solid var(--line)}.session-card{align-items:flex-start;flex-direction:column}.session-card button{width:100%}}
@media(max-width:520px){.profile-intro h1{font-size:clamp(36px,13vw,54px)}.profile-content{padding:28px 22px}.profile-actions{align-items:stretch;flex-direction:column}.profile-actions button{width:100%}.profile-actions a{text-align:center}.session-card{padding:26px 22px}}
</style>