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:
@@ -0,0 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json tsconfig.json vite.config.ts index.html ./
|
||||
RUN npm install
|
||||
COPY src ./src
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark bg-slate-950 text-slate-100">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Foundry VTT Admin Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "foundryvtt-admin-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4.0.0-alpha.25",
|
||||
"@tailwindcss/vite": "^4.0.0-alpha.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"pug": "^3.0.2"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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();
|
||||
})();
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.pug' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply antialiased selection:bg-violet-500/30;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import pug from 'pug';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
{
|
||||
name: 'vite-plugin-pug',
|
||||
transform(src, id) {
|
||||
if (id.endsWith('.pug')) {
|
||||
const html = pug.compile(src)();
|
||||
return {
|
||||
code: `export default ${JSON.stringify(html)};`,
|
||||
map: null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user