Add NPC editor to admin page
Build and publish frontend / publish (push) Successful in 58s

This commit is contained in:
Ein Anderssono
2026-10-22 00:04:31 +02:00
parent 9a8eb9ae85
commit 196ff1429b
3 changed files with 125 additions and 1 deletions
+116
View File
@@ -104,6 +104,7 @@
<p>Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.</p>
<nav id="admin-toolbar" aria-label="Administrationsvyer">
<button type="button" data-admin-view="players">Spelare</button>
<button type="button" data-admin-view="npcs">NPC:er</button>
<button type="button" data-admin-view="prompts">Prompter</button>
<button type="button" data-admin-view="rules">Luau-regler</button>
<button type="button" data-admin-view="logging">Driftloggar</button>
@@ -650,6 +651,120 @@
}
}
}
async function loadAdminNpcs(query = '', offset = 0) {
adminContent.replaceChildren();
const form = adminElement('form');
const label = adminElement('label', 'Sök NPC eller värld', form);
const input = document.createElement('input'); input.type = 'search'; input.maxLength = 100; input.value = query; label.append(input);
const search = adminElement('button', 'Sök', form); search.type = 'submit';
form.addEventListener('submit', event => { event.preventDefault(); loadAdminNpcs(input.value, 0); });
const result = await request(`/api/admin/npcs?q=${encodeURIComponent(query)}&limit=50&offset=${offset}`);
adminElement('p', `${result.total} NPC:er. Välj en för att granska och redigera profil, minne och ägodelar.`);
const list = adminElement('ul');
for (const npc of result.npcs) {
const item = adminElement('li', undefined, list);
const button = adminElement('button', `${npc.name} · ${npc.world} · ${npc.location}${npc.scene ? ` · ${npc.scene}` : ''}`, item);
button.type = 'button'; button.addEventListener('click', () => openAdminNpc(npc.id));
}
const paging = adminElement('div');
const previous = adminElement('button', 'Föregående sida', paging); previous.type = 'button'; previous.disabled = offset <= 0; previous.addEventListener('click', () => loadAdminNpcs(query, Math.max(0, offset - 50)));
const next = adminElement('button', 'Nästa sida', paging); next.type = 'button'; next.disabled = offset + result.npcs.length >= result.total; next.addEventListener('click', () => loadAdminNpcs(query, offset + 50));
}
async function openAdminNpc(npcId) {
adminContent.replaceChildren();
const back = adminElement('button', 'Tillbaka till NPC-listan'); back.type = 'button'; back.addEventListener('click', () => loadAdminNpcs());
const npc = await request(`/api/admin/npcs/${encodeURIComponent(npcId)}`);
adminElement('h2', `${npc.name} · ${npc.world}`);
adminElement('p', `Plats: ${npc.location}${npc.scene ? ` · ${npc.scene}` : ''}. ID och position visas som referens och ändras inte i den här vyn.`);
adminElement('h3', 'Namn, bakgrund och röstidentitet');
const identityForm = adminElement('form');
const nameLabel = adminElement('label', 'Namn ', identityForm);
const nameInput = document.createElement('input'); nameInput.required = true; nameInput.maxLength = 120; nameInput.value = npc.name; nameLabel.append(nameInput);
const biographyLabel = adminElement('label', 'Bakgrund ', identityForm);
const biographyInput = document.createElement('textarea'); biographyInput.required = true; biographyInput.maxLength = 4000; biographyInput.rows = 5; biographyInput.value = npc.biography; biographyLabel.append(biographyInput);
const voiceLabel = adminElement('label', 'Röstidentitet ', identityForm);
const voiceSelect = document.createElement('select');
for (const value of ['masculine', 'feminine', 'neutral']) { const option = document.createElement('option'); option.value = value; option.textContent = value; option.selected = value === npc.voice_identity; voiceSelect.append(option); }
voiceLabel.append(voiceSelect);
const identitySave = adminElement('button', 'Spara identitet', identityForm); identitySave.type = 'submit';
const identityStatus = adminElement('p'); identityStatus.setAttribute('role', 'status');
identityForm.addEventListener('submit', async event => {
event.preventDefault(); identitySave.disabled = true;
try {
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/identity`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_name: npc.name, expected_biography: npc.biography, expected_voice_identity: npc.voice_identity, name: nameInput.value, biography: biographyInput.value, voice_identity: voiceSelect.value})});
identityStatus.textContent = 'Identiteten sparades och revisionsloggades. Ladda om NPC:n för att hämta senaste version.';
} catch (error) { identityStatus.textContent = `Identiteten sparades inte: ${error.message}`; }
finally { identitySave.disabled = false; }
});
adminElement('h3', 'Egenskaper och framtida profilfält');
adminElement('p', 'Redigera profilens JSON. Kända spelvärden valideras av Rust; okända framtida egenskaper bevaras som JSON. Värld och position visas som referens och ändras inte här.');
const profileForm = adminElement('form');
const profileLabel = adminElement('label', 'NPC-profil i JSON', profileForm);
const profileInput = document.createElement('textarea'); profileInput.rows = 18; profileInput.cols = 80; profileInput.required = true; profileInput.value = JSON.stringify(npc.profile, null, 2); profileLabel.append(profileInput);
const profileSave = adminElement('button', 'Spara egenskaper', profileForm); profileSave.type = 'submit';
const profileStatus = adminElement('p'); profileStatus.setAttribute('role', 'status');
profileForm.addEventListener('submit', async event => {
event.preventDefault(); profileSave.disabled = true;
try {
const profile = JSON.parse(profileInput.value);
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/profile`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_profile: npc.profile, profile})});
profileStatus.textContent = 'Egenskaperna sparades och revisionsloggades. Ladda om NPC:n för att hämta senaste version.';
} catch (error) { profileStatus.textContent = `Egenskaperna sparades inte: ${error.message}`; }
finally { profileSave.disabled = false; }
});
adminElement('h3', 'Ägodelar');
const possessionsForm = adminElement('form');
const possessionsLabel = adminElement('label', 'En ägodel per rad: namn | antal', possessionsForm);
const possessionsInput = document.createElement('textarea'); possessionsInput.rows = 8; possessionsInput.cols = 60; possessionsInput.value = npc.possessions.map(item => `${item.name} | ${item.quantity}`).join('\n'); possessionsLabel.append(possessionsInput);
const possessionsSave = adminElement('button', 'Ersätt ägodelar', possessionsForm); possessionsSave.type = 'submit';
const possessionsStatus = adminElement('p'); possessionsStatus.setAttribute('role', 'status');
possessionsForm.addEventListener('submit', async event => {
event.preventDefault(); possessionsSave.disabled = true;
try {
const possessions = possessionsInput.value.split('\n').map(line => line.trim()).filter(Boolean).map(line => { const [name, amount] = line.split('|'); return {name: name?.trim() || '', quantity: Number(amount?.trim() || 1)}; });
const expected = npc.possessions.map(item => ({name: item.name, quantity: item.quantity}));
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/possessions`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected, possessions})});
possessionsStatus.textContent = 'Ägodelarna ersattes och revisionsloggades.';
} catch (error) { possessionsStatus.textContent = `Ägodelarna sparades inte: ${error.message}`; }
finally { possessionsSave.disabled = false; }
});
adminElement('h3', 'Minnesrekonstruktioner');
adminElement('p', 'Varje ändring behåller minnespostens källa och läggs i revisionsloggen. Ändringar påverkar NPC:ns aktiva minne direkt.');
if (npc.memories.length === 0) adminElement('p', 'Inga sparade minnen för den här NPC:n.');
for (const memory of npc.memories) {
const section = adminElement('section');
adminElement('h4', `${memory.character_name} · ${memory.layer} · ${memory.active ? 'aktivt' : 'inaktivt'} · ${memory.visibility}`, section);
adminElement('p', `Källa ${memory.source_event_id}; informationsväg ${memory.information_source}; prioritet ${memory.priority}.`, section);
const form = adminElement('form', undefined, section);
const label = adminElement('label', 'Minnestext', form);
const text = document.createElement('textarea'); text.rows = 4; text.cols = 80; text.required = true; text.maxLength = 3000; text.value = memory.text; label.append(text);
const priorityLabel = adminElement('label', ' Prioritet 0–100 ', form);
const priority = document.createElement('input'); priority.type = 'number'; priority.min = '0'; priority.max = '100'; priority.value = memory.priority; priorityLabel.append(priority);
const activeLabel = adminElement('label', undefined, form);
const active = document.createElement('input'); active.type = 'checkbox'; active.checked = memory.active; activeLabel.append(active, document.createTextNode(' Aktivt minne'));
const visibilityLabel = adminElement('label', ' Synlighet ', form);
const visibility = document.createElement('select');
for (const value of ['private', 'confidential', 'shareable']) { const option = document.createElement('option'); option.value = value; option.textContent = value; option.selected = value === memory.visibility; visibility.append(option); }
visibilityLabel.append(visibility);
const save = adminElement('button', 'Spara minnesändring', form); save.type = 'submit';
const status = adminElement('p', undefined, section); status.setAttribute('role', 'status');
form.addEventListener('submit', async event => {
event.preventDefault(); save.disabled = true;
try {
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/memories/${encodeURIComponent(memory.id)}`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_text: memory.text, expected_priority: memory.priority, expected_active: memory.active, expected_visibility: memory.visibility, text: text.value, priority: Number(priority.value), active: active.checked, visibility: visibility.value})});
status.textContent = 'Minnet sparades och revisionsloggades. Ladda om NPC:n för senaste version.';
} catch (error) { status.textContent = `Minnet sparades inte: ${error.message}`; }
finally { save.disabled = false; }
});
}
adminElement('h3', 'Löften och åtaganden');
if (npc.commitments.length === 0) adminElement('p', 'Inga registrerade åtaganden.');
for (const commitment of npc.commitments) adminJson(`${commitment.status} · ${commitment.created_at}`, commitment);
}
async function loadAdminPrompts() {
adminContent.replaceChildren();
const result = await request('/api/admin/prompts');
@@ -1243,6 +1358,7 @@
adminContent.setAttribute('aria-busy', 'true');
try {
if (button.dataset.adminView === 'players') await loadAdminPlayers();
else if (button.dataset.adminView === 'npcs') await loadAdminNpcs();
else if (button.dataset.adminView === 'prompts') await loadAdminPrompts();
else if (button.dataset.adminView === 'rules') await loadAdminRules();
else if (button.dataset.adminView === 'logging') await loadAdminLogging();