// Mythic Oracle — thread tracker import { getActiveCampaign } from './app.js'; import { getThreads, createThread, updateThread, deleteThread } from './api.js'; const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the thread tracker.'; const STATUSES = ['active', 'resolved', 'suspended', 'complicated']; const FIELD_DEFS = [ { key: 'title', label: 'Title', type: 'text' }, { key: 'status', label: 'Status', type: 'select' }, { key: 'notes', label: 'Notes', type: 'textarea' }, { key: 'related_npcs', label: 'Related NPCs', type: 'text' }, { key: 'related_location', label: 'Related Location', type: 'text' }, { key: 'origin', label: 'Origin', type: 'textarea' }, { key: 'stakes', label: 'Stakes', type: 'textarea' }, { key: 'last_development', label: 'Last Development', type: 'textarea' }, { key: 'next_beat', label: 'Next Beat', type: 'textarea' }, { key: 'suspected_resolution', label: 'Suspected Resolution', type: 'textarea' }, ]; const container = document.getElementById('view-threads'); let threadsCache = []; let activeTab = 'active'; let expandedId = null; let editingThreadId = null; function statusLabel(status) { return status.charAt(0).toUpperCase() + status.slice(1); } function renderNoCampaign() { container.innerHTML = ''; const msg = document.createElement('div'); msg.className = 'threads-empty-state'; msg.textContent = NO_CAMPAIGN_MESSAGE; container.appendChild(msg); container.dataset.skeleton = 'false'; } function ensureSkeleton() { if (container.dataset.skeleton === 'true') return; container.innerHTML = `

Threads

