85adbbf084
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
472 lines
14 KiB
JavaScript
472 lines
14 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 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: 'text' },
|
|
],
|
|
},
|
|
];
|
|
|
|
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 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 = 'npc-disposition-badge';
|
|
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));
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
function buildNpcDetail(npc) {
|
|
const detail = document.createElement('div');
|
|
detail.className = 'npc-card-detail';
|
|
|
|
SECTIONS.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 }) => {
|
|
sectionEl.appendChild(buildFieldRow(label, key === 'alive_status' ? statusLabel(npc.alive_status) : 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 buildModalForm(values) {
|
|
const body = container.querySelector('#npcModalBody');
|
|
body.innerHTML = '';
|
|
|
|
SECTIONS.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';
|
|
|
|
if (!hideLabel) {
|
|
const labelEl = document.createElement('label');
|
|
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') {
|
|
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 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);
|