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:
+203
-21
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user