`; container.dataset.skeleton = 'true'; bindSkeletonEvents(); } function bindSkeletonEvents() { container.querySelector('#threadCreateBtn').addEventListener('click', openCreateModal); container.querySelectorAll('.thread-tab').forEach((tab) => { tab.addEventListener('click', () => { if (tab.dataset.status === activeTab) return; activeTab = tab.dataset.status; container.querySelectorAll('.thread-tab').forEach((t) => t.classList.toggle('active', t === tab)); expandedId = null; renderList(); }); }); container.querySelector('#threadCancelBtn').addEventListener('click', closeModal); container.querySelector('#threadSaveBtn').addEventListener('click', saveModal); container.querySelector('#threadModalOverlay').addEventListener('click', (event) => { if (event.target.id === 'threadModalOverlay') closeModal(); }); } function renderList() { const listEl = container.querySelector('#threadList'); listEl.innerHTML = ''; const filtered = threadsCache.filter((thread) => thread.status === activeTab); if (filtered.length === 0) { const empty = document.createElement('div'); empty.className = 'thread-empty'; empty.textContent = `No ${activeTab} threads.`; listEl.appendChild(empty); return; } filtered.forEach((thread) => { listEl.appendChild(buildThreadCard(thread)); }); } function buildThreadCard(thread) { const card = document.createElement('div'); card.className = 'thread-card'; const header = document.createElement('button'); header.type = 'button'; header.className = 'thread-card-header'; const titleSpan = document.createElement('span'); titleSpan.className = 'thread-card-title'; titleSpan.textContent = thread.title; const badge = document.createElement('span'); badge.className = `thread-status-badge thread-status-${thread.status}`; badge.textContent = statusLabel(thread.status); header.append(titleSpan, badge); header.addEventListener('click', () => { expandedId = expandedId === thread.id ? null : thread.id; renderList(); }); card.appendChild(header); if (expandedId === thread.id) { card.classList.add('expanded'); card.appendChild(buildThreadDetail(thread, card)); } return card; } function buildThreadDetail(thread, card) { const detail = document.createElement('div'); detail.className = 'thread-card-detail'; FIELD_DEFS.forEach(({ key, label }) => { if (key === 'status') { detail.appendChild(buildStatusButtonRow(thread, card)); return; } const row = document.createElement('div'); row.className = 'thread-field'; const labelEl = document.createElement('span'); labelEl.className = 'thread-field-label'; labelEl.textContent = label; const valueEl = document.createElement('span'); valueEl.className = 'thread-field-value'; valueEl.textContent = thread[key] || '—'; row.append(labelEl, valueEl); detail.appendChild(row); }); const actions = document.createElement('div'); actions.className = 'thread-card-actions'; const editBtn = document.createElement('button'); editBtn.type = 'button'; editBtn.className = 'thread-edit-btn'; editBtn.textContent = 'Edit'; editBtn.addEventListener('click', () => openEditModal(thread)); const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'thread-delete-btn'; deleteBtn.textContent = 'Delete'; deleteBtn.addEventListener('click', () => handleDelete(thread)); actions.append(editBtn, deleteBtn); detail.appendChild(actions); return detail; } function buildStatusButtonRow(thread, card) { const row = document.createElement('div'); row.className = 'thread-field'; const labelEl = document.createElement('span'); labelEl.className = 'thread-field-label'; labelEl.textContent = 'Status'; row.appendChild(labelEl); const buttonRow = document.createElement('div'); buttonRow.className = 'thread-status-buttons'; STATUSES.forEach((status) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'thread-status-btn'; btn.dataset.status = status; btn.classList.toggle('active', status === thread.status); btn.textContent = statusLabel(status); btn.addEventListener('click', () => handleStatusChange(thread, status, card)); buttonRow.appendChild(btn); }); row.appendChild(buttonRow); return row; } async function handleStatusChange(thread, newStatus, card) { if (newStatus === thread.status) return; const campaign = getActiveCampaign(); if (!campaign) return; const payload = {}; FIELD_DEFS.forEach(({ key }) => { payload[key] = thread[key]; }); payload.status = newStatus; try { const updated = await updateThread(campaign.id, thread.id, payload); Object.assign(thread, updated); updateCardStatusUI(card, thread); } catch (err) { alert(err.message); } } function updateCardStatusUI(card, thread) { const badge = card.querySelector('.thread-status-badge'); if (badge) { badge.className = `thread-status-badge thread-status-${thread.status}`; badge.textContent = statusLabel(thread.status); } card.querySelectorAll('.thread-status-btn').forEach((btn) => { btn.classList.toggle('active', btn.dataset.status === thread.status); }); } function buildModalForm(values) { const body = container.querySelector('#threadModalBody'); body.innerHTML = ''; FIELD_DEFS.forEach(({ key, label, type }) => { const group = document.createElement('div'); group.className = 'modal-field'; const labelEl = document.createElement('label'); labelEl.className = 'modal-field-label'; labelEl.textContent = label; group.appendChild(labelEl); if (type === 'select') { group.appendChild(buildModalStatusButtons(values.status || 'active')); body.appendChild(group); return; } labelEl.setAttribute('for', `thread-field-${key}`); let input; if (type === 'textarea') { input = document.createElement('textarea'); input.className = 'modal-textarea'; input.rows = 3; input.value = values[key] || ''; } else { input = document.createElement('input'); input.type = 'text'; input.className = 'modal-input'; input.value = values[key] || ''; } input.id = `thread-field-${key}`; group.appendChild(input); body.appendChild(group); }); } function buildModalStatusButtons(currentStatus) { const wrapper = document.createElement('div'); wrapper.className = 'modal-status-buttons'; const hiddenInput = document.createElement('input'); hiddenInput.type = 'hidden'; hiddenInput.id = 'thread-field-status'; hiddenInput.value = currentStatus; wrapper.appendChild(hiddenInput); STATUSES.forEach((status) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'modal-status-btn'; btn.dataset.status = status; btn.classList.toggle('active', status === currentStatus); btn.textContent = statusLabel(status); btn.addEventListener('click', () => { hiddenInput.value = status; wrapper.querySelectorAll('.modal-status-btn').forEach((b) => { b.classList.toggle('active', b.dataset.status === status); }); }); wrapper.appendChild(btn); }); return wrapper; } function collectFormData() { const data = {}; FIELD_DEFS.forEach(({ key }) => { const el = container.querySelector(`#thread-field-${key}`); const value = el.value; data[key] = value.trim() === '' ? null : value; }); return data; } function openCreateModal() { editingThreadId = null; container.querySelector('#threadModalTitle').textContent = 'New Thread'; buildModalForm({}); showModal(); } function openEditModal(thread) { editingThreadId = thread.id; container.querySelector('#threadModalTitle').textContent = 'Edit Thread'; buildModalForm(thread); showModal(); } function showModal() { container.querySelector('#threadModalOverlay').classList.add('open'); } function closeModal() { container.querySelector('#threadModalOverlay').classList.remove('open'); } async function saveModal() { const campaign = getActiveCampaign(); if (!campaign) return; const data = collectFormData(); if (!data.title) { alert('Title is required.'); return; } if (!data.status) data.status = 'active'; try { if (editingThreadId === null) { await createThread(campaign.id, data); } else { await updateThread(campaign.id, editingThreadId, data); } closeModal(); await loadAndRender(); } catch (err) { alert(err.message); } } async function handleDelete(thread) { const confirmed = confirm(`Delete "${thread.title}"? This cannot be undone.`); if (!confirmed) return; const campaign = getActiveCampaign(); if (!campaign) return; await deleteThread(campaign.id, thread.id); expandedId = null; await loadAndRender(); } async function loadAndRender() { const campaign = getActiveCampaign(); if (!campaign) { renderNoCampaign(); return; } ensureSkeleton(); threadsCache = await getThreads(campaign.id); renderList(); } function init() { loadAndRender(); const navBtn = document.querySelector('.nav-item[data-view="threads"]'); if (navBtn) { navBtn.addEventListener('click', () => loadAndRender()); } } document.addEventListener('DOMContentLoaded', init);