Replace NPC alive_status/disposition dropdowns with color-coded status buttons

Card and modal both use clickable button rows instead of a select and
free-text input, so changing alive_status or disposition no longer
requires the full edit form. The Status section is moved to the top
of both views for quicker access, and disposition buttons (plus the
collapsed card's badge) are color-coded per value, reusing the same
CSS classes in both places.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
claudecode
2026-07-01 19:51:30 -04:00
parent e61328aa4e
commit ea8bd6dc64
2 changed files with 277 additions and 21 deletions
+203 -21
View File
@@ -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 }) => {