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:
@@ -1 +1,51 @@
|
||||
// Mythic Oracle — shared fetch wrapper, all API calls
|
||||
|
||||
const BASE_URL = '/api';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Request failed: ${response.status}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body.error) message = body.error;
|
||||
} catch {
|
||||
// no JSON body on the error response
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function getCampaigns() {
|
||||
return request('/campaigns');
|
||||
}
|
||||
|
||||
export function createCampaign(data) {
|
||||
return request('/campaigns', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function getCampaign(id) {
|
||||
return request(`/campaigns/${id}`);
|
||||
}
|
||||
|
||||
export function updateCampaign(id, data) {
|
||||
return request(`/campaigns/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function deleteCampaign(id) {
|
||||
return request(`/campaigns/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function getSystems() {
|
||||
return request('/systems');
|
||||
}
|
||||
|
||||
+73
-11
@@ -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);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Mythic Oracle — campaign management view
|
||||
|
||||
import { getCampaigns, createCampaign, deleteCampaign, getSystems } from './api.js';
|
||||
import { setActiveCampaign, getActiveCampaign } from './app.js';
|
||||
|
||||
const container = document.getElementById('view-campaign-management');
|
||||
|
||||
let campaignsCache = [];
|
||||
let systemsCache = [];
|
||||
|
||||
function escapeHtml(value) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = value;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function campaignItemHtml(campaign) {
|
||||
const active = getActiveCampaign();
|
||||
const isActive = Boolean(active) && active.id === campaign.id;
|
||||
|
||||
return `
|
||||
<li class="campaign-item${isActive ? ' active' : ''}" data-id="${campaign.id}">
|
||||
<button type="button" class="campaign-select" data-id="${campaign.id}">
|
||||
<span class="campaign-name">${escapeHtml(campaign.name)}</span>
|
||||
<span class="campaign-system">${escapeHtml(campaign.system_name)}</span>
|
||||
${isActive ? '<span class="campaign-active-badge">Active</span>' : ''}
|
||||
</button>
|
||||
<button type="button" class="campaign-delete" data-id="${campaign.id}" aria-label="Delete ${escapeHtml(campaign.name)}">×</button>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const systemOptions = systemsCache
|
||||
.map((system) => `<option value="${system.id}">${escapeHtml(system.name)}</option>`)
|
||||
.join('');
|
||||
|
||||
const campaignItems = campaignsCache.length
|
||||
? campaignsCache.map(campaignItemHtml).join('')
|
||||
: '<li class="campaign-empty">No campaigns yet — create one below to get started.</li>';
|
||||
|
||||
container.innerHTML = `
|
||||
<h2>Campaign Management</h2>
|
||||
|
||||
<ul class="campaign-list">
|
||||
${campaignItems}
|
||||
</ul>
|
||||
|
||||
<form id="createCampaignForm" class="campaign-form">
|
||||
<input type="text" id="campaignNameInput" placeholder="Campaign name" autocomplete="off" required>
|
||||
<select id="campaignSystemSelect" required>
|
||||
${systemOptions}
|
||||
</select>
|
||||
<button type="submit">Create Campaign</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadAndRender() {
|
||||
[campaignsCache, systemsCache] = await Promise.all([getCampaigns(), getSystems()]);
|
||||
render();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
container.addEventListener('submit', async (event) => {
|
||||
if (event.target.id !== 'createCampaignForm') return;
|
||||
event.preventDefault();
|
||||
|
||||
const nameInput = document.getElementById('campaignNameInput');
|
||||
const systemSelect = document.getElementById('campaignSystemSelect');
|
||||
const name = nameInput.value.trim();
|
||||
const systemId = Number(systemSelect.value);
|
||||
|
||||
if (!name || !systemId) return;
|
||||
|
||||
const campaign = await createCampaign({ name, system_id: systemId });
|
||||
await setActiveCampaign(campaign);
|
||||
await loadAndRender();
|
||||
});
|
||||
|
||||
container.addEventListener('click', async (event) => {
|
||||
const selectBtn = event.target.closest('.campaign-select');
|
||||
if (selectBtn) {
|
||||
const id = Number(selectBtn.dataset.id);
|
||||
const campaign = campaignsCache.find((c) => c.id === id);
|
||||
if (campaign) {
|
||||
await setActiveCampaign(campaign);
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteBtn = event.target.closest('.campaign-delete');
|
||||
if (deleteBtn) {
|
||||
const id = Number(deleteBtn.dataset.id);
|
||||
const campaign = campaignsCache.find((c) => c.id === id);
|
||||
if (!campaign) return;
|
||||
|
||||
const confirmed = confirm(
|
||||
`Delete "${campaign.name}"? This permanently removes all of its threads, NPCs, notes, and character data.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
await deleteCampaign(id);
|
||||
if (getActiveCampaign()?.id === id) {
|
||||
await setActiveCampaign(null);
|
||||
}
|
||||
await loadAndRender();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
bindEvents();
|
||||
loadAndRender();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
Reference in New Issue
Block a user