Files
Johannes 7b8842d3a1 1.0
2026-03-11 17:54:26 +01:00

1179 lines
47 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>.puSave Editor</title>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0e0f11;
--surface: #151618;
--surface2: #1c1e21;
--surface3: #232629;
--border: #2a2d32;
--text: #e8eaed;
--muted: #6b7280;
--accent: #ff6b35;
--accent2: #ffd166;
--accent3: #06d6a0;
--danger: #ef4444;
--radius: 6px;
}
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
display: flex;
flex-direction: column;
min-height: 100vh;
overflow: hidden;
}
/* NOISE OVERLAY */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
}
/* HEADER */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 28px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
gap: 16px;
}
.logo {
font-family: 'Bebas Neue', sans-serif;
font-size: 1.5rem;
letter-spacing: 0.08em;
color: var(--text);
display: flex;
align-items: center;
gap: 10px;
}
.logo span { color: var(--accent); }
.logo-sub {
font-family: 'DM Mono', monospace;
font-size: 0.6rem;
color: var(--muted);
letter-spacing: 0.2em;
text-transform: uppercase;
border: 1px solid var(--border);
padding: 2px 7px;
border-radius: 3px;
}
.header-right { display: flex; align-items: center; gap: 10px; }
#filename-display {
font-family: 'DM Mono', monospace;
font-size: 0.68rem;
color: var(--muted);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* BUTTONS */
.btn {
font-family: 'DM Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.05em;
padding: 8px 16px;
border-radius: var(--radius);
border: none;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn-primary { background: var(--accent); color: #fff; font-weight: 500; }
.btn-primary:hover { background: #ff8255; }
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--surface2); color: var(--text); }
.btn-danger { background: transparent; color: var(--danger); border: 1px solid rgba(239,68,68,0.3); font-size: 0.65rem; padding: 5px 10px; }
.btn-danger:hover { background: rgba(239,68,68,0.1); }
.btn-add { background: transparent; color: var(--accent3); border: 1px dashed rgba(6,214,160,0.35); font-size: 0.68rem; padding: 6px 12px; width: 100%; margin-top: 8px; }
.btn-add:hover { background: rgba(6,214,160,0.07); }
.btn-sm { padding: 4px 10px; font-size: 0.65rem; }
/* DROP ZONE */
#drop-zone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 40px;
text-align: center;
}
.drop-ring {
width: 120px; height: 120px;
border-radius: 50%;
border: 2px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-size: 2.8rem;
transition: all 0.2s;
background: var(--surface);
position: relative;
}
.drop-ring::before {
content: '';
position: absolute;
inset: -8px;
border-radius: 50%;
border: 1px dashed var(--border);
animation: spin 12s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#drop-zone.dragover .drop-ring { border-color: var(--accent); background: rgba(255,107,53,0.05); }
#drop-zone h2 { font-family: 'Bebas Neue', sans-serif; font-size: 2rem; letter-spacing: 0.1em; color: var(--text); }
#drop-zone p { font-family: 'DM Mono', monospace; font-size: 0.78rem; color: var(--muted); }
#file-input { display: none; }
/* EDITOR */
#editor { display: none; flex: 1; flex-direction: column; min-height: 0; }
.editor-layout { display: flex; flex: 1; min-height: 0; overflow: hidden; }
/* SIDEBAR */
.sidebar {
width: 220px;
min-width: 140px;
max-width: 400px;
border-right: 1px solid var(--border);
background: var(--surface);
overflow-y: auto;
overflow-x: hidden;
padding: 12px 0 24px;
flex-shrink: 0;
height: 100%;
}
.resize-handle {
width: 5px;
cursor: col-resize;
background: transparent;
flex-shrink: 0;
transition: background 0.15s;
}
.resize-handle:hover, .resize-handle.dragging { background: var(--accent); opacity: 0.4; }
.sidebar-section { margin-bottom: 2px; }
.sidebar-group-label {
font-family: 'DM Mono', monospace;
font-size: 0.58rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
padding: 10px 16px 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
transition: color 0.15s;
}
.sidebar-group-label:hover { color: var(--text); }
.sidebar-group-label .schev {
font-size: 0.5rem;
transition: transform 0.2s;
opacity: 0.4;
}
.sidebar-group-label .schev.closed { transform: rotate(-90deg); }
.sidebar-children-wrap { overflow: hidden; max-height: 2000px; transition: max-height 0.22s ease; }
.sidebar-children-wrap.closed { max-height: 0; }
.nav-item {
padding: 8px 16px;
font-size: 0.78rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--muted);
font-family: 'DM Mono', monospace;
border-left: 2px solid transparent;
transition: all 0.1s;
white-space: nowrap;
overflow: hidden;
}
.nav-item span.label { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.nav-item:hover { background: var(--surface2); color: var(--text); }
.nav-item.active { background: rgba(255,107,53,0.08); border-left-color: var(--accent); color: var(--accent); }
.nav-badge {
background: var(--surface3);
color: var(--muted);
font-size: 0.58rem;
padding: 1px 5px;
border-radius: 10px;
flex-shrink: 0;
}
.nav-item.active .nav-badge { background: rgba(255,107,53,0.15); color: var(--accent); }
/* CONTENT */
.content { padding: 28px 32px; overflow-y: auto; flex: 1; height: 100%; min-width: 0; }
.page-title {
font-family: 'Bebas Neue', sans-serif;
font-size: 1.8rem;
letter-spacing: 0.06em;
color: var(--text);
margin-bottom: 2px;
}
.page-sub {
font-family: 'DM Mono', monospace;
font-size: 0.68rem;
color: var(--muted);
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 8px;
}
/* CARDS */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 12px;
overflow: hidden;
}
.card-header {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
background: var(--surface2);
gap: 10px;
}
.card-header:hover { background: var(--surface3); }
.card-title {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.card-body { padding: 16px; }
.card-body.collapsed { display: none; }
.chevron { font-size: 0.6rem; color: var(--muted); transition: transform 0.2s; }
.chevron.open { transform: rotate(180deg); }
/* TAGS */
.tag {
display: inline-block;
font-family: 'DM Mono', monospace;
font-size: 0.6rem;
padding: 2px 7px;
border-radius: 3px;
background: var(--surface3);
color: var(--muted);
}
.tag-orange { background: rgba(255,107,53,0.12); color: var(--accent); }
.tag-green { background: rgba(6,214,160,0.1); color: var(--accent3); }
.tag-yellow { background: rgba(255,209,102,0.1); color: var(--accent2); }
.tag-red { background: rgba(239,68,68,0.1); color: var(--danger); }
/* FORMS */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-grid.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
.form-group { display: flex; flex-direction: column; gap: 5px; }
.form-group.full { grid-column: 1/-1; }
label {
font-family: 'DM Mono', monospace;
font-size: 0.62rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
input[type="text"], input[type="number"], select, textarea {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 0.78rem;
padding: 8px 10px;
width: 100%;
transition: border-color 0.15s;
outline: none;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
textarea { resize: vertical; min-height: 70px; }
select option { background: var(--surface2); }
hr.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
/* ID CHIPS */
.chips-wrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
min-height: 44px;
align-items: flex-start;
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: 'DM Mono', monospace;
font-size: 0.65rem;
padding: 3px 8px;
border-radius: 4px;
background: var(--surface3);
color: var(--text);
border: 1px solid var(--border);
}
.chip-remove {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 0.7rem;
padding: 0;
line-height: 1;
transition: color 0.1s;
}
.chip-remove:hover { color: var(--danger); }
.chip-input-row { display: flex; gap: 8px; margin-top: 8px; }
.chip-input-row input { flex: 1; }
/* ASSIGNED ITEM CARD */
.assigned-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
overflow: hidden;
}
.assigned-card-header {
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: var(--surface2);
cursor: pointer;
}
.assigned-card-header:hover { background: var(--surface3); }
.assigned-card-body { padding: 14px; }
.assigned-card-body.collapsed { display: none; }
/* PERK TOGGLE ROW */
.perk-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--surface2);
margin-bottom: 6px;
gap: 10px;
}
.perk-row-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.perk-id {
font-family: 'DM Mono', monospace;
font-size: 0.65rem;
color: var(--accent2);
flex-shrink: 0;
width: 40px;
}
.perk-date {
font-family: 'DM Mono', monospace;
font-size: 0.6rem;
color: var(--muted);
flex-shrink: 0;
}
.perk-controls { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* ROULETTE */
.roulette-block {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.roulette-block .form-group { flex: 1; min-width: 160px; }
/* TIMESTAMP DISPLAY */
.ts-display {
font-family: 'DM Mono', monospace;
font-size: 0.6rem;
color: var(--accent3);
margin-top: 3px;
}
/* TOAST */
#toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--accent);
color: #fff;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
padding: 10px 18px;
border-radius: var(--radius);
opacity: 0;
transform: translateY(10px);
transition: all 0.2s;
pointer-events: none;
z-index: 1000;
}
#toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 700px) {
.editor-layout { flex-direction: column; }
.sidebar { width: 100% !important; max-width: 100%; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
.resize-handle { display: none; }
.form-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<div class="logo">.pu<span>Save</span> <span class="logo-sub">editor</span></div>
<div class="header-right">
<span id="filename-display"></span>
<button class="btn btn-ghost" id="load-new-btn">Load new</button>
<button class="btn btn-primary" id="save-btn" style="display:none">↓ Save file</button>
</div>
</header>
<div id="drop-zone">
<div class="drop-ring">💾</div>
<h2>Drop your .puSave file</h2>
<p>or click below to browse</p>
<button class="btn btn-primary" onclick="document.getElementById('file-input').click()">Browse file</button>
<input type="file" id="file-input" accept=".puSave,.json">
</div>
<div id="editor">
<div class="editor-layout">
<nav class="sidebar" id="sidebar"></nav>
<div class="resize-handle" id="resize-handle"></div>
<main class="content" id="content"></main>
</div>
</div>
<div id="toast"></div>
<script>
let save = null;
let currentPage = 'overview';
let currentFileName = 'output.puSave';
const collapsedGroups = new Set();
// ─── SIDEBAR RESIZE ────────────────────────────────────────────────────────────
(function() {
const handle = document.getElementById('resize-handle');
const sidebar = document.getElementById('sidebar');
let dragging = false, startX = 0, startW = 0;
handle.addEventListener('mousedown', e => {
dragging = true; startX = e.clientX; startW = sidebar.offsetWidth;
handle.classList.add('dragging');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
sidebar.style.width = Math.min(400, Math.max(140, startW + (e.clientX - startX))) + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false; handle.classList.remove('dragging');
document.body.style.cursor = ''; document.body.style.userSelect = '';
});
})();
// ─── FILE LOADING ──────────────────────────────────────────────────────────────
document.getElementById('file-input').addEventListener('change', e => {
const file = e.target.files[0]; if (!file) return;
loadFile(file);
});
document.getElementById('load-new-btn').addEventListener('click', () => {
document.getElementById('file-input').click();
});
const dz = document.getElementById('drop-zone');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); });
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
dz.addEventListener('drop', e => {
e.preventDefault(); dz.classList.remove('dragover');
const file = e.dataTransfer.files[0]; if (file) loadFile(file);
});
function loadFile(file) {
currentFileName = file.name;
document.getElementById('filename-display').textContent = file.name;
const reader = new FileReader();
reader.onload = e => {
try {
save = JSON.parse(e.target.result);
document.getElementById('drop-zone').style.display = 'none';
document.getElementById('editor').style.display = 'flex';
document.getElementById('save-btn').style.display = '';
currentPage = 'overview';
renderSidebar();
renderContent();
} catch(err) {
alert('Failed to parse file: ' + err.message);
}
};
reader.readAsText(file);
}
// ─── SAVE FILE ─────────────────────────────────────────────────────────────────
document.getElementById('save-btn').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(save)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = currentFileName; a.click();
URL.revokeObjectURL(url);
toast('File downloaded!');
});
// ─── SIDEBAR ───────────────────────────────────────────────────────────────────
function renderSidebar() {
const sg = save.saveGame;
const sidebar = document.getElementById('sidebar');
const groups = [
{ key: 'overview', label: 'Overview', items: null },
{ key: 'meta', label: 'Meta', items: null },
{ key: 'punishments', label: 'Punishments', items: [
{ id: 'active-punishments', label: 'Active', badge: (sg.activePunishments||[]).length },
{ id: 'finished-punishments', label: 'Finished', badge: (sg.finishedPunishments||[]).length },
{ id: 'blocked-punishments', label: 'Blocked', badge: (sg.blockedPunishments||[]).length },
]},
{ key: 'classes', label: 'Classes', items: [
{ id: 'classes-overview', label: 'All Classes', badge: (sg.assignedClasses||[]).length },
...(sg.assignedClasses||[]).map(c => ({ id: 'class-'+c.id, label: 'Class '+c.id }))
]},
{ key: 'clubs', label: 'Clubs', items: (sg.assignedClubs||[]).map(c => ({ id: 'club-'+c.id, label: 'Club '+c.id })) },
{ key: 'partners', label: 'Partners', items: (sg.assignedPartners||[]).map(p => ({ id: 'partner-'+p.id, label: 'Partner '+p.id })) },
{ key: 'tasks', label: 'Active Tasks', items: null },
{ key: 'roulette', label: 'Roulette', items: null },
];
sidebar.innerHTML = groups.map(g => {
const collapsed = collapsedGroups.has(g.key);
if (!g.items) {
// single nav item
return `<div class="nav-item ${currentPage===g.key?'active':''}" onclick="navigate('${g.key}')">
<span class="label">${g.label}</span>
</div>`;
}
return `<div class="sidebar-section">
<div class="sidebar-group-label" onclick="toggleGroup('${g.key}')">
<span>${g.label}</span>
<span class="schev ${collapsed?'closed':''}">▼</span>
</div>
<div class="sidebar-children-wrap ${collapsed?'closed':''}">
${g.items.map(item => `
<div class="nav-item ${currentPage===item.id?'active':''}" onclick="navigate('${item.id}')" style="padding-left:24px">
<span class="label">${item.label}</span>
${item.badge !== undefined ? `<span class="nav-badge">${item.badge}</span>` : ''}
</div>
`).join('')}
${g.items.length === 0 ? `<div style="padding:6px 24px;font-family:'DM Mono',monospace;font-size:0.65rem;color:var(--muted)">none</div>` : ''}
</div>
</div>`;
}).join('');
}
function toggleGroup(key) {
if (collapsedGroups.has(key)) collapsedGroups.delete(key);
else collapsedGroups.add(key);
renderSidebar();
}
function navigate(page) {
currentPage = page;
renderSidebar();
renderContent();
}
// ─── CONTENT ROUTER ────────────────────────────────────────────────────────────
function renderContent() {
const el = document.getElementById('content');
if (currentPage === 'overview') renderOverview(el);
else if (currentPage === 'meta') renderMeta(el);
else if (currentPage === 'active-punishments') renderPunishmentList(el, 'activePunishments', 'Active Punishments', 'tag-orange');
else if (currentPage === 'finished-punishments') renderPunishmentList(el, 'finishedPunishments', 'Finished Punishments', 'tag-green');
else if (currentPage === 'blocked-punishments') renderPunishmentList(el, 'blockedPunishments', 'Blocked Punishments', 'tag-red');
else if (currentPage === 'classes-overview') renderClassesOverview(el);
else if (currentPage.startsWith('class-')) renderClass(el, currentPage.replace('class-',''));
else if (currentPage.startsWith('club-')) renderClub(el, currentPage.replace('club-',''));
else if (currentPage.startsWith('partner-')) renderPartner(el, currentPage.replace('partner-',''));
else if (currentPage === 'tasks') renderActiveTasks(el);
else if (currentPage === 'roulette') renderRoulette(el);
}
// ─── OVERVIEW ──────────────────────────────────────────────────────────────────
function renderOverview(el) {
const sg = save.saveGame;
el.innerHTML = `
<div class="page-title">Save Overview</div>
<div class="page-sub">
<span class="tag">${esc(save.mapId||'no mapId')}</span>
</div>
<div class="form-grid cols-3" style="margin-bottom:20px">
${stat('Active Major', sg.activeMajor||'—')}
${stat('Assigned Classes', (sg.assignedClasses||[]).length)}
${stat('Assigned Clubs', (sg.assignedClubs||[]).length)}
${stat('Assigned Partners', (sg.assignedPartners||[]).length)}
${stat('Active Punishments', (sg.activePunishments||[]).length)}
${stat('Finished Punishments', (sg.finishedPunishments||[]).length)}
${stat('Blocked Punishments', (sg.blockedPunishments||[]).length)}
${stat('Active Tasks', (sg.activeTasks||[]).length)}
${stat('Roulette ID', sg.roulette?.id||'—')}
</div>
`;
}
function stat(label, value) {
return `<div class="card">
<div class="card-body" style="padding:14px">
<div style="font-family:'DM Mono',monospace;font-size:0.6rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:6px">${label}</div>
<div style="font-family:'Bebas Neue',sans-serif;font-size:1.6rem;color:var(--accent);letter-spacing:0.05em">${value}</div>
</div>
</div>`;
}
// ─── META ──────────────────────────────────────────────────────────────────────
function renderMeta(el) {
el.innerHTML = `
<div class="page-title">Meta</div>
<div class="page-sub">map ID and major</div>
<div class="card"><div class="card-body">
<div class="form-grid">
<div class="form-group full">
<label>Map ID</label>
<input type="text" id="meta-mapId" value="${esc(save.mapId||'')}">
</div>
<div class="form-group full">
<label>Active Major ID</label>
<input type="text" id="meta-activeMajor" value="${esc(save.saveGame.activeMajor||'')}">
</div>
</div>
</div></div>
<button class="btn btn-primary" onclick="saveMeta()">Save</button>
`;
}
function saveMeta() {
save.mapId = document.getElementById('meta-mapId').value;
save.saveGame.activeMajor = document.getElementById('meta-activeMajor').value;
toast('Saved!');
}
// ─── PUNISHMENT LISTS ──────────────────────────────────────────────────────────
function renderPunishmentList(el, key, title, tagClass) {
const list = save.saveGame[key] || [];
el.innerHTML = `
<div class="page-title">${title}</div>
<div class="page-sub">${list.length} punishment ID${list.length!==1?'s':''}</div>
<div class="chips-wrap" id="pun-chips-${key}">
${list.map((id,i) => `
<span class="chip">
<span class="${tagClass||''}">${esc(id)}</span>
<button class="chip-remove" onclick="removePunId('${key}',${i})">✕</button>
</span>`).join('')}
</div>
<div class="chip-input-row">
<input type="text" id="pun-new-id-${key}" placeholder="Add punishment ID…">
<button class="btn btn-ghost btn-sm" onclick="addPunId('${key}')">Add</button>
</div>
<button class="btn btn-primary" style="margin-top:16px" onclick="toast('List auto-saved on each add/remove!')">✓ Changes are live</button>
`;
}
function addPunId(key) {
const input = document.getElementById(`pun-new-id-${key}`);
const val = input.value.trim(); if (!val) return;
save.saveGame[key].push(val);
input.value = '';
renderContent();
renderSidebar();
toast('Added!');
}
function removePunId(key, idx) {
save.saveGame[key].splice(idx, 1);
renderContent();
renderSidebar();
toast('Removed!');
}
// ─── CLASSES OVERVIEW ─────────────────────────────────────────────────────────
function renderClassesOverview(el) {
const classes = save.saveGame.assignedClasses || [];
el.innerHTML = `
<div class="page-title">Assigned Classes</div>
<div class="page-sub">${classes.length} class${classes.length!==1?'es':''} assigned</div>
<div style="overflow-x:auto;margin-bottom:20px">
<table id="classes-table" style="width:100%;border-collapse:collapse;font-family:'DM Mono',monospace;font-size:0.72rem">
<thead>
<tr style="border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:8px 10px;color:var(--muted);font-weight:400;letter-spacing:0.1em;text-transform:uppercase;font-size:0.6rem">Class ID</th>
<th style="text-align:left;padding:8px 10px;color:var(--muted);font-weight:400;letter-spacing:0.1em;text-transform:uppercase;font-size:0.6rem">Status</th>
<th style="text-align:left;padding:8px 10px;color:var(--muted);font-weight:400;letter-spacing:0.1em;text-transform:uppercase;font-size:0.6rem">Attendances</th>
<th style="text-align:left;padding:8px 10px;color:var(--muted);font-weight:400;letter-spacing:0.1em;text-transform:uppercase;font-size:0.6rem">Last Attended</th>
<th style="padding:8px 10px"></th>
</tr>
</thead>
<tbody>
${classes.map((c, i) => `
<tr style="border-bottom:1px solid var(--border);transition:background 0.1s" onmouseenter="this.style.background='var(--surface2)'" onmouseleave="this.style.background=''">
<td style="padding:6px 10px">
<input type="text" value="${esc(c.id)}" data-i="${i}" data-field="id"
style="background:transparent;border:none;border-bottom:1px solid transparent;border-radius:0;padding:3px 0;width:100%;min-width:80px;color:var(--accent2)"
onfocus="this.style.borderBottomColor='var(--accent)'"
onblur="this.style.borderBottomColor='transparent';updateClassField(${i},'id',this.value)">
</td>
<td style="padding:6px 10px">
<select data-i="${i}" data-field="status"
style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:3px 6px;color:var(--text);font-family:'DM Mono',monospace;font-size:0.7rem"
onchange="updateClassField(${i},'status',this.value)">
${['active','inactive','completed'].map(s => `<option value="${s}" ${c.status===s?'selected':''}>${s}</option>`).join('')}
</select>
</td>
<td style="padding:6px 10px">
<input type="number" value="${c.attendances||0}" data-i="${i}" data-field="attendances" min="0"
style="background:transparent;border:none;border-bottom:1px solid transparent;border-radius:0;padding:3px 0;width:60px;color:var(--text)"
onfocus="this.style.borderBottomColor='var(--accent)'"
onblur="this.style.borderBottomColor='transparent';updateClassField(${i},'attendances',+this.value)">
</td>
<td style="padding:6px 10px">
<input type="number" value="${c.lastAttended||0}" data-i="${i}" data-field="lastAttended"
style="background:transparent;border:none;border-bottom:1px solid transparent;border-radius:0;padding:3px 0;width:110px;color:var(--text)"
onfocus="this.style.borderBottomColor='var(--accent)'"
onblur="this.style.borderBottomColor='transparent';updateClassField(${i},'lastAttended',+this.value)"
title="${c.lastAttended ? new Date(c.lastAttended*1000).toLocaleString() : '—'}">
<div style="font-size:0.58rem;color:var(--accent3)">${c.lastAttended ? new Date(c.lastAttended*1000).toLocaleDateString() : '—'}</div>
</td>
<td style="padding:6px 10px;white-space:nowrap;display:flex;gap:6px;align-items:center">
<button class="btn btn-ghost btn-sm" onclick="navigate('class-${c.id}')" title="Open full editor">✎</button>
<button class="btn btn-danger btn-sm" onclick="removeClassAt(${i})">✕</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<hr class="divider">
<div style="font-family:'Bebas Neue',sans-serif;font-size:1.1rem;letter-spacing:0.06em;margin-bottom:12px;color:var(--text)">Add Class</div>
<div class="card"><div class="card-body">
<div class="form-grid cols-3">
<div class="form-group">
<label>Class ID</label>
<input type="text" id="add-cls-id" placeholder="e.g. 1234">
</div>
<div class="form-group">
<label>Status</label>
<select id="add-cls-status">
<option value="active">active</option>
<option value="inactive">inactive</option>
<option value="completed">completed</option>
</select>
</div>
<div class="form-group">
<label>Attendances</label>
<input type="number" id="add-cls-attendances" value="0" min="0">
</div>
<div class="form-group full">
<label>Last Attended</label>
<div style="display:flex;gap:10px;align-items:center">
<input type="number" id="add-cls-lastAttended" value="${Math.floor(Date.now()/1000) - 86400}" style="flex:1">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('add-cls-lastAttended').value=Math.floor(Date.now()/1000)">Now</button>
</div>
</div>
</div>
<button class="btn btn-primary" style="margin-top:14px;width:100%" onclick="addClassFromForm()">+ Add Class</button>
</div></div>
`;
}
function updateClassField(idx, field, value) {
if (!save.saveGame.assignedClasses[idx]) return;
save.saveGame.assignedClasses[idx][field] = value;
// refresh sidebar badge
renderSidebar();
}
function removeClassAt(idx) {
if (!confirm('Remove this class from the save?')) return;
save.saveGame.assignedClasses.splice(idx, 1);
renderSidebar(); renderContent();
}
function addClassFromForm() {
const id = document.getElementById('add-cls-id').value.trim();
if (!id) { toast('Class ID required!'); return; }
if (save.saveGame.assignedClasses.find(c => c.id === id)) { toast('ID already exists!'); return; }
if (!save.saveGame.assignedClasses) save.saveGame.assignedClasses = [];
save.saveGame.assignedClasses.push({
id,
status: document.getElementById('add-cls-status').value,
attendances: parseInt(document.getElementById('add-cls-attendances').value) || 0,
lastAttended: parseInt(document.getElementById('add-cls-lastAttended').value) || 0
});
document.getElementById('add-cls-id').value = '';
document.getElementById('add-cls-attendances').value = '0';
renderSidebar(); renderContent();
toast('Class added!');
}
function renderClass(el, id) {
const sg = save.saveGame;
const cls = (sg.assignedClasses||[]).find(c => c.id === id);
if (!cls) { el.innerHTML = `<div class="page-title">Not found</div>`; return; }
const lastDate = cls.lastAttended ? new Date(cls.lastAttended * 1000).toLocaleString() : '—';
el.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px">
<div>
<div class="page-title">Class ${esc(id)}</div>
<div class="page-sub"><span class="tag">${esc(cls.status||'active')}</span></div>
</div>
<button class="btn btn-danger" onclick="removeAssignedClass('${id}')">Remove class</button>
</div>
<div class="card"><div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>Class ID</label>
<input type="text" id="cls-id" value="${esc(cls.id)}">
</div>
<div class="form-group">
<label>Status</label>
<select id="cls-status">
${['active','inactive','completed'].map(s => `<option value="${s}" ${cls.status===s?'selected':''}>${s}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>Attendances</label>
<input type="number" id="cls-attendances" value="${cls.attendances||0}" min="0">
</div>
<div class="form-group">
<label>Last Attended (unix timestamp)</label>
<input type="number" id="cls-lastAttended" value="${cls.lastAttended||0}">
<div class="ts-display">${lastDate}</div>
</div>
</div>
</div></div>
<button class="btn btn-primary" onclick="saveClass('${id}')">Save class</button>
`;
// live timestamp preview
document.getElementById('cls-lastAttended').addEventListener('input', e => {
const ts = parseInt(e.target.value);
e.target.nextElementSibling.textContent = ts ? new Date(ts * 1000).toLocaleString() : '—';
});
}
function saveClass(id) {
const sg = save.saveGame;
const cls = sg.assignedClasses.find(c => c.id === id);
const newId = document.getElementById('cls-id').value;
cls.id = newId;
cls.status = document.getElementById('cls-status').value;
cls.attendances = parseInt(document.getElementById('cls-attendances').value) || 0;
cls.lastAttended = parseInt(document.getElementById('cls-lastAttended').value) || 0;
// update page key if ID changed
if (newId !== id) { currentPage = 'class-' + newId; renderSidebar(); }
toast('Class saved!');
}
function removeAssignedClass(id) {
if (!confirm('Remove this class from the save?')) return;
save.saveGame.assignedClasses = save.saveGame.assignedClasses.filter(c => c.id !== id);
currentPage = 'overview';
renderSidebar(); renderContent();
}
// ─── ASSIGNED CLUB ─────────────────────────────────────────────────────────────
function renderClub(el, id) {
const club = (save.saveGame.assignedClubs||[]).find(c => c.id === id);
if (!club) { el.innerHTML = `<div class="page-title">Not found</div>`; return; }
const perks = club.activePerks || {};
const perkKeys = Object.keys(perks);
el.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px">
<div>
<div class="page-title">Club ${esc(id)}</div>
<div class="page-sub">${perkKeys.length} active perk${perkKeys.length!==1?'s':''}</div>
</div>
<button class="btn btn-danger" onclick="removeAssigned('clubs','${id}')">Remove club</button>
</div>
<div class="card"><div class="card-body">
<div class="form-group">
<label>Club ID</label>
<input type="text" id="club-id" value="${esc(club.id)}">
</div>
</div></div>
<hr class="divider">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div style="font-family:'Bebas Neue',sans-serif;font-size:1.1rem;letter-spacing:0.06em">Active Perks</div>
<button class="btn btn-ghost btn-sm" onclick="addActivePerk('clubs','${id}')">+ Add perk</button>
</div>
${renderPerkRows('clubs', id, perks)}
<button class="btn btn-primary" style="margin-top:16px" onclick="saveClubAssignment('${id}')">Save club</button>
`;
}
function saveClubAssignment(id) {
const club = save.saveGame.assignedClubs.find(c => c.id === id);
const newId = document.getElementById('club-id').value;
club.id = newId;
if (newId !== id) { currentPage = 'club-' + newId; renderSidebar(); }
toast('Club saved!');
}
// ─── ASSIGNED PARTNER ──────────────────────────────────────────────────────────
function renderPartner(el, id) {
const partner = (save.saveGame.assignedPartners||[]).find(p => p.id === id);
if (!partner) { el.innerHTML = `<div class="page-title">Not found</div>`; return; }
const perks = partner.activePerks || {};
const perkKeys = Object.keys(perks);
el.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px">
<div>
<div class="page-title">Partner ${esc(id)}</div>
<div class="page-sub">${perkKeys.length} active perk${perkKeys.length!==1?'s':''}</div>
</div>
<button class="btn btn-danger" onclick="removeAssigned('partners','${id}')">Remove partner</button>
</div>
<div class="card"><div class="card-body">
<div class="form-group">
<label>Partner ID</label>
<input type="text" id="partner-id" value="${esc(partner.id)}">
</div>
</div></div>
<hr class="divider">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div style="font-family:'Bebas Neue',sans-serif;font-size:1.1rem;letter-spacing:0.06em">Active Perks</div>
<button class="btn btn-ghost btn-sm" onclick="addActivePerk('partners','${id}')">+ Add perk</button>
</div>
${renderPerkRows('partners', id, perks)}
<button class="btn btn-primary" style="margin-top:16px" onclick="savePartnerAssignment('${id}')">Save partner</button>
`;
}
function savePartnerAssignment(id) {
const partner = save.saveGame.assignedPartners.find(p => p.id === id);
const newId = document.getElementById('partner-id').value;
partner.id = newId;
if (newId !== id) { currentPage = 'partner-' + newId; renderSidebar(); }
toast('Partner saved!');
}
// ─── SHARED PERK ROWS ──────────────────────────────────────────────────────────
function renderPerkRows(type, entityId, perks) {
const keys = Object.keys(perks);
if (keys.length === 0) return `<div style="font-family:'DM Mono',monospace;font-size:0.7rem;color:var(--muted);padding:12px 0">No active perks</div>`;
return keys.map(pk => {
const perk = perks[pk];
const date = perk.activatedOn ? new Date(perk.activatedOn * 1000).toLocaleString() : '—';
return `
<div class="perk-row" id="perk-row-${type}-${entityId}-${pk}">
<div class="perk-row-left">
<span class="perk-id">#${esc(pk)}</span>
<div style="flex:1;min-width:0">
<div style="font-family:'DM Mono',monospace;font-size:0.68rem;color:var(--text)">Perk ID: ${esc(pk)}</div>
<div class="ts-display">${date}</div>
</div>
</div>
<div class="perk-controls">
<label style="display:flex;align-items:center;gap:5px;font-size:0.65rem;color:var(--muted);text-transform:none;letter-spacing:0">
<input type="checkbox" ${perk.active?'checked':''} onchange="togglePerkActive('${type}','${entityId}','${pk}',this.checked)">
active
</label>
<input type="number" value="${perk.activatedOn||0}" style="width:110px;font-size:0.65rem;padding:4px 6px"
onchange="setPerkTimestamp('${type}','${entityId}','${pk}',this.value)"
title="activatedOn timestamp">
<button class="btn btn-danger btn-sm" onclick="removeActivePerk('${type}','${entityId}','${pk}')">✕</button>
</div>
</div>
`;
}).join('');
}
function togglePerkActive(type, entityId, perkId, val) {
const list = type === 'clubs' ? save.saveGame.assignedClubs : save.saveGame.assignedPartners;
const entity = list.find(e => e.id === entityId);
if (entity?.activePerks?.[perkId]) entity.activePerks[perkId].active = val;
}
function setPerkTimestamp(type, entityId, perkId, val) {
const list = type === 'clubs' ? save.saveGame.assignedClubs : save.saveGame.assignedPartners;
const entity = list.find(e => e.id === entityId);
if (entity?.activePerks?.[perkId]) entity.activePerks[perkId].activatedOn = parseInt(val) || 0;
}
function removeActivePerk(type, entityId, perkId) {
const list = type === 'clubs' ? save.saveGame.assignedClubs : save.saveGame.assignedPartners;
const entity = list.find(e => e.id === entityId);
if (entity?.activePerks) { delete entity.activePerks[perkId]; renderContent(); }
}
function addActivePerk(type, entityId) {
const id = prompt('Enter perk ID:'); if (!id) return;
const list = type === 'clubs' ? save.saveGame.assignedClubs : save.saveGame.assignedPartners;
const entity = list.find(e => e.id === entityId);
if (entity) {
if (!entity.activePerks) entity.activePerks = {};
entity.activePerks[id] = { active: true, activatedOn: Math.floor(Date.now()/1000) };
renderContent();
toast('Perk added!');
}
}
// ─── REMOVE ASSIGNED ──────────────────────────────────────────────────────────
function removeAssigned(type, id) {
if (!confirm(`Remove this ${type.slice(0,-1)} from the save?`)) return;
if (type === 'clubs') save.saveGame.assignedClubs = save.saveGame.assignedClubs.filter(c => c.id !== id);
if (type === 'partners') save.saveGame.assignedPartners = save.saveGame.assignedPartners.filter(p => p.id !== id);
currentPage = 'overview';
renderSidebar(); renderContent();
}
// ─── ADD ASSIGNED ─────────────────────────────────────────────────────────────
function addAssignedClass() {
const id = prompt('Enter class ID:'); if (!id) return;
if (!save.saveGame.assignedClasses) save.saveGame.assignedClasses = [];
save.saveGame.assignedClasses.push({ id, status: 'active', attendances: 0, lastAttended: Math.floor(Date.now()/1000) });
currentPage = 'class-' + id;
renderSidebar(); renderContent(); toast('Class added!');
}
function addAssignedClub() {
const id = prompt('Enter club ID:'); if (!id) return;
if (!save.saveGame.assignedClubs) save.saveGame.assignedClubs = [];
save.saveGame.assignedClubs.push({ id, activePerks: {} });
currentPage = 'club-' + id;
renderSidebar(); renderContent(); toast('Club added!');
}
function addAssignedPartner() {
const id = prompt('Enter partner ID:'); if (!id) return;
if (!save.saveGame.assignedPartners) save.saveGame.assignedPartners = [];
save.saveGame.assignedPartners.push({ id, activePerks: {} });
currentPage = 'partner-' + id;
renderSidebar(); renderContent(); toast('Partner added!');
}
// ─── ACTIVE TASKS ──────────────────────────────────────────────────────────────
function renderActiveTasks(el) {
const tasks = save.saveGame.activeTasks || [];
el.innerHTML = `
<div class="page-title">Active Tasks</div>
<div class="page-sub">${tasks.length} task${tasks.length!==1?'s':''}</div>
<div class="chips-wrap" id="tasks-chips">
${tasks.map((t,i) => `
<span class="chip">
${typeof t === 'object' ? esc(JSON.stringify(t)) : esc(String(t))}
<button class="chip-remove" onclick="removeTask(${i})">✕</button>
</span>`).join('')}
${tasks.length === 0 ? `<span style="font-family:'DM Mono',monospace;font-size:0.65rem;color:var(--muted)">No active tasks</span>` : ''}
</div>
<div class="chip-input-row">
<input type="text" id="task-new" placeholder="Add task ID…">
<button class="btn btn-ghost btn-sm" onclick="addTask()">Add</button>
</div>
`;
}
function addTask() {
const val = document.getElementById('task-new').value.trim(); if (!val) return;
if (!save.saveGame.activeTasks) save.saveGame.activeTasks = [];
save.saveGame.activeTasks.push(val);
renderContent(); toast('Added!');
}
function removeTask(idx) {
save.saveGame.activeTasks.splice(idx, 1);
renderContent();
}
// ─── ROULETTE ──────────────────────────────────────────────────────────────────
function renderRoulette(el) {
const r = save.saveGame.roulette || {};
const date = r.rolledOn ? new Date(r.rolledOn * 1000).toLocaleString() : '—';
el.innerHTML = `
<div class="page-title">Roulette</div>
<div class="page-sub">last roll state</div>
<div class="card"><div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>Roulette ID</label>
<input type="text" id="rou-id" value="${esc(r.id||'')}">
</div>
<div class="form-group">
<label>Rolled On (unix timestamp)</label>
<input type="number" id="rou-rolledOn" value="${r.rolledOn||0}" oninput="this.nextElementSibling.textContent=parseInt(this.value)?new Date(parseInt(this.value)*1000).toLocaleString():'—'">
<div class="ts-display">${date}</div>
</div>
</div>
</div></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:4px">
<button class="btn btn-primary" onclick="saveRoulette()">Save roulette</button>
<button class="btn btn-ghost" onclick="setRouletteNow()">Set rolled-on to now</button>
</div>
`;
}
function saveRoulette() {
if (!save.saveGame.roulette) save.saveGame.roulette = {};
save.saveGame.roulette.id = document.getElementById('rou-id').value;
save.saveGame.roulette.rolledOn = parseInt(document.getElementById('rou-rolledOn').value) || 0;
toast('Roulette saved!');
}
function setRouletteNow() {
const ts = Math.floor(Date.now()/1000);
document.getElementById('rou-rolledOn').value = ts;
document.getElementById('rou-rolledOn').nextElementSibling.textContent = new Date(ts*1000).toLocaleString();
}
// ─── HELPERS ───────────────────────────────────────────────────────────────────
function esc(str) {
return String(str)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function toggleCard(header) {
const body = header.nextElementSibling;
const chev = header.querySelector('.chevron');
body.classList.toggle('collapsed');
if (chev) chev.classList.toggle('open', !body.classList.contains('collapsed'));
}
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2200);
}
</script>
</body>
</html>