feat: Phase 3 campaign management

Campaign CRUD API with cascading delete across all campaign-scoped
tables, a systems endpoint to seed the create-campaign form, active
campaign state wired to the sidebar chaos factor, and a campaign
management view for creating/switching/deleting campaigns.
This commit is contained in:
claudecode
2026-06-30 23:22:34 -04:00
parent 2faa168847
commit 3a7340975f
9 changed files with 438 additions and 13 deletions
+73 -11
View File
@@ -1,14 +1,20 @@
// Mythic Oracle — entry, sidebar routing, campaign context
import { getCampaign, updateCampaign } from './api.js';
const STORAGE_KEYS = {
sidebarCollapsed: 'mo-sidebar-collapsed',
activeView: 'mo-active-view',
theme: 'mo-theme',
activeCampaignId: 'mo-active-campaign-id',
};
const CHAOS_MIN = 1;
const CHAOS_MAX = 9;
let activeCampaign = null;
let navigateToView = null;
function initSidebarCollapse() {
const sidebar = document.getElementById('sidebar');
const toggle = document.getElementById('collapseToggle');
@@ -33,16 +39,15 @@ function initNavRouting() {
views.forEach((view) => {
view.classList.toggle('active', view.id === `view-${viewId}`);
});
localStorage.setItem(STORAGE_KEYS.activeView, viewId);
}
navItems.forEach((item) => {
item.addEventListener('click', () => {
const viewId = item.dataset.view;
activateView(viewId);
localStorage.setItem(STORAGE_KEYS.activeView, viewId);
});
item.addEventListener('click', () => activateView(item.dataset.view));
});
navigateToView = activateView;
const savedView = localStorage.getItem(STORAGE_KEYS.activeView);
const validView = savedView && document.getElementById(`view-${savedView}`);
activateView(validView ? savedView : 'oracle');
@@ -62,24 +67,81 @@ function initThemeToggle() {
});
}
function initChaosFactorControls() {
function renderChaosFactor() {
const value = document.getElementById('chaosValue');
value.textContent = activeCampaign ? activeCampaign.chaos_factor : '—';
}
function renderActiveCampaignName() {
const el = document.getElementById('activeCampaignName');
el.textContent = activeCampaign ? activeCampaign.name : 'No active campaign';
}
function initChaosFactorControls() {
const minus = document.getElementById('chaosMinus');
const plus = document.getElementById('chaosPlus');
function setValue(next) {
value.textContent = Math.min(CHAOS_MAX, Math.max(CHAOS_MIN, next));
async function adjust(delta) {
if (!activeCampaign) return;
const next = Math.min(CHAOS_MAX, Math.max(CHAOS_MIN, activeCampaign.chaos_factor + delta));
if (next === activeCampaign.chaos_factor) return;
const updated = await updateCampaign(activeCampaign.id, { chaos_factor: next });
activeCampaign = updated;
renderChaosFactor();
}
minus.addEventListener('click', () => setValue(Number(value.textContent) - 1));
plus.addEventListener('click', () => setValue(Number(value.textContent) + 1));
minus.addEventListener('click', () => adjust(-1));
plus.addEventListener('click', () => adjust(1));
}
function init() {
export async function setActiveCampaign(campaign) {
if (!campaign) {
activeCampaign = null;
localStorage.removeItem(STORAGE_KEYS.activeCampaignId);
renderChaosFactor();
renderActiveCampaignName();
return;
}
// Always re-fetch: the passed-in object (e.g. from a cached list) may be
// stale if the chaos factor was changed elsewhere since it was loaded.
const fresh = await getCampaign(campaign.id);
activeCampaign = fresh;
localStorage.setItem(STORAGE_KEYS.activeCampaignId, fresh.id);
renderChaosFactor();
renderActiveCampaignName();
}
export function getActiveCampaign() {
return activeCampaign;
}
async function initActiveCampaign() {
const savedId = localStorage.getItem(STORAGE_KEYS.activeCampaignId);
if (!savedId) {
renderChaosFactor();
renderActiveCampaignName();
navigateToView('campaign-management');
return;
}
try {
await setActiveCampaign({ id: savedId });
} catch {
await setActiveCampaign(null);
navigateToView('campaign-management');
}
}
async function init() {
initSidebarCollapse();
initNavRouting();
initThemeToggle();
initChaosFactorControls();
await initActiveCampaign();
}
document.addEventListener('DOMContentLoaded', init);