Compare commits

...

3 Commits

Author SHA1 Message Date
claudecode ea8bd6dc64 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>
2026-07-01 19:51:30 -04:00
claudecode e61328aa4e Replace thread status dropdown/text with inline status buttons
Card and modal both use a clickable button row for status instead of
static text or a select, so changing status no longer requires
opening the full edit form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:24:53 -04:00
claudecode 85adbbf084 Phase 6 — NPC tracker
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:13:38 -04:00
9 changed files with 1505 additions and 25 deletions
+9
View File
@@ -384,6 +384,15 @@ These are final. Do not propose alternatives unless explicitly asked.
- Session logs are append-style with a date per entry. - Session logs are append-style with a date per entry.
- World and lore are single persistent documents per campaign - World and lore are single persistent documents per campaign
stored in campaign_docs with doc_type 'world' and 'lore'. stored in campaign_docs with doc_type 'world' and 'lore'.
- Response envelope: { data } / { error } is the canonical API
response shape. campaigns.js uses raw JSON and is a known
exception to be reconciled in Phase 11.
- Route nesting: campaign-scoped resources use nested routes at
/api/campaigns/:campaignId/resource, mounted with
mergeParams: true.
- Scope rule: public/index.html and server/index.js are always
implicitly in scope when wiring a new feature view and do not
need explicit approval as scope expansions.
--- ---
+456
View File
@@ -1042,6 +1042,39 @@ h1, h2, h3, h4, h5, h6 {
white-space: pre-wrap; white-space: pre-wrap;
} }
.thread-status-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 2px;
}
.thread-status-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;
}
.thread-status-btn:hover {
color: var(--text-primary);
border-color: var(--accent-hover);
}
.thread-status-btn.active {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.thread-card-actions { .thread-card-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -1162,6 +1195,38 @@ h1, h2, h3, h4, h5, h6 {
border-color: var(--accent-hover); border-color: var(--accent-hover);
} }
.modal-status-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.modal-status-btn {
padding: 8px 14px;
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.08em;
text-transform: uppercase;
cursor: pointer;
transition: border-color var(--transition-speed) ease, color var(--transition-speed) ease, background var(--transition-speed) ease;
}
.modal-status-btn:hover {
color: var(--text-primary);
border-color: var(--accent-hover);
}
.modal-status-btn.active {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.modal-actions { .modal-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -1173,3 +1238,394 @@ h1, h2, h3, h4, h5, h6 {
margin-top: 0; margin-top: 0;
flex: 1; 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-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 {
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));
}
+1
View File
@@ -307,4 +307,5 @@
<script src="js/une.js" type="module"></script> <script src="js/une.js" type="module"></script>
<script src="js/dice.js" type="module"></script> <script src="js/dice.js" type="module"></script>
<script src="js/threads.js" type="module"></script> <script src="js/threads.js" type="module"></script>
<script src="js/npcs.js" type="module"></script>
</html> </html>
+26
View File
@@ -79,3 +79,29 @@ export async function deleteThread(campaignId, id) {
const result = await request(`/campaigns/${campaignId}/threads/${id}`, { method: 'DELETE' }); const result = await request(`/campaigns/${campaignId}/threads/${id}`, { method: 'DELETE' });
return result.data; 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;
}
+653 -1
View File
@@ -1 +1,653 @@
// 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 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);
+106 -15
View File
@@ -133,17 +133,22 @@ function buildThreadCard(thread) {
if (expandedId === thread.id) { if (expandedId === thread.id) {
card.classList.add('expanded'); card.classList.add('expanded');
card.appendChild(buildThreadDetail(thread)); card.appendChild(buildThreadDetail(thread, card));
} }
return card; return card;
} }
function buildThreadDetail(thread) { function buildThreadDetail(thread, card) {
const detail = document.createElement('div'); const detail = document.createElement('div');
detail.className = 'thread-card-detail'; detail.className = 'thread-card-detail';
FIELD_DEFS.forEach(({ key, label }) => { FIELD_DEFS.forEach(({ key, label }) => {
if (key === 'status') {
detail.appendChild(buildStatusButtonRow(thread, card));
return;
}
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'thread-field'; row.className = 'thread-field';
@@ -153,7 +158,7 @@ function buildThreadDetail(thread) {
const valueEl = document.createElement('span'); const valueEl = document.createElement('span');
valueEl.className = 'thread-field-value'; valueEl.className = 'thread-field-value';
valueEl.textContent = key === 'status' ? statusLabel(thread.status) : (thread[key] || '—'); valueEl.textContent = thread[key] || '—';
row.append(labelEl, valueEl); row.append(labelEl, valueEl);
detail.appendChild(row); detail.appendChild(row);
@@ -180,6 +185,66 @@ function buildThreadDetail(thread) {
return detail; return detail;
} }
function buildStatusButtonRow(thread, card) {
const row = document.createElement('div');
row.className = 'thread-field';
const labelEl = document.createElement('span');
labelEl.className = 'thread-field-label';
labelEl.textContent = 'Status';
row.appendChild(labelEl);
const buttonRow = document.createElement('div');
buttonRow.className = 'thread-status-buttons';
STATUSES.forEach((status) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'thread-status-btn';
btn.dataset.status = status;
btn.classList.toggle('active', status === thread.status);
btn.textContent = statusLabel(status);
btn.addEventListener('click', () => handleStatusChange(thread, status, card));
buttonRow.appendChild(btn);
});
row.appendChild(buttonRow);
return row;
}
async function handleStatusChange(thread, newStatus, card) {
if (newStatus === thread.status) return;
const campaign = getActiveCampaign();
if (!campaign) return;
const payload = {};
FIELD_DEFS.forEach(({ key }) => {
payload[key] = thread[key];
});
payload.status = newStatus;
try {
const updated = await updateThread(campaign.id, thread.id, payload);
Object.assign(thread, updated);
updateCardStatusUI(card, thread);
} catch (err) {
alert(err.message);
}
}
function updateCardStatusUI(card, thread) {
const badge = card.querySelector('.thread-status-badge');
if (badge) {
badge.className = `thread-status-badge thread-status-${thread.status}`;
badge.textContent = statusLabel(thread.status);
}
card.querySelectorAll('.thread-status-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.status === thread.status);
});
}
function buildModalForm(values) { function buildModalForm(values) {
const body = container.querySelector('#threadModalBody'); const body = container.querySelector('#threadModalBody');
body.innerHTML = ''; body.innerHTML = '';
@@ -191,21 +256,18 @@ function buildModalForm(values) {
const labelEl = document.createElement('label'); const labelEl = document.createElement('label');
labelEl.className = 'modal-field-label'; labelEl.className = 'modal-field-label';
labelEl.textContent = label; labelEl.textContent = label;
labelEl.setAttribute('for', `thread-field-${key}`);
group.appendChild(labelEl); group.appendChild(labelEl);
let input;
if (type === 'select') { if (type === 'select') {
input = document.createElement('select'); group.appendChild(buildModalStatusButtons(values.status || 'active'));
input.className = 'modal-select'; body.appendChild(group);
STATUSES.forEach((status) => { return;
const option = document.createElement('option'); }
option.value = status;
option.textContent = statusLabel(status); labelEl.setAttribute('for', `thread-field-${key}`);
input.appendChild(option);
}); let input;
input.value = values.status || 'active'; if (type === 'textarea') {
} else 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;
@@ -223,6 +285,35 @@ function buildModalForm(values) {
}); });
} }
function buildModalStatusButtons(currentStatus) {
const wrapper = document.createElement('div');
wrapper.className = 'modal-status-buttons';
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.id = 'thread-field-status';
hiddenInput.value = currentStatus;
wrapper.appendChild(hiddenInput);
STATUSES.forEach((status) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'modal-status-btn';
btn.dataset.status = status;
btn.classList.toggle('active', status === currentStatus);
btn.textContent = statusLabel(status);
btn.addEventListener('click', () => {
hiddenInput.value = status;
wrapper.querySelectorAll('.modal-status-btn').forEach((b) => {
b.classList.toggle('active', b.dataset.status === status);
});
});
wrapper.appendChild(btn);
});
return wrapper;
}
function collectFormData() { function collectFormData() {
const data = {}; const data = {};
FIELD_DEFS.forEach(({ key }) => { FIELD_DEFS.forEach(({ key }) => {
+22
View File
@@ -44,6 +44,28 @@ CREATE TABLE IF NOT EXISTS npcs (
description TEXT, description TEXT,
notes TEXT, notes TEXT,
motivations TEXT, motivations TEXT,
appearance TEXT,
age TEXT,
gender TEXT,
pronouns TEXT,
voice TEXT,
distinguishing_features TEXT,
faction TEXT,
occupation TEXT,
social_status TEXT,
relationship_to_pc TEXT,
loyalty TEXT,
personality_traits TEXT,
fears TEXT,
desires TEXT,
secrets TEXT,
first_encountered TEXT,
last_seen TEXT,
current_location TEXT,
current_goal TEXT,
role_in_threads TEXT,
alive_status TEXT DEFAULT 'alive',
disposition TEXT DEFAULT 'unknown',
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
+1 -1
View File
@@ -23,7 +23,7 @@ app.get('/health', (req, res) => {
app.use('/api/campaigns', campaignsRouter); app.use('/api/campaigns', campaignsRouter);
app.use('/api/characters', charactersRouter); app.use('/api/characters', charactersRouter);
app.use('/api/campaigns/:campaignId/threads', threadsRouter); app.use('/api/campaigns/:campaignId/threads', threadsRouter);
app.use('/api/npcs', npcsRouter); app.use('/api/campaigns/:campaignId/npcs', npcsRouter);
app.use('/api/notes', notesRouter); app.use('/api/notes', notesRouter);
app.use('/api/tables', tablesRouter); app.use('/api/tables', tablesRouter);
app.use('/api/systems', systemsRouter); app.use('/api/systems', systemsRouter);
+224 -1
View File
@@ -1,4 +1,227 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const db = require('../db');
const router = express.Router({ mergeParams: true });
const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing'];
const OPTIONAL_TEXT_FIELDS = [
'description',
'notes',
'motivations',
'appearance',
'age',
'gender',
'pronouns',
'voice',
'distinguishing_features',
'faction',
'occupation',
'social_status',
'relationship_to_pc',
'loyalty',
'personality_traits',
'fears',
'desires',
'secrets',
'first_encountered',
'last_seen',
'current_location',
'current_goal',
'role_in_threads',
'disposition',
];
const getCampaignByIdStmt = db.prepare('SELECT id FROM campaigns WHERE id = ?');
const listNpcsStmt = db.prepare('SELECT * FROM npcs WHERE campaign_id = ? ORDER BY created_at DESC');
const getNpcStmt = db.prepare('SELECT * FROM npcs WHERE id = ? AND campaign_id = ?');
const insertNpcStmt = db.prepare(`
INSERT INTO npcs (
campaign_id, name, description, notes, motivations, appearance, age,
gender, pronouns, voice, distinguishing_features, faction, occupation,
social_status, relationship_to_pc, loyalty, personality_traits, fears,
desires, secrets, first_encountered, last_seen, current_location,
current_goal, role_in_threads, alive_status, disposition
) VALUES (
@campaign_id, @name, @description, @notes, @motivations, @appearance, @age,
@gender, @pronouns, @voice, @distinguishing_features, @faction, @occupation,
@social_status, @relationship_to_pc, @loyalty, @personality_traits, @fears,
@desires, @secrets, @first_encountered, @last_seen, @current_location,
@current_goal, @role_in_threads, @alive_status, @disposition
)
`);
const updateNpcStmt = db.prepare(`
UPDATE npcs SET
name = @name,
description = @description,
notes = @notes,
motivations = @motivations,
appearance = @appearance,
age = @age,
gender = @gender,
pronouns = @pronouns,
voice = @voice,
distinguishing_features = @distinguishing_features,
faction = @faction,
occupation = @occupation,
social_status = @social_status,
relationship_to_pc = @relationship_to_pc,
loyalty = @loyalty,
personality_traits = @personality_traits,
fears = @fears,
desires = @desires,
secrets = @secrets,
first_encountered = @first_encountered,
last_seen = @last_seen,
current_location = @current_location,
current_goal = @current_goal,
role_in_threads = @role_in_threads,
alive_status = @alive_status,
disposition = @disposition
WHERE id = @id AND campaign_id = @campaign_id
`);
const deleteNpcStmt = db.prepare('DELETE FROM npcs WHERE id = ? AND campaign_id = ?');
function parsePositiveInt(raw, res, label) {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
res.status(400).json({ error: `${label} must be a positive integer` });
return null;
}
return value;
}
function loadCampaignId(req, res) {
const campaignId = parsePositiveInt(req.params.campaignId, res, 'campaign_id');
if (campaignId === null) return null;
if (!getCampaignByIdStmt.get(campaignId)) {
res.status(404).json({ error: 'campaign not found' });
return null;
}
return campaignId;
}
// Shared by create and update — PUT sends the full form, so both treat
// name/alive_status the same way rather than supporting partial patches.
function parseNpcPayload(body, res) {
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) {
res.status(400).json({ error: 'name is required' });
return null;
}
const aliveStatus = body.alive_status === undefined ? 'alive' : body.alive_status;
if (!ALIVE_STATUSES.includes(aliveStatus)) {
res.status(400).json({ error: `alive_status must be one of: ${ALIVE_STATUSES.join(', ')}` });
return null;
}
const payload = { name, alive_status: aliveStatus };
for (const field of OPTIONAL_TEXT_FIELDS) {
if (field === 'disposition') continue;
const value = body[field];
if (value === undefined || value === null) {
payload[field] = null;
} else if (typeof value === 'string') {
payload[field] = value;
} else {
res.status(400).json({ error: `${field} must be a string or null` });
return null;
}
}
const disposition = body.disposition;
if (disposition === undefined) {
payload.disposition = 'unknown';
} else if (disposition === null || typeof disposition === 'string') {
payload.disposition = disposition;
} else {
res.status(400).json({ error: 'disposition must be a string or null' });
return null;
}
return payload;
}
function withErrorHandling(handler) {
return (req, res) => {
try {
handler(req, res);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'unexpected server error' });
}
};
}
router.get('/', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
res.json({ data: listNpcsStmt.all(campaignId) });
}));
router.get('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
const npc = getNpcStmt.get(id, campaignId);
if (!npc) {
return res.status(404).json({ error: 'npc not found' });
}
res.json({ data: npc });
}));
router.post('/', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const payload = parseNpcPayload(req.body, res);
if (payload === null) return;
const result = insertNpcStmt.run({ ...payload, campaign_id: campaignId });
res.status(201).json({ data: getNpcStmt.get(result.lastInsertRowid, campaignId) });
}));
router.put('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
if (!getNpcStmt.get(id, campaignId)) {
return res.status(404).json({ error: 'npc not found' });
}
const payload = parseNpcPayload(req.body, res);
if (payload === null) return;
updateNpcStmt.run({ ...payload, id, campaign_id: campaignId });
res.json({ data: getNpcStmt.get(id, campaignId) });
}));
router.delete('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
if (!getNpcStmt.get(id, campaignId)) {
return res.status(404).json({ error: 'npc not found' });
}
deleteNpcStmt.run(id, campaignId);
res.json({ data: { id } });
}));
module.exports = router; module.exports = router;