ea8bd6dc64
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>
654 lines
20 KiB
JavaScript
654 lines
20 KiB
JavaScript
// Mythic Oracle — NPC tracker
|
|
|
|
import { getActiveCampaign } from './app.js';
|
|
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 = [
|
|
{
|
|
title: 'Identity',
|
|
fields: [
|
|
{ key: 'name', label: 'Name', type: 'text' },
|
|
{ key: 'description', label: 'Description', type: 'textarea' },
|
|
{ key: 'appearance', label: 'Appearance', type: 'textarea' },
|
|
{ key: 'age', label: 'Age', type: 'text' },
|
|
{ key: 'gender', label: 'Gender', type: 'text' },
|
|
{ key: 'pronouns', label: 'Pronouns', type: 'text' },
|
|
{ key: 'voice', label: 'Voice', type: 'text' },
|
|
{ key: 'distinguishing_features', label: 'Distinguishing Features', type: 'textarea' },
|
|
],
|
|
},
|
|
{
|
|
title: 'Social',
|
|
fields: [
|
|
{ key: 'faction', label: 'Faction', type: 'text' },
|
|
{ key: 'occupation', label: 'Occupation', type: 'text' },
|
|
{ key: 'social_status', label: 'Social Status', type: 'text' },
|
|
{ key: 'relationship_to_pc', label: 'Relationship to PC', type: 'text' },
|
|
{ key: 'loyalty', label: 'Loyalty', type: 'text' },
|
|
],
|
|
},
|
|
{
|
|
title: 'Personality',
|
|
fields: [
|
|
{ key: 'personality_traits', label: 'Personality Traits', type: 'textarea' },
|
|
{ key: 'fears', label: 'Fears', type: 'textarea' },
|
|
{ key: 'desires', label: 'Desires', type: 'textarea' },
|
|
{ key: 'secrets', label: 'Secrets', type: 'textarea' },
|
|
{ key: 'motivations', label: 'Motivations', type: 'textarea' },
|
|
],
|
|
},
|
|
{
|
|
title: 'Narrative',
|
|
fields: [
|
|
{ key: 'first_encountered', label: 'First Encountered', type: 'text' },
|
|
{ key: 'last_seen', label: 'Last Seen', type: 'text' },
|
|
{ key: 'current_location', label: 'Current Location', type: 'text' },
|
|
{ key: 'current_goal', label: 'Current Goal', type: 'textarea' },
|
|
{ key: 'role_in_threads', label: 'Role in Threads', type: 'textarea' },
|
|
],
|
|
},
|
|
{
|
|
title: 'Status',
|
|
fields: [
|
|
{ key: 'alive_status', label: 'Alive Status', type: 'select' },
|
|
{ key: 'disposition', label: 'Disposition', type: 'disposition' },
|
|
],
|
|
},
|
|
];
|
|
|
|
const NOTES_FIELD = { key: 'notes', label: 'Notes', type: 'textarea' };
|
|
|
|
const ALL_FIELDS = SECTIONS.flatMap((section) => section.fields).concat(NOTES_FIELD);
|
|
|
|
const container = document.getElementById('view-npcs');
|
|
|
|
let npcsCache = [];
|
|
let aliveFilter = 'all';
|
|
let dispositionFilter = 'all';
|
|
let expandedId = null;
|
|
let editingNpcId = null;
|
|
|
|
function statusLabel(value) {
|
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
}
|
|
|
|
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');
|
|
msg.className = 'npcs-empty-state';
|
|
msg.textContent = NO_CAMPAIGN_MESSAGE;
|
|
container.appendChild(msg);
|
|
container.dataset.skeleton = 'false';
|
|
}
|
|
|
|
function ensureSkeleton() {
|
|
if (container.dataset.skeleton === 'true') return;
|
|
|
|
container.innerHTML = `
|
|
<div class="npcs-header">
|
|
<h2 class="npcs-title">NPCs</h2>
|
|
<button class="npc-create-btn" id="npcCreateBtn" type="button">+ New NPC</button>
|
|
</div>
|
|
<div class="npc-filters">
|
|
<div class="npc-alive-tabs" id="npcAliveTabs">
|
|
<button class="npc-tab active" data-alive="all" type="button">All</button>
|
|
<button class="npc-tab" data-alive="alive" type="button">Alive</button>
|
|
<button class="npc-tab" data-alive="dead" type="button">Dead</button>
|
|
<button class="npc-tab" data-alive="unknown" type="button">Unknown</button>
|
|
<button class="npc-tab" data-alive="missing" type="button">Missing</button>
|
|
</div>
|
|
<select class="npc-disposition-filter" id="npcDispositionFilter">
|
|
<option value="all">All Dispositions</option>
|
|
</select>
|
|
</div>
|
|
<div class="npc-list" id="npcList"></div>
|
|
<div class="modal-overlay" id="npcModalOverlay">
|
|
<div class="modal">
|
|
<h3 class="modal-title" id="npcModalTitle">New NPC</h3>
|
|
<div class="modal-body" id="npcModalBody"></div>
|
|
<div class="modal-actions">
|
|
<button class="clear-btn" id="npcCancelBtn" type="button">Cancel</button>
|
|
<button class="roll-btn" id="npcSaveBtn" type="button">Save NPC</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.dataset.skeleton = 'true';
|
|
bindSkeletonEvents();
|
|
}
|
|
|
|
function bindSkeletonEvents() {
|
|
container.querySelector('#npcCreateBtn').addEventListener('click', openCreateModal);
|
|
|
|
container.querySelectorAll('.npc-tab').forEach((tab) => {
|
|
tab.addEventListener('click', () => {
|
|
if (tab.dataset.alive === aliveFilter) return;
|
|
aliveFilter = tab.dataset.alive;
|
|
container.querySelectorAll('.npc-tab').forEach((t) => t.classList.toggle('active', t === tab));
|
|
expandedId = null;
|
|
renderList();
|
|
});
|
|
});
|
|
|
|
container.querySelector('#npcDispositionFilter').addEventListener('change', (event) => {
|
|
dispositionFilter = event.target.value;
|
|
expandedId = null;
|
|
renderList();
|
|
});
|
|
|
|
container.querySelector('#npcCancelBtn').addEventListener('click', closeModal);
|
|
container.querySelector('#npcSaveBtn').addEventListener('click', saveModal);
|
|
container.querySelector('#npcModalOverlay').addEventListener('click', (event) => {
|
|
if (event.target.id === 'npcModalOverlay') closeModal();
|
|
});
|
|
}
|
|
|
|
function renderDispositionOptions() {
|
|
const select = container.querySelector('#npcDispositionFilter');
|
|
const distinct = [...new Set(npcsCache.map((npc) => dispositionDisplay(npc)))].sort((a, b) =>
|
|
a.localeCompare(b)
|
|
);
|
|
|
|
select.innerHTML = '';
|
|
|
|
const allOption = document.createElement('option');
|
|
allOption.value = 'all';
|
|
allOption.textContent = 'All Dispositions';
|
|
select.appendChild(allOption);
|
|
|
|
distinct.forEach((value) => {
|
|
const option = document.createElement('option');
|
|
option.value = value;
|
|
option.textContent = value;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
if (dispositionFilter !== 'all' && !distinct.includes(dispositionFilter)) {
|
|
dispositionFilter = 'all';
|
|
}
|
|
select.value = dispositionFilter;
|
|
}
|
|
|
|
function buildEmptyMessage() {
|
|
const aliveText = aliveFilter === 'all' ? '' : `${statusLabel(aliveFilter)} `;
|
|
if (dispositionFilter === 'all') {
|
|
return `No ${aliveText}NPCs.`;
|
|
}
|
|
return `No ${aliveText}NPCs with disposition "${dispositionFilter}".`;
|
|
}
|
|
|
|
function renderList() {
|
|
const listEl = container.querySelector('#npcList');
|
|
listEl.innerHTML = '';
|
|
|
|
const filtered = npcsCache.filter((npc) => {
|
|
const matchesAlive = aliveFilter === 'all' || npc.alive_status === aliveFilter;
|
|
const matchesDisposition = dispositionFilter === 'all' || dispositionDisplay(npc) === dispositionFilter;
|
|
return matchesAlive && matchesDisposition;
|
|
});
|
|
|
|
if (filtered.length === 0) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'npc-empty';
|
|
empty.textContent = npcsCache.length === 0 ? 'No NPCs yet.' : buildEmptyMessage();
|
|
listEl.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
filtered.forEach((npc) => {
|
|
listEl.appendChild(buildNpcCard(npc));
|
|
});
|
|
}
|
|
|
|
function buildNpcCard(npc) {
|
|
const card = document.createElement('div');
|
|
card.className = 'npc-card';
|
|
|
|
const header = document.createElement('button');
|
|
header.type = 'button';
|
|
header.className = 'npc-card-header';
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'npc-card-name';
|
|
nameSpan.textContent = npc.name;
|
|
|
|
const badges = document.createElement('span');
|
|
badges.className = 'npc-card-badges';
|
|
|
|
const dispositionBadge = document.createElement('span');
|
|
dispositionBadge.className = dispositionClass(npc);
|
|
dispositionBadge.textContent = dispositionDisplay(npc);
|
|
|
|
const aliveBadge = document.createElement('span');
|
|
aliveBadge.className = `npc-status-badge npc-status-${npc.alive_status}`;
|
|
aliveBadge.textContent = statusLabel(npc.alive_status);
|
|
|
|
badges.append(dispositionBadge, aliveBadge);
|
|
header.append(nameSpan, badges);
|
|
header.addEventListener('click', () => {
|
|
expandedId = expandedId === npc.id ? null : npc.id;
|
|
renderList();
|
|
});
|
|
card.appendChild(header);
|
|
|
|
if (expandedId === npc.id) {
|
|
card.classList.add('expanded');
|
|
card.appendChild(buildNpcDetail(npc, card));
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
function buildNpcDetail(npc, card) {
|
|
const detail = document.createElement('div');
|
|
detail.className = 'npc-card-detail';
|
|
|
|
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';
|
|
|
|
const titleEl = document.createElement('div');
|
|
titleEl.className = 'npc-section-title';
|
|
titleEl.textContent = section.title;
|
|
sectionEl.appendChild(titleEl);
|
|
|
|
section.fields.forEach(({ key, label }) => {
|
|
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);
|
|
});
|
|
|
|
const notesSection = document.createElement('div');
|
|
notesSection.className = 'npc-section';
|
|
const notesTitle = document.createElement('div');
|
|
notesTitle.className = 'npc-section-title';
|
|
notesTitle.textContent = NOTES_FIELD.label;
|
|
notesSection.appendChild(notesTitle);
|
|
notesSection.appendChild(buildFieldRow(null, npc.notes, true));
|
|
detail.appendChild(notesSection);
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'npc-card-actions';
|
|
|
|
const editBtn = document.createElement('button');
|
|
editBtn.type = 'button';
|
|
editBtn.className = 'npc-edit-btn';
|
|
editBtn.textContent = 'Edit';
|
|
editBtn.addEventListener('click', () => openEditModal(npc));
|
|
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.type = 'button';
|
|
deleteBtn.className = 'npc-delete-btn';
|
|
deleteBtn.textContent = 'Delete';
|
|
deleteBtn.addEventListener('click', () => handleDelete(npc));
|
|
|
|
actions.append(editBtn, deleteBtn);
|
|
detail.appendChild(actions);
|
|
|
|
return detail;
|
|
}
|
|
|
|
function buildFieldRow(label, value, valueOnly = false) {
|
|
const row = document.createElement('div');
|
|
row.className = 'npc-field';
|
|
|
|
if (!valueOnly) {
|
|
const labelEl = document.createElement('span');
|
|
labelEl.className = 'npc-field-label';
|
|
labelEl.textContent = label;
|
|
row.appendChild(labelEl);
|
|
}
|
|
|
|
const valueEl = document.createElement('span');
|
|
valueEl.className = 'npc-field-value';
|
|
valueEl.textContent = value || '—';
|
|
row.appendChild(valueEl);
|
|
|
|
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 = '';
|
|
|
|
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));
|
|
});
|
|
});
|
|
|
|
body.appendChild(buildModalSectionTitle(NOTES_FIELD.label));
|
|
body.appendChild(buildModalField(NOTES_FIELD, values, true));
|
|
}
|
|
|
|
function buildModalSectionTitle(title) {
|
|
const titleEl = document.createElement('div');
|
|
titleEl.className = 'modal-section-title';
|
|
titleEl.textContent = title;
|
|
return titleEl;
|
|
}
|
|
|
|
function buildModalField({ key, label, type }, values, hideLabel = false) {
|
|
const group = document.createElement('div');
|
|
group.className = 'modal-field';
|
|
|
|
const labelEl = hideLabel ? null : document.createElement('label');
|
|
if (labelEl) {
|
|
labelEl.className = 'modal-field-label';
|
|
labelEl.textContent = label;
|
|
group.appendChild(labelEl);
|
|
}
|
|
|
|
if (type === 'select') {
|
|
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;
|
|
input.value = values[key] || '';
|
|
} else {
|
|
input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'modal-input';
|
|
input.value = values[key] || '';
|
|
}
|
|
input.id = `npc-field-${key}`;
|
|
|
|
group.appendChild(input);
|
|
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 }) => {
|
|
const el = container.querySelector(`#npc-field-${key}`);
|
|
const value = el.value;
|
|
data[key] = value.trim() === '' ? null : value;
|
|
});
|
|
return data;
|
|
}
|
|
|
|
function openCreateModal() {
|
|
editingNpcId = null;
|
|
container.querySelector('#npcModalTitle').textContent = 'New NPC';
|
|
buildModalForm({});
|
|
showModal();
|
|
}
|
|
|
|
function openEditModal(npc) {
|
|
editingNpcId = npc.id;
|
|
container.querySelector('#npcModalTitle').textContent = 'Edit NPC';
|
|
buildModalForm(npc);
|
|
showModal();
|
|
}
|
|
|
|
function showModal() {
|
|
container.querySelector('#npcModalOverlay').classList.add('open');
|
|
}
|
|
|
|
function closeModal() {
|
|
container.querySelector('#npcModalOverlay').classList.remove('open');
|
|
}
|
|
|
|
async function saveModal() {
|
|
const campaign = getActiveCampaign();
|
|
if (!campaign) return;
|
|
|
|
const data = collectFormData();
|
|
if (!data.name) {
|
|
alert('Name is required.');
|
|
return;
|
|
}
|
|
if (!data.alive_status) data.alive_status = 'alive';
|
|
if (!data.disposition) data.disposition = 'unknown';
|
|
|
|
try {
|
|
if (editingNpcId === null) {
|
|
await createNpc(campaign.id, data);
|
|
} else {
|
|
await updateNpc(campaign.id, editingNpcId, data);
|
|
}
|
|
closeModal();
|
|
await loadAndRender();
|
|
} catch (err) {
|
|
alert(err.message);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(npc) {
|
|
const confirmed = confirm(`Delete "${npc.name}"? This cannot be undone.`);
|
|
if (!confirmed) return;
|
|
|
|
const campaign = getActiveCampaign();
|
|
if (!campaign) return;
|
|
|
|
await deleteNpc(campaign.id, npc.id);
|
|
expandedId = null;
|
|
await loadAndRender();
|
|
}
|
|
|
|
async function loadAndRender() {
|
|
const campaign = getActiveCampaign();
|
|
if (!campaign) {
|
|
renderNoCampaign();
|
|
return;
|
|
}
|
|
|
|
ensureSkeleton();
|
|
npcsCache = await getNpcs(campaign.id);
|
|
renderDispositionOptions();
|
|
renderList();
|
|
}
|
|
|
|
function init() {
|
|
loadAndRender();
|
|
|
|
const navBtn = document.querySelector('.nav-item[data-view="npcs"]');
|
|
if (navBtn) {
|
|
navBtn.addEventListener('click', () => loadAndRender());
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|