chore: initial commit of foundryvtt-admin

Spun out of the hbm-books working folder into its own repo. A small
backend+frontend+COBOL admin tool for managing Foundry VTT worlds
(socket inspection, join/shutdown debugging).
This commit is contained in:
2026-08-17 21:46:42 +02:00
commit 460d136d21
28 changed files with 2494 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
div(class="min-h-screen bg-slate-950 text-slate-100 font-sans p-6")
div(class="max-w-7xl mx-auto space-y-6")
// Header
header(class="flex flex-col sm:flex-row justify-between items-start sm:items-center pb-6 border-b border-slate-800 gap-4")
div
h1(class="text-3xl font-extrabold tracking-tight bg-gradient-to-r from-violet-400 via-indigo-400 to-fuchsia-400 bg-clip-text text-transparent") Foundry VTT Admin Bridge
p(class="text-sm text-slate-400") Manage character sheets & worlds remotely via Flat Files and REST APIs
div(class="flex gap-3")
button#btn-refresh(class="px-4 py-2 bg-slate-900 border border-slate-800 rounded-xl text-sm font-semibold hover:bg-slate-800 hover:border-slate-700 transition-all" title="Refresh local frontend data from backend cache") Refresh Cache
button#btn-sync-foundry(class="px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white rounded-xl text-sm font-semibold transition shadow-lg shadow-indigo-500/10" title="Force backend to pull fresh data from Foundry VTT websocket") Sync from Foundry
// World Management Control Panel (Glassmorphism card)
div(class="bg-slate-900/30 backdrop-blur-xl border border-slate-850 p-6 rounded-3xl shadow-2xl grid grid-cols-1 lg:grid-cols-3 gap-6")
div(class="space-y-3 col-span-1 lg:col-span-2")
h2(class="text-lg font-bold text-slate-200 flex items-center gap-2")
span(class="w-2.5 h-2.5 rounded-full bg-violet-500 animate-pulse")
| World Configuration & Setup
p(class="text-xs text-slate-400") Select a world, launch it from the setup menu, or switch active proxy targeting.
div(class="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2")
div
label(class="block text-[10px] font-bold text-slate-400 uppercase mb-1.5") Target World
select#world-selector(class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500 transition")
option(value="" disabled selected) Loading worlds...
div
label(class="block text-[10px] font-bold text-slate-400 uppercase mb-1.5") Admin User
input#world-user(class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500 transition" type="text" value="Admin")
div
label(class="block text-[10px] font-bold text-slate-400 uppercase mb-1.5") Password
input#world-pass(class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500 transition" type="password" placeholder="World password")
div(class="flex flex-col justify-end gap-3 col-span-1")
div(class="flex gap-2")
button#btn-launch(class="flex-1 px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white rounded-xl text-xs font-bold transition shadow-lg shadow-indigo-500/10") Launch World
button#btn-shutdown(class="flex-1 px-4 py-2 bg-red-950/40 hover:bg-red-900/20 text-red-400 border border-red-900/30 rounded-xl text-xs font-bold transition") Shutdown World
button#btn-select(class="w-full px-4 py-2 bg-slate-900 border border-slate-800 hover:bg-slate-800 hover:border-slate-700 text-slate-200 rounded-xl text-xs font-bold transition") Connect & Sync
// Package & System Management Control Panel (Glassmorphism card)
div(class="bg-slate-900/30 backdrop-blur-xl border border-slate-850 p-6 rounded-3xl shadow-2xl grid grid-cols-1 lg:grid-cols-3 gap-6")
div(class="space-y-3 col-span-1 lg:col-span-2")
h2(class="text-lg font-bold text-slate-200 flex items-center gap-2")
span(class="w-2.5 h-2.5 rounded-full bg-indigo-500")
| Package & System Management
p(class="text-xs text-slate-400") Install or update game systems and modules directly using their manifest JSON URLs.
div(class="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2")
div(class="sm:col-span-2")
label(class="block text-[10px] font-bold text-slate-400 uppercase mb-1.5") Manifest URL
input#package-manifest(class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500 transition" type="text" value="https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json" placeholder="https://domain.com/system.json")
div
label(class="block text-[10px] font-bold text-slate-400 uppercase mb-1.5") Package Type
select#package-type(class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500 transition")
option(value="system" selected) System
option(value="module") Module
div(class="flex flex-col justify-end gap-3 col-span-1")
button#btn-update-package(class="w-full px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white rounded-xl text-xs font-bold transition shadow-lg shadow-indigo-500/10") Update Package
div#package-status(class="text-[11px] text-slate-400 mt-1 text-center min-h-[16px]")
// Status Banner & Metrics
div(class="grid grid-cols-1 md:grid-cols-4 gap-6")
// Active World Status Banner
div(class="bg-gradient-to-br from-slate-900/50 to-slate-950/20 backdrop-blur-md border border-slate-850 p-6 rounded-2xl col-span-1 md:col-span-2 flex flex-col justify-between gap-3")
div
div(class="flex justify-between items-start")
span(class="text-[10px] font-bold text-violet-400 uppercase tracking-widest") Currently Connected
span#active-world-system(class="text-[10px] font-bold text-indigo-400 uppercase tracking-wider bg-indigo-500/10 px-2.5 py-0.5 rounded-full border border-indigo-500/15") System: -
h3#active-world-name(class="text-xl font-bold text-slate-100 mt-1.5") -
div(class="flex items-center gap-2 text-xs text-slate-400")
span#connection-dot(class="w-2.5 h-2.5 rounded-full bg-slate-500")
span#connection-status Offline
span#active-world-user(class="ml-auto bg-slate-900 px-2 py-0.5 rounded text-[10px]") -
// Metrics
div(class="bg-slate-900/40 backdrop-blur-md border border-slate-800/80 p-6 rounded-2xl flex flex-col justify-between")
p(class="text-xs font-semibold tracking-wider text-slate-400 uppercase") Players
h3#metric-players(class="text-4xl font-bold text-indigo-400") -
div(class="bg-slate-900/40 backdrop-blur-md border border-slate-800/80 p-6 rounded-2xl flex flex-col justify-between")
p(class="text-xs font-semibold tracking-wider text-slate-400 uppercase") NPCs
h3#metric-npcs(class="text-4xl font-bold text-fuchsia-400") -
// Search and Filters
div(class="flex flex-col sm:flex-row justify-between items-center gap-4")
div(class="relative w-full sm:max-w-xs")
input#search-input(class="w-full px-4 py-2 bg-slate-900 border border-slate-800 rounded-xl text-sm focus:outline-none focus:border-violet-500 transition" placeholder="Search actors...")
div(class="flex gap-2 w-full sm:w-auto")
button.btn-filter.flex-1(class="sm:flex-none px-4 py-1.5 text-xs font-bold rounded-full transition bg-violet-600 text-white" data-filter="all") All
button.btn-filter.flex-1(class="sm:flex-none px-4 py-1.5 text-xs font-bold rounded-full transition bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200" data-filter="character") Players
button.btn-filter.flex-1(class="sm:flex-none px-4 py-1.5 text-xs font-bold rounded-full transition bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200" data-filter="npc") NPCs
// Actors Grid
div#actors-grid(class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6")
// Edit Modal (Hidden by default)
div#edit-modal(class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4")
div#modal-container(class="bg-slate-900 border border-slate-800 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden flex flex-col md:flex-row transition-all duration-300")
// Left Panel: Edit Form
div(class="w-full p-6 flex flex-col justify-between")
div
div(class="flex justify-between items-center pb-4 mb-4 border-b border-slate-800")
h3(class="text-lg font-bold text-slate-100") Edit Actor Sheet
button#modal-close(class="text-slate-400 hover:text-slate-200 text-xl") ☼
form#edit-form(class="space-y-4")
input(type="hidden" id="edit-id")
div
label(class="block text-xs font-semibold text-slate-400 uppercase mb-2") Name
input#edit-name(class="w-full px-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500")
div(class="grid grid-cols-2 gap-4")
div
label(class="block text-xs font-semibold text-slate-400 uppercase mb-2") Current HP
input#edit-hp-value(class="w-full px-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500" type="number")
div
label(class="block text-xs font-semibold text-slate-400 uppercase mb-2") Max HP
input#edit-hp-max(class="w-full px-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500" type="number")
div
label(class="block text-xs font-semibold text-slate-400 uppercase mb-2") Level
input#edit-level(class="w-full px-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500" type="number")
div(class="flex justify-between items-center pt-4 border-t border-slate-800 gap-3")
button#btn-debug-json(class="px-3 py-2 bg-slate-800 hover:bg-slate-750 text-xs font-semibold text-violet-400 border border-slate-700/80 rounded-xl transition" type="button") Debug JSON
div(class="flex gap-2")
button#btn-cancel(class="px-4 py-2 bg-slate-800 hover:bg-slate-750 text-slate-300 rounded-xl text-xs font-semibold transition" type="button") Cancel
button#btn-save(class="px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white rounded-xl text-xs font-bold" type="submit") Save
// Right Panel: Debug JSON
div#debug-panel(class="hidden w-full md:w-[500px] border-t md:border-t-0 md:border-l border-slate-800 flex flex-col bg-slate-950/40")
div(class="px-6 py-4 border-b border-slate-800 flex justify-between items-center bg-slate-900")
h4(class="text-sm font-bold text-slate-200") Character Sheet JSON Debugger
button#btn-copy-json(class="text-xs bg-slate-800 hover:bg-slate-700 text-slate-300 px-2.5 py-1 rounded border border-slate-700 transition") Copy JSON
div(class="p-4 flex-1 overflow-auto max-h-[300px] md:max-h-[400px]")
pre#debug-json-content(class="text-[10px] font-mono text-slate-300 whitespace-pre-wrap selection:bg-violet-900/50")
+501
View File
@@ -0,0 +1,501 @@
import './style.css';
import template from './index.pug';
document.querySelector<HTMLDivElement>('#app')!.innerHTML = template;
interface Actor {
_id: string;
name: string;
type: string;
system: {
attributes?: {
hp?: { value: number; max: number; };
};
details?: {
level?: number;
};
level?: number;
};
}
interface World {
id: string;
title: string;
active: boolean;
}
let actors: Actor[] = [];
let worlds: World[] = [];
let currentFilter = 'all';
let searchQuery = '';
let activeActor: Actor | null = null;
const API_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:30001';
// DOM Selectors - Worlds Configuration
const worldSelector = document.getElementById('world-selector') as HTMLSelectElement;
const worldUser = document.getElementById('world-user') as HTMLInputElement;
const worldPass = document.getElementById('world-pass') as HTMLInputElement;
const btnLaunch = document.getElementById('btn-launch') as HTMLButtonElement;
const btnShutdown = document.getElementById('btn-shutdown') as HTMLButtonElement;
const btnSelect = document.getElementById('btn-select') as HTMLButtonElement;
// DOM Selectors - Package Management
const packageManifest = document.getElementById('package-manifest') as HTMLInputElement;
const packageType = document.getElementById('package-type') as HTMLSelectElement;
const btnUpdatePackage = document.getElementById('btn-update-package') as HTMLButtonElement;
const packageStatus = document.getElementById('package-status')!;
// DOM Selectors - Status Banner
const activeWorldName = document.getElementById('active-world-name')!;
const connectionDot = document.getElementById('connection-dot')!;
const connectionStatus = document.getElementById('connection-status')!;
const activeWorldUser = document.getElementById('active-world-user')!;
const activeWorldSystem = document.getElementById('active-world-system')!;
// DOM Selectors - Actors
const grid = document.getElementById('actors-grid')!;
const searchInput = document.getElementById('search-input') as HTMLInputElement;
const btnRefresh = document.getElementById('btn-refresh')!;
const btnSyncFoundry = document.getElementById('btn-sync-foundry')!;
const filters = document.querySelectorAll('.btn-filter');
const modal = document.getElementById('edit-modal')!;
const modalClose = document.getElementById('modal-close')!;
const editForm = document.getElementById('edit-form') as HTMLFormElement;
const btnCancel = document.getElementById('btn-cancel')!;
const modalContainer = document.getElementById('modal-container')!;
const btnDebugJson = document.getElementById('btn-debug-json')!;
const debugPanel = document.getElementById('debug-panel')!;
const debugJsonContent = document.getElementById('debug-json-content')!;
const btnCopyJson = document.getElementById('btn-copy-json')!;
// Metrics Selectors
const metricPlayers = document.getElementById('metric-players')!;
const metricNPCs = document.getElementById('metric-npcs')!;
// Fetch worlds and configuration
async function loadWorlds() {
try {
const resActive = await fetch(`${API_URL}/api/worlds/active`);
const activeInfo = await resActive.json();
const resWorlds = await fetch(`${API_URL}/api/worlds`);
if (!resWorlds.ok) {
const errorMsg = await resWorlds.json();
throw new Error(errorMsg.error || 'Setup offline');
}
worlds = await resWorlds.json();
// Update active banner
if (activeInfo.world) {
activeWorldName.textContent = activeInfo.worldTitle || activeInfo.world;
activeWorldSystem.textContent = `System: ${activeInfo.system || 'Unknown'}`;
activeWorldUser.textContent = `User: ${activeInfo.user}`;
connectionStatus.textContent = activeInfo.connected ? 'Connected' : 'Connecting...';
connectionDot.className = `w-2.5 h-2.5 rounded-full ${activeInfo.connected ? 'bg-emerald-500 animate-pulse' : 'bg-emerald-500 animate-pulse'}`;
} else {
activeWorldName.textContent = 'None (Setup Menu)';
activeWorldSystem.textContent = 'System: -';
activeWorldUser.textContent = '-';
connectionStatus.textContent = 'Offline';
connectionDot.className = 'w-2.5 h-2.5 rounded-full bg-slate-500';
}
// Populate Selector Dropdown
worldSelector.innerHTML = '';
if (worlds.length === 0) {
worldSelector.innerHTML = '<option value="" disabled>No worlds installed</option>';
} else {
worlds.forEach(w => {
const option = document.createElement('option');
option.value = w.id;
option.textContent = w.title + (w.active ? ' (Active)' : '');
worldSelector.appendChild(option);
});
}
// Select active world in dropdown if any, otherwise select first
const active = worlds.find(w => w.active);
if (active) {
worldSelector.value = active.id;
} else if (activeInfo.world) {
worldSelector.value = activeInfo.world;
}
updateWorldButtons();
} catch (err: any) {
console.error('Failed to load worlds:', err);
activeWorldName.textContent = 'Setup Locked / Offline';
connectionStatus.textContent = err.message || 'Check Server';
connectionDot.className = 'w-2.5 h-2.5 rounded-full bg-red-500';
}
}
function updateWorldButtons() {
const selectedId = worldSelector.value;
const selectedWorld = worlds.find(w => w.id === selectedId);
if (!selectedWorld) {
btnLaunch.disabled = true;
btnShutdown.disabled = true;
return;
}
if (selectedWorld.active) {
btnLaunch.disabled = true;
btnLaunch.classList.add('opacity-50');
btnShutdown.disabled = false;
btnShutdown.classList.remove('opacity-50');
} else {
btnLaunch.disabled = false;
btnLaunch.classList.remove('opacity-50');
btnShutdown.disabled = true;
btnShutdown.classList.add('opacity-50');
}
}
// Fetch actors data
async function loadData() {
try {
const res = await fetch(`${API_URL}/api/actors`);
actors = await res.json();
updateMetrics();
renderGrid();
} catch (err) {
console.error('Failed to load actors:', err);
}
}
function updateMetrics() {
metricPlayers.textContent = String(actors.filter(a => a.type === 'character').length);
metricNPCs.textContent = String(actors.filter(a => a.type === 'npc').length);
}
function renderGrid() {
grid.innerHTML = '';
const filtered = actors.filter(actor => {
const matchesFilter = currentFilter === 'all' || actor.type === currentFilter;
const matchesSearch = actor.name.toLowerCase().includes(searchQuery.toLowerCase());
return matchesFilter && matchesSearch;
});
if (filtered.length === 0) {
grid.innerHTML = `
<div class="col-span-full py-16 text-center text-slate-500 text-sm bg-slate-900/10 border border-dashed border-slate-800 rounded-2xl">
No actors synced for this world yet. Ensure the world is active and proxy is connected.
</div>
`;
return;
}
filtered.forEach(actor => {
const hpVal = actor.system?.attributes?.hp?.value ?? 0;
const hpMax = actor.system?.attributes?.hp?.max ?? 0;
const hpPct = hpMax > 0 ? Math.min(100, Math.max(0, (hpVal / hpMax) * 100)) : 0;
const level = actor.system?.details?.level ?? actor.system?.level ?? 0;
let barColor = 'bg-violet-500';
if (actor.type === 'npc') {
barColor = 'bg-fuchsia-500';
}
const card = document.createElement('div');
card.className = 'bg-slate-900/30 backdrop-blur-md border border-slate-850 hover:border-slate-700 p-6 rounded-2xl flex flex-col justify-between transition group shadow-lg';
card.innerHTML = `
<div>
<div class="flex justify-between items-start mb-4">
<span class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${
actor.type === 'character' ? 'bg-indigo-500/15 text-indigo-400' : 'bg-fuchsia-500/15 text-fuchsia-400'
}">${actor.type}</span>
<span class="text-xs font-bold text-slate-400 bg-slate-950 px-2 py-0.5 rounded border border-slate-850">Lvl ${level}</span>
</div>
<h4 class="text-lg font-bold text-slate-100 group-hover:text-violet-400 transition mb-2">${actor.name}</h4>
<!-- HP Bar -->
<div class="mt-4">
<div class="flex justify-between text-xs font-medium text-slate-400 mb-1">
<span>HP</span>
<span>${hpVal} / ${hpMax}</span>
</div>
<div class="w-full bg-slate-950 rounded-full h-1.5 overflow-hidden border border-slate-850">
<div class="h-full rounded-full ${barColor}" style="width: ${hpPct}%"></div>
</div>
</div>
</div>
<button class="btn-edit mt-6 w-full py-2 bg-slate-950 hover:bg-violet-900/20 hover:text-violet-400 border border-slate-800 hover:border-violet-800/40 rounded-xl text-xs font-semibold text-slate-300 transition" data-id="${actor._id}">
Edit Sheet
</button>
`;
card.querySelector('.btn-edit')!.addEventListener('click', () => openEditModal(actor));
grid.appendChild(card);
});
}
function openEditModal(actor: Actor) {
activeActor = actor;
const idInput = document.getElementById('edit-id') as HTMLInputElement;
const nameInput = document.getElementById('edit-name') as HTMLInputElement;
const hpValInput = document.getElementById('edit-hp-value') as HTMLInputElement;
const hpMaxInput = document.getElementById('edit-hp-max') as HTMLInputElement;
const levelInput = document.getElementById('edit-level') as HTMLInputElement;
idInput.value = actor._id;
nameInput.value = actor.name;
hpValInput.value = String(actor.system?.attributes?.hp?.value ?? 0);
hpMaxInput.value = String(actor.system?.attributes?.hp?.max ?? 0);
levelInput.value = String(actor.system?.details?.level ?? actor.system?.level ?? 0);
// If JSON debugger was open, update its content
if (!debugPanel.classList.contains('hidden') && activeActor) {
debugJsonContent.textContent = JSON.stringify(activeActor, null, 2);
}
modal.classList.remove('hidden');
}
function closeEditModal() {
modal.classList.add('hidden');
debugPanel.classList.add('hidden');
modalContainer.classList.add('max-w-md');
modalContainer.classList.remove('md:max-w-4xl');
editForm.reset();
activeActor = null;
}
// World Management Actions
worldSelector.addEventListener('change', updateWorldButtons);
btnLaunch.addEventListener('click', async () => {
const worldId = worldSelector.value;
if (!worldId) return;
btnLaunch.disabled = true;
btnLaunch.textContent = 'Launching...';
try {
const res = await fetch(`${API_URL}/api/worlds/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ worldId })
});
if (res.ok) {
console.log(`World ${worldId} launched successfully.`);
}
} catch (err) {
console.error('Failed to launch world:', err);
} finally {
btnLaunch.textContent = 'Launch World';
await loadWorlds();
setTimeout(loadData, 2000);
}
});
btnShutdown.addEventListener('click', async () => {
btnShutdown.disabled = true;
btnShutdown.textContent = 'Stopping...';
try {
const res = await fetch(`${API_URL}/api/worlds/shutdown`, {
method: 'POST'
});
if (res.ok) {
console.log('Active world stopped.');
}
} catch (err) {
console.error('Failed to stop world:', err);
} finally {
btnShutdown.textContent = 'Shutdown World';
await loadWorlds();
actors = [];
renderGrid();
updateMetrics();
}
});
btnSelect.addEventListener('click', async () => {
const worldId = worldSelector.value;
if (!worldId) return;
btnSelect.disabled = true;
btnSelect.textContent = 'Connecting...';
try {
const res = await fetch(`${API_URL}/api/worlds/select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worldId,
user: worldUser.value,
password: worldPass.value
})
});
if (res.ok) {
console.log(`Switched target world to ${worldId}`);
}
} catch (err) {
console.error('Failed to select world:', err);
} finally {
btnSelect.textContent = 'Connect & Sync';
await loadWorlds();
setTimeout(loadData, 2000);
}
});
// Refresh, Filters and Edit submits
btnRefresh.addEventListener('click', async () => {
await loadWorlds();
await loadData();
});
btnSyncFoundry.addEventListener('click', async () => {
btnSyncFoundry.disabled = true;
const originalText = btnSyncFoundry.textContent;
btnSyncFoundry.textContent = 'Syncing...';
try {
const res = await fetch(`${API_URL}/api/actors/refetch`, { method: 'POST' });
if (res.ok) {
actors = await res.json();
updateMetrics();
renderGrid();
} else {
console.error('Failed to refetch actors from backend');
}
} catch (err) {
console.error('Error on refetch:', err);
} finally {
btnSyncFoundry.disabled = false;
btnSyncFoundry.textContent = originalText;
}
});
modalClose.addEventListener('click', closeEditModal);
btnCancel.addEventListener('click', closeEditModal);
btnDebugJson.addEventListener('click', () => {
if (debugPanel.classList.contains('hidden')) {
debugPanel.classList.remove('hidden');
modalContainer.classList.remove('max-w-md');
modalContainer.classList.add('md:max-w-4xl');
if (activeActor) {
debugJsonContent.textContent = JSON.stringify(activeActor, null, 2);
}
} else {
debugPanel.classList.add('hidden');
modalContainer.classList.add('max-w-md');
modalContainer.classList.remove('md:max-w-4xl');
}
});
btnCopyJson.addEventListener('click', () => {
if (activeActor) {
navigator.clipboard.writeText(JSON.stringify(activeActor, null, 2));
btnCopyJson.textContent = 'Copied!';
setTimeout(() => {
btnCopyJson.textContent = 'Copy JSON';
}, 2000);
}
});
searchInput.addEventListener('input', (e) => {
searchQuery = (e.target as HTMLInputElement).value;
renderGrid();
});
filters.forEach(btn => {
btn.addEventListener('click', (e) => {
filters.forEach(b => {
b.classList.remove('bg-violet-600', 'text-white');
b.classList.add('bg-slate-900', 'border-slate-800', 'text-slate-400');
});
const target = e.target as HTMLButtonElement;
target.classList.remove('bg-slate-900', 'border-slate-800', 'text-slate-400');
target.classList.add('bg-violet-600', 'text-white');
currentFilter = target.dataset.filter!;
renderGrid();
});
});
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const id = (document.getElementById('edit-id') as HTMLInputElement).value;
const name = (document.getElementById('edit-name') as HTMLInputElement).value;
const hpVal = parseInt((document.getElementById('edit-hp-value') as HTMLInputElement).value, 10);
const hpMax = parseInt((document.getElementById('edit-hp-max') as HTMLInputElement).value, 10);
const level = parseInt((document.getElementById('edit-level') as HTMLInputElement).value, 10);
const actor = actors.find(a => a._id === id);
if (!actor) return;
const updates: any = {
name: name,
system: {
attributes: {
hp: { value: hpVal, max: hpMax }
},
details: {
level: level
}
}
};
try {
const res = await fetch(`${API_URL}/api/actors/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, updates })
});
if (res.ok) {
closeEditModal();
loadData();
}
} catch (err) {
console.error('Updating failed', err);
}
});
btnUpdatePackage.addEventListener('click', async () => {
const manifest = packageManifest.value.trim();
const type = packageType.value;
if (!manifest) {
packageStatus.textContent = 'Error: Manifest URL is required.';
packageStatus.className = 'text-[11px] text-red-400 mt-1 text-center min-h-[16px]';
return;
}
btnUpdatePackage.disabled = true;
btnUpdatePackage.textContent = 'Updating...';
packageStatus.textContent = 'Initiating update...';
packageStatus.className = 'text-[11px] text-slate-400 mt-1 text-center min-h-[16px]';
try {
const res = await fetch(`${API_URL}/api/packages/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ manifest, type })
});
const result = await res.json();
if (res.ok && result.success) {
packageStatus.textContent = '✓ Package updated successfully!';
packageStatus.className = 'text-[11px] text-emerald-400 mt-1 text-center min-h-[16px] font-bold';
} else {
packageStatus.textContent = `Error: ${result.error || 'Update failed'}`;
packageStatus.className = 'text-[11px] text-red-400 mt-1 text-center min-h-[16px]';
}
} catch (err: any) {
console.error('Package update failed:', err);
packageStatus.textContent = `Error: ${err.message || 'Connection error'}`;
packageStatus.className = 'text-[11px] text-red-400 mt-1 text-center min-h-[16px]';
} finally {
btnUpdatePackage.disabled = false;
btnUpdatePackage.textContent = 'Update Package';
await loadWorlds();
}
});
// Initial startup
(async () => {
await loadWorlds();
await loadData();
})();
+4
View File
@@ -0,0 +1,4 @@
declare module '*.pug' {
const content: string;
export default content;
}
+7
View File
@@ -0,0 +1,7 @@
@import "tailwindcss";
@layer base {
body {
@apply antialiased selection:bg-violet-500/30;
}
}