diff --git a/public/css/styles.css b/public/css/styles.css index 8ab8499..687ad8b 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -837,3 +837,339 @@ h1, h2, h3, h4, h5, h6 { font-size: 0.8rem; color: var(--text-secondary); } + +/* --- Threads --- */ + +.threads-empty-state { + max-width: 700px; + margin: 40px auto; + padding: 20px; + text-align: center; + font-style: italic; + color: var(--text-muted); +} + +.threads-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + max-width: 700px; + margin: 0 auto 16px; +} + +.threads-title { + margin: 0; + font-size: 1.4rem; +} + +.thread-create-btn { + flex-shrink: 0; + padding: 10px 18px; + background: var(--bg); + border: 1px solid var(--accent); + border-radius: 3px; + color: var(--accent); + font-family: var(--font-heading); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: background var(--transition-speed) ease, border-color var(--transition-speed) ease; +} + +.thread-create-btn:hover { + background: var(--bg-hover); + border-color: var(--accent-hover); +} + +.thread-tabs { + display: flex; + gap: 8px; + max-width: 700px; + margin: 0 auto 16px; +} + +.thread-tab { + flex: 1; + padding: 10px 4px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg); + color: var(--text-secondary); + font-family: var(--font-heading); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease, background var(--transition-speed) ease; +} + +.thread-tab:hover { + color: var(--text-primary); + border-color: var(--accent-hover); +} + +.thread-tab.active { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.thread-list { + max-width: 700px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 10px; +} + +.thread-empty { + padding: 24px; + text-align: center; + font-style: italic; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-elevated); +} + +.thread-card { + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-elevated); + overflow: hidden; +} + +.thread-card.expanded { + border-color: var(--accent); +} + +.thread-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 14px 18px; + border: none; + background: none; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 0.95rem; + text-align: left; + cursor: pointer; +} + +.thread-card-header:hover { + background: var(--bg-hover); +} + +.thread-card-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.thread-status-badge { + flex-shrink: 0; + padding: 3px 10px; + border-radius: 12px; + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + border: 1px solid transparent; +} + +.thread-status-active { + color: var(--info); + border-color: var(--info); + background: color-mix(in srgb, var(--info) 12%, transparent); +} + +.thread-status-resolved { + color: var(--success); + border-color: var(--success); + background: color-mix(in srgb, var(--success) 12%, transparent); +} + +.thread-status-suspended { + color: var(--text-muted); + border-color: var(--text-muted); + background: color-mix(in srgb, var(--text-muted) 12%, transparent); +} + +.thread-status-complicated { + color: var(--danger); + border-color: var(--danger); + background: color-mix(in srgb, var(--danger) 12%, transparent); +} + +.thread-card-detail { + padding: 4px 18px 18px; + border-top: 1px solid var(--border); + max-height: 400px; + overflow-y: auto; +} + +.thread-field { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 0; + border-bottom: 1px solid var(--bg); +} + +.thread-field:last-of-type { + border-bottom: none; +} + +.thread-field-label { + font-family: var(--font-heading); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +.thread-field-value { + font-size: 0.9rem; + color: var(--text-primary); + white-space: pre-wrap; +} + +.thread-card-actions { + display: flex; + gap: 10px; + margin-top: 12px; +} + +.thread-edit-btn, +.thread-delete-btn { + flex: 1; + padding: 10px; + border-radius: 3px; + font-family: var(--font-heading); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + background: var(--bg); + transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease; +} + +.thread-edit-btn { + border: 1px solid var(--accent); + color: var(--accent); +} + +.thread-edit-btn:hover { + background: var(--bg-hover); + border-color: var(--accent-hover); +} + +.thread-delete-btn { + border: 1px solid var(--danger); + color: var(--danger); +} + +.thread-delete-btn:hover { + background: color-mix(in srgb, var(--danger) 10%, var(--bg)); +} + +/* --- Modal (Threads) --- */ + +.modal-overlay { + display: none; + position: fixed; + inset: 0; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + z-index: 100; +} + +.modal-overlay.open { + display: flex; +} + +.modal { + width: 100%; + max-width: 520px; + max-height: 85vh; + margin: 0 16px; + display: flex; + flex-direction: column; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 4px; + padding: 20px; +} + +.modal-title { + margin-bottom: 14px; + font-size: 1.2rem; +} + +.modal-body { + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding-right: 4px; +} + +.modal-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.modal-field-label { + font-family: var(--font-heading); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.modal-input, +.modal-select, +.modal-textarea { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 0.9rem; +} + +.modal-textarea { + resize: vertical; + min-height: 60px; +} + +.modal-input:focus, +.modal-select:focus, +.modal-textarea:focus { + outline: none; + border-color: var(--accent-hover); +} + +.modal-actions { + display: flex; + gap: 10px; + margin-top: 16px; +} + +.modal-actions .roll-btn, +.modal-actions .clear-btn { + margin-top: 0; + flex: 1; +} diff --git a/public/index.html b/public/index.html index 0a770ca..0a6100e 100644 --- a/public/index.html +++ b/public/index.html @@ -306,4 +306,5 @@ + diff --git a/public/js/api.js b/public/js/api.js index 6a579d8..268b6e6 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -53,3 +53,29 @@ export function getSystems() { export function getTable(name) { return request(`/tables/${name}`); } + +export async function getThreads(campaignId) { + const { data } = await request(`/campaigns/${campaignId}/threads`); + return data; +} + +export async function createThread(campaignId, data) { + const result = await request(`/campaigns/${campaignId}/threads`, { + method: 'POST', + body: JSON.stringify(data), + }); + return result.data; +} + +export async function updateThread(campaignId, id, data) { + const result = await request(`/campaigns/${campaignId}/threads/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + return result.data; +} + +export async function deleteThread(campaignId, id) { + const result = await request(`/campaigns/${campaignId}/threads/${id}`, { method: 'DELETE' }); + return result.data; +} diff --git a/public/js/threads.js b/public/js/threads.js index b62f0e9..ea3e5d8 100644 --- a/public/js/threads.js +++ b/public/js/threads.js @@ -1 +1,315 @@ -// Mythic Oracle — threads +// 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)); + } + + return card; +} + +function buildThreadDetail(thread) { + const detail = document.createElement('div'); + detail.className = 'thread-card-detail'; + + FIELD_DEFS.forEach(({ key, label }) => { + 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 = key === 'status' ? statusLabel(thread.status) : (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 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; + labelEl.setAttribute('for', `thread-field-${key}`); + group.appendChild(labelEl); + + let input; + if (type === 'select') { + input = document.createElement('select'); + input.className = 'modal-select'; + STATUSES.forEach((status) => { + const option = document.createElement('option'); + option.value = status; + option.textContent = statusLabel(status); + input.appendChild(option); + }); + input.value = values.status || 'active'; + } else 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 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); diff --git a/server/db.js b/server/db.js index 3b2315f..241f381 100644 --- a/server/db.js +++ b/server/db.js @@ -22,12 +22,19 @@ CREATE TABLE IF NOT EXISTS campaigns ( ); CREATE TABLE IF NOT EXISTS threads ( - id INTEGER PRIMARY KEY, - campaign_id INTEGER NOT NULL REFERENCES campaigns(id), - title TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - notes TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY, + campaign_id INTEGER NOT NULL REFERENCES campaigns(id), + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + related_npcs TEXT, + related_location TEXT, + origin TEXT, + stakes TEXT, + last_development TEXT, + next_beat TEXT, + suspected_resolution TEXT ); CREATE TABLE IF NOT EXISTS npcs ( diff --git a/server/index.js b/server/index.js index bbfe758..4c8915a 100644 --- a/server/index.js +++ b/server/index.js @@ -22,7 +22,7 @@ app.get('/health', (req, res) => { app.use('/api/campaigns', campaignsRouter); app.use('/api/characters', charactersRouter); -app.use('/api/threads', threadsRouter); +app.use('/api/campaigns/:campaignId/threads', threadsRouter); app.use('/api/npcs', npcsRouter); app.use('/api/notes', notesRouter); app.use('/api/tables', tablesRouter); diff --git a/server/routes/threads.js b/server/routes/threads.js index f577a6c..45ca570 100644 --- a/server/routes/threads.js +++ b/server/routes/threads.js @@ -1,4 +1,178 @@ const express = require('express'); -const router = express.Router(); +const db = require('../db'); + +const router = express.Router({ mergeParams: true }); + +const STATUSES = ['active', 'resolved', 'suspended', 'complicated']; + +const OPTIONAL_TEXT_FIELDS = [ + 'notes', + 'related_npcs', + 'related_location', + 'origin', + 'stakes', + 'last_development', + 'next_beat', + 'suspected_resolution', +]; + +const getCampaignByIdStmt = db.prepare('SELECT id FROM campaigns WHERE id = ?'); +const listThreadsStmt = db.prepare('SELECT * FROM threads WHERE campaign_id = ? ORDER BY created_at DESC'); +const getThreadStmt = db.prepare('SELECT * FROM threads WHERE id = ? AND campaign_id = ?'); + +const insertThreadStmt = db.prepare(` + INSERT INTO threads ( + campaign_id, title, status, notes, related_npcs, related_location, + origin, stakes, last_development, next_beat, suspected_resolution + ) VALUES ( + @campaign_id, @title, @status, @notes, @related_npcs, @related_location, + @origin, @stakes, @last_development, @next_beat, @suspected_resolution + ) +`); + +const updateThreadStmt = db.prepare(` + UPDATE threads SET + title = @title, + status = @status, + notes = @notes, + related_npcs = @related_npcs, + related_location = @related_location, + origin = @origin, + stakes = @stakes, + last_development = @last_development, + next_beat = @next_beat, + suspected_resolution = @suspected_resolution + WHERE id = @id AND campaign_id = @campaign_id +`); + +const deleteThreadStmt = db.prepare('DELETE FROM threads WHERE id = ? AND campaign_id = ?'); + +function parsePositiveInt(raw, res, label) { + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + res.status(400).json({ error: `${label} must be a positive integer` }); + return null; + } + return value; +} + +function loadCampaignId(req, res) { + const campaignId = parsePositiveInt(req.params.campaignId, res, 'campaign_id'); + if (campaignId === null) return null; + + if (!getCampaignByIdStmt.get(campaignId)) { + res.status(404).json({ error: 'campaign not found' }); + return null; + } + return campaignId; +} + +// Shared by create and update — PUT sends the full form, so both treat +// title/status the same way rather than supporting partial patches. +function parseThreadPayload(body, res) { + const title = typeof body.title === 'string' ? body.title.trim() : ''; + if (!title) { + res.status(400).json({ error: 'title is required' }); + return null; + } + + const status = body.status === undefined ? 'active' : body.status; + if (!STATUSES.includes(status)) { + res.status(400).json({ error: `status must be one of: ${STATUSES.join(', ')}` }); + return null; + } + + const payload = { title, status }; + + for (const field of OPTIONAL_TEXT_FIELDS) { + const value = body[field]; + if (value === undefined || value === null) { + payload[field] = null; + } else if (typeof value === 'string') { + payload[field] = value; + } else { + res.status(400).json({ error: `${field} must be a string or null` }); + return null; + } + } + + return payload; +} + +function withErrorHandling(handler) { + return (req, res) => { + try { + handler(req, res); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'unexpected server error' }); + } + }; +} + +router.get('/', withErrorHandling((req, res) => { + const campaignId = loadCampaignId(req, res); + if (campaignId === null) return; + + res.json({ data: listThreadsStmt.all(campaignId) }); +})); + +router.get('/:id', withErrorHandling((req, res) => { + const campaignId = loadCampaignId(req, res); + if (campaignId === null) return; + + const id = parsePositiveInt(req.params.id, res, 'id'); + if (id === null) return; + + const thread = getThreadStmt.get(id, campaignId); + if (!thread) { + return res.status(404).json({ error: 'thread not found' }); + } + res.json({ data: thread }); +})); + +router.post('/', withErrorHandling((req, res) => { + const campaignId = loadCampaignId(req, res); + if (campaignId === null) return; + + const payload = parseThreadPayload(req.body, res); + if (payload === null) return; + + const result = insertThreadStmt.run({ ...payload, campaign_id: campaignId }); + res.status(201).json({ data: getThreadStmt.get(result.lastInsertRowid, campaignId) }); +})); + +router.put('/:id', withErrorHandling((req, res) => { + const campaignId = loadCampaignId(req, res); + if (campaignId === null) return; + + const id = parsePositiveInt(req.params.id, res, 'id'); + if (id === null) return; + + if (!getThreadStmt.get(id, campaignId)) { + return res.status(404).json({ error: 'thread not found' }); + } + + const payload = parseThreadPayload(req.body, res); + if (payload === null) return; + + updateThreadStmt.run({ ...payload, id, campaign_id: campaignId }); + res.json({ data: getThreadStmt.get(id, campaignId) }); +})); + +router.delete('/:id', withErrorHandling((req, res) => { + const campaignId = loadCampaignId(req, res); + if (campaignId === null) return; + + const id = parsePositiveInt(req.params.id, res, 'id'); + if (id === null) return; + + if (!getThreadStmt.get(id, campaignId)) { + return res.status(404).json({ error: 'thread not found' }); + } + + deleteThreadStmt.run(id, campaignId); + res.json({ data: { id } }); +})); module.exports = router;