diff --git a/apps/web/pages/dashboard.vue b/apps/web/pages/dashboard.vue index a23cba9..bc36a8f 100644 --- a/apps/web/pages/dashboard.vue +++ b/apps/web/pages/dashboard.vue @@ -21,6 +21,9 @@ const campaigns = ref([]) const drafts = ref([]) const loading = ref(true) const error = ref('') +const editingCampaignId = ref(null) +const renamingCampaignId = ref(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 () => {
{{ campaign.status.toUpperCase() }} CAMPAIGN
PRIVATE MULTIPLAYER

{{ campaign.title }}

{{ campaign.current_scene }}

CONTINUE STORY{{ new Date(campaign.updated_at).toLocaleDateString() }}
+
+ + +
{{ draft.status.toUpperCase() }} DRAFT
@@ -90,6 +130,19 @@ onMounted(async () => {
AI COAUTHOR READYSERVER DICE ONLINEPRIVATE BY DEFAULT
+ + + +
+ RENAME UNIVERSE + +
+ + +
+
+
+
@@ -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}} diff --git a/apps/web/server/api/v1/campaigns/[id].delete.ts b/apps/web/server/api/v1/campaigns/[id].delete.ts new file mode 100644 index 0000000..92a08cf --- /dev/null +++ b/apps/web/server/api/v1/campaigns/[id].delete.ts @@ -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>( + `campaigns?select=world_id&id=eq.${campaignId}`, + ) + const worldId = campaigns[0]?.world_id as string | undefined + if (worldId) { + const otherCampaigns = await stageTwoDatabase>( + `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) + } +}) diff --git a/apps/web/server/api/v1/campaigns/[id].patch.ts b/apps/web/server/api/v1/campaigns/[id].patch.ts new file mode 100644 index 0000000..d1e777b --- /dev/null +++ b/apps/web/server/api/v1/campaigns/[id].patch.ts @@ -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>>(`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) + } +})