<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<title>RPG Ledger MCP UI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #020617;
color: #e5e7eb;
display: flex;
height: 100vh;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px 24px;
box-sizing: border-box;
overflow: hidden;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.topbar-title {
font-size: 18px;
font-weight: 600;
}
.topbar select {
width: auto;
min-width: 200px;
margin-bottom: 0;
}
h1 {
font-size: 18px;
margin: 0 0 12px;
}
h2, h3 {
margin-top: 0;
}
select {
width: 100%;
padding: 6px 8px;
margin-bottom: 12px;
background: #020617;
color: #e5e7eb;
border: 1px solid #1f2937;
border-radius: 4px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 9999px;
background: #1d4ed8;
font-size: 11px;
margin-right: 6px;
}
.card {
background: #020617;
border-radius: 12px;
border: 1px solid #1f2937;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 10px 25px rgba(15,23,42,0.6);
}
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.muted {
color: #9ca3af;
font-size: 13px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
gap: 8px;
margin: 8px 0 12px;
}
.stat-box {
background: #020617;
border-radius: 8px;
border: 1px solid #1f2937;
padding: 6px 8px;
text-align: center;
font-size: 12px;
}
.stat-label {
font-size: 11px;
color: #9ca3af;
}
ul {
margin: 6px 0;
padding-left: 20px;
font-size: 14px;
}
.gold {
font-weight: 600;
color: #fbbf24;
}
.notes {
white-space: pre-wrap;
font-size: 13px;
color: #d1d5db;
}
.layout-top {
display: flex;
gap: 16px;
flex: 1;
overflow: hidden;
}
.column {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.logs {
flex: 0.9;
overflow-y: auto;
margin-top: 8px;
}
.worldmap-diagram {
margin-top: 12px;
background: #020617;
border-radius: 8px;
border: 1px solid #1f2937;
padding: 8px;
}
.worldmap-svg {
width: 100%;
max-height: 220px;
}
.worldmap-node {
cursor: pointer;
}
.log-entry {
font-size: 12px;
border-bottom: 1px solid #1f2937;
padding: 4px 0;
}
.log-op {
font-weight: 600;
}
.log-ts {
color: #6b7280;
font-size: 11px;
margin-right: 4px;
}
.pill {
display: inline-block;
padding: 1px 6px;
border-radius: 9999px;
border: 1px solid #374151;
font-size: 11px;
background: #111827;
margin-right: 4px;
}
.party-card-wrapper {
margin-top: 12px;
}
.party-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 8px;
}
.character-card {
background: #020617;
border-radius: 10px;
border: 1px solid #1f2937;
padding: 10px 12px;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
}
.character-card:hover {
border-color: #2563eb;
box-shadow: 0 10px 25px rgba(37,99,235,0.35);
transform: translateY(-1px);
}
.character-card.selected {
border-color: #22c55e;
box-shadow: 0 0 0 1px rgba(34,197,94,0.5);
}
.character-name-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 6px;
margin-bottom: 4px;
}
.character-name-row h3 {
margin: 0;
font-size: 15px;
}
.character-subline {
font-size: 12px;
color: #9ca3af;
margin-bottom: 6px;
}
.bar-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 6px;
}
.bar-label {
font-size: 11px;
color: #9ca3af;
}
.bar-track {
position: relative;
height: 6px;
border-radius: 9999px;
background: #111827;
overflow: hidden;
}
.bar-fill-hp {
position: absolute;
inset: 0;
background: linear-gradient(90deg, #22c55e, #16a34a);
}
.bar-fill-xp {
position: absolute;
inset: 0;
background: linear-gradient(90deg, #38bdf8, #0ea5e9);
}
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.quick-btn {
font-size: 11px;
padding: 3px 6px;
border-radius: 6px;
border: 1px solid #374151;
background: #020617;
color: #e5e7eb;
cursor: pointer;
}
.quick-btn:hover {
border-color: #60a5fa;
background: #111827;
}
.layout-panel {
flex: 0.9;
display: flex;
flex-direction: column;
min-width: 320px;
max-width: 420px;
}
.tabs {
display: inline-flex;
border-radius: 9999px;
border: 1px solid #1f2937;
background: #020617;
padding: 2px;
}
.tab-btn {
border: none;
background: transparent;
color: #9ca3af;
font-size: 12px;
padding: 4px 10px;
border-radius: 9999px;
cursor: pointer;
}
.tab-btn.active {
background: #111827;
color: #e5e7eb;
}
.todo-entry {
font-size: 12px;
border-bottom: 1px solid #1f2937;
padding: 6px 0;
}
.todo-tags {
font-size: 11px;
color: #9ca3af;
margin-top: 2px;
}
.todo-controls {
margin-top: 4px;
display: flex;
gap: 4px;
}
.todo-controls input {
flex: 1;
min-width: 0;
font-size: 11px;
padding: 2px 4px;
background: #020617;
border-radius: 4px;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.todo-controls button {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #374151;
background: #020617;
color: #e5e7eb;
cursor: pointer;
}
.todo-controls button:hover {
border-color: #22c55e;
background: #111827;
}
.todo-done {
color: #22c55e;
}
.todo-open {
color: #fbbf24;
}
.campaign-summary-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 16px;
}
.character-detail {
margin-top: 12px;
}
.detail-section-title {
font-size: 13px;
font-weight: 600;
margin: 8px 0 4px;
}
.inventory-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 4px;
}
.inventory-table th,
.inventory-table td {
border-bottom: 1px solid #1f2937;
padding: 2px 4px;
text-align: left;
}
.inventory-table th {
color: #9ca3af;
font-weight: 500;
font-size: 11px;
}
.inventory-table td.qty {
width: 40px;
}
@media (max-width: 900px) {
body {
flex-direction: column;
height: auto;
}
.layout-top {
flex-direction: column;
}
.layout-panel {
max-width: none;
}
}
</style>
</head>
<body>
<div class="main">
<div class="topbar">
<div class="topbar-title">RPG Ledger MCP</div>
<div>
<label for="campaignSelect" class="muted" style="margin-right:6px;">Kampania:</label>
<select id="campaignSelect"></select>
</div>
</div>
<div class="layout-top">
<div class="column">
<div class="tabs" style="margin-bottom:8px;">
<button id="mainTabParty" class="tab-btn active" type="button">Drużyna</button>
<button id="mainTabMap" class="tab-btn" type="button">Mapa</button>
<button id="mainTabFactions" class="tab-btn" type="button">Frakcje</button>
</div>
<div id="campaignSummary" class="card">
<div class="muted">Wczytywanie kampanii...</div>
</div>
<div id="worldMap"></div>
<div id="factionsPanel" class="card" style="margin-top:12px; display:none;">
<div class="card-header-row" style="margin-bottom:8px;">
<div>
<div class="badge">Frakcje</div>
<h2>Frakcje kampanii</h2>
</div>
</div>
<div id="factionsContent" class="logs"></div>
</div>
<div id="partyWrapper" class="card party-card-wrapper">
<div class="card-header-row">
<div>
<div class="badge">Drużyna</div>
<h2>Postacie</h2>
</div>
</div>
<div id="partyGrid" class="party-grid"></div>
</div>
<div id="characterDetail" class="card character-detail"></div>
</div>
<div class="column layout-panel">
<div class="card">
<div class="card-header-row" style="margin-bottom:8px;">
<div>
<div class="badge">Panel</div>
<h2>Logi / TODO</h2>
</div>
<div class="tabs">
<button id="tabLogs" class="tab-btn active" type="button">Log MCP</button>
<button id="tabTodos" class="tab-btn" type="button">TODOs</button>
</div>
</div>
<p id="panelSubtitle" class="muted" style="margin:0 0 8px;">
Ostatnie mutacje wykonywane przez agenta / MCP.
</p>
<div id="logs" class="logs"></div>
<div id="todos" class="logs" style="display:none;"></div>
</div>
</div>
</div>
</div>
<script>
const campaignSelect = document.getElementById("campaignSelect");
const campaignSummaryEl = document.getElementById("campaignSummary");
const worldMapEl = document.getElementById("worldMap");
const partyWrapperEl = document.getElementById("partyWrapper");
const partyGridEl = document.getElementById("partyGrid");
const characterDetailEl = document.getElementById("characterDetail");
const factionsPanelEl = document.getElementById("factionsPanel");
const factionsContentEl = document.getElementById("factionsContent");
const logsEl = document.getElementById("logs");
const todosEl = document.getElementById("todos");
const tabLogsBtn = document.getElementById("tabLogs");
const tabTodosBtn = document.getElementById("tabTodos");
const panelSubtitleEl = document.getElementById("panelSubtitle");
const mainTabPartyBtn = document.getElementById("mainTabParty");
const mainTabMapBtn = document.getElementById("mainTabMap");
const mainTabFactionsBtn = document.getElementById("mainTabFactions");
let currentCampaign = null;
let selectedCharacterId = null;
let currentPanelTab = "logs";
let currentMainView = "party";
async function loadCampaigns() {
const res = await fetch("/api/campaigns");
const campaigns = await res.json();
campaignSelect.innerHTML = "";
campaigns.forEach((c) => {
const opt = document.createElement("option");
opt.value = c.id;
opt.textContent = c.name;
campaignSelect.appendChild(opt);
});
if (!campaigns.length) {
currentCampaign = null;
campaignSummaryEl.innerHTML =
"<p class='muted'>Brak dostępnych kampanii.</p>";
worldMapEl.innerHTML = "";
partyGridEl.innerHTML = "";
characterDetailEl.innerHTML = "";
return;
}
let initialId = campaigns[0].id;
try {
const storedId = window.localStorage
? localStorage.getItem("rpgLedgerLastCampaignId")
: null;
if (storedId && campaigns.some((c) => c.id === storedId)) {
initialId = storedId;
}
} catch (e) {
console.warn("failed to read last campaign from storage", e);
}
campaignSelect.value = initialId;
await loadCampaign(initialId);
}
async function loadCampaign(id) {
const res = await fetch(`/api/campaigns/${id}`);
currentCampaign = await res.json();
renderCampaignSummary(currentCampaign);
renderWorldMap(currentCampaign);
renderParty(currentCampaign);
await loadFactions();
updateMainViewVisibility();
await loadFactions();
const chars = currentCampaign.characters || [];
if (chars.length) {
selectedCharacterId = chars[0].id;
renderCharacterDetail(chars[0]);
} else {
characterDetailEl.innerHTML =
"<p class='muted'>Brak postaci w tej kampanii.</p>";
}
}
function updateMainViewVisibility() {
if (currentMainView === "party") {
if (partyWrapperEl) partyWrapperEl.style.display = "";
characterDetailEl.style.display = "";
worldMapEl.style.display = "none";
factionsPanelEl.style.display = "none";
} else if (currentMainView === "map") {
if (partyWrapperEl) partyWrapperEl.style.display = "none";
characterDetailEl.style.display = "none";
worldMapEl.style.display = "";
// frakcje zostaną odświeżone przy przełączeniu na zakładkę
factionsPanelEl.style.display = "none";
} else if (currentMainView === "factions") {
if (partyWrapperEl) partyWrapperEl.style.display = "none";
characterDetailEl.style.display = "none";
worldMapEl.style.display = "none";
factionsPanelEl.style.display = "";
}
}
async function loadFactions() {
if (!currentCampaign || !currentCampaign.id) {
factionsPanelEl.style.display = "none";
factionsContentEl.innerHTML = "";
return;
}
try {
const res = await fetch(`/api/factions/${currentCampaign.id}`);
if (!res.ok) throw new Error("failed to load factions");
const factions = await res.json();
renderFactions(factions || []);
} catch (e) {
console.error("failed to load factions", e);
factionsPanelEl.style.display = "none";
factionsContentEl.innerHTML = "";
}
}
function renderFactions(factions) {
if (!Array.isArray(factions) || !factions.length) {
if (currentMainView === "factions") {
factionsPanelEl.style.display = "";
} else {
factionsPanelEl.style.display = "none";
}
factionsContentEl.innerHTML =
"<p class='muted'>Brak zdefiniowanych frakcji w tej kampanii.</p>" +
`<div style="margin-top:8px; font-size:12px;" class="muted">Użyj AI/MG lub MCP, aby dodać pierwszą frakcję.</div>`;
return;
}
if (currentMainView === "factions") {
factionsPanelEl.style.display = "";
} else {
factionsPanelEl.style.display = "none";
}
const rows = factions
.map((f) => {
const id = f.id || "";
const name = f.name || id || "";
const rep = f.rep ?? 0;
const desc = f.description || "";
return `
<tr>
<td><span class="muted" style="font-size:11px;">${id}</span><br />${name}</td>
<td style="text-align:center;">${rep}</td>
<td>${desc ? `<span class="muted" style="font-size:12px;">${desc}</span>` : ""}</td>
<td style="text-align:center;">
<button class="quick-btn" data-faction-id="${id}" data-rep-delta="-5">-5</button>
<button class="quick-btn" data-faction-id="${id}" data-rep-delta="-1">-1</button>
<button class="quick-btn" data-faction-id="${id}" data-rep-delta="1">+1</button>
<button class="quick-btn" data-faction-id="${id}" data-rep-delta="5">+5</button>
</td>
</tr>
`;
})
.join("");
const tableHtml = `
<table class="inventory-table" style="margin-top:4px;">
<thead>
<tr>
<th>Frakcja</th>
<th style="width:60px; text-align:center;">Rep</th>
<th>Opis</th>
<th style="width:150px; text-align:center;">Zmiana</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
const addFormHtml = `
<div style="margin-top:10px; font-size:12px;">
<div class="detail-section-title">Dodaj frakcję</div>
<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">
<input id="factionIdInput" type="text" placeholder="id frakcji" style="flex:1; min-width:120px; font-size:12px; padding:4px; background:#020617; border-radius:4px; border:1px solid #1f2937; color:#e5e7eb;" />
<input id="factionNameInput" type="text" placeholder="Nazwa frakcji" style="flex:1.5; min-width:140px; font-size:12px; padding:4px; background:#020617; border-radius:4px; border:1px solid #1f2937; color:#e5e7eb;" />
<input id="factionRepInput" type="number" placeholder="Rep (np. 0)" style="width:80px; font-size:12px; padding:4px; background:#020617; border-radius:4px; border:1px solid #1f2937; color:#e5e7eb;" />
<button id="factionAddBtn" type="button" class="quick-btn">Dodaj</button>
</div>
</div>
`;
factionsContentEl.innerHTML = tableHtml + addFormHtml;
}
function renderCampaignSummary(c) {
campaignSummaryEl.innerHTML = `
<div class="campaign-summary-grid">
<div>
<div class="badge">Kampania</div>
<h2>${c.name}</h2>
${c.notes ? `<p class="notes" style="margin-top:8px;">${c.notes}</p>` : ""}
</div>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-label">Dzień</div>
<div>${c.day ?? "-"}</div>
</div>
<div class="stat-box">
<div class="stat-label">Lokacja</div>
<div>${c.location ?? "-"}</div>
</div>
</div>
</div>
`;
}
function renderWorldMap(campaign) {
const locations = campaign.locations || [];
const currentName = campaign.location || "";
const currentId = (campaign.current_location_id || "").toString();
const connections = campaign.connections || [];
if (!locations.length && !currentName) {
worldMapEl.innerHTML = "";
return;
}
const rows = locations.length
? locations
: [{
id: currentName || "place",
name: currentName || "Nieznane miejsce",
type: "place",
tags: [],
description: ""
}];
const rowsHtml = rows
.map((loc) => {
const name = loc.name || loc.id;
const type = loc.type || "place";
const tags = loc.tags || [];
const isCurrent =
(loc.id && loc.id === currentId) || name === currentName;
const tagsHtml = tags
.map((t) => `<span class="pill">${t}</span>`)
.join(" ");
return `
<div class="log-entry">
<div>
<span class="muted">${type}</span>
<strong style="margin-left:4px;">${name}</strong>
${tagsHtml}
</div>
<div style="margin-top:4px; display:flex; align-items:center; gap:8px; font-size:12px;">
${
isCurrent
? `<span class="pill">Tu jesteście</span>`
: `<button class="quick-btn" data-location-name="${name}">Podróż tutaj</button>`
}
${loc.description ? `<span class="muted">${loc.description}</span>` : ""}
</div>
</div>
`;
})
.join("");
// Prosty \"schemat\" mapy w SVG: lokacje na okręgu + krawędzie między nimi.
const nodes = [];
const centerX = 150;
const centerY = 90;
const radius = 60;
const count = rows.length || 1;
rows.forEach((loc, idx) => {
const angle = (2 * Math.PI * idx) / count;
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
const key = String(loc.id || loc.name || idx);
nodes.push({ loc, key, x, y });
});
const nodeById = {};
nodes.forEach((n) => {
if (n.loc.id) {
nodeById[String(n.loc.id)] = n;
}
});
let edgesSvg = "";
if (connections.length) {
connections.forEach((conn) => {
const a = String(conn.from || "");
const b = String(conn.to || "");
const na = nodeById[a];
const nb = nodeById[b];
if (!na || !nb) return;
edgesSvg += `<line x1="${na.x}" y1="${na.y}" x2="${nb.x}" y2="${nb.y}" />`;
});
}
const nodesSvg = nodes
.map((n) => {
const name = n.loc.name || n.loc.id || "";
const isCurrentNode =
(n.loc.id && n.loc.id === currentId) || name === currentName;
const fill = isCurrentNode ? "#22c55e" : "#111827";
const stroke = isCurrentNode ? "#bbf7d0" : "#4b5563";
return `
<g class="worldmap-node" data-location-name="${name}">
<circle cx="${n.x}" cy="${n.y}" r="10" fill="${fill}" stroke="${stroke}" stroke-width="1.5" />
<text x="${n.x}" y="${n.y + 20}" text-anchor="middle" font-size="9" fill="#e5e7eb">
${name}
</text>
</g>
`;
})
.join("");
const diagramHtml = `
<div class="worldmap-diagram">
<svg viewBox="0 0 300 200" class="worldmap-svg">
<g stroke="#374151" stroke-width="1.2">
${edgesSvg}
</g>
${nodesSvg}
</svg>
</div>
`;
let connectionsHtml = "";
if (connections.length && locations.length) {
const byId = {};
for (const loc of locations) {
if (!loc.id) continue;
byId[String(loc.id)] = loc;
}
const items = connections
.map((conn) => {
const a = String(conn.from || "");
const b = String(conn.to || "");
if (!a || !b || !byId[a] || !byId[b]) return "";
const nameA = byId[a].name || a;
const nameB = byId[b].name || b;
const dist =
conn.distance != null && conn.distance !== ""
? ` \u2013 dystans ${conn.distance}`
: "";
const type = conn.type ? ` (${conn.type})` : "";
return `<div class="muted" style="font-size:12px;">${nameA} \u2192 ${nameB}${type}${dist}</div>`;
})
.filter(Boolean)
.join("");
if (items) {
connectionsHtml = `
<div style="margin-top:8px;">
<div class="detail-section-title">Połączenia</div>
${items}
</div>
`;
}
}
worldMapEl.innerHTML = `
<div class="card">
<div class="card-header-row">
<div>
<div class="badge">Mapa świata</div>
<p class="muted" style="margin: 4px 0 0; font-size:12px;">
Lista znanych lokacji i aktualne położenie drużyny.
</p>
</div>
</div>
<div class="logs" style="margin-top:8px; max-height:220px;">
${rowsHtml}
</div>
${diagramHtml}
${connectionsHtml}
</div>
`;
if (currentMainView !== "map") {
worldMapEl.style.display = "none";
}
}
function renderParty(campaign) {
const characters = campaign.characters || [];
if (!characters.length) {
partyGridEl.innerHTML = "";
return;
}
const cardsHtml = characters
.map((ch) => {
const inv = ch.inventory || [];
const invPreview = inv
.slice(0, 3)
.map((item) =>
`${item.name}${item.qty && item.qty !== 1 ? ` (x${item.qty})` : ""}`
)
.join(", ");
const hp = ch.hp ?? 0;
const maxHp = ch.maxHp ?? ch.hp ?? 1;
const hpPercent = Math.max(0, Math.min(100, (hp / maxHp) * 100));
const xp = ch.xp ?? 0;
const xpPercent = Math.max(0, Math.min(100, (xp % 1000) / 10));
const alignment = ch.alignment || "";
const isSelected = ch.id === selectedCharacterId;
return `
<div class="character-card ${isSelected ? "selected" : ""}" data-char-id="${
ch.id
}">
<div class="character-name-row">
<h3>${ch.name}</h3>
<span class="muted">${ch.class || ""}${alignment ? ` · ${alignment}` : ""}</span>
</div>
<div class="character-subline">
Poziom ${ch.level ?? "?"} · <span class="gold">${ch.gold ?? 0} gp</span>
</div>
<div class="bar-row">
<div class="bar-label">HP: ${hp} / ${maxHp}</div>
<div class="bar-track">
<div class="bar-fill-hp" style="width:${hpPercent}%;"></div>
</div>
<div class="bar-label" style="margin-top:4px;">XP: ${xp}</div>
<div class="bar-track">
<div class="bar-fill-xp" style="width:${xpPercent}%;"></div>
</div>
</div>
<div class="muted" style="font-size:12px;">
Ekwipunek: ${invPreview || "brak"}
</div>
<div class="quick-actions">
<button class="quick-btn" data-action="hp_minus" data-char-id="${ch.id}">-5 HP</button>
<button class="quick-btn" data-action="hp_plus" data-char-id="${ch.id}">+5 HP</button>
<button class="quick-btn" data-action="gold_minus" data-char-id="${ch.id}">-5 gp</button>
<button class="quick-btn" data-action="gold_plus" data-char-id="${ch.id}">+5 gp</button>
<button class="quick-btn" data-action="loot" data-char-id="${ch.id}">Loot</button>
<button class="quick-btn" data-action="note" data-char-id="${ch.id}">Notatka</button>
</div>
</div>
`;
})
.join("");
partyGridEl.innerHTML = cardsHtml;
}
function renderCharacterDetail(ch) {
if (!ch) {
characterDetailEl.innerHTML = "";
return;
}
const stats = ch.stats || {};
const inventory = ch.inventory || [];
const alignment = ch.alignment || "";
const persona = ch.persona || "";
characterDetailEl.innerHTML = `
<div class="card">
<div class="card-header-row">
<div>
<div class="badge">Postać</div>
<h2>${ch.name}</h2>
</div>
</div>
<div class="stats-grid" style="margin-top:8px;">
<div class="stat-box">
<div class="stat-label">Klasa</div>
<div>${ch.class || "-"}</div>
</div>
<div class="stat-box">
<div class="stat-label">Poziom</div>
<div>${ch.level ?? "-"}</div>
</div>
<div class="stat-box">
<div class="stat-label">HP</div>
<div>${ch.hp ?? "-"}</div>
</div>
<div class="stat-box">
<div class="stat-label">Gold</div>
<div class="gold">${ch.gold ?? 0} gp</div>
</div>
</div>
${
alignment || persona
? `
<div>
<div class="detail-section-title">Charakter</div>
${
alignment
? `<p class="muted" style="margin:2px 0;">Alignment: <strong>${alignment}</strong></p>`
: ""
}
${
persona
? `<p class="notes" style="margin-top:4px;">${persona}</p>`
: ""
}
</div>
`
: ""
}
<div>
<div class="detail-section-title">Statystyki</div>
<div class="stats-grid">
<div class="stat-box"><div class="stat-label">STR</div><div>${stats.str ?? "-"}</div></div>
<div class="stat-box"><div class="stat-label">DEX</div><div>${stats.dex ?? "-"}</div></div>
<div class="stat-box"><div class="stat-label">INT</div><div>${stats.int ?? "-"}</div></div>
<div class="stat-box"><div class="stat-label">WIS</div><div>${stats.wis ?? "-"}</div></div>
<div class="stat-box"><div class="stat-label">CHA</div><div>${stats.cha ?? "-"}</div></div>
</div>
</div>
<div>
<div class="detail-section-title">Ekwipunek</div>
${
inventory.length
? `
<table class="inventory-table">
<thead>
<tr><th>Nazwa</th><th class="qty">Ilość</th></tr>
</thead>
<tbody>
${inventory
.map(
(item) =>
`<tr><td>${item.name}</td><td class="qty">${item.qty ?? 1}</td></tr>`
)
.join("")}
</tbody>
</table>
`
: `<p class="muted">Brak przedmiotów.</p>`
}
</div>
${
ch.notes
? `
<div>
<div class="detail-section-title">Notatki</div>
<p class="notes">${ch.notes}</p>
</div>
`
: ""
}
</div>
`;
}
async function loadLogs() {
if (!currentCampaign || !currentCampaign.id) {
return;
}
let url = `/api/logs?campaign_id=${encodeURIComponent(
currentCampaign.id
)}`;
const res = await fetch(url);
const lines = await res.json();
const relevant = lines.filter(
(e) => e.type === "mutate" || e.type === "history"
);
let todos = [];
try {
const todosRes = await fetch("/api/dev-todos");
if (todosRes.ok) {
todos = await todosRes.json();
}
} catch (e) {
console.error("failed to load dev todos", e);
}
logsEl.innerHTML = relevant
.map((e) => {
const ts = e.ts || "";
const base = e.text || e.summary || "";
const op = e.op || e.type;
let details = base;
if (e.type === "mutate") {
const amount = e.amount;
const value = e.value;
if (op === "location_set") {
const locLabel =
(value && typeof value === "object" && (value.name || value.id)) ||
(typeof value === "string" && value) ||
"";
if (locLabel) {
details = `lokacja = ${locLabel}${base ? " · " + base : ""}`;
}
} else if (op === "hp_add" && typeof amount === "number") {
details = `HP ${amount >= 0 ? "+" + amount : amount}${base ? " · " + base : ""}`;
} else if (op === "gold_add" && typeof amount === "number") {
details = `gold ${amount >= 0 ? "+" + amount : amount}${base ? " · " + base : ""}`;
} else if (op === "xp_add" && typeof amount === "number") {
details = `XP ${amount >= 0 ? "+" + amount : amount}${base ? " · " + base : ""}`;
} else if ((op === "inventory_add" || op === "inventory_remove") && value) {
let itemLabel = "";
if (typeof value === "object") {
itemLabel = value.name || value.id || "";
const qty = value.qty;
if (qty != null) {
itemLabel += itemLabel ? ` (x${qty})` : `x${qty}`;
}
}
if (!itemLabel && typeof value === "string") {
itemLabel = value;
}
if (itemLabel) {
const verb = op === "inventory_add" ? "eq +" : "eq -";
details = `${verb} ${itemLabel}${base ? " · " + base : ""}`;
}
} else if (op === "delete_character") {
const cid = e.char_id || (value && value.id) || "";
details = `usuń postać ${cid || "?"}${base ? " · " + base : ""}`;
} else if (op === "update_campaign" && value && typeof value === "object") {
const keys = Object.keys(value);
const changed = keys.length ? `pola: ${keys.join(", ")}` : "";
details = `${changed || "aktualizacja kampanii"}${base ? " · " + base : ""}`;
} else if (op === "upsert_location" && value) {
let locId = "";
let locName = "";
if (typeof value === "object") {
locId = value.id || "";
locName = value.name || "";
} else if (typeof value === "string") {
locId = value;
}
const label =
locName && locId ? `${locId} (${locName})` : locName || locId;
details = `lokacja: ${label || "?"}${base ? " · " + base : ""}`;
} else if (op === "faction_rep_add" && value && typeof value === "object") {
const fid = value.id || "";
const fname = value.name || "";
const deltaVal = value.delta;
const deltaStr =
typeof deltaVal === "number"
? (deltaVal >= 0 ? "+" + deltaVal : String(deltaVal))
: "";
const label =
fname && fid ? `${fid} (${fname})` : fname || fid || "frakcja";
details = `${label}: rep ${deltaStr}${base ? " · " + base : ""}`;
}
if (!details && e.char_id) {
details = `char_id=${e.char_id}`;
}
if (!details && value != null) {
try {
const s = JSON.stringify(value);
details = s.length > 120 ? s.slice(0, 117) + "..." : s;
} catch (_) {
// ignore
}
}
}
return `
<div class="log-entry">
<span class="log-ts">${ts}</span>
<span class="log-op">${op}</span>
${details ? ` · ${details}` : ""}
</div>
`;
})
.join("");
todosEl.innerHTML = todos
.map((e) => {
const done = !!e.done;
const statusClass = done ? "todo-done" : "todo-open";
const statusLabel = done ? "DONE" : "TODO";
const tags = (e.tags || []).join(", ");
const comment = e.comment || "";
const ts = e.ts;
return `
<div class="todo-entry">
<div>
<span class="${statusClass}">[${statusLabel}]</span>
<strong>${e.summary || ""}</strong>
</div>
${
e.details
? `<div class="muted" style="margin-top:2px;">${e.details}</div>`
: ""
}
${
tags
? `<div class="todo-tags">Tagi: ${tags}</div>`
: ""
}
${
comment
? `<div class="muted" style="margin-top:2px;">Komentarz: ${comment}</div>`
: ""
}
<div class="todo-controls">
<input
type="text"
placeholder="Komentarz"
value="${comment.replace(/"/g, """)}"
data-todo-ts="${ts}"
/>
<button type="button" data-todo-ts="${ts}" data-status="${
done ? "reopen" : "done"
}">
${done ? "Otwórz" : "Oznacz jako DONE"}
</button>
</div>
</div>
`;
})
.join("");
}
campaignSelect.addEventListener("change", (e) => {
const id = e.target.value;
if (!id) return;
try {
if (window.localStorage) {
localStorage.setItem("rpgLedgerLastCampaignId", id);
}
} catch (err) {
console.warn("failed to store last campaign id", err);
}
loadCampaign(id)
.then(() => loadLogs())
.catch((err) => console.error(err));
});
function setMainView(view) {
currentMainView = view;
if (view === "party") {
mainTabPartyBtn.classList.add("active");
mainTabMapBtn.classList.remove("active");
mainTabFactionsBtn.classList.remove("active");
} else if (view === "map") {
mainTabMapBtn.classList.add("active");
mainTabPartyBtn.classList.remove("active");
mainTabFactionsBtn.classList.remove("active");
} else if (view === "factions") {
mainTabFactionsBtn.classList.add("active");
mainTabPartyBtn.classList.remove("active");
mainTabMapBtn.classList.remove("active");
}
updateMainViewVisibility();
if (view === "factions") {
loadFactions();
}
}
mainTabPartyBtn.addEventListener("click", () => setMainView("party"));
mainTabMapBtn.addEventListener("click", () => setMainView("map"));
mainTabFactionsBtn.addEventListener("click", () => setMainView("factions"));
tabLogsBtn.addEventListener("click", () => {
currentPanelTab = "logs";
tabLogsBtn.classList.add("active");
tabTodosBtn.classList.remove("active");
logsEl.style.display = "";
todosEl.style.display = "none";
panelSubtitleEl.textContent =
"Ostatnie mutacje wykonywane przez agenta / MCP.";
});
tabTodosBtn.addEventListener("click", () => {
currentPanelTab = "todos";
tabTodosBtn.classList.add("active");
tabLogsBtn.classList.remove("active");
logsEl.style.display = "none";
todosEl.style.display = "";
panelSubtitleEl.textContent =
"Lista TODO zarejestrowanych w logu kampanii.";
});
todosEl.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-todo-ts]");
if (!btn) return;
const ts = btn.getAttribute("data-todo-ts");
const status = btn.getAttribute("data-status") === "done";
const input = todosEl.querySelector(`input[data-todo-ts="${ts}"]`);
const comment = input ? input.value : "";
fetch("/api/todo-status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ todo_ts: ts, status, comment }),
})
.then((res) => {
if (!res.ok) throw new Error("failed");
return res.json();
})
.then(() => loadLogs())
.catch((err) => console.error("failed to update todo status", err));
});
function selectCharacterById(id) {
if (!currentCampaign) return;
const ch = (currentCampaign.characters || []).find((c) => c.id === id);
if (ch) {
selectedCharacterId = id;
renderCharacterDetail(ch);
renderParty(currentCampaign);
}
}
async function callMutate(payload) {
const res = await fetch("/api/mutate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
console.error("mutate failed", await res.text());
return;
}
const data = await res.json();
currentCampaign = data;
if (selectedCharacterId) {
selectCharacterById(selectedCharacterId);
} else {
renderCampaignSummary(currentCampaign);
renderParty(currentCampaign);
}
renderWorldMap(currentCampaign);
loadLogs();
}
function handleQuickAction(action, charId) {
if (!currentCampaign) return;
const campaignId = currentCampaign.id;
if (!campaignId) return;
if (action === "hp_minus" || action === "hp_plus") {
const delta = action === "hp_minus" ? -5 : 5;
callMutate({
campaign_id: campaignId,
op: "hp_add",
char_id: charId,
amount: delta,
});
} else if (action === "gold_minus" || action === "gold_plus") {
const delta = action === "gold_minus" ? -5 : 5;
callMutate({
campaign_id: campaignId,
op: "gold_add",
char_id: charId,
amount: delta,
});
} else if (action === "loot") {
const name = prompt("Nazwa przedmiotu do dodania:");
if (!name) return;
const qtyInput = prompt("Ilość (domyślnie 1):", "1");
const qty = parseInt(qtyInput || "1", 10) || 1;
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "item";
callMutate({
campaign_id: campaignId,
op: "inventory_add",
char_id: charId,
value: { id, name, qty },
text: name,
});
} else if (action === "note") {
const text = prompt("Notatka dla postaci:");
if (!text) return;
callMutate({
campaign_id: campaignId,
op: "char_note_add",
char_id: charId,
text,
});
}
}
partyGridEl.addEventListener("click", (e) => {
const actionBtn = e.target.closest("[data-action]");
if (actionBtn) {
const action = actionBtn.getAttribute("data-action");
const charId = actionBtn.getAttribute("data-char-id");
handleQuickAction(action, charId);
e.stopPropagation();
return;
}
const card = e.target.closest(".character-card");
if (card) {
const charId = card.getAttribute("data-char-id");
selectCharacterById(charId);
}
});
worldMapEl.addEventListener("click", (e) => {
const target = e.target.closest("[data-location-name]");
if (!target || !currentCampaign) return;
const name = target.getAttribute("data-location-name");
callMutate({
campaign_id: currentCampaign.id,
op: "location_set",
value: name,
});
});
factionsContentEl.addEventListener("click", (e) => {
const btn = e.target.closest("button.quick-btn[data-faction-id][data-rep-delta]");
if (!btn || !currentCampaign || !currentCampaign.id) return;
const factionId = btn.getAttribute("data-faction-id");
const deltaStr = btn.getAttribute("data-rep-delta") || "0";
const delta = parseInt(deltaStr, 10) || 0;
if (!factionId || !delta) return;
fetch(`/api/factions/${currentCampaign.id}/rep`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ faction_id: factionId, delta }),
})
.then((res) => {
if (!res.ok) throw new Error("failed");
return res.json();
})
.then(() => {
loadFactions();
loadLogs();
})
.catch((err) => console.error("failed to update faction rep", err));
});
factionsContentEl.addEventListener("click", (e) => {
const addBtn = e.target.closest("#factionAddBtn");
if (!addBtn || !currentCampaign || !currentCampaign.id) return;
const idInput = document.getElementById("factionIdInput");
const nameInput = document.getElementById("factionNameInput");
const repInput = document.getElementById("factionRepInput");
if (!idInput || !nameInput || !repInput) return;
const rawId = idInput.value.trim();
const name = nameInput.value.trim();
const repVal = parseInt(repInput.value || "0", 10) || 0;
if (!rawId && !name) return;
const factionId =
rawId ||
(name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "faction");
fetch(`/api/factions/${currentCampaign.id}/rep`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
faction_id: factionId,
delta: repVal,
name: name || factionId,
}),
})
.then((res) => {
if (!res.ok) throw new Error("failed");
return res.json();
})
.then(() => {
idInput.value = "";
nameInput.value = "";
repInput.value = "";
loadFactions();
loadLogs();
})
.catch((err) => console.error("failed to add faction", err));
});
loadCampaigns()
.then(() => loadLogs())
.catch((err) => {
console.error(err);
campaignSummaryEl.innerHTML =
"<p class='muted'>Nie udało się załadować kampanii.</p>";
});
setInterval(() => {
loadLogs().catch((err) => console.error(err));
}, 5000);
</script>
</body>
</html>