feat(dashboard): add edit button per universe card with rename/delete actions
Some checks failed
CI / validate (push) Has been cancelled
Some checks failed
CI / validate (push) Has been cancelled
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -21,6 +21,9 @@ const campaigns = ref<CampaignRow[]>([])
|
||||
const drafts = ref<DraftSession[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const editingCampaignId = ref<string | null>(null)
|
||||
const renamingCampaignId = ref<string | null>(null)
|
||||
const newName = ref('')
|
||||
|
||||
const visibleCampaigns = computed(() => filter.value === 'active'
|
||||
? campaigns.value.filter(campaign => campaign.status === 'active')
|
||||
@@ -41,6 +44,35 @@ function draftSummary(draft: DraftSession) {
|
||||
|| 'Continue your conversation with the coauthor.'
|
||||
}
|
||||
|
||||
function toggleEdit(campaign: CampaignRow) {
|
||||
if (editingCampaignId.value === campaign.id) {
|
||||
editingCampaignId.value = null
|
||||
} else {
|
||||
editingCampaignId.value = campaign.id
|
||||
}
|
||||
}
|
||||
|
||||
function openRename(campaign: CampaignRow) {
|
||||
renamingCampaignId.value = campaign.id
|
||||
newName.value = campaign.title
|
||||
}
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
await auth.restore()
|
||||
@@ -78,6 +110,14 @@ onMounted(async () => {
|
||||
<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>
|
||||
<div class="card-actions" :class="{active: editingCampaignId === campaign.id}">
|
||||
<template v-if="editingCampaignId === campaign.id">
|
||||
<button @click.stop="openRename(campaign)" title="Rename this universe">✎</button>
|
||||
<button @click.stop="requestDelete(campaign)" title="Delete this universe">✕</button>
|
||||
<button @click.stop="toggleEdit(campaign)" class="ghost-action" title="Done editing">DONE</button>
|
||||
</template>
|
||||
<button v-else @click.stop="toggleEdit(campaign)" title="Edit this universe">EDIT</button>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-for="draft in visibleDrafts" :key="draft.id" :to="`/worlds/new?session=${draft.id}`" class="world-card active-world draft-world">
|
||||
<div class="world-art"><div class="eclipse" /><span>{{ draft.status.toUpperCase() }} DRAFT</span></div>
|
||||
@@ -90,6 +130,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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -130,6 +183,19 @@ 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 i{display:inline-block;width:6px;height:6px;background:var(--acid);border-radius:50%;margin-right:8px}
|
||||
.system-strip b{color:var(--acid)}
|
||||
.card-actions{position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2;opacity:0;pointer-events:none;transition:opacity .2s ease}
|
||||
.card-actions.active{opacity:1;pointer-events:auto}
|
||||
.card-actions button{height:34px;border:1px solid var(--line);background:rgba(10,10,10,.75);color:var(--muted);font:600 8px var(--mono);letter-spacing:.1em;line-height:1;display:grid;place-items:center;padding:0 12px;transition:all .2s ease;cursor:pointer}
|
||||
.card-actions button.icon-btn{width:34px;font-size:14px}
|
||||
.card-actions button:hover,.card-actions button.active-item{border-color:var(--acid);color:var(--acid);background:rgba(217,247,95,.08)}
|
||||
.card-actions button.ghost-action{border-color:transparent;color:var(--muted)}
|
||||
.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: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}}
|
||||
|
||||
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