Files
mythic-oracle/public/js/threads.js
T
claudecode e61328aa4e Replace thread status dropdown/text with inline status buttons
Card and modal both use a clickable button row for status instead of
static text or a select, so changing status no longer requires
opening the full edit form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:24:53 -04:00

407 lines
12 KiB
JavaScript

// 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 = `
<div class="threads-header">
<h2 class="threads-title">Threads</h2>
<button class="thread-create-btn" id="threadCreateBtn" type="button">+ New Thread</button>
</div>
<div class="thread-tabs" id="threadTabs">
<button class="thread-tab active" data-status="active" type="button">Active</button>
<button class="thread-tab" data-status="resolved" type="button">Resolved</button>
<button class="thread-tab" data-status="suspended" type="button">Suspended</button>
<button class="thread-tab" data-status="complicated" type="button">Complicated</button>
</div>
<div class="thread-list" id="threadList"></div>
<div class="modal-overlay" id="threadModalOverlay">
<div class="modal">
<h3 class="modal-title" id="threadModalTitle">New Thread</h3>
<div class="modal-body" id="threadModalBody"></div>
<div class="modal-actions">
<button class="clear-btn" id="threadCancelBtn" type="button">Cancel</button>
<button class="roll-btn" id="threadSaveBtn" type="button">Save Thread</button>
</div>
</div>
</div>
`;
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);