Phase 6 — NPC tracker
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1173,3 +1173,320 @@ h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-section-title {
|
||||
margin: 4px 0 -4px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-section-title:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* --- NPCs --- */
|
||||
|
||||
.npcs-empty-state {
|
||||
max-width: 700px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.npcs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.npcs-title {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.npc-create-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 18px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-speed) ease, border-color var(--transition-speed) ease;
|
||||
}
|
||||
|
||||
.npc-create-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.npc-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.npc-alive-tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.npc-tab {
|
||||
flex: 1;
|
||||
padding: 10px 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease, background var(--transition-speed) ease;
|
||||
}
|
||||
|
||||
.npc-tab:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.npc-tab.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.npc-disposition-filter {
|
||||
flex-shrink: 0;
|
||||
width: 180px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.npc-disposition-filter:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.npc-list {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.npc-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.npc-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.npc-card.expanded {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.npc-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.95rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.npc-card-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.npc-card-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npc-card-badges {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.npc-status-badge,
|
||||
.npc-disposition-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.npc-disposition-badge {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.npc-status-alive {
|
||||
color: var(--success);
|
||||
border-color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
}
|
||||
|
||||
.npc-status-dead {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
}
|
||||
|
||||
.npc-status-unknown {
|
||||
color: var(--text-muted);
|
||||
border-color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
|
||||
}
|
||||
|
||||
.npc-status-missing {
|
||||
color: var(--info);
|
||||
border-color: var(--info);
|
||||
background: color-mix(in srgb, var(--info) 12%, transparent);
|
||||
}
|
||||
|
||||
.npc-card-detail {
|
||||
border-top: 1px solid var(--border);
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.npc-section {
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.npc-section:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.npc-section-title {
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.npc-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--bg);
|
||||
}
|
||||
|
||||
.npc-field:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.npc-field-label {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.npc-field-value {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.npc-card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 14px 18px 18px;
|
||||
}
|
||||
|
||||
.npc-edit-btn,
|
||||
.npc-delete-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
background: var(--bg);
|
||||
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease;
|
||||
}
|
||||
|
||||
.npc-edit-btn {
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.npc-edit-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.npc-delete-btn {
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.npc-delete-btn:hover {
|
||||
background: color-mix(in srgb, var(--danger) 10%, var(--bg));
|
||||
}
|
||||
|
||||
@@ -307,4 +307,5 @@
|
||||
<script src="js/une.js" type="module"></script>
|
||||
<script src="js/dice.js" type="module"></script>
|
||||
<script src="js/threads.js" type="module"></script>
|
||||
<script src="js/npcs.js" type="module"></script>
|
||||
</html>
|
||||
|
||||
@@ -79,3 +79,29 @@ export async function deleteThread(campaignId, id) {
|
||||
const result = await request(`/campaigns/${campaignId}/threads/${id}`, { method: 'DELETE' });
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function getNpcs(campaignId) {
|
||||
const { data } = await request(`/campaigns/${campaignId}/npcs`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createNpc(campaignId, data) {
|
||||
const result = await request(`/campaigns/${campaignId}/npcs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function updateNpc(campaignId, id, data) {
|
||||
const result = await request(`/campaigns/${campaignId}/npcs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function deleteNpc(campaignId, id) {
|
||||
const result = await request(`/campaigns/${campaignId}/npcs/${id}`, { method: 'DELETE' });
|
||||
return result.data;
|
||||
}
|
||||
|
||||
+471
-1
@@ -1 +1,471 @@
|
||||
// Mythic Oracle — NPCs
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user