Important Fixes, New mechanics, and many more
Some checks failed
CI / validate (push) Failing after 9m21s
Some checks failed
CI / validate (push) Failing after 9m21s
This commit is contained in:
@@ -1,5 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ section?: string }>()
|
||||
const { session, restore, signOut } = useDngAuth()
|
||||
onMounted(() => void restore())
|
||||
|
||||
const initials = computed(() => {
|
||||
const email = session.value?.user.email ?? ''
|
||||
return email.slice(0, 2).toUpperCase() || 'G'
|
||||
})
|
||||
|
||||
async function leave() {
|
||||
if (session.value?.user.is_anonymous && !window.confirm('This guest account cannot be recovered after sign out. Leave anyway?')) return
|
||||
await signOut()
|
||||
await navigateTo('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,7 +22,8 @@ defineProps<{ section?: string }>()
|
||||
<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>
|
||||
<div class="avatar">MV</div>
|
||||
<button v-if="session" class="avatar" :title="session.user.is_anonymous ? 'Guest session — sign out' : `Sign out ${session.user.email ?? ''}`" @click="leave">{{ initials }}</button>
|
||||
<div v-else class="avatar">D&G</div>
|
||||
</div>
|
||||
</header>
|
||||
<main><slot /></main>
|
||||
@@ -17,5 +31,5 @@ defineProps<{ section?: string }>()
|
||||
</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;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)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
||||
</style>
|
||||
|
||||
52
apps/web/components/CoauthorConversation.vue
Normal file
52
apps/web/components/CoauthorConversation.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
interface Message { id: string; role: 'coauthor' | 'player' | 'status'; body: string; label?: string }
|
||||
interface Question { key: string; label: string; options: string[] }
|
||||
|
||||
const props = defineProps<{
|
||||
messages: Message[]
|
||||
question: Question | null
|
||||
selectedAnswer?: string
|
||||
busy?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
answer: [key: string, value: string]
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const customAnswer = ref('')
|
||||
|
||||
function submitCustom() {
|
||||
if (!props.question || !customAnswer.value.trim()) return
|
||||
emit('answer', props.question.key, customAnswer.value.trim())
|
||||
customAnswer.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="conversation" aria-live="polite">
|
||||
<ol class="messages">
|
||||
<li v-for="message in messages" :key="message.id" :class="message.role">
|
||||
<small>{{ message.label || (message.role === 'player' ? 'YOU' : message.role === 'status' ? 'SYSTEM' : 'COAUTHOR') }}</small>
|
||||
<p>{{ message.body }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div v-if="question" class="question-card">
|
||||
<p>{{ question.label }}</p>
|
||||
<div class="options">
|
||||
<button v-for="option in question.options" :key="option" type="button" :class="{ selected: selectedAnswer === option }" :disabled="busy" @click="emit('answer', question.key, option)">{{ option }}</button>
|
||||
</div>
|
||||
<form class="custom" @submit.prevent="submitCustom">
|
||||
<input v-model="customAnswer" :disabled="busy" maxlength="160" placeholder="Or write your own answer…" :aria-label="`Custom answer: ${question.label}`">
|
||||
<button :disabled="busy || !customAnswer.trim()">SEND →</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<button v-if="messages.at(-1)?.role === 'status' && messages.at(-1)?.label === 'ERROR'" class="retry" type="button" @click="emit('retry')">TRY AGAIN</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conversation{max-width:780px}.messages{list-style:none;padding:0;margin:0}.messages li{max-width:680px;margin:22px 0;padding:5px 20px;border-left:2px solid var(--acid)}.messages li.player{margin-left:auto;border-left:0;border-right:2px solid #5f5f58;text-align:right}.messages li.status{border:1px solid var(--line);background:#10100f}.messages li.status[aria-invalid=true]{border-color:#7b4137}.messages small{font:500 8px var(--mono);letter-spacing:.15em;color:var(--acid)}.messages .player small{color:var(--ink)}.messages .status small{color:var(--muted)}.messages p{margin:9px 0 0;font-size:14px;line-height:1.65;color:#d0d0ca;white-space:pre-wrap}.question-card{margin:28px 0 0 20px;padding:24px;border:1px solid var(--line);background:#0d0d0c}.question-card>p{margin:0 0 18px;font:600 14px/1.5 var(--display)}.options{display:flex;gap:9px;flex-wrap:wrap}.options button,.retry{padding:12px 14px;border:1px solid var(--line);background:#10100f;color:var(--muted);font:500 9px var(--mono)}.options button:hover,.options button.selected{border-color:var(--acid);color:var(--acid)}button:disabled{opacity:.45;cursor:not-allowed}.custom{display:flex;margin-top:12px}.custom input{min-width:0;flex:1;padding:13px 14px;border:1px solid var(--line);background:#080808;color:var(--ink);font:12px var(--body)}.custom button{border:0;background:var(--acid);color:#080808;padding:0 17px;font:600 8px var(--mono)}.retry{margin:18px 0 0 20px;color:#ff9d85;border-color:#7b4137}@media(max-width:600px){.messages li.player{margin-left:28px}.question-card{margin-left:0}.custom{flex-direction:column}.custom button{min-height:42px}}
|
||||
</style>
|
||||
32
apps/web/components/CoauthorWorldPreview.vue
Normal file
32
apps/web/components/CoauthorWorldPreview.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorldStarter } from '@dng/shared'
|
||||
|
||||
const draft = defineModel<WorldStarter>({ required: true })
|
||||
defineEmits<{ revise: []; confirm: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="preview">
|
||||
<header>
|
||||
<div><p class="kicker">GENERATED STARTING KIT</p><input v-model="draft.title" aria-label="World title"></div>
|
||||
<button class="revise" type="button" @click="$emit('revise')">← REVISE ANSWERS</button>
|
||||
</header>
|
||||
<p class="edit-note">Everything below is editable before launch.</p>
|
||||
<div class="grid">
|
||||
<section class="wide"><label>PREMISE</label><textarea v-model="draft.premise" rows="5" /></section>
|
||||
<section><label>GENRE</label><input v-model="draft.genre"></section>
|
||||
<section><label>TONE</label><input v-model="draft.tone"></section>
|
||||
<section><label>STARTING LOCATION</label><input v-model="draft.startingLocation.name"><textarea v-model="draft.startingLocation.summary" rows="3" /></section>
|
||||
<section><label>OPENING HOOK</label><textarea v-model="draft.hook" rows="5" /></section>
|
||||
<section class="wide"><label>KEY PEOPLE</label><div class="entities"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><input v-model="npc.name"><textarea v-model="npc.summary" rows="3" /></article></div></section>
|
||||
<section class="wide"><label>FACTIONS</label><div class="entities two"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><input v-model="faction.name"><textarea v-model="faction.summary" rows="3" /></article></div></section>
|
||||
<section><label>OPENING SCENE</label><textarea v-model="draft.openingScene" rows="6" /></section>
|
||||
<section><label>CONTENT BOUNDARIES</label><div v-for="(_, index) in draft.contentBoundaries" :key="index" class="boundary"><input v-model="draft.contentBoundaries[index]"><button type="button" aria-label="Remove boundary" @click="draft.contentBoundaries.splice(index, 1)">×</button></div><button class="add" type="button" @click="draft.contentBoundaries.push('')">+ ADD BOUNDARY</button></section>
|
||||
</div>
|
||||
<footer><p><b>PRIVATE WORLD</b><span>Only invited campaign members can see it.</span></p><button type="button" @click="$emit('confirm')">CONFIRM & ENTER WORLD →</button></footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preview{max-width:1300px;width:100%;padding:clamp(40px,5vw,76px)}header{display:flex;justify-content:space-between;align-items:end;gap:20px}.kicker,label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}header>div{flex:1}header input{border:0;border-bottom:1px solid var(--line);font:600 clamp(30px,4vw,54px) var(--display);letter-spacing:-.05em;padding:12px 0;background:transparent}.revise,.add{background:transparent;color:var(--muted);border:1px solid var(--line);padding:12px;font:500 8px var(--mono)}.edit-note{color:var(--muted);font-size:11px}.grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-top:28px}.grid>section{padding:26px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.wide{grid-column:1/-1}input,textarea{box-sizing:border-box;width:100%;margin-top:12px;padding:13px;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);font:12px/1.55 var(--body)}.entities{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.entities.two{grid-template-columns:repeat(2,1fr)}.entities article{padding:16px;border:1px solid var(--line);background:#0c0c0b}.entities small{font:500 7px var(--mono);color:var(--acid)}.boundary{display:flex}.boundary input{margin-top:8px}.boundary button{margin-top:8px;width:42px;border:1px solid var(--line);background:#151513;color:var(--muted)}.add{margin-top:12px}footer{position:sticky;bottom:0;display:flex;justify-content:space-between;align-items:center;padding:18px 22px;background:#11110f;border:1px solid var(--line);margin-top:24px}footer p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}footer p span{margin-top:5px;color:var(--muted);font-weight:400}footer>button{min-height:48px;border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono)}@media(max-width:760px){.preview{padding:36px 18px}header{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.wide{grid-column:auto}.entities,.entities.two{grid-template-columns:1fr}footer{align-items:stretch;flex-direction:column;gap:14px}}
|
||||
</style>
|
||||
33
apps/web/composables/useDngApi.ts
Normal file
33
apps/web/composables/useDngApi.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
interface DngApiOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
body?: Record<string, unknown>
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
}
|
||||
|
||||
export function useDngApi() {
|
||||
const auth = useDngAuth()
|
||||
|
||||
async function api<T>(path: string, options: DngApiOptions = {}): Promise<T> {
|
||||
const token = await auth.accessToken()
|
||||
try {
|
||||
const response = await $fetch(path, {
|
||||
...options,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
} as Parameters<typeof $fetch>[1])
|
||||
return response as T
|
||||
} catch (cause) {
|
||||
const error = cause as { statusCode?: number; status?: number; response?: { status?: number } }
|
||||
const status = error.statusCode ?? error.status ?? error.response?.status
|
||||
if (status === 401) {
|
||||
auth.invalidate()
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem('dng-post-auth-redirect', window.location.pathname + window.location.search)
|
||||
await navigateTo('/')
|
||||
}
|
||||
}
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
|
||||
return { api }
|
||||
}
|
||||
188
apps/web/composables/useDngAuth.ts
Normal file
188
apps/web/composables/useDngAuth.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
interface DngAuthUser {
|
||||
id: string
|
||||
email?: string | null
|
||||
is_anonymous?: boolean
|
||||
user_metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface DngSession {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
user: DngAuthUser
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
user: DngAuthUser
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dng-auth-session'
|
||||
|
||||
export function useDngAuth() {
|
||||
const config = useRuntimeConfig()
|
||||
const session = useState<DngSession | null>('dng-auth-session', () => null)
|
||||
const hydrated = useState('dng-auth-hydrated', () => false)
|
||||
|
||||
function authHeaders(accessToken?: string) {
|
||||
return {
|
||||
apikey: config.public.supabaseAnonKey,
|
||||
'Content-Type': 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function persist(next: DngSession | null) {
|
||||
session.value = next
|
||||
if (!import.meta.client) return
|
||||
if (next) localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
else localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function normalizeUser(user: DngAuthUser): DngAuthUser {
|
||||
return {
|
||||
...user,
|
||||
email: user.email || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredSession(value: string): DngSession | null {
|
||||
try {
|
||||
const saved = JSON.parse(value) as Partial<DngSession>
|
||||
if (
|
||||
typeof saved.accessToken !== 'string' || !saved.accessToken
|
||||
|| typeof saved.refreshToken !== 'string' || !saved.refreshToken
|
||||
|| typeof saved.expiresAt !== 'number' || !Number.isFinite(saved.expiresAt)
|
||||
|| !saved.user || typeof saved.user.id !== 'string' || !saved.user.id
|
||||
) return null
|
||||
return { ...saved, user: normalizeUser(saved.user) } as DngSession
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUser(accessToken: string) {
|
||||
const user = await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
||||
headers: authHeaders(accessToken),
|
||||
})
|
||||
return normalizeUser(user)
|
||||
}
|
||||
|
||||
function fromResponse(response: AuthResponse): DngSession {
|
||||
return {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt: Date.now() + response.expires_in * 1000,
|
||||
user: normalizeUser(response.user),
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!session.value?.refreshToken) return null
|
||||
try {
|
||||
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: { refresh_token: session.value.refreshToken },
|
||||
})
|
||||
const next = fromResponse(response)
|
||||
persist(next)
|
||||
return next
|
||||
} catch {
|
||||
persist(null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
if (!import.meta.client || hydrated.value) return session.value
|
||||
|
||||
let restoredFromStorage = false
|
||||
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ''))
|
||||
const hashAccessToken = hash.get('access_token')
|
||||
const hashRefreshToken = hash.get('refresh_token')
|
||||
if (hashAccessToken && hashRefreshToken) {
|
||||
const expiresIn = Number(hash.get('expires_in') ?? 3600)
|
||||
const user = await fetchUser(hashAccessToken)
|
||||
persist({ accessToken: hashAccessToken, refreshToken: hashRefreshToken, expiresAt: Date.now() + expiresIn * 1000, user })
|
||||
history.replaceState(null, '', `${window.location.pathname}${window.location.search}`)
|
||||
} else {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
const parsed = parseStoredSession(saved)
|
||||
if (parsed) {
|
||||
session.value = parsed
|
||||
restoredFromStorage = true
|
||||
} else {
|
||||
persist(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (session.value && session.value.expiresAt <= Date.now() + 60_000) {
|
||||
await refresh()
|
||||
} else if (session.value && restoredFromStorage) {
|
||||
try {
|
||||
const user = await fetchUser(session.value.accessToken)
|
||||
persist({ ...session.value, user })
|
||||
} catch {
|
||||
// A saved token may belong to an old Supabase project or have been
|
||||
// revoked before its local expiry. Refresh once, then discard it.
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
hydrated.value = true
|
||||
return session.value
|
||||
}
|
||||
|
||||
async function requestMagicLink(email: string) {
|
||||
const redirectTo = `${window.location.origin}/auth/callback`
|
||||
await $fetch(`${config.public.supabaseUrl}/auth/v1/otp?redirect_to=${encodeURIComponent(redirectTo)}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: { email: email.trim().toLowerCase(), create_user: true },
|
||||
})
|
||||
}
|
||||
|
||||
async function signInAnonymously() {
|
||||
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/signup`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: {
|
||||
data: { display_name: 'Guest Adventurer' },
|
||||
gotrue_meta_security: {},
|
||||
},
|
||||
})
|
||||
const next = fromResponse(response)
|
||||
persist(next)
|
||||
hydrated.value = true
|
||||
return next
|
||||
}
|
||||
|
||||
async function accessToken() {
|
||||
await restore()
|
||||
if (!session.value) throw new Error('Enter the alpha to continue.')
|
||||
if (session.value.expiresAt <= Date.now() + 60_000) await refresh()
|
||||
if (!session.value) throw new Error('Your session expired. Sign in again.')
|
||||
return session.value.accessToken
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
const token = session.value?.accessToken
|
||||
persist(null)
|
||||
if (!token) return
|
||||
await $fetch(`${config.public.supabaseUrl}/auth/v1/logout`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
persist(null)
|
||||
hydrated.value = true
|
||||
}
|
||||
|
||||
return { session, hydrated, restore, refresh, requestMagicLink, signInAnonymously, accessToken, signOut, invalidate }
|
||||
}
|
||||
29
apps/web/pages/auth/callback.vue
Normal file
29
apps/web/pages/auth/callback.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
const { restore } = useDngAuth()
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const session = await restore()
|
||||
if (!session) throw new Error('The sign-in link is invalid or expired.')
|
||||
const requested = localStorage.getItem('dng-post-auth-redirect')
|
||||
localStorage.removeItem('dng-post-auth-redirect')
|
||||
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
|
||||
await navigateTo(destination, { replace: true })
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : 'Could not complete sign-in.'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="callback noise">
|
||||
<AppMark />
|
||||
<p v-if="!error">VERIFYING YOUR SIGNAL…</p>
|
||||
<template v-else><p class="error">{{ error }}</p><NuxtLink to="/">REQUEST A NEW LINK</NuxtLink></template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.callback{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:28px}.callback p{font:500 10px var(--mono);letter-spacing:.16em;color:var(--muted)}.callback .error{max-width:420px;color:#ff9d85;text-align:center}.callback a{color:var(--acid);font:600 9px var(--mono)}
|
||||
</style>
|
||||
292
apps/web/pages/campaign/[id].vue
Normal file
292
apps/web/pages/campaign/[id].vue
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const { worlds } = useDemo()
|
||||
interface CampaignRow {
|
||||
id: string
|
||||
title: string
|
||||
current_scene: string
|
||||
status: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const { api } = useDngApi()
|
||||
const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
const campaigns = ref<CampaignRow[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const visibleCampaigns = computed(() => filter.value === 'active'
|
||||
? campaigns.value.filter(campaign => campaign.status === 'active')
|
||||
: campaigns.value)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns')
|
||||
campaigns.value = result.campaigns
|
||||
} catch (cause) {
|
||||
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,13 +40,15 @@ const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
|
||||
<div class="filter-row">
|
||||
<button v-for="item in ['all','active','drafts']" :key="item" :class="{active:filter===item}" @click="filter=item as typeof filter">{{ item }}</button>
|
||||
<span>1 / 5 ALPHA WORLDS</span>
|
||||
<span>{{ campaigns.length }} / 5 ALPHA CAMPAIGNS</span>
|
||||
</div>
|
||||
|
||||
<section class="world-grid">
|
||||
<NuxtLink v-for="world in worlds" :key="world.title" to="/campaign/demo" class="world-card active-world">
|
||||
<div class="world-art"><div class="eclipse" /><span>ACTIVE CAMPAIGN</span></div>
|
||||
<div class="world-info"><small>{{ world.genre }}</small><h2>{{ world.title }}</h2><p>{{ world.premise }}</p><div><b>ROUND 03</b><span>2 PARTY MEMBERS</span></div></div>
|
||||
<p v-if="loading" class="load-state">RECEIVING PRIVATE CAMPAIGNS…</p>
|
||||
<p v-else-if="error" class="load-state error">{{ error }} <NuxtLink to="/">SIGN IN AGAIN</NuxtLink></p>
|
||||
<NuxtLink v-for="campaign in visibleCampaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`" class="world-card active-world">
|
||||
<div class="world-art"><div class="eclipse" /><span>{{ campaign.status.toUpperCase() }} CAMPAIGN</span></div>
|
||||
<div class="world-info"><small>PRIVATE MULTIPLAYER</small><h2>{{ campaign.title }}</h2><p>{{ campaign.current_scene }}</p><div><b>CONTINUE STORY</b><span>{{ new Date(campaign.updated_at).toLocaleDateString() }}</span></div></div>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/worlds/new" class="world-card new-card">
|
||||
<span class="plus">+</span><h2>MAKE THE NEXT<br>IMPOSSIBLE PLACE</h2><p>Begin with a sentence. The coauthor will ask the rest.</p>
|
||||
@@ -32,5 +61,5 @@ const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em;margin-right:18px}.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:1.35fr .65fr;gap:18px}.world-card{min-height:420px;border:1px solid var(--line);color:var(--ink);text-decoration:none;background:#0e0e0d;transition:.25s ease}.world-card:hover{border-color:#65655e;transform:translateY(-3px)}.active-world{display:grid;grid-template-columns:.9fr 1.1fr}.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}.world-info{padding:42px;display:flex;flex-direction:column}.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}.world-info p,.new-card p{color:var(--muted);font-size:13px;line-height:1.7}.world-info div{margin-top:auto;padding-top:26px;border-top:1px solid var(--line);display:flex;justify-content:space-between;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:850px){.dash-head{align-items:start;flex-direction:column}.world-grid{grid-template-columns:1fr}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}@media(max-width:520px){.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}}
|
||||
.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;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em;margin-right:18px}.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:.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}.world-info{padding:42px;display:flex;flex-direction:column}.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}.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;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){.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
const { signedIn } = useDemo()
|
||||
const email = ref('founder@example.com')
|
||||
const { session, restore, requestMagicLink, signInAnonymously } = useDngAuth()
|
||||
const email = ref('')
|
||||
const notice = ref('')
|
||||
const guestError = ref('')
|
||||
const sending = ref(false)
|
||||
const entering = ref(false)
|
||||
|
||||
function enterDemo() {
|
||||
signedIn.value = true
|
||||
navigateTo('/dashboard')
|
||||
onMounted(() => void restore())
|
||||
|
||||
async function enterAlpha() {
|
||||
if (entering.value) return
|
||||
entering.value = true
|
||||
notice.value = ''
|
||||
guestError.value = ''
|
||||
try {
|
||||
await restore()
|
||||
if (!session.value) await signInAnonymously()
|
||||
const requested = localStorage.getItem('dng-post-auth-redirect')
|
||||
localStorage.removeItem('dng-post-auth-redirect')
|
||||
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
|
||||
await navigateTo(destination)
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { msg?: string; message?: string }; message?: string }
|
||||
const rawMessage = error.data?.msg ?? error.data?.message ?? error.message ?? ''
|
||||
guestError.value = /anonymous.*(disabled|sign.?in)/i.test(rawMessage)
|
||||
? 'Guest access is disabled in Supabase Auth settings.'
|
||||
: /database error|saving new user/i.test(rawMessage)
|
||||
? 'The Supabase database migration is incomplete. Apply bootstrap.sql.'
|
||||
: rawMessage || 'Guest access is unavailable right now.'
|
||||
} finally {
|
||||
entering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function requestAccess() {
|
||||
notice.value = `Invite request saved for ${email.value}. Demo access is available now.`
|
||||
async function requestAccess() {
|
||||
if (sending.value) return
|
||||
sending.value = true
|
||||
notice.value = ''
|
||||
try {
|
||||
await requestMagicLink(email.value)
|
||||
notice.value = `Magic link sent to ${email.value}. Check your inbox.`
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { msg?: string; message?: string }; message?: string }
|
||||
notice.value = error.data?.msg ?? error.data?.message ?? error.message ?? 'This email is not on the alpha allowlist.'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,7 +53,7 @@ function requestAccess() {
|
||||
<div class="landing noise">
|
||||
<header class="landing-nav">
|
||||
<AppMark />
|
||||
<span class="alpha-tag">INVITE-ONLY ALPHA</span>
|
||||
<span class="alpha-tag">PRIVATE ALPHA</span>
|
||||
</header>
|
||||
|
||||
<main class="hero">
|
||||
@@ -26,9 +62,10 @@ function requestAccess() {
|
||||
<h1>STORIES THAT<br><em>WAIT FOR NO ONE.</em></h1>
|
||||
<p class="lede">Build an original universe with an AI coauthor. Gather real players and persistent AI companions. Take your turn when life allows—the Groundkeeper remembers everything.</p>
|
||||
<div class="hero-actions">
|
||||
<button class="btn btn-acid" @click="enterDemo">ENTER THE ALPHA <span>↗</span></button>
|
||||
<button class="btn btn-acid" :disabled="entering" @click="enterAlpha">{{ entering ? 'OPENING…' : session ? 'OPEN DASHBOARD' : 'ENTER THE ALPHA' }} <span>↗</span></button>
|
||||
<a href="#how" class="btn btn-ghost">SEE HOW IT WORKS</a>
|
||||
</div>
|
||||
<p v-if="guestError" class="guest-error" role="alert">{{ guestError }}</p>
|
||||
<div class="trust-row">
|
||||
<span>PRIVATE WORLDS</span><i />
|
||||
<span>SERVER-OWNED DICE</span><i />
|
||||
@@ -51,11 +88,11 @@ function requestAccess() {
|
||||
<article><b>01</b><h2>DESCRIBE THE IMPOSSIBLE</h2><p>A coauthor asks sharp questions, then turns your idea into a playable private world.</p></article>
|
||||
<article><b>02</b><h2>ASSEMBLE YOUR PARTY</h2><p>Invite friends, add AI companions, and decide who can step in when someone is away.</p></article>
|
||||
<article><b>03</b><h2>ACT ON YOUR TIME</h2><p>The Groundkeeper resolves each shared round only when the party is ready.</p></article>
|
||||
<form @submit.prevent="requestAccess"><label for="email">REQUEST AN INVITE</label><div><input id="email" v-model="email" type="email" required><button>→</button></div><small>{{ notice || 'We only email about alpha access.' }}</small></form>
|
||||
<form @submit.prevent="requestAccess"><label for="email">OPTIONAL EMAIL SIGN-IN</label><div><input id="email" v-model="email" type="email" autocomplete="email" placeholder="you@example.com" required><button :disabled="sending">{{ sending ? '…' : '→' }}</button></div><small>{{ notice || 'No email is needed. Use this only for an invited email account.' }}</small></form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing{min-height:100vh;overflow:hidden}.landing-nav{height:90px;padding:0 clamp(22px,6vw,90px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.alpha-tag{font:500 9px var(--mono);letter-spacing:.18em;border:1px solid var(--line);padding:10px 14px;color:var(--muted)}.hero{min-height:680px;padding:72px clamp(22px,6vw,90px) 60px;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);gap:7vw;align-items:center}.eyebrow{font:500 10px var(--mono);letter-spacing:.22em;color:var(--muted)}.eyebrow span{display:inline-block;width:26px;height:1px;background:var(--acid);vertical-align:middle;margin-right:10px}.hero h1{margin:22px 0 28px;font:600 clamp(52px,6.6vw,108px)/.91 var(--display);letter-spacing:-.07em}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px var(--acid)}.lede{max-width:650px;font-size:clamp(16px,1.5vw,20px);line-height:1.7;color:#b9b9b2}.hero-actions{display:flex;gap:12px;margin-top:38px}.btn{min-height:52px;padding:0 22px;border:1px solid var(--line);font:600 10px var(--mono);letter-spacing:.13em;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:20px}.btn-acid{border-color:var(--acid);background:var(--acid);color:#080808}.btn-ghost{color:var(--ink);background:transparent}.trust-row{display:flex;align-items:center;gap:14px;margin-top:42px;font:500 8px var(--mono);letter-spacing:.16em;color:var(--muted)}.trust-row i{width:3px;height:3px;background:var(--acid);border-radius:50%}.signal-card{position:relative;min-height:510px;border:1px solid var(--line);background:linear-gradient(145deg,#111 0%,#090909 60%);box-shadow:0 40px 120px #000;padding:22px;clip-path:polygon(0 0,calc(100% - 20px) 0,100% 20px,100% 100%,20px 100%,0 calc(100% - 20px))}.signal-top,.party-status{display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.13em;color:var(--muted)}.signal-top b{color:var(--acid)}.orbit{position:relative;width:245px;height:245px;margin:48px auto 30px;border:1px solid #2b2b29;border-radius:50%;display:grid;place-items:center}.orbit:before,.orbit:after{content:"";position:absolute;border:1px solid #262624;border-radius:50%;inset:28px}.orbit:after{inset:61px;border-color:var(--acid-dim)}.orbit i{position:absolute;width:7px;height:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 18px var(--acid)}.orbit i:nth-child(1){top:23px;left:52px}.orbit i:nth-child(2){right:-3px;top:113px}.orbit i:nth-child(3){bottom:20px;left:85px}.orbit span{font:600 28px var(--display);color:var(--acid)}.transmission{border-left:2px solid var(--acid);padding:8px 18px;margin:0 12px 34px}.transmission small{font:500 8px var(--mono);letter-spacing:.18em;color:var(--acid)}.transmission p{font:500 15px/1.6 var(--body);color:#d6d6d0}.party-status{padding:18px 12px 0;border-top:1px solid var(--line)}.party-status b{color:var(--acid)}.how-grid{display:grid;grid-template-columns:repeat(3,1fr) 1.2fr;border-top:1px solid var(--line)}.how-grid article,.how-grid form{min-height:215px;padding:34px;border-right:1px solid var(--line)}.how-grid article>b{font:500 9px var(--mono);color:var(--acid)}.how-grid h2{margin:34px 0 12px;font:600 13px var(--display);letter-spacing:-.03em}.how-grid p,.how-grid small{font-size:12px;line-height:1.6;color:var(--muted)}.how-grid label{font:600 10px var(--mono);letter-spacing:.12em}.how-grid form div{display:flex;margin:35px 0 12px}.how-grid input{width:100%;background:#10100f;border:1px solid var(--line);padding:14px;color:var(--ink)}.how-grid form button{width:50px;border:0;background:var(--acid);font-size:20px}@media(max-width:950px){.hero{grid-template-columns:1fr}.signal-card{max-width:620px}.how-grid{grid-template-columns:1fr 1fr}}@media(max-width:620px){.alpha-tag{display:none}.hero{padding-top:50px}.hero h1{font-size:48px}.hero-actions{flex-direction:column}.trust-row{flex-wrap:wrap}.signal-card{min-height:470px}.how-grid{grid-template-columns:1fr}.how-grid article,.how-grid form{border-bottom:1px solid var(--line)}}
|
||||
.landing{min-height:100vh;overflow:hidden}.landing-nav{height:90px;padding:0 clamp(22px,6vw,90px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.alpha-tag{font:500 9px var(--mono);letter-spacing:.18em;border:1px solid var(--line);padding:10px 14px;color:var(--muted)}.hero{min-height:680px;padding:72px clamp(22px,6vw,90px) 60px;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);gap:7vw;align-items:center}.eyebrow{font:500 10px var(--mono);letter-spacing:.22em;color:var(--muted)}.eyebrow span{display:inline-block;width:26px;height:1px;background:var(--acid);vertical-align:middle;margin-right:10px}.hero h1{margin:22px 0 28px;font:600 clamp(52px,6.6vw,108px)/.91 var(--display);letter-spacing:-.07em}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px var(--acid)}.lede{max-width:650px;font-size:clamp(16px,1.5vw,20px);line-height:1.7;color:#b9b9b2}.hero-actions{display:flex;gap:12px;margin-top:38px}.guest-error{max-width:620px;margin:14px 0 0;color:#ff9d85;font:500 10px/1.5 var(--mono);letter-spacing:.04em}.btn{min-height:52px;padding:0 22px;border:1px solid var(--line);font:600 10px var(--mono);letter-spacing:.13em;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:20px}.btn:disabled{opacity:.7;cursor:wait}.btn-acid{border-color:var(--acid);background:var(--acid);color:#080808}.btn-ghost{color:var(--ink);background:transparent}.trust-row{display:flex;align-items:center;gap:14px;margin-top:42px;font:500 8px var(--mono);letter-spacing:.16em;color:var(--muted)}.trust-row i{width:3px;height:3px;background:var(--acid);border-radius:50%}.signal-card{position:relative;min-height:510px;border:1px solid var(--line);background:linear-gradient(145deg,#111 0%,#090909 60%);box-shadow:0 40px 120px #000;padding:22px;clip-path:polygon(0 0,calc(100% - 20px) 0,100% 20px,100% 100%,20px 100%,0 calc(100% - 20px))}.signal-top,.party-status{display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.13em;color:var(--muted)}.signal-top b{color:var(--acid)}.orbit{position:relative;width:245px;height:245px;margin:48px auto 30px;border:1px solid #2b2b29;border-radius:50%;display:grid;place-items:center}.orbit:before,.orbit:after{content:"";position:absolute;border:1px solid #262624;border-radius:50%;inset:28px}.orbit:after{inset:61px;border-color:var(--acid-dim)}.orbit i{position:absolute;width:7px;height:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 18px var(--acid)}.orbit i:nth-child(1){top:23px;left:52px}.orbit i:nth-child(2){right:-3px;top:113px}.orbit i:nth-child(3){bottom:20px;left:85px}.orbit span{font:600 28px var(--display);color:var(--acid)}.transmission{border-left:2px solid var(--acid);padding:8px 18px;margin:0 12px 34px}.transmission small{font:500 8px var(--mono);letter-spacing:.18em;color:var(--acid)}.transmission p{font:500 15px/1.6 var(--body);color:#d6d6d0}.party-status{padding:18px 12px 0;border-top:1px solid var(--line)}.party-status b{color:var(--acid)}.how-grid{display:grid;grid-template-columns:repeat(3,1fr) 1.2fr;border-top:1px solid var(--line)}.how-grid article,.how-grid form{min-height:215px;padding:34px;border-right:1px solid var(--line)}.how-grid article>b{font:500 9px var(--mono);color:var(--acid)}.how-grid h2{margin:34px 0 12px;font:600 13px var(--display);letter-spacing:-.03em}.how-grid p,.how-grid small{font-size:12px;line-height:1.6;color:var(--muted)}.how-grid label{font:600 10px var(--mono);letter-spacing:.12em}.how-grid form div{display:flex;margin:35px 0 12px}.how-grid input{width:100%;background:#10100f;border:1px solid var(--line);padding:14px;color:var(--ink)}.how-grid form button{width:50px;border:0;background:var(--acid);font-size:20px}@media(max-width:950px){.hero{grid-template-columns:1fr}.signal-card{max-width:620px}.how-grid{grid-template-columns:1fr 1fr}}@media(max-width:620px){.alpha-tag{display:none}.hero{padding-top:50px}.hero h1{font-size:48px}.hero-actions{flex-direction:column}.trust-row{flex-wrap:wrap}.signal-card{min-height:470px}.how-grid{grid-template-columns:1fr}.how-grid article,.how-grid form{border-bottom:1px solid var(--line)}}
|
||||
</style>
|
||||
|
||||
38
apps/web/pages/join/[token].vue
Normal file
38
apps/web/pages/join/[token].vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { session, restore } = useDngAuth()
|
||||
const { api } = useDngApi()
|
||||
const state = ref<'joining' | 'signin' | 'error'>('joining')
|
||||
const message = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const token = String(route.params.token ?? '')
|
||||
await restore()
|
||||
if (!session.value) {
|
||||
localStorage.setItem('dng-post-auth-redirect', route.fullPath)
|
||||
state.value = 'signin'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await api<{ campaignId: string }>('/api/v1/invites/join', { method: 'POST', body: { token } })
|
||||
await navigateTo(`/campaign/${result.campaignId}`, { replace: true })
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
message.value = error.data?.statusMessage ?? error.message ?? 'This invite is invalid or expired.'
|
||||
state.value = 'error'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="join noise">
|
||||
<AppMark />
|
||||
<div v-if="state === 'joining'" class="card"><small>PARTY INVITE</small><h1>JOINING CAMPAIGN…</h1><p>Verifying your seat at the table.</p></div>
|
||||
<div v-else-if="state === 'signin'" class="card"><small>PARTY INVITE</small><h1>ENTER THE ALPHA FIRST</h1><p>Your invite is saved. Guest access needs no email; after entering, this campaign will open automatically.</p><NuxtLink to="/">ENTER THE ALPHA →</NuxtLink></div>
|
||||
<div v-else class="card"><small>INVITE ERROR</small><h1>THE DOOR STAYED SHUT</h1><p>{{ message }}</p><NuxtLink to="/dashboard">BACK TO DASHBOARD</NuxtLink></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.join{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:30px;padding:24px}.card{width:min(560px,calc(100vw - 40px));padding:42px;border:1px solid var(--line);background:#0d0d0c}.card small{font:600 8px var(--mono);letter-spacing:.16em;color:var(--acid)}.card h1{font:600 clamp(26px,5vw,45px)/1.05 var(--display);letter-spacing:-.05em}.card p{color:var(--muted);line-height:1.7}.card a{display:inline-flex;margin-top:20px;padding:15px 18px;background:var(--acid);color:#080808;text-decoration:none;font:600 9px var(--mono)}
|
||||
</style>
|
||||
@@ -1,48 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorldStarter } from '@dng/shared'
|
||||
|
||||
const { worlds, activeWorld } = useDemo()
|
||||
const step = ref<'chat' | 'generating' | 'preview'>('chat')
|
||||
const input = ref('A science-fiction mystery about a dead relay sending messages from the future.')
|
||||
const answers = reactive({ tone: '', conflict: '', role: '' })
|
||||
const questionIndex = ref(0)
|
||||
const questions = [
|
||||
{ key: 'tone', label: 'What should the story feel like?', options: ['Tense & uncanny', 'Bold & adventurous', 'Melancholy & intimate'] },
|
||||
{ key: 'conflict', label: 'What pressure drives the opening?', options: ['A ticking clock', 'A fragile alliance', 'A dangerous discovery'] },
|
||||
{ key: 'role', label: 'Who are the players in this world?', options: ['A freelance crew', 'Reluctant investigators', 'Agents of a fading power'] },
|
||||
]
|
||||
type Stage = 'seed' | 'questions' | 'generating' | 'preview' | 'confirming'
|
||||
interface Message { id: string; role: 'coauthor' | 'player' | 'status'; body: string; label?: string }
|
||||
interface DynamicQuestion { id: string; label: string; options: string[] }
|
||||
interface RespondResult { readyToGenerate: boolean; question?: DynamicQuestion }
|
||||
|
||||
const { api } = useDngApi()
|
||||
const stage = ref<Stage>('seed')
|
||||
const busy = ref(false)
|
||||
const sessionId = ref<string | null>(null)
|
||||
const seed = ref('A science-fiction mystery about a dead relay sending messages from the future.')
|
||||
const answers = reactive<Record<string, string>>({})
|
||||
const currentQuestion = ref<DynamicQuestion | null>(null)
|
||||
const answerCount = ref(0)
|
||||
const draft = ref<WorldStarter | null>(null)
|
||||
const retryAction = ref<'begin' | 'respond' | 'generate'>('begin')
|
||||
const pendingAnswer = ref<{ key: string; value: string } | null>(null)
|
||||
const messages = ref<Message[]>([
|
||||
{ id: 'welcome', role: 'coauthor', body: 'Give me the first impossible sentence. I’ll ask a few focused questions, then turn it into a private world your party can enter tonight.' },
|
||||
])
|
||||
|
||||
function answerFor(key: string) {
|
||||
return answers[key as keyof typeof answers]
|
||||
const displayedQuestion = computed(() => currentQuestion.value ? { key: currentQuestion.value.id, label: currentQuestion.value.label, options: currentQuestion.value.options } : null)
|
||||
const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, 5) / 5) * 55))
|
||||
|
||||
function addMessage(role: Message['role'], body: string, label?: string) {
|
||||
messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label })
|
||||
}
|
||||
|
||||
function begin() {
|
||||
if (!input.value.trim()) return
|
||||
questionIndex.value = 1
|
||||
async function begin() {
|
||||
const prompt = seed.value.trim()
|
||||
if (!prompt || stage.value !== 'seed' || busy.value) return
|
||||
busy.value = true
|
||||
retryAction.value = 'begin'
|
||||
try {
|
||||
const result = await api<{ session: { id: string } }>('/api/v1/coauthor/sessions', { method: 'POST', body: { message: prompt } })
|
||||
sessionId.value = result.session.id
|
||||
addMessage('player', prompt)
|
||||
stage.value = 'questions'
|
||||
retryAction.value = 'respond'
|
||||
await requestNextQuestion()
|
||||
} catch (error) {
|
||||
addMessage('status', errorText(error) || 'Could not start a private coauthor session. Sign in and try again.', 'ERROR')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function choose(key: string, value: string) {
|
||||
;(answers as Record<string, string>)[key] = value
|
||||
if (questionIndex.value < questions.length) questionIndex.value += 1
|
||||
async function answerQuestion(key: string, value: string) {
|
||||
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
|
||||
busy.value = true
|
||||
pendingAnswer.value = { key, value }
|
||||
try {
|
||||
await api(`/api/v1/coauthor/sessions/${sessionId.value}/messages`, {
|
||||
method: 'POST', body: { content: value },
|
||||
})
|
||||
answers[key] = value
|
||||
addMessage('player', value)
|
||||
currentQuestion.value = null
|
||||
answerCount.value = Math.min(answerCount.value + 1, 5)
|
||||
pendingAnswer.value = null
|
||||
retryAction.value = 'respond'
|
||||
await requestNextQuestion()
|
||||
} catch (error) {
|
||||
addMessage('status', errorText(error) || 'That answer could not be saved. Try again.', 'ERROR')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function requestNextQuestion() {
|
||||
if (!sessionId.value) return
|
||||
const result = await api<RespondResult>(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' })
|
||||
if (result.readyToGenerate || answerCount.value >= 5) {
|
||||
currentQuestion.value = null
|
||||
await generate()
|
||||
return
|
||||
}
|
||||
if (!result.question) throw new Error('The coauthor did not return its next question.')
|
||||
currentQuestion.value = result.question
|
||||
addMessage('coauthor', result.question.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, 5)} OF UP TO 5`)
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
if (error && typeof error === 'object') {
|
||||
const value = error as { data?: { statusMessage?: string; message?: string }; statusMessage?: string; message?: string }
|
||||
return value.data?.statusMessage || value.data?.message || value.statusMessage || value.message
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
step.value = 'generating'
|
||||
if (!sessionId.value) return
|
||||
stage.value = 'generating'
|
||||
retryAction.value = 'generate'
|
||||
addMessage('status', 'Building premise, location, key people, factions, hidden pressure, and an opening scene…', 'GENERATING')
|
||||
try {
|
||||
draft.value = await $fetch<WorldStarter>('/api/worlds/generate', { method: 'POST', body: { prompt: input.value, answers } })
|
||||
} catch {
|
||||
draft.value = structuredClone(activeWorld.value)
|
||||
await api(`/api/v1/coauthor/sessions/${sessionId.value}/generate`, { method: 'POST' })
|
||||
const deadline = Date.now() + 90_000
|
||||
while (Date.now() < deadline) {
|
||||
const result = await api<{ session: { status: string; generatedWorld?: unknown; generated_world?: unknown } }>(`/api/v1/coauthor/sessions/${sessionId.value}`)
|
||||
if (result.session.status === 'ready') {
|
||||
draft.value = (result.session.generatedWorld ?? result.session.generated_world) as WorldStarter
|
||||
break
|
||||
}
|
||||
if (result.session.status === 'failed') throw new Error('The coauthor job failed. Try generation again.')
|
||||
await new Promise(resolve => setTimeout(resolve, 1_000))
|
||||
}
|
||||
if (!draft.value) throw new Error('World generation is taking too long. Your session is saved; try again shortly.')
|
||||
messages.value.pop()
|
||||
addMessage('status', `“${draft.value.title}” is ready for your review. Every visible field remains editable.`, 'READY')
|
||||
stage.value = 'preview'
|
||||
} catch (error) {
|
||||
messages.value.pop()
|
||||
addMessage('status', errorText(error) || 'The coauthor could not generate this world. Your answers are safe—try again.', 'ERROR')
|
||||
stage.value = 'questions'
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 700))
|
||||
step.value = 'preview'
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!draft.value) return
|
||||
activeWorld.value = draft.value
|
||||
if (!worlds.value.some(world => world.title === draft.value!.title)) worlds.value.unshift(draft.value)
|
||||
navigateTo('/campaign/demo')
|
||||
function retry() {
|
||||
const last = messages.value.at(-1)
|
||||
if (last?.role === 'status' && last.label === 'ERROR') messages.value.pop()
|
||||
if (retryAction.value === 'begin') begin()
|
||||
else if (retryAction.value === 'generate') generate()
|
||||
else if (pendingAnswer.value && currentQuestion.value) answerQuestion(pendingAnswer.value.key, pendingAnswer.value.value)
|
||||
else requestNextQuestion().catch(error => addMessage('status', errorText(error) || 'The coauthor could not continue. Try again.', 'ERROR'))
|
||||
}
|
||||
|
||||
function revise() {
|
||||
stage.value = 'seed'
|
||||
sessionId.value = null
|
||||
currentQuestion.value = null
|
||||
answerCount.value = 0
|
||||
pendingAnswer.value = null
|
||||
for (const key of Object.keys(answers)) delete answers[key]
|
||||
draft.value = null
|
||||
addMessage('coauthor', 'Let’s reshape it from the opening sentence. I’ll adapt the next questions to your new direction.', 'COAUTHOR · REVISION')
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!draft.value || !sessionId.value || stage.value === 'confirming') return
|
||||
stage.value = 'confirming'
|
||||
try {
|
||||
const confirmed = await api<{ worldId: string }>(`/api/v1/coauthor/sessions/${sessionId.value}/confirm`, {
|
||||
method: 'POST', body: { world: draft.value },
|
||||
})
|
||||
const created = await api<{ campaignId: string }>('/api/v1/campaigns', {
|
||||
method: 'POST', body: { worldId: confirmed.worldId, title: draft.value.title },
|
||||
})
|
||||
await navigateTo(`/campaign/${created.campaignId}`)
|
||||
} catch (error) {
|
||||
addMessage('status', errorText(error) || 'The world could not be confirmed. Your draft is still safe.', 'ERROR')
|
||||
stage.value = 'preview'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -51,50 +161,32 @@ function confirm() {
|
||||
<div class="forge noise">
|
||||
<aside>
|
||||
<p class="step-label">CREATION PROTOCOL</p>
|
||||
<div class="progress"><i :style="{ width: `${progress}%` }" /></div>
|
||||
<ol>
|
||||
<li :class="{ active: step==='chat' }"><b>01</b><span>Seed idea<small>Say what cannot exist yet.</small></span></li>
|
||||
<li :class="{ active: questionIndex>0 && step==='chat' }"><b>02</b><span>Shape the signal<small>Tone, pressure, player role.</small></span></li>
|
||||
<li :class="{ active: step==='generating' }"><b>03</b><span>Generate structure<small>A playable starting kit.</small></span></li>
|
||||
<li :class="{ active: step==='preview' }"><b>04</b><span>Review & launch<small>You remain the final author.</small></span></li>
|
||||
<li :class="{ active: stage === 'seed', done: stage !== 'seed' }"><b>01</b><span>Seed idea<small>Say what cannot exist yet.</small></span></li>
|
||||
<li :class="{ active: stage === 'questions', done: answerCount >= 3 }"><b>02</b><span>Shape the signal<small>{{ answerCount }} / up to 5 answers captured.</small></span></li>
|
||||
<li :class="{ active: stage === 'generating', done: stage === 'preview' || stage === 'confirming' }"><b>03</b><span>Generate structure<small>A playable starting kit.</small></span></li>
|
||||
<li :class="{ active: stage === 'preview' || stage === 'confirming' }"><b>04</b><span>Review & launch<small>You remain the final author.</small></span></li>
|
||||
</ol>
|
||||
<div class="boundary"><span>13+ BOUNDARY</span><p>Dark themes are welcome. Explicit sexual content and extreme graphic violence are not.</p></div>
|
||||
</aside>
|
||||
|
||||
<main v-if="step==='chat'" class="coauthor">
|
||||
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
|
||||
<p class="kicker">COAUTHOR / SESSION 01</p>
|
||||
<h1>WHAT SHOULD<br>WE BUILD<span>?</span></h1>
|
||||
<div class="chat-line ai"><small>COAUTHOR</small><p>Give me the first impossible sentence. I’ll help turn it into a world your party can enter tonight.</p></div>
|
||||
<form class="seed" @submit.prevent="begin"><textarea v-model="input" aria-label="World idea" rows="3"/><button>BEGIN <span>→</span></button></form>
|
||||
|
||||
<div v-for="(question, index) in questions" v-show="questionIndex > index" :key="question.key" class="question-block">
|
||||
<div class="chat-line ai"><small>COAUTHOR · {{ String(index+2).padStart(2,'0') }}</small><p>{{ question.label }}</p></div>
|
||||
<div class="choices">
|
||||
<button v-for="option in question.options" :key="option" :class="{selected:answerFor(question.key)===option}" @click="choose(question.key, option)">{{ option }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="Object.values(answers).every(Boolean)" class="generate" @click="generate">GENERATE STARTING WORLD <span>↗</span></button>
|
||||
<h1>BUILD THE<br>IMPOSSIBLE<span>.</span></h1>
|
||||
<CoauthorConversation :messages="messages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
|
||||
<form v-if="stage === 'seed'" class="seed" @submit.prevent="begin">
|
||||
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
|
||||
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span>→</span></button>
|
||||
</form>
|
||||
<div v-if="stage === 'generating'" class="scanner" aria-label="Generating world"><i /><i /><i /><span>D&G</span></div>
|
||||
</main>
|
||||
|
||||
<main v-else-if="step==='generating'" class="generating">
|
||||
<div class="scanner"><i/><i/><i/><span>D&G</span></div><p>ASSEMBLING A PLAYABLE WORLD</p><small>Premise · Location · 3 NPCs · 2 factions · Hidden pressure</small>
|
||||
</main>
|
||||
|
||||
<main v-else-if="draft" class="preview">
|
||||
<div class="preview-top"><div><p class="kicker">GENERATED STARTING KIT</p><input v-model="draft.title" aria-label="World title"></div><button @click="step='chat'">← REVISE INPUT</button></div>
|
||||
<div class="preview-grid">
|
||||
<section class="premise"><label>PREMISE</label><textarea v-model="draft.premise" rows="6"/><div><span>{{ draft.genre }}</span><span>{{ draft.tone }}</span></div></section>
|
||||
<section><label>STARTING LOCATION</label><h2>{{ draft.startingLocation.name }}</h2><p>{{ draft.startingLocation.summary }}</p></section>
|
||||
<section class="wide"><label>KEY PEOPLE</label><div class="entity-row"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><h3>{{ npc.name }}</h3><p>{{ npc.summary }}</p></article></div></section>
|
||||
<section class="wide"><label>FACTIONS</label><div class="entity-row factions"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><h3>{{ faction.name }}</h3><p>{{ faction.summary }}</p></article></div></section>
|
||||
<section><label>OPENING HOOK</label><p>{{ draft.hook }}</p></section>
|
||||
<section><label>CONTENT BOUNDARIES</label><ul><li v-for="boundary in draft.contentBoundaries" :key="boundary">{{ boundary }}</li></ul></section>
|
||||
</div>
|
||||
<div class="confirm-bar"><p><b>PRIVATE WORLD</b><span>Only invited campaign members can see it.</span></p><button @click="confirm">CONFIRM & ENTER WORLD <span>→</span></button></div>
|
||||
</main>
|
||||
<CoauthorWorldPreview v-else-if="draft" v-model="draft" @revise="revise" @confirm="confirm" />
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.forge{min-height:calc(100vh - 76px);display:grid;grid-template-columns:300px 1fr}.forge>aside{padding:50px 34px;border-right:1px solid var(--line);display:flex;flex-direction:column}.step-label,.kicker{font:500 9px var(--mono);letter-spacing:.18em;color:var(--acid)}ol{list-style:none;padding:28px 0;margin:0}li{display:flex;gap:18px;padding:20px 0;color:#4d4d48}li>b{font:500 9px var(--mono)}li span{font:600 10px var(--display);letter-spacing:.04em}li small{display:block;margin-top:7px;font:400 10px/1.4 var(--body);color:#55554f}li.active{color:var(--ink)}li.active b{color:var(--acid)}li.active small{color:var(--muted)}.boundary{margin-top:auto;padding:18px;border:1px solid var(--line)}.boundary span{font:600 8px var(--mono);color:var(--acid)}.boundary p{font-size:10px;line-height:1.5;color:var(--muted)}.coauthor,.preview{padding:clamp(44px,6vw,82px);max-width:1100px;width:100%}.coauthor h1{font:600 clamp(42px,5vw,72px)/.95 var(--display);letter-spacing:-.06em;margin:16px 0 54px}.coauthor h1 span{color:var(--acid)}.chat-line{max-width:680px;border-left:2px solid var(--acid);padding:3px 20px;margin:26px 0 18px}.chat-line small{font:500 8px var(--mono);letter-spacing:.15em;color:var(--acid)}.chat-line p{font-size:15px;line-height:1.65;color:#d0d0ca}.seed{display:flex;align-items:stretch;max-width:760px}.seed textarea,.preview textarea,.preview input{width:100%;resize:none;background:#10100f;border:1px solid var(--line);color:var(--ink);padding:18px;font:500 14px/1.6 var(--body)}.seed button,.generate,.confirm-bar button{border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono);letter-spacing:.12em}.choices{display:flex;gap:9px;flex-wrap:wrap;margin-left:20px}.choices button{padding:12px 14px;border:1px solid var(--line);background:#10100f;color:var(--muted);font:500 9px var(--mono)}.choices button.selected{border-color:var(--acid);color:var(--acid)}.generate{margin-top:42px;min-height:52px}.generating{display:grid;place-content:center;text-align:center}.scanner{position:relative;width:250px;height:250px;border:1px solid var(--line);border-radius:50%;display:grid;place-items:center;margin:0 auto 34px;animation:rotate 8s linear infinite}.scanner:after{content:"";position:absolute;inset:35px;border:1px dashed var(--acid-dim);border-radius:50%}.scanner i{position:absolute;width:7px;height:7px;background:var(--acid);border-radius:50%}.scanner i:nth-child(1){top:10px}.scanner i:nth-child(2){left:20px;bottom:55px}.scanner i:nth-child(3){right:5px;top:90px}.scanner span{font:600 26px var(--display);color:var(--acid)}.generating p{font:600 11px var(--mono);letter-spacing:.16em}.generating small{color:var(--muted)}@keyframes rotate{to{transform:rotate(360deg)}}.preview{max-width:1300px}.preview-top{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:34px}.preview-top input{border:0;border-bottom:1px solid var(--line);font:600 clamp(30px,4vw,54px) var(--display);letter-spacing:-.05em;padding:12px 0;background:transparent}.preview-top button{background:transparent;color:var(--muted);border:0;font:500 8px var(--mono)}.preview-grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line)}.preview-grid>section{padding:28px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.preview-grid .wide{grid-column:1/-1}.preview-grid label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}.preview-grid h2{font:600 22px var(--display);margin:20px 0 12px}.preview-grid p{font-size:12px;line-height:1.7;color:var(--muted)}.premise div{display:flex;gap:8px;margin-top:14px}.premise div span{padding:7px 9px;border:1px solid var(--line);font:500 8px var(--mono);color:var(--muted)}.entity-row{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);margin-top:22px}.entity-row article{background:#0d0d0c;padding:20px}.entity-row small{font:500 7px var(--mono);color:var(--acid)}.entity-row h3{font:600 13px var(--display)}.factions{grid-template-columns:1fr 1fr}.preview-grid ul{padding-left:18px;color:var(--muted);font-size:12px}.confirm-bar{position:sticky;bottom:0;display:flex;justify-content:space-between;align-items:center;padding:18px 22px;background:#11110f;border:1px solid var(--line);margin-top:24px}.confirm-bar p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}.confirm-bar p span{margin-top:5px;color:var(--muted);font-weight:400}.confirm-bar button{min-height:48px}@media(max-width:850px){.forge{grid-template-columns:1fr}.forge>aside{display:none}.coauthor,.preview{padding:40px 20px}.preview-grid{grid-template-columns:1fr}.preview-grid .wide{grid-column:auto}.entity-row,.factions{grid-template-columns:1fr}.confirm-bar{align-items:stretch;flex-direction:column;gap:14px}}
|
||||
.forge{min-height:calc(100vh - 76px);display:grid;grid-template-columns:300px 1fr}.forge>aside{padding:50px 34px;border-right:1px solid var(--line);display:flex;flex-direction:column}.step-label,.kicker{font:500 9px var(--mono);letter-spacing:.18em;color:var(--acid)}.progress{height:3px;margin-top:22px;background:#252522}.progress i{display:block;height:100%;background:var(--acid);transition:width .35s ease}ol{list-style:none;padding:20px 0;margin:0}li{display:flex;gap:18px;padding:18px 0;color:#4d4d48}li>b{font:500 9px var(--mono)}li span{font:600 10px var(--display);letter-spacing:.04em}li small{display:block;margin-top:7px;font:400 10px/1.4 var(--body);color:#55554f}li.active{color:var(--ink)}li.active b,li.done b{color:var(--acid)}li.active small{color:var(--muted)}li.done:not(.active){color:#77776f}.boundary{margin-top:auto;padding:18px;border:1px solid var(--line)}.boundary span{font:600 8px var(--mono);color:var(--acid)}.boundary p{font-size:10px;line-height:1.5;color:var(--muted)}.coauthor{padding:clamp(44px,6vw,82px);max-width:1000px;width:100%}.coauthor h1{font:600 clamp(42px,5vw,72px)/.95 var(--display);letter-spacing:-.06em;margin:16px 0 44px}.coauthor h1 span{color:var(--acid)}.seed{display:flex;align-items:stretch;max-width:780px;margin-top:22px}.seed textarea{width:100%;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);padding:18px;font:500 14px/1.6 var(--body)}.seed button{border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono);letter-spacing:.12em}.seed button:disabled{opacity:.4}.scanner{position:relative;width:180px;height:180px;border:1px solid var(--line);border-radius:50%;display:grid;place-items:center;margin:40px auto 0;animation:rotate 8s linear infinite}.scanner:after{content:"";position:absolute;inset:28px;border:1px dashed var(--acid-dim);border-radius:50%}.scanner i{position:absolute;width:6px;height:6px;background:var(--acid);border-radius:50%}.scanner i:nth-child(1){top:8px}.scanner i:nth-child(2){left:13px;bottom:40px}.scanner i:nth-child(3){right:3px;top:64px}.scanner span{font:600 20px var(--display);color:var(--acid)}@keyframes rotate{to{transform:rotate(360deg)}}@media(max-width:850px){.forge{grid-template-columns:1fr}.forge>aside{display:none}.coauthor{box-sizing:border-box;padding:40px 20px}}
|
||||
</style>
|
||||
|
||||
56
apps/web/server/api/v1/campaigns/[id].get.ts
Normal file
56
apps/web/server/api/v1/campaigns/[id].get.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const access = await requireCampaignAccess(campaignId, user.id)
|
||||
const campaign = access.campaign
|
||||
const worldId = String(campaign.world_id)
|
||||
const [worlds, members, characters, openRounds, rounds] = await Promise.all([
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`worlds?select=id,title,genre,tone,premise,content_boundaries,hook,opening_scene&id=eq.${worldId}&limit=1`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`campaign_members?select=id,user_id,role,active,ai_takeover_allowed,joined_at&campaign_id=eq.${campaignId}&order=joined_at.asc`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`characters?select=id,campaign_id,user_id,name,concept,controller,abilities,hp,max_hp,defense,proficiency,inventory,statuses,persona,created_at&campaign_id=eq.${campaignId}&order=created_at.asc`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`rounds?select=id,campaign_id,number,status,forced_by,narration,next_prompt,queued_at,resolved_at,error,created_at&campaign_id=eq.${campaignId}&status=eq.open&order=number.desc&limit=1`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`rounds?select=id,campaign_id,number,status,forced_by,narration,next_prompt,queued_at,resolved_at,error,created_at&campaign_id=eq.${campaignId}&order=number.desc&limit=30`,
|
||||
),
|
||||
])
|
||||
const fallbackRounds = openRounds.length ? [] : await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`rounds?select=id,campaign_id,number,status,forced_by,narration,next_prompt,queued_at,resolved_at,error,created_at&campaign_id=eq.${campaignId}&order=number.desc&limit=1`,
|
||||
)
|
||||
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(',')})`,
|
||||
)
|
||||
: []
|
||||
const profileById = new Map(profiles.map(profile => [profile.id, profile]))
|
||||
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))
|
||||
const [intents, events, diceRolls] = await Promise.all([
|
||||
round ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`player_intents?select=id,round_id,member_id,character_id,action,ready,created_at,updated_at&round_id=eq.${String(round.id)}&order=created_at.asc`,
|
||||
) : Promise.resolve([]),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`game_events?select=id,campaign_id,round_id,event_type,payload,created_at&campaign_id=eq.${campaignId}&order=created_at.desc&limit=50`,
|
||||
),
|
||||
visibleRoundIds.length ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`dice_rolls?select=id,round_id,actor_id,target_id,check_kind,formula,rolls,kept,modifier,total,difficulty,success,created_at&round_id=in.(${visibleRoundIds.join(',')})&order=created_at.asc`,
|
||||
) : Promise.resolve([]),
|
||||
])
|
||||
return { campaign, world: worlds[0] ?? null, members: visibleMembers, characters, round, rounds: visibleRounds, intents, events: events.slice().reverse(), diceRolls }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
53
apps/web/server/api/v1/campaigns/[id]/characters.post.ts
Normal file
53
apps/web/server/api/v1/campaigns/[id]/characters.post.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { AbilityScoresSchema } from '@dng/shared'
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
concept: z.string().trim().min(3).max(600),
|
||||
controller: z.enum(['human', 'ai']).default('human'),
|
||||
abilities: AbilityScoresSchema,
|
||||
hp: z.number().int().min(0),
|
||||
maxHp: z.number().int().min(1),
|
||||
defense: z.number().int().min(1).max(40),
|
||||
proficiency: z.number().int().min(1).max(10).default(2),
|
||||
inventory: z.array(z.string().trim().min(1).max(120)).max(50).default([]),
|
||||
statuses: z.array(z.string().trim().min(1).max(80)).max(12).default([]),
|
||||
persona: z.record(z.string(), z.unknown()).default({}),
|
||||
}).strict().refine(value => value.hp <= value.maxHp, { message: 'hp must not exceed maxHp', path: ['hp'] })
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const access = await requireCampaignAccess(campaignId, user.id)
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
if (body.controller === 'ai' && !access.owner) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can add AI heroes.' })
|
||||
}
|
||||
requireStageTwoSafeText([body.name, body.concept, ...body.inventory, ...body.statuses, JSON.stringify(body.persona)].join('\n'))
|
||||
const rows = await stageTwoDatabase<Array<Record<string, unknown>>>('characters', {
|
||||
method: 'POST',
|
||||
prefer: 'return=representation',
|
||||
body: JSON.stringify({
|
||||
campaign_id: campaignId,
|
||||
user_id: body.controller === 'human' ? user.id : null,
|
||||
name: body.name,
|
||||
concept: body.concept,
|
||||
controller: body.controller,
|
||||
abilities: body.abilities,
|
||||
hp: body.hp,
|
||||
max_hp: body.maxHp,
|
||||
defense: body.defense,
|
||||
proficiency: body.proficiency,
|
||||
inventory: body.inventory,
|
||||
statuses: body.statuses,
|
||||
persona: body.persona,
|
||||
}),
|
||||
})
|
||||
setResponseStatus(event, 201)
|
||||
return { character: rows[0] }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
32
apps/web/server/api/v1/campaigns/[id]/invites.post.ts
Normal file
32
apps/web/server/api/v1/campaigns/[id]/invites.post.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({
|
||||
maxUses: z.number().int().min(1).max(20).default(1),
|
||||
expiresInHours: z.number().int().min(1).max(720).default(72),
|
||||
}).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
await requireCampaignAccess(campaignId, user.id, true)
|
||||
const body = BodySchema.parse((await readBody(event)) ?? {})
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||
const expiresAt = new Date(Date.now() + body.expiresInHours * 3_600_000).toISOString()
|
||||
await stageTwoDatabase('invites', {
|
||||
method: 'POST',
|
||||
prefer: 'return=minimal',
|
||||
body: JSON.stringify({
|
||||
campaign_id: campaignId, created_by: user.id, token_hash: tokenHash,
|
||||
max_uses: body.maxUses, expires_at: expiresAt,
|
||||
}),
|
||||
})
|
||||
setResponseStatus(event, 201)
|
||||
return { token, expiresAt, maxUses: body.maxUses }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ aiTakeoverAllowed: z.boolean() }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const memberId = stageTwoUuid(getRouterParam(event, 'memberId'), 'member id')
|
||||
await requireCampaignAccess(campaignId, user.id, true)
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
const members = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`campaign_members?id=eq.${memberId}&campaign_id=eq.${campaignId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
prefer: 'return=representation',
|
||||
body: JSON.stringify({ ai_takeover_allowed: body.aiTakeoverAllowed }),
|
||||
},
|
||||
)
|
||||
if (!members[0]) throw createError({ statusCode: 404, statusMessage: 'Campaign member not found.' })
|
||||
return { member: members[0] }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const roundId = stageTwoUuid(getRouterParam(event, 'roundId'), 'round id')
|
||||
await requireCampaignAccess(campaignId, user.id, true)
|
||||
const rounds = await stageTwoDatabase<Array<{ id: string; status: string }>>(
|
||||
`rounds?select=id,status&id=eq.${roundId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
||||
if (rounds[0].status !== 'open') throw createError({ statusCode: 409, statusMessage: 'Round is not open.' })
|
||||
const jobId = await stageTwoRpc<string>('stage_two_force_round', {
|
||||
p_round_id: roundId, p_owner_id: user.id,
|
||||
})
|
||||
setResponseStatus(event, 202)
|
||||
return { jobId, status: 'queued' }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({
|
||||
characterId: z.string().uuid(),
|
||||
action: z.string().trim().min(1).max(2000),
|
||||
ready: z.boolean().default(false),
|
||||
}).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const roundId = stageTwoUuid(getRouterParam(event, 'roundId'), 'round id')
|
||||
await requireCampaignAccess(campaignId, user.id)
|
||||
const rounds = await stageTwoDatabase<Array<{ id: string; status: string }>>(
|
||||
`rounds?select=id,status&id=eq.${roundId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
||||
if (rounds[0].status !== 'open') throw createError({ statusCode: 409, statusMessage: 'Round is not open.' })
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
requireStageTwoSafeText(body.action)
|
||||
const result = await stageTwoRpc<Record<string, unknown>>('stage_two_submit_intent', {
|
||||
p_round_id: roundId,
|
||||
p_user_id: user.id,
|
||||
p_character_id: body.characterId,
|
||||
p_action: body.action,
|
||||
p_ready: body.ready,
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ ready: z.boolean().default(true) }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const roundId = stageTwoUuid(getRouterParam(event, 'roundId'), 'round id')
|
||||
await requireCampaignAccess(campaignId, user.id)
|
||||
const body = BodySchema.parse((await readBody(event)) ?? {})
|
||||
const rounds = await stageTwoDatabase<Array<{ id: string; status: string }>>(
|
||||
`rounds?select=id,status&id=eq.${roundId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
||||
if (rounds[0].status !== 'open') throw createError({ statusCode: 409, statusMessage: 'Round is not open.' })
|
||||
const members = await stageTwoDatabase<Array<{ id: string }>>(
|
||||
`campaign_members?select=id&campaign_id=eq.${campaignId}&user_id=eq.${user.id}&active=eq.true&limit=1`,
|
||||
)
|
||||
if (!members[0]) throw createError({ statusCode: 403, statusMessage: 'Active campaign membership not found.' })
|
||||
const intents = await stageTwoDatabase<Array<{ character_id: string; action: string }>>(
|
||||
`player_intents?select=character_id,action&round_id=eq.${roundId}&member_id=eq.${members[0].id}&limit=1`,
|
||||
)
|
||||
if (!intents[0]) throw createError({ statusCode: 409, statusMessage: 'Submit an action before marking ready.' })
|
||||
return await stageTwoRpc<Record<string, unknown>>('stage_two_submit_intent', {
|
||||
p_round_id: roundId,
|
||||
p_user_id: user.id,
|
||||
p_character_id: intents[0].character_id,
|
||||
p_action: intents[0].action,
|
||||
p_ready: body.ready,
|
||||
})
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const roundId = stageTwoUuid(getRouterParam(event, 'roundId'), 'round id')
|
||||
await requireCampaignAccess(campaignId, user.id, true)
|
||||
const rounds = await stageTwoDatabase<Array<{ id: string; status: string }>>(
|
||||
`rounds?select=id,status&id=eq.${roundId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
||||
if (rounds[0].status !== 'failed') throw createError({ statusCode: 409, statusMessage: 'Only a failed round can be retried.' })
|
||||
const jobId = await stageTwoRpc<string>('stage_two_retry_failed_round', {
|
||||
p_round_id: roundId,
|
||||
p_owner_id: user.id,
|
||||
})
|
||||
setResponseStatus(event, 202)
|
||||
return { jobId, status: 'queued' }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
17
apps/web/server/api/v1/campaigns/index.get.ts
Normal file
17
apps/web/server/api/v1/campaigns/index.get.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const memberships = await stageTwoDatabase<Array<{ campaign_id: string }>>(
|
||||
`campaign_members?select=campaign_id&user_id=eq.${user.id}&active=eq.true`,
|
||||
)
|
||||
const filters = [`owner_id.eq.${user.id}`, ...memberships.map(item => `id.eq.${item.campaign_id}`)]
|
||||
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`campaigns?select=id,world_id,owner_id,title,current_scene,next_prompt,status,created_at,updated_at&or=(${filters.join(',')})&order=updated_at.desc`,
|
||||
)
|
||||
return { campaigns }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
19
apps/web/server/api/v1/campaigns/index.post.ts
Normal file
19
apps/web/server/api/v1/campaigns/index.post.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ worldId: z.string().uuid(), title: z.string().trim().min(3).max(100) }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
requireStageTwoSafeText(body.title)
|
||||
const campaignId = await stageTwoRpc<string>('stage_two_create_campaign', {
|
||||
p_world_id: stageTwoUuid(body.worldId, 'world id'), p_owner_id: user.id, p_title: body.title,
|
||||
})
|
||||
setResponseStatus(event, 201)
|
||||
return { campaignId }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
15
apps/web/server/api/v1/coauthor/sessions/[id].get.ts
Normal file
15
apps/web/server/api/v1/coauthor/sessions/[id].get.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const id = stageTwoUuid(getRouterParam(event, 'id'))
|
||||
const sessions = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`coauthor_sessions?select=id,status,messages,generated_world,confirmed_world_id,created_at,updated_at&id=eq.${id}&owner_id=eq.${user.id}&limit=1`,
|
||||
)
|
||||
if (!sessions[0]) throw createError({ statusCode: 404, statusMessage: 'Coauthor session not found.' })
|
||||
return { session: { ...sessions[0], generatedWorld: sessions[0].generated_world ?? null } }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { WorldStarterSchema } from '@dng/shared'
|
||||
import { z } from 'zod'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ world: WorldStarterSchema.optional() }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessionId = stageTwoUuid(getRouterParam(event, 'id'), 'session id')
|
||||
const body = BodySchema.parse((await readBody(event)) ?? {})
|
||||
const sessions = await stageTwoDatabase<Array<{ generated_world: unknown }>>(
|
||||
`coauthor_sessions?select=generated_world&id=eq.${sessionId}&owner_id=eq.${user.id}&limit=1`,
|
||||
)
|
||||
if (!sessions[0]) throw createError({ statusCode: 404, statusMessage: 'Coauthor session not found.' })
|
||||
const world = WorldStarterSchema.parse(body.world ?? sessions[0].generated_world)
|
||||
requireStageTwoSafeText(JSON.stringify(world))
|
||||
if (body.world) {
|
||||
await stageTwoDatabase(`coauthor_sessions?id=eq.${sessionId}&owner_id=eq.${user.id}`, {
|
||||
method: 'PATCH', prefer: 'return=minimal', body: JSON.stringify({ generated_world: world, updated_at: new Date().toISOString() }),
|
||||
})
|
||||
}
|
||||
const worldId = await stageTwoRpc<string>('stage_two_confirm_world', {
|
||||
p_session_id: sessionId, p_owner_id: user.id,
|
||||
})
|
||||
return { worldId }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessionId = stageTwoUuid(getRouterParam(event, 'id'), 'session id')
|
||||
const jobId = await stageTwoRpc<string>('stage_two_enqueue_world_generation', {
|
||||
p_session_id: sessionId, p_owner_id: user.id,
|
||||
})
|
||||
setResponseStatus(event, 202)
|
||||
return { jobId, status: 'queued' }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ content: z.string().trim().min(1).max(5000) }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessionId = stageTwoUuid(getRouterParam(event, 'id'), 'session id')
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
requireStageTwoSafeText(body.content)
|
||||
const session = await stageTwoRpc<Record<string, unknown>>('stage_two_append_coauthor_message', {
|
||||
p_session_id: sessionId, p_owner_id: user.id, p_content: body.content,
|
||||
})
|
||||
return { session }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const MessageSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string().min(1).max(5000) })
|
||||
const QuestionSchema = z.object({
|
||||
question: z.string().trim().min(5).max(300),
|
||||
options: z.array(z.string().trim().min(1).max(100)).length(3),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessionId = stageTwoUuid(getRouterParam(event, 'id'), 'session id')
|
||||
const sessions = await stageTwoDatabase<Array<{ status: string; messages: unknown }>>(
|
||||
`coauthor_sessions?select=status,messages&id=eq.${sessionId}&owner_id=eq.${user.id}&limit=1`,
|
||||
)
|
||||
const session = sessions[0]
|
||||
if (!session) throw createError({ statusCode: 404, statusMessage: 'Coauthor session not found.' })
|
||||
if (session.status !== 'collecting') throw createError({ statusCode: 409, statusMessage: 'This coauthor session is not accepting answers.' })
|
||||
const messages = z.array(MessageSchema).min(1).max(12).parse(session.messages)
|
||||
const answerCount = Math.max(0, messages.filter(message => message.role === 'user').length - 1)
|
||||
if (answerCount >= 4) return { readyToGenerate: true }
|
||||
|
||||
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+.`,
|
||||
messages,
|
||||
schemaName: 'coauthor_question',
|
||||
jsonSchema: QuestionSchema.toJSONSchema(),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(completion.endpoint, {
|
||||
method: 'POST', headers: completion.headers, body: JSON.stringify(completion.body), signal: controller.signal,
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
||||
const question = QuestionSchema.parse(parseJsonCompletion(await response.json()))
|
||||
requireStageTwoSafeText([question.question, ...question.options].join('\n'))
|
||||
await stageTwoRpc('stage_two_append_coauthor_assistant_message', {
|
||||
p_session_id: sessionId, p_owner_id: user.id, p_content: question.question,
|
||||
})
|
||||
return {
|
||||
readyToGenerate: false,
|
||||
question: { id: `question-${messages.length}`, label: question.question, options: question.options },
|
||||
}
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
13
apps/web/server/api/v1/coauthor/sessions/index.get.ts
Normal file
13
apps/web/server/api/v1/coauthor/sessions/index.get.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessions = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`coauthor_sessions?select=id,status,messages,generated_world,confirmed_world_id,created_at,updated_at&owner_id=eq.${user.id}&order=updated_at.desc`,
|
||||
)
|
||||
return { sessions }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
24
apps/web/server/api/v1/coauthor/sessions/index.post.ts
Normal file
24
apps/web/server/api/v1/coauthor/sessions/index.post.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ message: z.string().trim().min(1).max(5000).optional() }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const body = BodySchema.parse((await readBody(event)) ?? {})
|
||||
if (body.message) requireStageTwoSafeText(body.message)
|
||||
const sessions = await stageTwoDatabase<Array<Record<string, unknown>>>('coauthor_sessions', {
|
||||
method: 'POST',
|
||||
prefer: 'return=representation',
|
||||
body: JSON.stringify({
|
||||
owner_id: user.id,
|
||||
messages: body.message ? [{ role: 'user', content: body.message }] : [],
|
||||
}),
|
||||
})
|
||||
setResponseStatus(event, 201)
|
||||
return { session: sessions[0] }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
19
apps/web/server/api/v1/invites/join.post.ts
Normal file
19
apps/web/server/api/v1/invites/join.post.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoRpc } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ token: z.string().trim().min(20).max(200) }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
const tokenHash = createHash('sha256').update(body.token).digest('hex')
|
||||
const campaignId = await stageTwoRpc<string>('stage_two_join_campaign', {
|
||||
p_token_hash: tokenHash, p_user_id: user.id,
|
||||
})
|
||||
return { campaignId }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -9,4 +9,12 @@ describe('required Supabase runtime config', () => {
|
||||
it('reports missing credentials clearly', () => {
|
||||
expect(() => assertRequiredSupabaseConfig({ public: {} })).toThrow('SUPABASE_SERVICE_ROLE_KEY is required')
|
||||
})
|
||||
|
||||
it('rejects credentials from different Supabase projects', () => {
|
||||
expect(() => assertRequiredSupabaseConfig({
|
||||
supabaseUrl: 'https://server-project.supabase.co',
|
||||
supabaseServiceRoleKey: 'service',
|
||||
public: { supabaseUrl: 'https://public-project.supabase.co', supabaseAnonKey: 'anon' },
|
||||
})).toThrow('must point to the same project')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,4 +15,10 @@ export function assertRequiredSupabaseConfig(config: unknown): void {
|
||||
const details = result.error.issues.map(issue => issue.message).join('; ')
|
||||
throw new Error(`Invalid Supabase runtime configuration: ${details}`)
|
||||
}
|
||||
|
||||
const serverOrigin = new URL(result.data.supabaseUrl).origin
|
||||
const publicOrigin = new URL(result.data.public.supabaseUrl).origin
|
||||
if (serverOrigin !== publicOrigin) {
|
||||
throw new Error('Invalid Supabase runtime configuration: SUPABASE_URL and NUXT_PUBLIC_SUPABASE_URL must point to the same project')
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/web/server/utils/stage-two-supabase.test.ts
Normal file
21
apps/web/server/utils/stage-two-supabase.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { stageTwoDatabase } from './stage-two-supabase'
|
||||
|
||||
describe('stage-two Supabase client', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('accepts successful PostgREST return=minimal responses with an empty body', async () => {
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
supabaseUrl: 'https://example.supabase.co',
|
||||
supabaseServiceRoleKey: 'service-role-key',
|
||||
public: { supabaseAnonKey: 'anon-key' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 201 })))
|
||||
|
||||
await expect(stageTwoDatabase('invites', {
|
||||
method: 'POST',
|
||||
prefer: 'return=minimal',
|
||||
body: '{}',
|
||||
})).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
126
apps/web/server/utils/stage-two-supabase.ts
Normal file
126
apps/web/server/utils/stage-two-supabase.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { z } from 'zod'
|
||||
import type { H3Event } from 'h3'
|
||||
import { moderate13Plus } from '@dng/shared'
|
||||
|
||||
const UuidSchema = z.string().uuid()
|
||||
const AuthEmailSchema = z.preprocess(
|
||||
value => value === null || value === '' ? undefined : value,
|
||||
z.string().email().optional(),
|
||||
)
|
||||
const AuthUserSchema = z.object({ id: UuidSchema, email: AuthEmailSchema })
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
prefer?: string
|
||||
}
|
||||
|
||||
export interface StageTwoUser {
|
||||
id: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export class StageTwoDatabaseError extends Error {
|
||||
constructor(public status: number, message: string, public code?: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
const config = useRuntimeConfig()
|
||||
const url = z.string().url().parse(config.supabaseUrl)
|
||||
const serviceKey = z.string().min(1).parse(config.supabaseServiceRoleKey)
|
||||
const anonKey = z.string().min(1).parse(config.public.supabaseAnonKey)
|
||||
return { url: url.replace(/\/$/, ''), serviceKey, anonKey }
|
||||
}
|
||||
|
||||
export function stageTwoUuid(value: unknown, label = 'id'): string {
|
||||
const parsed = UuidSchema.safeParse(value)
|
||||
if (!parsed.success) throw createError({ statusCode: 400, statusMessage: `Invalid ${label}.` })
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export function requireStageTwoSafeText(text: string): void {
|
||||
const result = moderate13Plus(text)
|
||||
if (!result.allowed) {
|
||||
throw createError({ statusCode: 422, statusMessage: `Content is outside the 13+ policy: ${result.categories.join(', ')}.` })
|
||||
}
|
||||
}
|
||||
|
||||
export async function stageTwoDatabase<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { url, serviceKey } = runtime()
|
||||
const response = await fetch(new URL(`/rest/v1/${path}`, url), {
|
||||
...options,
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.prefer ? { Prefer: options.prefer } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { code?: string; message?: string } | null
|
||||
throw new StageTwoDatabaseError(response.status, payload?.message || `Supabase request failed (${response.status}).`, payload?.code)
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
// PostgREST commonly returns 201/200 with an empty body for
|
||||
// `Prefer: return=minimal`; parsing that as JSON throws after a successful
|
||||
// database mutation and incorrectly turns the route into HTTP 500.
|
||||
const text = await response.text()
|
||||
if (!text.trim()) return undefined as T
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
|
||||
export function stageTwoRpc<T>(name: string, body: Record<string, unknown>): Promise<T> {
|
||||
return stageTwoDatabase<T>(`rpc/${name}`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export async function requireStageTwoUser(event: H3Event): Promise<StageTwoUser> {
|
||||
const authorization = getHeader(event, 'authorization')
|
||||
if (!authorization?.startsWith('Bearer ') || authorization.length <= 7) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'A Supabase access token is required.' })
|
||||
}
|
||||
const { url, anonKey } = runtime()
|
||||
const response = await fetch(new URL('/auth/v1/user', url), {
|
||||
headers: { apikey: anonKey, Authorization: authorization },
|
||||
})
|
||||
if (!response.ok) throw createError({ statusCode: 401, statusMessage: 'The access token is invalid or expired.' })
|
||||
const parsed = AuthUserSchema.safeParse(await response.json())
|
||||
if (!parsed.success) throw createError({ statusCode: 401, statusMessage: 'Supabase returned an invalid user.' })
|
||||
|
||||
const profiles = await stageTwoDatabase<Array<{ id: string }>>(`profiles?select=id&id=eq.${parsed.data.id}&limit=1`)
|
||||
if (!profiles[0]) throw createError({ statusCode: 403, statusMessage: 'This account is not enabled for the alpha.' })
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export async function requireCampaignAccess(campaignId: string, userId: string, ownerOnly = false) {
|
||||
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`campaigns?select=*&id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
const campaign = campaigns[0]
|
||||
if (!campaign) throw createError({ statusCode: 404, statusMessage: 'Campaign not found.' })
|
||||
if (campaign.owner_id === userId) return { campaign, owner: true }
|
||||
if (ownerOnly) throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can do that.' })
|
||||
const members = await stageTwoDatabase<Array<{ id: string; active: boolean }>>(
|
||||
`campaign_members?select=id,active&campaign_id=eq.${campaignId}&user_id=eq.${userId}&active=eq.true&limit=1`,
|
||||
)
|
||||
if (!members[0]) throw createError({ statusCode: 404, statusMessage: 'Campaign not found.' })
|
||||
return { campaign, owner: false, memberId: members[0].id }
|
||||
}
|
||||
|
||||
export function stageTwoApiError(error: unknown): never {
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
||||
if (error instanceof StageTwoDatabaseError) {
|
||||
if (error.code === 'PGRST202' || error.code === 'PGRST205') {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'The Supabase database schema is not installed or is out of date. Apply supabase/bootstrap.sql.',
|
||||
})
|
||||
}
|
||||
const conflict = /duplicate key|already exists|not claimable/i.test(error.message)
|
||||
throw createError({ statusCode: conflict ? 409 : error.status >= 500 ? 502 : 400, statusMessage: error.message })
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
throw createError({ statusCode: 400, statusMessage: error.issues.map(issue => issue.message).join('; ') })
|
||||
}
|
||||
throw createError({ statusCode: 500, statusMessage: error instanceof Error ? error.message : 'Unexpected server error.' })
|
||||
}
|
||||
Reference in New Issue
Block a user