Compare commits
3 Commits
68bc6b8810
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8bd6dc64 | |||
| e61328aa4e | |||
| 85adbbf084 |
@@ -384,6 +384,15 @@ These are final. Do not propose alternatives unless explicitly asked.
|
||||
- Session logs are append-style with a date per entry.
|
||||
- World and lore are single persistent documents per campaign
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1042,6 +1042,39 @@ h1, h2, h3, h4, h5, h6 {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -1162,6 +1195,38 @@ h1, h2, h3, h4, h5, h6 {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -1173,3 +1238,394 @@ 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-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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+653
-1
@@ -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
@@ -133,17 +133,22 @@ function buildThreadCard(thread) {
|
||||
|
||||
if (expandedId === thread.id) {
|
||||
card.classList.add('expanded');
|
||||
card.appendChild(buildThreadDetail(thread));
|
||||
card.appendChild(buildThreadDetail(thread, card));
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function buildThreadDetail(thread) {
|
||||
function buildThreadDetail(thread, card) {
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'thread-card-detail';
|
||||
|
||||
FIELD_DEFS.forEach(({ key, label }) => {
|
||||
if (key === 'status') {
|
||||
detail.appendChild(buildStatusButtonRow(thread, card));
|
||||
return;
|
||||
}
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'thread-field';
|
||||
|
||||
@@ -153,7 +158,7 @@ function buildThreadDetail(thread) {
|
||||
|
||||
const valueEl = document.createElement('span');
|
||||
valueEl.className = 'thread-field-value';
|
||||
valueEl.textContent = key === 'status' ? statusLabel(thread.status) : (thread[key] || '—');
|
||||
valueEl.textContent = thread[key] || '—';
|
||||
|
||||
row.append(labelEl, valueEl);
|
||||
detail.appendChild(row);
|
||||
@@ -180,6 +185,66 @@ function buildThreadDetail(thread) {
|
||||
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) {
|
||||
const body = container.querySelector('#threadModalBody');
|
||||
body.innerHTML = '';
|
||||
@@ -191,21 +256,18 @@ function buildModalForm(values) {
|
||||
const labelEl = document.createElement('label');
|
||||
labelEl.className = 'modal-field-label';
|
||||
labelEl.textContent = label;
|
||||
labelEl.setAttribute('for', `thread-field-${key}`);
|
||||
group.appendChild(labelEl);
|
||||
|
||||
let input;
|
||||
if (type === 'select') {
|
||||
input = document.createElement('select');
|
||||
input.className = 'modal-select';
|
||||
STATUSES.forEach((status) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = status;
|
||||
option.textContent = statusLabel(status);
|
||||
input.appendChild(option);
|
||||
});
|
||||
input.value = values.status || 'active';
|
||||
} else if (type === 'textarea') {
|
||||
group.appendChild(buildModalStatusButtons(values.status || 'active'));
|
||||
body.appendChild(group);
|
||||
return;
|
||||
}
|
||||
|
||||
labelEl.setAttribute('for', `thread-field-${key}`);
|
||||
|
||||
let input;
|
||||
if (type === 'textarea') {
|
||||
input = document.createElement('textarea');
|
||||
input.className = 'modal-textarea';
|
||||
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() {
|
||||
const data = {};
|
||||
FIELD_DEFS.forEach(({ key }) => {
|
||||
|
||||
+29
-7
@@ -38,13 +38,35 @@ CREATE TABLE IF NOT EXISTS threads (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS npcs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
notes TEXT,
|
||||
motivations TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
notes 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'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_logs (
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ app.get('/health', (req, res) => {
|
||||
app.use('/api/campaigns', campaignsRouter);
|
||||
app.use('/api/characters', charactersRouter);
|
||||
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/tables', tablesRouter);
|
||||
app.use('/api/systems', systemsRouter);
|
||||
|
||||
+224
-1
@@ -1,4 +1,227 @@
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user