diff --git a/public/css/styles.css b/public/css/styles.css index 3705dbe..720c451 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1516,6 +1516,80 @@ h1, h2, h3, h4, h5, h6 { white-space: pre-wrap; } +.npc-status-buttons, +.npc-disposition-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 2px; +} + +.npc-status-btn, +.npc-disposition-btn { + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg); + color: var(--text-secondary); + font-family: var(--font-heading); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease, background var(--transition-speed) ease; +} + +.npc-status-btn:hover, +.npc-disposition-btn:hover { + color: var(--text-primary); + border-color: var(--accent-hover); +} + +.npc-status-btn.active, +.npc-disposition-btn.active { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +/* Disposition color coding — button active state, and the collapsed + card badge (reuses the same rules). May be revised in Phase 11. */ +.npc-disposition-btn-unknown.active, +.npc-disposition-badge.npc-disposition-btn-unknown { + border-color: var(--text-muted); + color: var(--text-muted); + background: color-mix(in srgb, var(--text-muted) 12%, transparent); +} + +.npc-disposition-btn-hostile.active, +.npc-disposition-badge.npc-disposition-btn-hostile { + border-color: var(--danger); + color: var(--danger); + background: color-mix(in srgb, var(--danger) 12%, transparent); +} + +.npc-disposition-btn-indifferent.active, +.npc-disposition-badge.npc-disposition-btn-indifferent { + border-color: var(--info); + color: var(--info); + background: color-mix(in srgb, var(--info) 12%, transparent); +} + +.npc-disposition-btn-neutral.active, +.npc-disposition-badge.npc-disposition-btn-neutral { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.npc-disposition-btn-friendly.active, +.npc-disposition-badge.npc-disposition-btn-friendly { + border-color: var(--success); + color: var(--success); + background: color-mix(in srgb, var(--success) 12%, transparent); +} + .npc-card-actions { display: flex; gap: 10px; diff --git a/public/js/npcs.js b/public/js/npcs.js index abe1907..8095884 100644 --- a/public/js/npcs.js +++ b/public/js/npcs.js @@ -5,6 +5,7 @@ import { getNpcs, createNpc, updateNpc, deleteNpc } from './api.js'; const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the NPC tracker.'; const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing']; +const DISPOSITIONS = ['unknown', 'hostile', 'indifferent', 'neutral', 'friendly']; const SECTIONS = [ { @@ -54,7 +55,7 @@ const SECTIONS = [ title: 'Status', fields: [ { key: 'alive_status', label: 'Alive Status', type: 'select' }, - { key: 'disposition', label: 'Disposition', type: 'text' }, + { key: 'disposition', label: 'Disposition', type: 'disposition' }, ], }, ]; @@ -79,6 +80,10 @@ function dispositionDisplay(npc) { return npc.disposition || 'Unknown'; } +function dispositionClass(npc) { + return `npc-disposition-badge npc-disposition-btn-${npc.disposition || 'unknown'}`; +} + function renderNoCampaign() { container.innerHTML = ''; const msg = document.createElement('div'); @@ -223,7 +228,7 @@ function buildNpcCard(npc) { badges.className = 'npc-card-badges'; const dispositionBadge = document.createElement('span'); - dispositionBadge.className = 'npc-disposition-badge'; + dispositionBadge.className = dispositionClass(npc); dispositionBadge.textContent = dispositionDisplay(npc); const aliveBadge = document.createElement('span'); @@ -240,17 +245,20 @@ function buildNpcCard(npc) { if (expandedId === npc.id) { card.classList.add('expanded'); - card.appendChild(buildNpcDetail(npc)); + card.appendChild(buildNpcDetail(npc, card)); } return card; } -function buildNpcDetail(npc) { +function buildNpcDetail(npc, card) { const detail = document.createElement('div'); detail.className = 'npc-card-detail'; - SECTIONS.forEach((section) => { + const statusSection = SECTIONS.find((section) => section.title === 'Status'); + const orderedSections = [statusSection, ...SECTIONS.filter((section) => section !== statusSection)]; + + orderedSections.forEach((section) => { const sectionEl = document.createElement('div'); sectionEl.className = 'npc-section'; @@ -260,7 +268,15 @@ function buildNpcDetail(npc) { sectionEl.appendChild(titleEl); section.fields.forEach(({ key, label }) => { - sectionEl.appendChild(buildFieldRow(label, key === 'alive_status' ? statusLabel(npc.alive_status) : npc[key])); + if (key === 'alive_status') { + sectionEl.appendChild(buildAliveStatusButtonRow(npc, card)); + return; + } + if (key === 'disposition') { + sectionEl.appendChild(buildDispositionButtonRow(npc, card)); + return; + } + sectionEl.appendChild(buildFieldRow(label, npc[key])); }); detail.appendChild(sectionEl); @@ -315,11 +331,145 @@ function buildFieldRow(label, value, valueOnly = false) { return row; } +function buildAliveStatusButtonRow(npc, card) { + const row = document.createElement('div'); + row.className = 'npc-field'; + + const labelEl = document.createElement('span'); + labelEl.className = 'npc-field-label'; + labelEl.textContent = 'Alive Status'; + row.appendChild(labelEl); + + const buttonRow = document.createElement('div'); + buttonRow.className = 'npc-status-buttons'; + + ALIVE_STATUSES.forEach((status) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'npc-status-btn'; + btn.dataset.value = status; + btn.classList.toggle('active', status === npc.alive_status); + btn.textContent = statusLabel(status); + btn.addEventListener('click', () => handleAliveStatusChange(npc, status, card)); + buttonRow.appendChild(btn); + }); + + row.appendChild(buttonRow); + return row; +} + +function buildDispositionButtonRow(npc, card) { + const row = document.createElement('div'); + row.className = 'npc-field'; + + const labelEl = document.createElement('span'); + labelEl.className = 'npc-field-label'; + labelEl.textContent = 'Disposition'; + row.appendChild(labelEl); + + const buttonRow = document.createElement('div'); + buttonRow.className = 'npc-disposition-buttons'; + + DISPOSITIONS.forEach((value) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `npc-disposition-btn npc-disposition-btn-${value}`; + btn.dataset.value = value; + btn.classList.toggle('active', value === npc.disposition); + btn.textContent = statusLabel(value); + btn.addEventListener('click', () => handleDispositionChange(npc, value, card)); + buttonRow.appendChild(btn); + }); + + row.appendChild(buttonRow); + return row; +} + +async function handleAliveStatusChange(npc, newStatus, card) { + if (newStatus === npc.alive_status) return; + + const campaign = getActiveCampaign(); + if (!campaign) return; + + const payload = {}; + ALL_FIELDS.forEach(({ key }) => { + payload[key] = npc[key]; + }); + payload.alive_status = newStatus; + + try { + const updated = await updateNpc(campaign.id, npc.id, payload); + Object.assign(npc, updated); + updateCardStatusUI(card, npc); + } catch (err) { + alert(err.message); + } +} + +async function handleDispositionChange(npc, newDisposition, card) { + if (newDisposition === npc.disposition) return; + + const campaign = getActiveCampaign(); + if (!campaign) return; + + const payload = {}; + ALL_FIELDS.forEach(({ key }) => { + payload[key] = npc[key]; + }); + payload.disposition = newDisposition; + + try { + const updated = await updateNpc(campaign.id, npc.id, payload); + Object.assign(npc, updated); + updateCardStatusUI(card, npc); + } catch (err) { + alert(err.message); + } +} + +function updateCardStatusUI(card, npc) { + const aliveBadge = card.querySelector('.npc-status-badge'); + if (aliveBadge) { + aliveBadge.className = `npc-status-badge npc-status-${npc.alive_status}`; + aliveBadge.textContent = statusLabel(npc.alive_status); + } + + const dispositionBadge = card.querySelector('.npc-disposition-badge'); + if (dispositionBadge) { + dispositionBadge.className = dispositionClass(npc); + dispositionBadge.textContent = dispositionDisplay(npc); + } + + card.querySelectorAll('.npc-status-btn').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.value === npc.alive_status); + }); + + card.querySelectorAll('.npc-disposition-btn').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.value === npc.disposition); + }); +} + function buildModalForm(values) { const body = container.querySelector('#npcModalBody'); body.innerHTML = ''; - SECTIONS.forEach((section) => { + const identitySection = SECTIONS.find((section) => section.title === 'Identity'); + const statusSection = SECTIONS.find((section) => section.title === 'Status'); + const [nameField, ...restOfIdentityFields] = identitySection.fields; + + body.appendChild(buildModalField(nameField, values)); + + body.appendChild(buildModalSectionTitle(statusSection.title)); + statusSection.fields.forEach((field) => { + body.appendChild(buildModalField(field, values)); + }); + + body.appendChild(buildModalSectionTitle(identitySection.title)); + restOfIdentityFields.forEach((field) => { + body.appendChild(buildModalField(field, values)); + }); + + SECTIONS.filter((section) => section !== identitySection && section !== statusSection).forEach((section) => { body.appendChild(buildModalSectionTitle(section.title)); section.fields.forEach((field) => { body.appendChild(buildModalField(field, values)); @@ -341,26 +491,29 @@ function buildModalField({ key, label, type }, values, hideLabel = false) { const group = document.createElement('div'); group.className = 'modal-field'; - if (!hideLabel) { - const labelEl = document.createElement('label'); + const labelEl = hideLabel ? null : document.createElement('label'); + if (labelEl) { labelEl.className = 'modal-field-label'; labelEl.textContent = label; - labelEl.setAttribute('for', `npc-field-${key}`); group.appendChild(labelEl); } - let input; if (type === 'select') { - input = document.createElement('select'); - input.className = 'modal-select'; - ALIVE_STATUSES.forEach((status) => { - const option = document.createElement('option'); - option.value = status; - option.textContent = statusLabel(status); - input.appendChild(option); - }); - input.value = values.alive_status || 'alive'; - } else if (type === 'textarea') { + group.appendChild(buildModalButtonGroup('npc-field-alive_status', ALIVE_STATUSES, values.alive_status || 'alive')); + return group; + } + + if (type === 'disposition') { + group.appendChild( + buildModalButtonGroup('npc-field-disposition', DISPOSITIONS, values.disposition || 'unknown', 'npc-disposition-btn') + ); + return group; + } + + if (labelEl) labelEl.setAttribute('for', `npc-field-${key}`); + + let input; + if (type === 'textarea') { input = document.createElement('textarea'); input.className = 'modal-textarea'; input.rows = 3; @@ -377,6 +530,35 @@ function buildModalField({ key, label, type }, values, hideLabel = false) { return group; } +function buildModalButtonGroup(inputId, options, currentValue, valueClassPrefix) { + const wrapper = document.createElement('div'); + wrapper.className = 'modal-status-buttons'; + + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.id = inputId; + hiddenInput.value = currentValue; + wrapper.appendChild(hiddenInput); + + options.forEach((value) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = valueClassPrefix ? `modal-status-btn ${valueClassPrefix}-${value}` : 'modal-status-btn'; + btn.dataset.value = value; + btn.classList.toggle('active', value === currentValue); + btn.textContent = statusLabel(value); + btn.addEventListener('click', () => { + hiddenInput.value = value; + wrapper.querySelectorAll('.modal-status-btn').forEach((b) => { + b.classList.toggle('active', b.dataset.value === value); + }); + }); + wrapper.appendChild(btn); + }); + + return wrapper; +} + function collectFormData() { const data = {}; ALL_FIELDS.forEach(({ key }) => {