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