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:
@@ -1516,6 +1516,80 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
white-space: pre-wrap;
|
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 {
|
.npc-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
+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 NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the NPC tracker.';
|
||||||
const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing'];
|
const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing'];
|
||||||
|
const DISPOSITIONS = ['unknown', 'hostile', 'indifferent', 'neutral', 'friendly'];
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -54,7 +55,7 @@ const SECTIONS = [
|
|||||||
title: 'Status',
|
title: 'Status',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'alive_status', label: 'Alive Status', type: 'select' },
|
{ 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';
|
return npc.disposition || 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispositionClass(npc) {
|
||||||
|
return `npc-disposition-badge npc-disposition-btn-${npc.disposition || 'unknown'}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderNoCampaign() {
|
function renderNoCampaign() {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
const msg = document.createElement('div');
|
const msg = document.createElement('div');
|
||||||
@@ -223,7 +228,7 @@ function buildNpcCard(npc) {
|
|||||||
badges.className = 'npc-card-badges';
|
badges.className = 'npc-card-badges';
|
||||||
|
|
||||||
const dispositionBadge = document.createElement('span');
|
const dispositionBadge = document.createElement('span');
|
||||||
dispositionBadge.className = 'npc-disposition-badge';
|
dispositionBadge.className = dispositionClass(npc);
|
||||||
dispositionBadge.textContent = dispositionDisplay(npc);
|
dispositionBadge.textContent = dispositionDisplay(npc);
|
||||||
|
|
||||||
const aliveBadge = document.createElement('span');
|
const aliveBadge = document.createElement('span');
|
||||||
@@ -240,17 +245,20 @@ function buildNpcCard(npc) {
|
|||||||
|
|
||||||
if (expandedId === npc.id) {
|
if (expandedId === npc.id) {
|
||||||
card.classList.add('expanded');
|
card.classList.add('expanded');
|
||||||
card.appendChild(buildNpcDetail(npc));
|
card.appendChild(buildNpcDetail(npc, card));
|
||||||
}
|
}
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNpcDetail(npc) {
|
function buildNpcDetail(npc, card) {
|
||||||
const detail = document.createElement('div');
|
const detail = document.createElement('div');
|
||||||
detail.className = 'npc-card-detail';
|
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');
|
const sectionEl = document.createElement('div');
|
||||||
sectionEl.className = 'npc-section';
|
sectionEl.className = 'npc-section';
|
||||||
|
|
||||||
@@ -260,7 +268,15 @@ function buildNpcDetail(npc) {
|
|||||||
sectionEl.appendChild(titleEl);
|
sectionEl.appendChild(titleEl);
|
||||||
|
|
||||||
section.fields.forEach(({ key, label }) => {
|
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);
|
detail.appendChild(sectionEl);
|
||||||
@@ -315,11 +331,145 @@ function buildFieldRow(label, value, valueOnly = false) {
|
|||||||
return row;
|
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) {
|
function buildModalForm(values) {
|
||||||
const body = container.querySelector('#npcModalBody');
|
const body = container.querySelector('#npcModalBody');
|
||||||
body.innerHTML = '';
|
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));
|
body.appendChild(buildModalSectionTitle(section.title));
|
||||||
section.fields.forEach((field) => {
|
section.fields.forEach((field) => {
|
||||||
body.appendChild(buildModalField(field, values));
|
body.appendChild(buildModalField(field, values));
|
||||||
@@ -341,26 +491,29 @@ function buildModalField({ key, label, type }, values, hideLabel = false) {
|
|||||||
const group = document.createElement('div');
|
const group = document.createElement('div');
|
||||||
group.className = 'modal-field';
|
group.className = 'modal-field';
|
||||||
|
|
||||||
if (!hideLabel) {
|
const labelEl = hideLabel ? null : document.createElement('label');
|
||||||
const labelEl = document.createElement('label');
|
if (labelEl) {
|
||||||
labelEl.className = 'modal-field-label';
|
labelEl.className = 'modal-field-label';
|
||||||
labelEl.textContent = label;
|
labelEl.textContent = label;
|
||||||
labelEl.setAttribute('for', `npc-field-${key}`);
|
|
||||||
group.appendChild(labelEl);
|
group.appendChild(labelEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
let input;
|
|
||||||
if (type === 'select') {
|
if (type === 'select') {
|
||||||
input = document.createElement('select');
|
group.appendChild(buildModalButtonGroup('npc-field-alive_status', ALIVE_STATUSES, values.alive_status || 'alive'));
|
||||||
input.className = 'modal-select';
|
return group;
|
||||||
ALIVE_STATUSES.forEach((status) => {
|
}
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = status;
|
if (type === 'disposition') {
|
||||||
option.textContent = statusLabel(status);
|
group.appendChild(
|
||||||
input.appendChild(option);
|
buildModalButtonGroup('npc-field-disposition', DISPOSITIONS, values.disposition || 'unknown', 'npc-disposition-btn')
|
||||||
});
|
);
|
||||||
input.value = values.alive_status || 'alive';
|
return group;
|
||||||
} else if (type === 'textarea') {
|
}
|
||||||
|
|
||||||
|
if (labelEl) labelEl.setAttribute('for', `npc-field-${key}`);
|
||||||
|
|
||||||
|
let input;
|
||||||
|
if (type === 'textarea') {
|
||||||
input = document.createElement('textarea');
|
input = document.createElement('textarea');
|
||||||
input.className = 'modal-textarea';
|
input.className = 'modal-textarea';
|
||||||
input.rows = 3;
|
input.rows = 3;
|
||||||
@@ -377,6 +530,35 @@ function buildModalField({ key, label, type }, values, hideLabel = false) {
|
|||||||
return group;
|
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() {
|
function collectFormData() {
|
||||||
const data = {};
|
const data = {};
|
||||||
ALL_FIELDS.forEach(({ key }) => {
|
ALL_FIELDS.forEach(({ key }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user