Compare commits
4 Commits
agent/code
...
68b4fe9f33
| Author | SHA1 | Date | |
|---|---|---|---|
| 68b4fe9f33 | |||
| fdf02446f5 | |||
| 034966f9ef | |||
| 0a38ffdaf0 |
@@ -1,6 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{ section?: string }>()
|
defineProps<{ section?: string }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
campaignAction: [payload: { action: 'rename' | 'delete'; campaignId: string }]
|
||||||
|
}>()
|
||||||
const { session, restore } = useDngAuth()
|
const { session, restore } = useDngAuth()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const contextMenu = ref<HTMLElement | null>(null)
|
||||||
|
const contextOpen = ref(false)
|
||||||
|
const contextX = ref(0)
|
||||||
|
const contextY = ref(0)
|
||||||
|
const contextCampaignId = ref('')
|
||||||
|
const contextCampaignTitle = ref('')
|
||||||
onMounted(() => void restore())
|
onMounted(() => void restore())
|
||||||
|
|
||||||
const initials = computed(() => {
|
const initials = computed(() => {
|
||||||
@@ -12,10 +23,73 @@ const avatarUrl = computed(() => {
|
|||||||
const value = session.value?.user.user_metadata?.avatar_url
|
const value = session.value?.user.user_metadata?.avatar_url
|
||||||
return typeof value === 'string' && value ? value : ''
|
return typeof value === 'string' && value ? value : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function closeContextMenu() {
|
||||||
|
contextOpen.value = false
|
||||||
|
contextCampaignId.value = ''
|
||||||
|
contextCampaignTitle.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openContextMenu(event: MouseEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
const campaign = event.target instanceof Element
|
||||||
|
? event.target.closest<HTMLElement>('[data-context-campaign]')
|
||||||
|
: null
|
||||||
|
contextCampaignId.value = campaign?.dataset.contextCampaign ?? ''
|
||||||
|
contextCampaignTitle.value = campaign?.dataset.contextLabel ?? ''
|
||||||
|
contextX.value = event.clientX
|
||||||
|
contextY.value = event.clientY
|
||||||
|
contextOpen.value = true
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
const bounds = contextMenu.value?.getBoundingClientRect()
|
||||||
|
if (!bounds) return
|
||||||
|
contextX.value = Math.max(12, Math.min(contextX.value, window.innerWidth - bounds.width - 12))
|
||||||
|
contextY.value = Math.max(12, Math.min(contextY.value, window.innerHeight - bounds.height - 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCampaignAction(action: 'rename' | 'delete') {
|
||||||
|
const campaignId = contextCampaignId.value
|
||||||
|
closeContextMenu()
|
||||||
|
if (campaignId) emit('campaignAction', { action, campaignId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAccountSection(section: 'identity' | 'settings') {
|
||||||
|
closeContextMenu()
|
||||||
|
await navigateTo({ path: '/profile', hash: `#${section}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
closeContextMenu()
|
||||||
|
if (window.history.length > 1) router.back()
|
||||||
|
else void navigateTo('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFromPointer(event: PointerEvent) {
|
||||||
|
if (contextOpen.value && event.target instanceof Node && !contextMenu.value?.contains(event.target)) closeContextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFromKeyboard(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') closeContextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointerdown', closeFromPointer)
|
||||||
|
document.addEventListener('keydown', closeFromKeyboard)
|
||||||
|
window.addEventListener('resize', closeContextMenu)
|
||||||
|
window.addEventListener('scroll', closeContextMenu, true)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('pointerdown', closeFromPointer)
|
||||||
|
document.removeEventListener('keydown', closeFromKeyboard)
|
||||||
|
window.removeEventListener('resize', closeContextMenu)
|
||||||
|
window.removeEventListener('scroll', closeContextMenu, true)
|
||||||
|
})
|
||||||
|
watch(() => route.fullPath, closeContextMenu)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="shell">
|
<div class="shell" @contextmenu="openContextMenu">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<AppMark />
|
<AppMark />
|
||||||
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
||||||
@@ -28,9 +102,35 @@ const avatarUrl = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main><slot /></main>
|
<main><slot /></main>
|
||||||
|
<Teleport to="body">
|
||||||
|
<nav
|
||||||
|
v-if="contextOpen"
|
||||||
|
ref="contextMenu"
|
||||||
|
class="context-menu"
|
||||||
|
:style="{ left: `${contextX}px`, top: `${contextY}px` }"
|
||||||
|
aria-label="Site actions"
|
||||||
|
role="menu"
|
||||||
|
@contextmenu.prevent
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<small>{{ contextCampaignId ? 'UNIVERSE CONTROL' : 'D&G COMMANDS' }}</small>
|
||||||
|
<strong>{{ contextCampaignTitle || 'QUICK ACCESS' }}</strong>
|
||||||
|
</header>
|
||||||
|
<div v-if="contextCampaignId" class="context-group">
|
||||||
|
<button role="menuitem" @click="runCampaignAction('rename')"><span>✎</span> RENAME UNIVERSE</button>
|
||||||
|
<button class="danger" role="menuitem" @click="runCampaignAction('delete')"><span>×</span> DELETE UNIVERSE</button>
|
||||||
|
</div>
|
||||||
|
<div class="context-group">
|
||||||
|
<button role="menuitem" @click="openAccountSection('identity')"><span>◎</span> EDIT PROFILE</button>
|
||||||
|
<button role="menuitem" @click="goBack"><span>←</span> PREVIOUS PAGE</button>
|
||||||
|
<button role="menuitem" @click="openAccountSection('settings')"><span>⚙</span> SETTINGS</button>
|
||||||
|
</div>
|
||||||
|
<footer><i /> PRIVATE ALPHA / SECURE</footer>
|
||||||
|
</nav>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{position:relative;overflow:hidden;display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;text-decoration:none;font:700 10px var(--mono)}.avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.avatar span{position:relative}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{position:relative;overflow:hidden;display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;text-decoration:none;font:700 10px var(--mono)}.avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.avatar span{position:relative}.context-menu{position:fixed;z-index:2000;width:min(286px,calc(100vw - 24px));border:1px solid #3c3c37;background:rgba(10,10,9,.98);color:var(--ink);box-shadow:0 24px 70px rgba(0,0,0,.68);backdrop-filter:blur(18px)}.context-menu header{display:grid;gap:7px;padding:17px 18px 15px;border-bottom:1px solid var(--line);background:linear-gradient(120deg,rgba(217,247,95,.08),transparent 70%)}.context-menu header small{color:var(--acid);font:600 7px var(--mono);letter-spacing:.17em}.context-menu header strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 12px var(--display);letter-spacing:-.02em}.context-group{padding:6px;border-bottom:1px solid var(--line)}.context-group button{width:100%;min-height:40px;display:grid;grid-template-columns:28px 1fr;align-items:center;padding:0 10px;border:0;background:transparent;color:#c4c4bd;text-align:left;font:600 8px var(--mono);letter-spacing:.11em}.context-group button span{color:var(--acid);font:500 15px var(--mono)}.context-group button:hover,.context-group button:focus-visible{background:#171715;color:var(--ink);filter:none}.context-group button.danger{color:#e5aaa0}.context-group button.danger span{color:#ff8875}.context-menu footer{padding:11px 15px;color:#6e6e68;font:500 6px var(--mono);letter-spacing:.14em}.context-menu footer i{display:inline-block;width:5px;height:5px;margin-right:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 8px var(--acid)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const campaigns = ref<CampaignRow[]>([])
|
|||||||
const drafts = ref<DraftSession[]>([])
|
const drafts = ref<DraftSession[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const renamingCampaignId = ref<string | null>(null)
|
||||||
|
const newName = ref('')
|
||||||
|
|
||||||
const visibleCampaigns = computed(() => filter.value === 'active'
|
const visibleCampaigns = computed(() => filter.value === 'active'
|
||||||
? campaigns.value.filter(campaign => campaign.status === 'active')
|
? campaigns.value.filter(campaign => campaign.status === 'active')
|
||||||
@@ -41,6 +43,34 @@ function draftSummary(draft: DraftSession) {
|
|||||||
|| 'Continue your conversation with the coauthor.'
|
|| 'Continue your conversation with the coauthor.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRename(campaign: CampaignRow) {
|
||||||
|
renamingCampaignId.value = campaign.id
|
||||||
|
newName.value = campaign.title
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCampaignAction(payload: { action: 'rename' | 'delete'; campaignId: string }) {
|
||||||
|
const campaign = campaigns.value.find(item => item.id === payload.campaignId)
|
||||||
|
if (!campaign) return
|
||||||
|
if (payload.action === 'rename') openRename(campaign)
|
||||||
|
else void requestDelete(campaign)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRename() {
|
||||||
|
if (!renamingCampaignId.value || !newName.value.trim()) return
|
||||||
|
try {
|
||||||
|
await api(`/api/v1/campaigns/${renamingCampaignId.value}`, { method: 'PATCH', body: { title: newName.value } })
|
||||||
|
campaigns.value = campaigns.value.map(c => c.id === renamingCampaignId.value ? { ...c, title: newName.value.trim(), updated_at: new Date().toISOString() } : c)
|
||||||
|
} catch { /* handled silently */ } finally { renamingCampaignId.value = null; newName.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestDelete(campaign: CampaignRow) {
|
||||||
|
if (!confirm(`Delete "${campaign.title}"? This cannot be undone.`)) return
|
||||||
|
try {
|
||||||
|
await api(`/api/v1/campaigns/${campaign.id}`, { method: 'DELETE' })
|
||||||
|
campaigns.value = campaigns.value.filter(c => c.id !== campaign.id)
|
||||||
|
} catch { /* handled silently — refresh on next mount */ }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await auth.restore()
|
await auth.restore()
|
||||||
@@ -60,7 +90,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell section="COMMAND DECK">
|
<AppShell section="COMMAND DECK" @campaign-action="handleCampaignAction">
|
||||||
<div class="dash-wrap noise">
|
<div class="dash-wrap noise">
|
||||||
<section class="dash-head">
|
<section class="dash-head">
|
||||||
<div><p class="kicker">WELCOME BACK, {{ displayName.toUpperCase() }}</p><h1>YOUR UNIVERSES<span>.</span></h1><p>Every world remembers where you left it.</p></div>
|
<div><p class="kicker">WELCOME BACK, {{ displayName.toUpperCase() }}</p><h1>YOUR UNIVERSES<span>.</span></h1><p>Every world remembers where you left it.</p></div>
|
||||||
@@ -75,7 +105,14 @@ onMounted(async () => {
|
|||||||
<section class="world-grid">
|
<section class="world-grid">
|
||||||
<p v-if="loading" class="load-state">RECEIVING PRIVATE CAMPAIGNS…</p>
|
<p v-if="loading" class="load-state">RECEIVING PRIVATE CAMPAIGNS…</p>
|
||||||
<p v-else-if="error" class="load-state error">{{ error }} <NuxtLink to="/auth/sign-in">SIGN IN AGAIN</NuxtLink></p>
|
<p v-else-if="error" class="load-state error">{{ error }} <NuxtLink to="/auth/sign-in">SIGN IN AGAIN</NuxtLink></p>
|
||||||
<NuxtLink v-for="campaign in visibleCampaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`" class="world-card active-world">
|
<NuxtLink
|
||||||
|
v-for="campaign in visibleCampaigns"
|
||||||
|
:key="campaign.id"
|
||||||
|
:to="`/campaign/${campaign.id}`"
|
||||||
|
class="world-card active-world"
|
||||||
|
:data-context-campaign="campaign.id"
|
||||||
|
:data-context-label="campaign.title"
|
||||||
|
>
|
||||||
<div class="world-art"><div class="eclipse" /><span>{{ campaign.status.toUpperCase() }} CAMPAIGN</span></div>
|
<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>
|
<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>
|
||||||
@@ -90,6 +127,19 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<section class="system-strip"><span><i /> AI COAUTHOR READY</span><span>SERVER DICE <b>ONLINE</b></span><span>PRIVATE BY DEFAULT</span></section>
|
<section class="system-strip"><span><i /> AI COAUTHOR READY</span><span>SERVER DICE <b>ONLINE</b></span><span>PRIVATE BY DEFAULT</span></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<dialog v-if="renamingCampaignId" class="rename-dialog" open @close.stop="">
|
||||||
|
<form @submit.prevent="confirmRename" class="rename-panel">
|
||||||
|
<small>RENAME UNIVERSE</small>
|
||||||
|
<input v-model="newName" required maxlength="200" autofocus placeholder="Universe name" aria-label="New universe name" />
|
||||||
|
<div class="rename-actions">
|
||||||
|
<button type="button" class="ghost-button" @click="renamingCampaignId = null; newName = ''">CANCEL</button>
|
||||||
|
<button class="acid-button" type="submit">SAVE</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</Teleport>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -130,6 +180,13 @@ onMounted(async () => {
|
|||||||
.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{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 i{display:inline-block;width:6px;height:6px;background:var(--acid);border-radius:50%;margin-right:8px}
|
||||||
.system-strip b{color:var(--acid)}
|
.system-strip b{color:var(--acid)}
|
||||||
|
.rename-dialog{position:fixed;inset:0;margin:auto;z-index:1000;width:min(440px,calc(100vw - 40px));padding:0;border:1px solid var(--line);background:#0a0a0a;color:var(--ink);box-shadow:0 60px 120px rgba(0,0,0,.7);font-family:inherit;display:grid}
|
||||||
|
.rename-dialog::backdrop{background:rgba(0,0,0,.55)}
|
||||||
|
.rename-panel{padding:32px;display:grid;gap:18px}
|
||||||
|
.rename-panel small{font:600 8px var(--mono);letter-spacing:.18em;color:var(--acid)}
|
||||||
|
.rename-panel input{box-sizing:border-box;width:100%;min-height:48px;padding:0 14px;border:1px solid var(--line);background:#0e0e0d;color:var(--ink);font:12px var(--body);outline:none}
|
||||||
|
.rename-panel input:focus{border-color:var(--acid)}
|
||||||
|
.rename-actions{display:flex;justify-content:flex-end;gap:10px}
|
||||||
@media(max-width:1050px){.world-grid{grid-template-columns:1fr}}
|
@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:850px){.dash-head{align-items:start;flex-direction:column}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}
|
||||||
@media(max-width:520px){.dash-head h1{font-size:clamp(34px,12vw,52px)}.create-button{width:100%;justify-content:center}.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}.world-info div{align-items:flex-start;flex-direction:column}.system-strip{flex-direction:column}}
|
@media(max-width:520px){.dash-head h1{font-size:clamp(34px,12vw,52px)}.create-button{width:100%;justify-content:center}.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}.world-info div{align-items:flex-start;flex-direction:column}.system-strip{flex-direction:column}}
|
||||||
|
|||||||
@@ -204,14 +204,14 @@ onBeforeUnmount(clearPreview)
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="profile-forms">
|
<div class="profile-forms">
|
||||||
<form class="profile-panel" @submit.prevent="saveProfile">
|
<form id="identity" class="profile-panel" @submit.prevent="saveProfile">
|
||||||
<header><span>01</span><div><small>PUBLIC IDENTITY</small><h2>NAME & DESCRIPTION</h2></div></header>
|
<header><span>01</span><div><small>PUBLIC IDENTITY</small><h2>NAME & DESCRIPTION</h2></div></header>
|
||||||
<label>DISPLAY NAME<input v-model="displayName" required minlength="1" maxlength="80" autocomplete="name" placeholder="How your party knows you"></label>
|
<label>DISPLAY NAME<input v-model="displayName" required minlength="1" maxlength="80" autocomplete="name" placeholder="How your party knows you"></label>
|
||||||
<label>PROFILE DESCRIPTION<textarea v-model="description" maxlength="500" rows="7" placeholder="Tell your party who you are, what you enjoy playing, or what kind of stories you seek." /><span>{{ description.length }} / 500</span></label>
|
<label>PROFILE DESCRIPTION<textarea v-model="description" maxlength="500" rows="7" placeholder="Tell your party who you are, what you enjoy playing, or what kind of stories you seek." /><span>{{ description.length }} / 500</span></label>
|
||||||
<button class="acid-button" :disabled="saving || !displayName.trim()">{{ saving ? 'SAVING PROFILE…' : avatarFile ? 'SAVE PROFILE & PICTURE →' : 'SAVE PROFILE →' }}</button>
|
<button class="acid-button" :disabled="saving || !displayName.trim()">{{ saving ? 'SAVING PROFILE…' : avatarFile ? 'SAVE PROFILE & PICTURE →' : 'SAVE PROFILE →' }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form class="profile-panel" @submit.prevent="changePassword">
|
<form id="settings" class="profile-panel" @submit.prevent="changePassword">
|
||||||
<header><span>02</span><div><small>SECURITY</small><h2>PASSWORD MANAGER</h2></div></header>
|
<header><span>02</span><div><small>SECURITY</small><h2>PASSWORD MANAGER</h2></div></header>
|
||||||
<p class="panel-copy">Choose a new password for this account. Your current signed-in session authorizes the change.</p>
|
<p class="panel-copy">Choose a new password for this account. Your current signed-in session authorizes the change.</p>
|
||||||
<div class="password-grid"><label>NEW PASSWORD<input v-model="password" type="password" minlength="8" autocomplete="new-password" placeholder="At least 8 characters"></label><label>CONFIRM PASSWORD<input v-model="passwordConfirmation" type="password" minlength="8" autocomplete="new-password" placeholder="Repeat new password"></label></div>
|
<div class="password-grid"><label>NEW PASSWORD<input v-model="password" type="password" minlength="8" autocomplete="new-password" placeholder="At least 8 characters"></label><label>CONFIRM PASSWORD<input v-model="passwordConfirmation" type="password" minlength="8" autocomplete="new-password" placeholder="Repeat new password"></label></div>
|
||||||
|
|||||||
44
apps/web/server/api/v1/campaigns/[id].delete.ts
Normal file
44
apps/web/server/api/v1/campaigns/[id].delete.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { requireCampaignAccess, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||||
|
import { requireStageTwoUser } from '~/server/utils/stage-two-supabase'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const user = await requireStageTwoUser(event)
|
||||||
|
const campaignId = stageTwoUuid(event.context.params?.id ?? '')
|
||||||
|
const { owner } = await requireCampaignAccess(campaignId, user.id, true)
|
||||||
|
if (!owner) throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can delete it.' })
|
||||||
|
|
||||||
|
// Delete associated data first to respect relational integrity
|
||||||
|
await stageTwoDatabase(`characters?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`rounds?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`game_events?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`dice_rolls?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`player_intents?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`story_summaries?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`memories?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`relationships?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`audit_entries?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Delete linked world record if it has no other campaigns
|
||||||
|
const campaigns = await stageTwoDatabase<Array<{ world_id: string }>>(
|
||||||
|
`campaigns?select=world_id&id=eq.${campaignId}`,
|
||||||
|
)
|
||||||
|
const worldId = campaigns[0]?.world_id as string | undefined
|
||||||
|
if (worldId) {
|
||||||
|
const otherCampaigns = await stageTwoDatabase<Array<{ id: string }>>(
|
||||||
|
`campaigns?select=id&world_id=eq.${worldId}&id=neq.${campaignId}`,
|
||||||
|
)
|
||||||
|
if (!otherCampaigns.length) {
|
||||||
|
await stageTwoDatabase(`world_entities?world_id=eq.${worldId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`worlds?id=eq.${worldId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await stageTwoDatabase(`campaign_members?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`invites?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`campaigns?id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
stageTwoApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
27
apps/web/server/api/v1/campaigns/[id].patch.ts
Normal file
27
apps/web/server/api/v1/campaigns/[id].patch.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { requireCampaignAccess, requireStageTwoSafeText, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||||
|
import { requireStageTwoUser } from '~/server/utils/stage-two-supabase'
|
||||||
|
|
||||||
|
const PatchBodySchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const user = await requireStageTwoUser(event)
|
||||||
|
const campaignId = stageTwoUuid(event.context.params?.id ?? '')
|
||||||
|
await requireCampaignAccess(campaignId, user.id, true)
|
||||||
|
const body = PatchBodySchema.parse(await readBody(event))
|
||||||
|
requireStageTwoSafeText(body.title)
|
||||||
|
|
||||||
|
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(`campaigns?id=eq.${campaignId}&limit=1`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ title: body.title, updated_at: new Date().toISOString() }),
|
||||||
|
prefer: 'return=representation',
|
||||||
|
})
|
||||||
|
if (!campaigns[0]) throw createError({ statusCode: 404, statusMessage: 'Campaign not found.' })
|
||||||
|
return { campaign: campaigns[0] }
|
||||||
|
} catch (error) {
|
||||||
|
stageTwoApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user