Files
Project_University_Tools/Mapeditor/Mapeditor.html
Johannes 7b8842d3a1 1.0
2026-03-11 17:54:26 +01:00

1957 lines
76 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>.pu File Editor</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Syne:wght@400;600;800&display=swap" rel="stylesheet"/>
<style>
:root {
--bg: #0d0d0f;
--surface: #141418;
--surface2: #1c1c22;
--border: #2a2a35;
--accent: #c8f542;
--accent2: #7b5ea7;
--danger: #f54242;
--text: #e8e8f0;
--muted: #6b6b80;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Syne', sans-serif;
height: 100vh;
max-height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* HEADER */
header {
border-bottom: 1px solid var(--border);
padding: 18px 32px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface);
position: sticky;
top: 0;
z-index: 100;
}
header h1 {
font-size: 1.1rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
header h1 span { color: var(--muted); font-weight: 400; }
.header-actions { display: flex; gap: 10px; align-items: center; }
/* BUTTONS */
button {
font-family: 'Space Mono', monospace;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
cursor: pointer;
border: none;
border-radius: var(--radius);
padding: 9px 18px;
transition: all 0.15s;
text-transform: uppercase;
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover { background: #d9ff50; transform: translateY(-1px); }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
.btn-danger {
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
padding: 6px 12px;
font-size: 0.65rem;
}
.btn-danger:hover { background: var(--danger); color: #fff; }
.btn-add {
background: var(--surface2);
color: var(--accent);
border: 1px dashed var(--accent);
width: 100%;
padding: 10px;
margin-top: 10px;
font-size: 0.7rem;
}
.btn-add:hover { background: rgba(200, 245, 66, 0.08); }
/* DROP ZONE */
#drop-zone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 60px;
text-align: center;
}
.drop-circle {
width: 120px; height: 120px;
border-radius: 50%;
border: 2px dashed var(--border);
display: flex; align-items: center; justify-content: center;
font-size: 2.5rem;
transition: all 0.2s;
}
#drop-zone.dragover .drop-circle { border-color: var(--accent); background: rgba(200,245,66,0.05); }
#drop-zone h2 { font-size: 1.6rem; font-weight: 800; color: var(--text); }
#drop-zone p { color: var(--muted); font-size: 0.9rem; font-family: 'Space Mono', monospace; }
#file-input { display: none; }
/* EDITOR LAYOUT */
#editor { display: none; flex: 1; flex-direction: column; min-height: 0; overflow: hidden; }
.editor-layout { display: flex; flex: 1; min-height: 0; overflow: hidden; height: 100%; }
/* SIDEBAR */
.sidebar {
width: 240px;
min-width: 160px;
max-width: 480px;
border-right: 1px solid var(--border);
background: var(--surface);
overflow-y: auto;
overflow-x: hidden;
padding: 16px 0;
flex-shrink: 0;
height: 100%;
box-sizing: border-box;
align-self: stretch;
}
/* RESIZE HANDLE */
.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.5; }
.sidebar-section { margin-bottom: 4px; }
.sidebar-label {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
padding: 8px 20px 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
transition: color 0.15s;
}
.sidebar-label:hover { color: var(--text); }
.sidebar-label .section-chevron {
font-size: 0.55rem;
transition: transform 0.2s;
opacity: 0.5;
}
.sidebar-label .section-chevron.collapsed { transform: rotate(-90deg); }
.sidebar-children {
overflow: hidden;
max-height: 2000px;
transition: max-height 0.25s ease;
}
.sidebar-children.collapsed { max-height: 0; }
.sidebar-item {
padding: 9px 20px;
font-size: 0.82rem;
cursor: pointer;
transition: all 0.1s;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-left: 3px solid transparent;
color: var(--muted);
font-family: 'Space Mono', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-item:hover { background: var(--surface2); color: var(--text); }
.sidebar-item.active {
background: rgba(200,245,66,0.07);
border-left-color: var(--accent);
color: var(--accent);
}
.sidebar-badge {
background: var(--surface2);
color: var(--muted);
font-size: 0.6rem;
padding: 2px 6px;
border-radius: 20px;
font-family: 'Space Mono', monospace;
}
.sidebar-item.active .sidebar-badge { background: rgba(200,245,66,0.15); color: var(--accent); }
/* CONTENT AREA */
.content { padding: 32px; overflow-y: auto; flex: 1; min-height: 0; min-width: 0; align-self: stretch; box-sizing: border-box; }
.section-title {
font-size: 1.4rem;
font-weight: 800;
margin-bottom: 6px;
color: var(--text);
}
.section-subtitle {
font-size: 0.78rem;
color: var(--muted);
font-family: 'Space Mono', monospace;
margin-bottom: 28px;
}
/* FORM */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group.full { grid-column: 1 / -1; }
label {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
font-family: 'Space Mono', monospace;
}
input[type="text"], input[type="number"], textarea, select {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 10px 14px;
font-family: 'Space Mono', monospace;
font-size: 0.8rem;
transition: border-color 0.15s;
outline: none;
}
input:focus, textarea:focus, select:focus { border-color: var(--accent); }
textarea { resize: vertical; min-height: 90px; line-height: 1.5; }
/* CARDS */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 14px;
transition: border-color 0.15s;
}
.card:hover { border-color: var(--border); }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
cursor: pointer;
user-select: none;
}
.card-title {
font-weight: 700;
font-size: 0.9rem;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.card-title .tag {
font-family: 'Space Mono', monospace;
font-size: 0.6rem;
padding: 2px 8px;
border-radius: 20px;
background: rgba(200,245,66,0.1);
color: var(--accent);
font-weight: 400;
}
.card-body { display: block; }
.card-body.collapsed { display: none; }
.chevron { color: var(--muted); font-size: 0.8rem; transition: transform 0.2s; }
.chevron.open { transform: rotate(180deg); }
/* PREREQUISITES */
.prereqs-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.prereq-chip {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 20px;
padding: 4px 12px;
font-family: 'Space Mono', monospace;
font-size: 0.7rem;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
}
.prereq-chip button {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 0;
font-size: 0.8rem;
line-height: 1;
text-transform: none;
letter-spacing: 0;
}
.prereq-chip button:hover { color: var(--danger); }
.prereq-input-row { display: flex; gap: 8px; margin-top: 8px; }
.prereq-input-row input { flex: 1; }
/* PARAMETER BLOCK */
.param-block {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 10px;
}
.param-block-header {
font-family: 'Space Mono', monospace;
font-size: 0.72rem;
color: var(--accent2);
margin-bottom: 12px;
font-weight: 700;
letter-spacing: 0.08em;
}
.param-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
/* IMAGE PREVIEW */
.image-preview {
width: 100%;
max-height: 180px;
object-fit: contain;
border-radius: var(--radius);
border: 1px solid var(--border);
margin-top: 8px;
background: var(--surface2);
}
.image-upload-label {
display: block;
background: var(--surface2);
border: 1px dashed var(--border);
border-radius: var(--radius);
padding: 12px;
text-align: center;
cursor: pointer;
color: var(--muted);
font-family: 'Space Mono', monospace;
font-size: 0.72rem;
margin-top: 8px;
transition: all 0.15s;
}
.image-upload-label:hover { border-color: var(--accent); color: var(--accent); }
/* TOAST */
#toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--accent);
color: #000;
font-family: 'Space Mono', monospace;
font-size: 0.75rem;
font-weight: 700;
padding: 12px 20px;
border-radius: var(--radius);
opacity: 0;
transform: translateY(10px);
transition: all 0.25s;
pointer-events: none;
z-index: 999;
}
#toast.show { opacity: 1; transform: translateY(0); }
/* DIVIDER */
.divider { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
/* EMPTY STATE */
.empty-state {
text-align: center;
padding: 40px;
color: var(--muted);
font-family: 'Space Mono', monospace;
font-size: 0.78rem;
}
/* JSON PREVIEW */
.json-preview {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
font-family: 'Space Mono', monospace;
font-size: 0.72rem;
color: var(--muted);
max-height: 300px;
overflow-y: auto;
white-space: pre;
word-break: break-all;
line-height: 1.6;
}
@media (max-width: 700px) {
.editor-layout { grid-template-columns: 1fr; }
.sidebar { border-right: none; border-bottom: 1px solid var(--border); }
.form-grid { grid-template-columns: 1fr; }
.param-grid { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<header>
<h1>.PU <span>file editor</span></h1>
<div class="header-actions">
<span id="filename-display" style="font-family:'Space Mono',monospace;font-size:0.72rem;color:var(--muted)"></span>
<button class="btn-ghost" id="load-new-btn">Load new file</button>
<button class="btn-primary" id="save-btn" style="display:none">↓ Save file</button>
</div>
</header>
<!-- Drop Zone -->
<div id="drop-zone">
<div class="drop-circle">📂</div>
<h2>Drop your .pu file</h2>
<p>or click below to browse</p>
<button class="btn-primary" onclick="document.getElementById('file-input').click()">Browse file</button>
<input type="file" id="file-input" accept=".pu,.json">
</div>
<!-- Editor -->
<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">Saved!</div>
<script>
let data = null;
let currentSection = 'general';
let currentItemId = null;
let currentFileName = 'output.pu';
// ─── 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;
const newW = Math.min(480, Math.max(160, startW + (e.clientX - startX)));
sidebar.style.width = newW + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
handle.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
});
})();
// ─── FILE LOADING ───────────────────────────────────────────────────────────
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault(); dropZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file) loadFile(file);
});
fileInput.addEventListener('change', e => { if (e.target.files[0]) loadFile(e.target.files[0]); });
document.getElementById('load-new-btn').addEventListener('click', () => fileInput.click());
function loadFile(file) {
currentFileName = file.name.replace(/\.[^.]+$/, '') + '_edited.pu';
document.getElementById('filename-display').textContent = file.name;
const reader = new FileReader();
reader.onload = e => {
try {
data = JSON.parse(e.target.result);
initEditor();
} catch(err) {
alert('Could not parse file as JSON: ' + err.message);
}
};
reader.readAsText(file);
}
// ─── INIT EDITOR ─────────────────────────────────────────────────────────────
function initEditor() {
document.getElementById('drop-zone').style.display = 'none';
document.getElementById('editor').style.display = 'flex';
document.getElementById('save-btn').style.display = '';
renderSidebar();
navigate('general', null);
}
// track which sections are collapsed
const collapsedSections = new Set();
function toggleSectionCollapse(key) {
if (collapsedSections.has(key)) {
collapsedSections.delete(key);
} else {
collapsedSections.add(key);
}
renderSidebar();
}
function renderSidebar() {
const sidebar = document.getElementById('sidebar');
const sections = [
{ key: 'general', label: 'General', icon: '⚙' },
{ key: 'majors', label: 'Majors', icon: '🎓', children: true },
{ key: 'classes', label: 'Classes', icon: '📚', children: true },
{ key: 'partners', label: 'Partners', icon: '🤝', children: true },
{ key: 'clubs', label: 'Clubs', icon: '🏷', children: true },
{ key: 'punishments', label: 'Punishments', icon: '⚡', children: true },
];
sidebar.innerHTML = sections.map(s => {
const items = data[s.key] || {};
const keys = Object.keys(items);
const isActive = currentSection === s.key;
const isCollapsed = collapsedSections.has(s.key);
const labelClick = s.children
? `onclick="toggleSectionCollapse('${s.key}')"`
: `onclick="navigate('${s.key}', null)"`;
let html = `
<div class="sidebar-section">
<div class="sidebar-label" ${labelClick}>
<span>${s.icon} ${s.label}</span>
${s.children ? `<span class="section-chevron ${isCollapsed ? 'collapsed' : ''}">▼</span>` : ''}
</div>
<div class="sidebar-children ${isCollapsed ? 'collapsed' : ''}">
<div class="sidebar-item ${isActive && !currentItemId ? 'active' : ''}"
onclick="navigate('${s.key}', null)">${s.label === 'General' ? 'Settings' : 'Overview'}
${s.children ? `<span class="sidebar-badge">${keys.length}</span>` : ''}
</div>
`;
if (s.children) {
keys.forEach(k => {
const item = items[k];
const name = item.name || item.title || k;
const shortName = name;
html += `<div class="sidebar-item ${currentSection === s.key && currentItemId === k ? 'active' : ''}"
onclick="navigate('${s.key}', '${k}')" style="padding-left:28px;font-size:0.75rem">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0">${name}</span>
</div>`;
});
}
html += '</div></div>';
return html;
}).join('');
}
// ─── NAVIGATION ──────────────────────────────────────────────────────────────
function navigate(section, itemId) {
currentSection = section;
currentItemId = itemId;
renderSidebar();
renderContent();
}
function renderContent() {
const content = document.getElementById('content');
if (currentSection === 'general') {
renderGeneral(content);
} else if (currentItemId === null) {
renderOverview(content, currentSection);
} else {
if (currentSection === 'majors') renderMajor(content, currentItemId);
else if (currentSection === 'classes') renderClass(content, currentItemId);
else if (currentSection === 'clubs') renderClub(content, currentItemId);
else if (currentSection === 'partners') renderPartner(content, currentItemId);
else if (currentSection === 'punishments') renderPunishment(content, currentItemId);
else renderGenericItem(content, currentSection, currentItemId);
}
}
// ─── GENERAL ─────────────────────────────────────────────────────────────────
function renderGeneral(el) {
const g = data.general || {};
el.innerHTML = `
<div class="section-title">General Settings</div>
<div class="section-subtitle">module metadata & info</div>
<div class="form-grid">
<div class="form-group full">
<label>Map ID</label>
<input type="text" id="g-mapId" value="${esc(data.mapId || '')}" placeholder="UUID">
</div>
<div class="form-group full">
<label>Title</label>
<input type="text" id="g-title" value="${esc(g.title || '')}" placeholder="Module title">
</div>
<div class="form-group full">
<label>Description</label>
<textarea id="g-desc" rows="4">${esc(g.description || '')}</textarea>
</div>
</div>
<hr class="divider">
<div style="display:flex;gap:12px;margin-top:4px">
<button class="btn-primary" onclick="saveGeneral()">Save changes</button>
</div>
`;
}
function saveGeneral() {
data.mapId = document.getElementById('g-mapId').value;
data.general = data.general || {};
data.general.title = document.getElementById('g-title').value;
data.general.description = document.getElementById('g-desc').value;
toast('General saved');
renderSidebar();
}
// ─── OVERVIEW ────────────────────────────────────────────────────────────────
function renderOverview(el, section) {
const items = data[section] || {};
const keys = Object.keys(items);
el.innerHTML = `
<div class="section-title">${capitalize(section)}</div>
<div class="section-subtitle">${keys.length} item${keys.length !== 1 ? 's' : ''}</div>
${keys.length === 0 ? '<div class="empty-state">No items yet. Add one below!</div>' : ''}
${keys.map(k => {
const item = items[k];
const name = item.name || item.title || k;
const badge = item.tier ? `<span style="font-family:'Space Mono',monospace;font-size:0.6rem;padding:2px 8px;border-radius:20px;background:rgba(123,94,167,0.15);color:var(--accent2);margin-left:8px">${esc(item.tier)}</span>` : '';
const taskCount = item.tasks ? Object.keys(item.tasks).length : 0;
const meta = [
item.id ? `id: ${esc(item.id)}` : null,
taskCount ? `${taskCount} task${taskCount!==1?'s':''}` : null,
item.days && item.days.length ? `days: ${item.days.map(d=>['Su','Mo','Tu','We','Th','Fr','Sa'][d]).join(', ')}` : null,
].filter(Boolean).join(' · ');
return `<div class="card" onclick="navigate('${section}', '${k}')" style="cursor:pointer">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-weight:700;margin-bottom:5px">${esc(name)}${badge}</div>
<div style="font-family:'Space Mono',monospace;font-size:0.7rem;color:var(--muted)">${meta}</div>
</div>
<div style="color:var(--accent);font-size:1.2rem"></div>
</div>
</div>`;
}).join('')}
<button class="btn-add" onclick="addItem('${section}')">+ Add new ${section.slice(0,-1)}</button>
`;
}
function addItem(section) {
const items = data[section] = data[section] || {};
const newKey = String(Date.now());
if (section === 'classes') {
items[newKey] = { id: newKey, type: 'class', name: 'New class', name2: '', comment: '', prerequisites: [], days: [], description: '', tier: 'beginner', tasks: {} };
} else if (section === 'majors') {
items[newKey] = { id: newKey, type: 'major', name: 'New major', name2: '', description: '', prerequisites: [], tasks: {} };
} else {
items[newKey] = { id: newKey, name: 'New item', description: '' };
}
navigate(section, newKey);
}
// ─── MAJOR EDITOR ────────────────────────────────────────────────────────────
function renderMajor(el, id) {
const major = data.majors[id];
if (!major) return;
const tasks = major.tasks || {};
const taskKeys = Object.keys(tasks);
el.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px">
<div>
<div class="section-title">${esc(major.name || id)}</div>
<div class="section-subtitle">major editor · id ${esc(major.id || id)}</div>
</div>
<button class="btn-danger" onclick="deleteItem('majors','${id}')">Delete major</button>
</div>
<!-- BASIC INFO -->
<div class="card">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Basic Info <span class="tag">required</span></div>
<span class="chevron open">▼</span>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>ID</label>
<input type="text" id="m-id" value="${esc(major.id||'')}" placeholder="20000">
</div>
<div class="form-group">
<label>Type</label>
<input type="text" id="m-type" value="${esc(major.type||'major')}" placeholder="major">
</div>
<div class="form-group">
<label>Name (primary)</label>
<input type="text" id="m-name" value="${esc(major.name||'')}">
</div>
<div class="form-group">
<label>Name (secondary)</label>
<input type="text" id="m-name2" value="${esc(major.name2||'')}">
</div>
<div class="form-group full">
<label>Description</label>
<textarea id="m-desc">${esc(major.description||'')}</textarea>
</div>
</div>
</div>
</div>
<!-- PREREQUISITES -->
<div class="card">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Prerequisites <span class="tag">${(major.prerequisites||[]).length} items</span></div>
<span class="chevron open">▼</span>
</div>
<div class="card-body">
<div class="prereqs-list" id="prereqs-list">
${(major.prerequisites||[]).map(p => prereqChip(p)).join('')}
</div>
<div class="prereq-input-row">
<input type="number" id="prereq-input" placeholder="Enter ID (e.g. 66)">
<button class="btn-ghost" onclick="addPrereq('majors','${id}')">Add</button>
</div>
</div>
</div>
<!-- IMAGE -->
<div class="card">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Image</div>
<span class="chevron open">▼</span>
</div>
<div class="card-body">
${major.image ? `<img class="image-preview" id="m-img-preview" src="${major.image}">` : `<img class="image-preview" id="m-img-preview" src="" style="display:none">`}
<label class="image-upload-label">
<input type="file" accept="image/*" style="display:none" onchange="handleImageUpload(event,'majors','${id}')">
📷 Upload new image (will be embedded as base64)
</label>
</div>
</div>
<!-- TASKS -->
<div style="display:flex;align-items:center;justify-content:space-between;margin: 20px 0 12px">
<div>
<div style="font-weight:800;font-size:1.1rem">Tasks</div>
<div style="font-family:'Space Mono',monospace;font-size:0.7rem;color:var(--muted)">${taskKeys.length} task${taskKeys.length!==1?'s':''}</div>
</div>
<button class="btn-ghost" onclick="addTask('majors','${id}')">+ Add task</button>
</div>
${taskKeys.length === 0 ? '<div class="empty-state">No tasks yet.</div>' : ''}
<div id="tasks-container">
${taskKeys.map(tk => renderTaskCard('majors', id, tk, tasks[tk])).join('')}
</div>
<hr class="divider">
<button class="btn-primary" onclick="saveMajor('${id}')">Save major</button>
`;
}
function renderTaskCard(section, itemId, taskKey, task) {
const params = task.parameters || {};
const paramKeys = Object.keys(params);
const tags = (task.tags || []).join(', ');
return `
<div class="card" id="task-card-${taskKey}">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Task #${esc(task.id||taskKey)}
<span class="tag">${task.isExam ? 'exam' : 'task'}</span>
${tags ? `<span class="tag" style="background:rgba(123,94,167,0.15);color:var(--accent2)">${esc(tags)}</span>` : ''}
</div>
<div style="display:flex;align-items:center;gap:8px">
<button class="btn-danger" onclick="event.stopPropagation();deleteTask('${section}','${itemId}','${taskKey}')">Delete</button>
<span class="chevron open">▼</span>
</div>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>Task ID</label>
<input type="text" id="t-${taskKey}-id" value="${esc(task.id||taskKey)}">
</div>
<div class="form-group">
<label>Is Exam?</label>
<select id="t-${taskKey}-isExam">
<option value="true" ${task.isExam?'selected':''}>Yes</option>
<option value="false" ${!task.isExam?'selected':''}>No</option>
</select>
</div>
<div class="form-group full">
<label>Task text (HTML allowed)</label>
<textarea id="t-${taskKey}-task" rows="6">${esc(task.task||'')}</textarea>
</div>
<div class="form-group full">
<label>Tags (comma-separated)</label>
<input type="text" id="t-${taskKey}-tags" value="${esc((task.tags||[]).join(', '))}" placeholder="crawl, intermediate, exam">
</div>
</div>
${paramKeys.length > 0 ? `
<div style="margin-top:14px">
<div style="font-family:'Space Mono',monospace;font-size:0.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px">Parameters</div>
${paramKeys.map(pk => renderParamBlock(taskKey, pk, params[pk])).join('')}
</div>
` : ''}
<button class="btn-add" onclick="addParam('${section}','${itemId}','${taskKey}')">+ Add parameter</button>
</div>
</div>
`;
}
function renderParamBlock(taskKey, paramKey, param) {
return `
<div class="param-block" id="param-${taskKey}-${paramKey}">
<div class="param-block-header" style="display:flex;justify-content:space-between">
<span>\$param${paramKey}</span>
<button class="btn-danger" onclick="deleteParam('${taskKey}','${paramKey}')">Remove</button>
</div>
<div class="param-grid">
<div class="form-group">
<label>Value</label>
<input type="number" id="p-${taskKey}-${paramKey}-val" value="${param.value||0}">
</div>
<div class="form-group">
<label>Unit</label>
<select id="p-${taskKey}-${paramKey}-unit">
${['minutes','hours','days','weeks',''].map(u =>
`<option value="${u}" ${param.unit===u?'selected':''}>${u||'(none)'}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label>Timer info</label>
<input type="text" id="p-${taskKey}-${paramKey}-timer" value="${esc(param.timerInfo||'')}">
</div>
</div>
<div class="form-grid" style="margin-top:10px">
<div class="form-group">
<label>Apply multiplier</label>
<select id="p-${taskKey}-${paramKey}-mult">
<option value="false" ${!param.applyMultiplier?'selected':''}>No</option>
<option value="true" ${param.applyMultiplier?'selected':''}>Yes</option>
</select>
</div>
<div class="form-group">
<label>Spawn timer</label>
<select id="p-${taskKey}-${paramKey}-spawn">
<option value="false" ${!param.spawnTimer?'selected':''}>No</option>
<option value="true" ${param.spawnTimer?'selected':''}>Yes</option>
</select>
</div>
<div class="form-group">
<label>Hidden task</label>
<select id="p-${taskKey}-${paramKey}-hidden">
<option value="false" ${!param.hiddenTask?'selected':''}>No</option>
<option value="true" ${param.hiddenTask?'selected':''}>Yes</option>
</select>
</div>
<div class="form-group">
<label>Punish time</label>
<select id="p-${taskKey}-${paramKey}-pun">
<option value="false" ${!param.punishTime?'selected':''}>No</option>
<option value="true" ${param.punishTime?'selected':''}>Yes</option>
</select>
</div>
<div class="form-group">
<label>Auto-start timer</label>
<select id="p-${taskKey}-${paramKey}-autostart">
<option value="false" ${!param.startTimerAutomatically?'selected':''}>No</option>
<option value="true" ${param.startTimerAutomatically?'selected':''}>Yes</option>
</select>
</div>
<div class="form-group">
<label>Provide counter</label>
<select id="p-${taskKey}-${paramKey}-counter">
<option value="false" ${!param.provideCounter?'selected':''}>No</option>
<option value="true" ${param.provideCounter?'selected':''}>Yes</option>
</select>
</div>
</div>
</div>
`;
}
// ─── SAVE MAJOR ──────────────────────────────────────────────────────────────
function saveMajor(id) {
const major = data.majors[id];
major.id = document.getElementById('m-id').value;
major.type = document.getElementById('m-type').value;
major.name = document.getElementById('m-name').value;
major.name2 = document.getElementById('m-name2').value;
major.description = document.getElementById('m-desc').value;
// save tasks
const tasks = major.tasks || {};
Object.keys(tasks).forEach(tk => {
const task = tasks[tk];
const idEl = document.getElementById(`t-${tk}-id`);
const examEl = document.getElementById(`t-${tk}-isExam`);
const textEl = document.getElementById(`t-${tk}-task`);
const tagsEl = document.getElementById(`t-${tk}-tags`);
if (idEl) task.id = idEl.value;
if (examEl) task.isExam = examEl.value === 'true';
if (textEl) task.task = textEl.value;
if (tagsEl) task.tags = tagsEl.value.split(',').map(s => s.trim()).filter(Boolean);
const params = task.parameters || {};
Object.keys(params).forEach(pk => {
const p = params[pk];
const valEl = document.getElementById(`p-${tk}-${pk}-val`);
const unitEl = document.getElementById(`p-${tk}-${pk}-unit`);
const timerEl = document.getElementById(`p-${tk}-${pk}-timer`);
const multEl = document.getElementById(`p-${tk}-${pk}-mult`);
const spawnEl = document.getElementById(`p-${tk}-${pk}-spawn`);
const hiddenEl = document.getElementById(`p-${tk}-${pk}-hidden`);
const punEl = document.getElementById(`p-${tk}-${pk}-pun`);
if (valEl) p.value = Number(valEl.value);
if (unitEl) p.unit = unitEl.value;
if (timerEl) p.timerInfo = timerEl.value;
if (multEl) p.applyMultiplier = multEl.value === 'true';
if (spawnEl) p.spawnTimer = spawnEl.value === 'true';
if (hiddenEl) p.hiddenTask = hiddenEl.value === 'true';
if (punEl) p.punishTime = punEl.value === 'true';
const autostartEl = document.getElementById(`p-${tk}-${pk}-autostart`);
const counterEl = document.getElementById(`p-${tk}-${pk}-counter`);
if (autostartEl) p.startTimerAutomatically = autostartEl.value === 'true';
if (counterEl) p.provideCounter = counterEl.value === 'true';
});
});
toast('Major saved!');
renderSidebar();
}
// ─── PREREQUISITES ───────────────────────────────────────────────────────────
function prereqChip(id) {
return `<div class="prereq-chip">${id}<button onclick="removePrereq(${id})">×</button></div>`;
}
function addPrereq(sectionId, itemId) {
const input = document.getElementById('prereq-input');
const val = parseInt(input.value);
if (isNaN(val)) return;
const item = data[sectionId][itemId];
item.prerequisites = item.prerequisites || [];
if (!item.prerequisites.includes(val)) {
item.prerequisites.push(val);
document.getElementById('prereqs-list').innerHTML = item.prerequisites.map(p => prereqChip(p)).join('');
}
input.value = '';
}
function removePrereq(id) {
const item = data[currentSection][currentItemId];
if (!item) return;
item.prerequisites = (item.prerequisites || []).filter(p => p !== id);
document.getElementById('prereqs-list').innerHTML = item.prerequisites.map(p => prereqChip(p)).join('');
}
// ─── TASKS ───────────────────────────────────────────────────────────────────
function addTask(section, itemId) {
const item = data[section][itemId];
item.tasks = item.tasks || {};
const newKey = String(Date.now());
item.tasks[newKey] = {
id: newKey,
task: 'New task description.',
tags: [],
parameters: {},
isExam: false
};
navigate(section, itemId);
}
function deleteTask(section, itemId, taskKey) {
if (!confirm('Delete this task?')) return;
delete data[section][itemId].tasks[taskKey];
navigate(section, itemId);
}
function addParam(section, itemId, taskKey) {
const task = data[section][itemId].tasks[taskKey];
task.parameters = task.parameters || {};
const existing = Object.keys(task.parameters).map(Number).filter(n => !isNaN(n));
const nextKey = existing.length > 0 ? String(Math.max(...existing) + 2) : '2';
task.parameters[nextKey] = {
value: 0, unit: 'minutes', timerInfo: '',
applyMultiplier: false, spawnTimer: false,
startTimerAutomatically: false, provideCounter: false,
hiddenTask: false, punTier: '', punishTime: false, punishTimeMinutes: 0
};
navigate(section, itemId);
}
function deleteParam(taskKey, paramKey) {
const item = data[currentSection][currentItemId];
if (!item) return;
Object.keys(item.tasks).forEach(tk => {
if (tk === taskKey || item.tasks[tk].id === taskKey) {
delete item.tasks[tk].parameters[paramKey];
}
});
const el = document.getElementById(`param-${taskKey}-${paramKey}`);
if (el) el.remove();
}
// ─── IMAGE ───────────────────────────────────────────────────────────────────
function handleImageUpload(event, section, itemId) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
data[section][itemId].image = e.target.result;
const preview = document.getElementById('m-img-preview');
if (preview) { preview.src = e.target.result; preview.style.display = ''; }
toast('Image updated');
};
reader.readAsDataURL(file);
}
// ─── CLASS EDITOR ─────────────────────────────────────────────────────────────
const TIER_OPTIONS = ['beginner','intermediate','advanced','master'];
const DAY_OPTIONS = [
{v:0,l:'Sun'},{v:1,l:'Mon'},{v:2,l:'Tue'},{v:3,l:'Wed'},
{v:4,l:'Thu'},{v:5,l:'Fri'},{v:6,l:'Sat'}
];
function renderClass(el, id) {
const cls = data.classes[id];
if (!cls) return;
const tasks = cls.tasks || {};
const taskKeys = Object.keys(tasks);
const days = cls.days || [];
el.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px">
<div>
<div class="section-title">${esc(cls.name || id)}</div>
<div class="section-subtitle">class editor · id ${esc(cls.id || id)}</div>
</div>
<button class="btn-danger" onclick="deleteItem('classes','${id}')">Delete class</button>
</div>
<!-- BASIC INFO -->
<div class="card">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Basic Info <span class="tag">required</span></div>
<span class="chevron open">▼</span>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>ID</label>
<input type="text" id="c-id" value="${esc(cls.id||'')}">
</div>
<div class="form-group">
<label>Type</label>
<input type="text" id="c-type" value="${esc(cls.type||'class')}">
</div>
<div class="form-group">
<label>Name (primary)</label>
<input type="text" id="c-name" value="${esc(cls.name||'')}">
</div>
<div class="form-group">
<label>Name (secondary)</label>
<input type="text" id="c-name2" value="${esc(cls.name2||'')}">
</div>
<div class="form-group">
<label>Tier</label>
<select id="c-tier">
${TIER_OPTIONS.map(t => `<option value="${t}" ${cls.tier===t?'selected':''}>${t}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>Comment</label>
<input type="text" id="c-comment" value="${esc(cls.comment||'')}">
</div>
<div class="form-group full">
<label>Description</label>
<textarea id="c-desc">${esc(cls.description||'')}</textarea>
</div>
</div>
</div>
</div>
<!-- DAYS -->
<div class="card">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Active Days <span class="tag">${days.length} selected</span></div>
<span class="chevron open">▼</span>
</div>
<div class="card-body">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:4px">
${DAY_OPTIONS.map(d => `
<label style="display:flex;align-items:center;gap:6px;font-family:'Space Mono',monospace;font-size:0.75rem;cursor:pointer;padding:6px 12px;border-radius:20px;border:1px solid var(--border);background:${days.includes(d.v)?'rgba(200,245,66,0.1)':'var(--surface2)'};color:${days.includes(d.v)?'var(--accent)':'var(--muted)'}">
<input type="checkbox" id="c-day-${d.v}" ${days.includes(d.v)?'checked':''} style="display:none" onchange="updateDayLabel(this,${d.v})">
${d.l}
</label>
`).join('')}
</div>
</div>
</div>
<!-- PREREQUISITES -->
<div class="card">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Prerequisites <span class="tag">${(cls.prerequisites||[]).length} items</span></div>
<span class="chevron open">▼</span>
</div>
<div class="card-body">
<div class="prereqs-list" id="prereqs-list">
${(cls.prerequisites||[]).map(p => prereqChip(p)).join('')}
</div>
<div class="prereq-input-row">
<input type="number" id="prereq-input" placeholder="Enter ID (e.g. 2)">
<button class="btn-ghost" onclick="addPrereq('classes','${id}')">Add</button>
</div>
</div>
</div>
<!-- IMAGE -->
<div class="card">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Image</div>
<span class="chevron open">▼</span>
</div>
<div class="card-body">
${cls.image ? `<img class="image-preview" id="m-img-preview" src="${cls.image}">` : `<img class="image-preview" id="m-img-preview" src="" style="display:none">`}
<label class="image-upload-label">
<input type="file" accept="image/*" style="display:none" onchange="handleImageUpload(event,'classes','${id}')">
📷 Upload new image (will be embedded as base64)
</label>
</div>
</div>
<!-- TASKS -->
<div style="display:flex;align-items:center;justify-content:space-between;margin: 20px 0 12px">
<div>
<div style="font-weight:800;font-size:1.1rem">Tasks</div>
<div style="font-family:'Space Mono',monospace;font-size:0.7rem;color:var(--muted)">${taskKeys.length} task${taskKeys.length!==1?'s':''}</div>
</div>
<button class="btn-ghost" onclick="addTask('classes','${id}')">+ Add task</button>
</div>
${taskKeys.length === 0 ? '<div class="empty-state">No tasks yet.</div>' : ''}
<div id="tasks-container">
${taskKeys.map(tk => renderTaskCard('classes', id, tk, tasks[tk])).join('')}
</div>
<hr class="divider">
<button class="btn-primary" onclick="saveClass('${id}')">Save class</button>
`;
}
function updateDayLabel(checkbox, dayVal) {
const label = checkbox.parentElement;
if (checkbox.checked) {
label.style.background = 'rgba(200,245,66,0.1)';
label.style.color = 'var(--accent)';
} else {
label.style.background = 'var(--surface2)';
label.style.color = 'var(--muted)';
}
}
function saveClass(id) {
const cls = data.classes[id];
cls.id = document.getElementById('c-id').value;
cls.type = document.getElementById('c-type').value;
cls.name = document.getElementById('c-name').value;
cls.name2 = document.getElementById('c-name2').value;
cls.tier = document.getElementById('c-tier').value;
cls.comment = document.getElementById('c-comment').value;
cls.description = document.getElementById('c-desc').value;
// save days
cls.days = DAY_OPTIONS
.map(d => d.v)
.filter(v => { const el = document.getElementById(`c-day-${v}`); return el && el.checked; });
// save tasks
const tasks = cls.tasks || {};
Object.keys(tasks).forEach(tk => {
const task = tasks[tk];
const idEl = document.getElementById(`t-${tk}-id`);
const examEl = document.getElementById(`t-${tk}-isExam`);
const textEl = document.getElementById(`t-${tk}-task`);
const tagsEl = document.getElementById(`t-${tk}-tags`);
if (idEl) task.id = idEl.value;
if (examEl) task.isExam = examEl.value === 'true';
if (textEl) task.task = textEl.value;
if (tagsEl) task.tags = tagsEl.value.split(',').map(s => s.trim()).filter(Boolean);
const params = task.parameters || {};
Object.keys(params).forEach(pk => {
const p = params[pk];
const valEl = document.getElementById(`p-${tk}-${pk}-val`);
const unitEl = document.getElementById(`p-${tk}-${pk}-unit`);
const timerEl = document.getElementById(`p-${tk}-${pk}-timer`);
const multEl = document.getElementById(`p-${tk}-${pk}-mult`);
const spawnEl = document.getElementById(`p-${tk}-${pk}-spawn`);
const hiddenEl = document.getElementById(`p-${tk}-${pk}-hidden`);
const punEl = document.getElementById(`p-${tk}-${pk}-pun`);
if (valEl) p.value = Number(valEl.value);
if (unitEl) p.unit = unitEl.value;
if (timerEl) p.timerInfo = timerEl.value;
if (multEl) p.applyMultiplier = multEl.value === 'true';
if (spawnEl) p.spawnTimer = spawnEl.value === 'true';
if (hiddenEl) p.hiddenTask = hiddenEl.value === 'true';
if (punEl) p.punishTime = punEl.value === 'true';
const autostartEl = document.getElementById(`p-${tk}-${pk}-autostart`);
const counterEl = document.getElementById(`p-${tk}-${pk}-counter`);
if (autostartEl) p.startTimerAutomatically = autostartEl.value === 'true';
if (counterEl) p.provideCounter = counterEl.value === 'true';
});
});
toast('Class saved!');
renderSidebar();
}
// ─── GENERIC ITEM EDITOR ─────────────────────────────────────────────────────
// ─── CLUB EDITOR ─────────────────────────────────────────────────────────────
function renderClub(el, id) {
const club = data.clubs[id] || {};
const perks = club.perks || {};
const perkKeys = Object.keys(perks);
const tierColors = { elite: '#c8f542', rare: '#7b5ea7', common: '#888' };
const tierColor = tierColors[club.tier] || '#888';
el.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px">
<div>
<div class="section-title">${esc(club.name || id)}</div>
<div class="section-subtitle">club editor · ${id}
${club.tier ? `<span class="tag" style="background:rgba(200,245,66,0.12);color:${tierColor};margin-left:6px">${esc(club.tier)}</span>` : ''}
</div>
</div>
<button class="btn-danger" onclick="deleteItem('clubs','${id}')">Delete</button>
</div>
<div class="card">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Club Info</div>
<span class="chevron open">▼</span>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>ID</label>
<input type="text" id="cl-id" value="${esc(club.id||id)}">
</div>
<div class="form-group">
<label>Name</label>
<input type="text" id="cl-name" value="${esc(club.name||'')}">
</div>
<div class="form-group">
<label>Tier</label>
<select id="cl-tier">
${['normal','elite',''].map(t =>
`<option value="${t}" ${club.tier===t?'selected':''}>${t||'(none)'}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label>Comment</label>
<input type="text" id="cl-comment" value="${esc(club.comment||'')}">
</div>
<div class="form-group full">
<label>Description</label>
<textarea id="cl-desc" rows="3">${esc(club.description||'')}</textarea>
</div>
<div class="form-group full">
<label>Image (base64 data URI)</label>
<div style="display:flex;gap:10px;align-items:flex-start">
${club.image ? `<img src="${club.image}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--border);flex-shrink:0" id="cl-img-preview">` : `<div id="cl-img-preview" style="width:72px;height:72px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:0.7rem">no img</div>`}
<div style="flex:1">
<input type="file" accept="image/*" id="cl-img-file" style="width:100%" onchange="previewClubImage('${id}')">
<div style="font-size:0.7rem;color:var(--muted);margin-top:4px">Upload to replace image</div>
</div>
</div>
</div>
</div>
</div>
</div>
<hr class="divider">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div>
<div style="font-size:1rem;font-weight:700;color:var(--text)">Perks</div>
<div style="font-size:0.75rem;color:var(--muted)">${perkKeys.length} perk${perkKeys.length!==1?'s':''}</div>
</div>
<button class="btn-ghost" onclick="addPerk('${id}')">+ Add perk</button>
</div>
${perkKeys.map(pk => renderPerkCard(id, pk, perks[pk])).join('')}
<button class="btn-primary" style="margin-top:16px" onclick="saveClub('${id}')">Save club</button>
`;
}
function renderPerkCard(clubId, perkKey, perk) {
const tags = (perk.tags || []).join(', ');
return `
<div class="card" id="perk-card-${perkKey}">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Perk #${esc(perk.id||perkKey)}
${perk.perkType ? `<span class="tag" style="background:rgba(123,94,167,0.15);color:var(--accent2)">${esc(perk.perkType)}</span>` : ''}
${perk.perkVal !== undefined ? `<span class="tag" style="background:rgba(200,245,66,0.1);color:var(--accent)">${perk.perkVal > 0 ? '+' : ''}${perk.perkVal}%</span>` : ''}
</div>
<div style="display:flex;align-items:center;gap:8px">
<button class="btn-danger" onclick="event.stopPropagation();deletePerk('${clubId}','${perkKey}')">Delete</button>
<span class="chevron open">▼</span>
</div>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>Perk ID</label>
<input type="text" id="pk-${perkKey}-id" value="${esc(perk.id||perkKey)}">
</div>
<div class="form-group">
<label>Perk Type</label>
<select id="pk-${perkKey}-perkType">
${['text','difficulty','reward','attendance','resetRoulette','skip','multiplier','other'].map(t =>
`<option value="${t}" ${perk.perkType===t?'selected':''}>${t}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label>Perk Value (%)</label>
<input type="number" id="pk-${perkKey}-perkVal" value="${perk.perkVal||0}" placeholder="-5">
</div>
<div class="form-group">
<label>Mod Type</label>
<select id="pk-${perkKey}-modType">
${['text','difficulty','reward','attendance','resetRoulette','skip','multiplier','other'].map(t =>
`<option value="${t}" ${perk.modType===t?'selected':''}>${t}</option>`
).join('')}
</select>
</div>
<div class="form-group full">
<label>Job (what the user must do to earn this perk)</label>
<textarea id="pk-${perkKey}-job" rows="4">${esc(perk.job||'')}</textarea>
</div>
<div class="form-group full">
<label>Perk (reward description)</label>
<textarea id="pk-${perkKey}-perk" rows="2">${esc(perk.perk||'')}</textarea>
</div>
<div class="form-group full">
<label>Mod Val (display/modifier text)</label>
<textarea id="pk-${perkKey}-modVal" rows="3">${esc(perk.modVal||'')}</textarea>
</div>
<div class="form-group full">
<label>Tags (comma-separated)</label>
<input type="text" id="pk-${perkKey}-tags" value="${esc(tags)}" placeholder="gambling, risk, reward">
</div>
</div>
</div>
</div>
`;
}
function saveClub(id) {
const club = data.clubs[id];
club.id = document.getElementById('cl-id').value;
club.name = document.getElementById('cl-name').value;
club.tier = document.getElementById('cl-tier').value;
club.comment = document.getElementById('cl-comment').value;
club.description = document.getElementById('cl-desc').value;
// save perks
const perks = club.perks || {};
Object.keys(perks).forEach(pk => {
const p = perks[pk];
p.id = document.getElementById(`pk-${pk}-id`)?.value || p.id;
p.perkType= document.getElementById(`pk-${pk}-perkType`)?.value || p.perkType;
p.perkVal = parseFloat(document.getElementById(`pk-${pk}-perkVal`)?.value) || p.perkVal;
p.modType = document.getElementById(`pk-${pk}-modType`)?.value || p.modType;
p.job = document.getElementById(`pk-${pk}-job`)?.value || p.job;
p.perk = document.getElementById(`pk-${pk}-perk`)?.value || p.perk;
p.modVal = document.getElementById(`pk-${pk}-modVal`)?.value || p.modVal;
p.tags = (document.getElementById(`pk-${pk}-tags`)?.value || '').split(',').map(t=>t.trim()).filter(Boolean);
});
toast('Club saved!');
renderSidebar();
renderContent();
}
function addPerk(clubId) {
const club = data.clubs[clubId];
if (!club.perks) club.perks = {};
const existingKeys = Object.keys(club.perks).map(Number).filter(n=>!isNaN(n));
const newKey = String((existingKeys.length ? Math.max(...existingKeys) : 0) + 1);
club.perks[newKey] = {
id: newKey, job: '', perk: '', tags: [],
modType: 'text', modVal: '', perkType: 'difficulty', perkVal: -5
};
renderContent();
document.getElementById(`perk-card-${newKey}`)?.scrollIntoView({ behavior: 'smooth' });
}
function deletePerk(clubId, perkKey) {
if (!confirm('Delete this perk?')) return;
delete data.clubs[clubId].perks[perkKey];
renderContent();
}
function previewClubImage(clubId) {
const file = document.getElementById('cl-img-file').files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
data.clubs[clubId].image = e.target.result;
const prev = document.getElementById('cl-img-preview');
if (prev) { prev.src = e.target.result; prev.style.display='block'; prev.tagName!=='IMG' && (prev.outerHTML=`<img src="${e.target.result}" id="cl-img-preview" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--border);flex-shrink:0">`); }
toast('Image loaded!');
};
reader.readAsDataURL(file);
}
// ─── PARTNER EDITOR ──────────────────────────────────────────────────────────
function renderPartner(el, id) {
const partner = data.partners[id] || {};
const perks = partner.perks || {};
const perkKeys = Object.keys(perks);
const isUrl = partner.image && partner.image.startsWith('http');
el.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px">
<div>
<div class="section-title">${esc(partner.name || id)}</div>
<div class="section-subtitle">partner editor · ${id}
${partner.tier ? `<span class="tag" style="background:rgba(123,94,167,0.15);color:var(--accent2);margin-left:6px">tier ${esc(partner.tier)}</span>` : ''}
</div>
</div>
<button class="btn-danger" onclick="deleteItem('partners','${id}')">Delete</button>
</div>
<div class="card">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Partner Info</div>
<span class="chevron open">▼</span>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>ID</label>
<input type="text" id="pt-id" value="${esc(partner.id||id)}">
</div>
<div class="form-group">
<label>Name</label>
<input type="text" id="pt-name" value="${esc(partner.name||'')}">
</div>
<div class="form-group">
<label>Name 2</label>
<input type="text" id="pt-name2" value="${esc(partner.name2||'')}" placeholder="subtitle / alias">
</div>
<div class="form-group">
<label>Tier</label>
<input type="text" id="pt-tier" value="${esc(partner.tier||'')}" placeholder="1, 2, elite…">
</div>
<div class="form-group full">
<label>Description</label>
<textarea id="pt-desc" rows="3">${esc(partner.description||'')}</textarea>
</div>
<div class="form-group full">
<label>Image</label>
<div style="display:flex;gap:12px;align-items:flex-start">
${partner.image
? `<img src="${esc(partner.image)}" style="width:72px;height:88px;object-fit:cover;border-radius:6px;border:1px solid var(--border);flex-shrink:0" id="pt-img-preview">`
: `<div id="pt-img-preview" style="width:72px;height:88px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:0.7rem">no img</div>`}
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
<div>
<label style="font-size:0.7rem;color:var(--muted)">URL</label>
<input type="text" id="pt-img-url" value="${isUrl ? esc(partner.image) : ''}" placeholder="https://…" oninput="previewPartnerImageUrl('${id}')">
</div>
<div>
<label style="font-size:0.7rem;color:var(--muted)">— or upload file —</label>
<input type="file" accept="image/*" id="pt-img-file" onchange="previewPartnerImageFile('${id}')">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<hr class="divider">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div>
<div style="font-size:1rem;font-weight:700;color:var(--text)">Perks</div>
<div style="font-size:0.75rem;color:var(--muted)">${perkKeys.length} perk${perkKeys.length!==1?'s':''}</div>
</div>
<button class="btn-ghost" onclick="addPartnerPerk('${id}')">+ Add perk</button>
</div>
${perkKeys.map(pk => renderPartnerPerkCard(id, pk, perks[pk])).join('')}
<button class="btn-primary" style="margin-top:16px" onclick="savePartner('${id}')">Save partner</button>
`;
}
function renderPartnerPerkCard(partnerId, perkKey, perk) {
const tags = (perk.tags || []).join(', ');
// modVal can be number or string
const modValStr = perk.modVal !== undefined ? String(perk.modVal) : '';
const perkValStr = perk.perkVal !== undefined ? String(perk.perkVal) : '';
// detect if perkVal is numeric
const perkValIsNum = !isNaN(parseFloat(perkValStr)) && perkValStr.trim() !== '';
return `
<div class="card" id="ptperk-card-${perkKey}">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Perk #${esc(perk.id||perkKey)}
${perk.perkType ? `<span class="tag" style="background:rgba(123,94,167,0.15);color:var(--accent2)">${esc(perk.perkType)}</span>` : ''}
${perk.modType ? `<span class="tag">${esc(perk.modType)}</span>` : ''}
</div>
<div style="display:flex;align-items:center;gap:8px">
<button class="btn-danger" onclick="event.stopPropagation();deletePartnerPerk('${partnerId}','${perkKey}')">Delete</button>
<span class="chevron open">▼</span>
</div>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>Perk ID</label>
<input type="text" id="ptpk-${perkKey}-id" value="${esc(perk.id||perkKey)}">
</div>
<div class="form-group">
<label>Mod Type</label>
<select id="ptpk-${perkKey}-modType">
${['text','difficulty','reward','attendance','resetRoulette','skip','multiplier','other'].map(t =>
`<option value="${t}" ${perk.modType===t?'selected':''}>${t}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label>Mod Val (number or text)</label>
<input type="text" id="ptpk-${perkKey}-modVal" value="${esc(modValStr)}" placeholder="100 or description text">
</div>
<div class="form-group">
<label>Perk Type</label>
<select id="ptpk-${perkKey}-perkType">
${['text','difficulty','reward','attendance','resetRoulette','skip','multiplier','other'].map(t =>
`<option value="${t}" ${perk.perkType===t?'selected':''}>${t}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label>Perk Val (number or text)</label>
<input type="text" id="ptpk-${perkKey}-perkVal" value="${esc(perkValStr)}" placeholder="1 or description text">
</div>
<div class="form-group full">
<label>Job (what is required / the modifier applied)</label>
<textarea id="ptpk-${perkKey}-job" rows="3">${esc(perk.job||'')}</textarea>
</div>
<div class="form-group full">
<label>Perk (reward / effect description)</label>
<textarea id="ptpk-${perkKey}-perk" rows="2">${esc(perk.perk||'')}</textarea>
</div>
<div class="form-group full">
<label>Tags (comma-separated)</label>
<input type="text" id="ptpk-${perkKey}-tags" value="${esc(tags)}" placeholder="posture, mannerisms">
</div>
</div>
</div>
</div>
`;
}
function savePartner(id) {
const partner = data.partners[id];
partner.id = document.getElementById('pt-id').value;
partner.name = document.getElementById('pt-name').value;
partner.name2 = document.getElementById('pt-name2').value;
partner.tier = document.getElementById('pt-tier').value;
partner.description = document.getElementById('pt-desc').value;
// image: prefer URL field if filled, else keep existing
const urlVal = document.getElementById('pt-img-url').value.trim();
if (urlVal) partner.image = urlVal;
// save perks — coerce modVal/perkVal to number if numeric
const perks = partner.perks || {};
Object.keys(perks).forEach(pk => {
const p = perks[pk];
p.id = document.getElementById(`ptpk-${pk}-id`)?.value ?? p.id;
p.modType = document.getElementById(`ptpk-${pk}-modType`)?.value ?? p.modType;
p.perkType= document.getElementById(`ptpk-${pk}-perkType`)?.value ?? p.perkType;
p.job = document.getElementById(`ptpk-${pk}-job`)?.value ?? p.job;
p.perk = document.getElementById(`ptpk-${pk}-perk`)?.value ?? p.perk;
p.tags = (document.getElementById(`ptpk-${pk}-tags`)?.value || '').split(',').map(t=>t.trim()).filter(Boolean);
const rawModVal = document.getElementById(`ptpk-${pk}-modVal`)?.value ?? '';
p.modVal = isNaN(parseFloat(rawModVal)) || rawModVal.trim()==='' ? rawModVal : parseFloat(rawModVal);
const rawPerkVal = document.getElementById(`ptpk-${pk}-perkVal`)?.value ?? '';
p.perkVal = isNaN(parseFloat(rawPerkVal)) || rawPerkVal.trim()==='' ? rawPerkVal : parseFloat(rawPerkVal);
});
toast('Partner saved!');
renderSidebar();
renderContent();
}
function addPartnerPerk(partnerId) {
const partner = data.partners[partnerId];
if (!partner.perks) partner.perks = {};
const existingKeys = Object.keys(partner.perks).map(Number).filter(n=>!isNaN(n));
const newKey = String((existingKeys.length ? Math.max(...existingKeys) : 0) + 1);
partner.perks[newKey] = {
id: newKey, job: '', perk: '', tags: [],
modType: 'difficulty', modVal: 0, perkType: 'text', perkVal: ''
};
renderContent();
document.getElementById(`ptperk-card-${newKey}`)?.scrollIntoView({ behavior: 'smooth' });
}
function deletePartnerPerk(partnerId, perkKey) {
if (!confirm('Delete this perk?')) return;
delete data.partners[partnerId].perks[perkKey];
renderContent();
}
function previewPartnerImageUrl(partnerId) {
const url = document.getElementById('pt-img-url').value.trim();
const prev = document.getElementById('pt-img-preview');
if (url && prev) {
if (prev.tagName === 'IMG') { prev.src = url; }
else { prev.outerHTML = `<img src="${url}" id="pt-img-preview" style="width:72px;height:88px;object-fit:cover;border-radius:6px;border:1px solid var(--border);flex-shrink:0">`; }
}
}
function previewPartnerImageFile(partnerId) {
const file = document.getElementById('pt-img-file').files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
data.partners[partnerId].image = e.target.result;
document.getElementById('pt-img-url').value = '';
const prev = document.getElementById('pt-img-preview');
if (prev) {
if (prev.tagName === 'IMG') { prev.src = e.target.result; }
else { prev.outerHTML = `<img src="${e.target.result}" id="pt-img-preview" style="width:72px;height:88px;object-fit:cover;border-radius:6px;border:1px solid var(--border);flex-shrink:0">`; }
}
toast('Image loaded!');
};
reader.readAsDataURL(file);
}
// ─── PUNISHMENT EDITOR ───────────────────────────────────────────────────────
const PUNISHMENT_TIERS = ['light', 'hard', 'hardcore', ''];
function renderPunishment(el, id) {
const pun = data.punishments[id] || {};
const tasks = pun.tasks || {};
const taskKeys = Object.keys(tasks);
const tierColors = { light: '#82c4ff', hard: '#f5a623', hardcore: '#f54242' };
const tierColor = tierColors[pun.tier] || '#888';
const isUrl = pun.image && pun.image.startsWith('http');
el.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px">
<div>
<div class="section-title">${esc(pun.name || id)}</div>
<div class="section-subtitle">punishment editor · ${id}
${pun.tier ? `<span class="tag" style="background:rgba(200,245,66,0.08);color:${tierColor};margin-left:6px">${esc(pun.tier)}</span>` : ''}
</div>
</div>
<button class="btn-danger" onclick="deleteItem('punishments','${id}')">Delete</button>
</div>
<div class="card">
<div class="card-header" onclick="toggleCard(this)">
<div class="card-title">Punishment Info</div>
<span class="chevron open">▼</span>
</div>
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>ID</label>
<input type="text" id="pun-id" value="${esc(pun.id||id)}">
</div>
<div class="form-group">
<label>Name</label>
<input type="text" id="pun-name" value="${esc(pun.name||'')}">
</div>
<div class="form-group">
<label>Tier</label>
<select id="pun-tier">
${PUNISHMENT_TIERS.map(t =>
`<option value="${t}" ${pun.tier===t?'selected':''}>${t||'(none)'}</option>`
).join('')}
</select>
</div>
<div class="form-group full">
<label>Image</label>
<div style="display:flex;gap:12px;align-items:flex-start">
${pun.image
? `<img src="${esc(pun.image)}" style="width:72px;height:88px;object-fit:cover;border-radius:6px;border:1px solid var(--border);flex-shrink:0" id="pun-img-preview">`
: `<div id="pun-img-preview" style="width:72px;height:88px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:0.7rem">no img</div>`}
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
<div>
<label style="font-size:0.7rem;color:var(--muted)">URL</label>
<input type="text" id="pun-img-url" value="${isUrl ? esc(pun.image) : ''}" placeholder="https://…" oninput="previewPunishmentImageUrl()">
</div>
<div>
<label style="font-size:0.7rem;color:var(--muted)">— or upload file —</label>
<input type="file" accept="image/*" id="pun-img-file" onchange="previewPunishmentImageFile('${id}')">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<hr class="divider">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div>
<div style="font-size:1rem;font-weight:700;color:var(--text)">Tasks</div>
<div style="font-size:0.75rem;color:var(--muted)">${taskKeys.length} task${taskKeys.length!==1?'s':''}</div>
</div>
<button class="btn-ghost" onclick="addTask('punishments','${id}')">+ Add task</button>
</div>
${taskKeys.map(tk => renderTaskCard('punishments', id, tk, tasks[tk])).join('')}
<button class="btn-primary" style="margin-top:16px" onclick="savePunishment('${id}')">Save punishment</button>
`;
}
function savePunishment(id) {
const pun = data.punishments[id];
pun.id = document.getElementById('pun-id').value;
pun.name = document.getElementById('pun-name').value;
pun.tier = document.getElementById('pun-tier').value;
const urlVal = document.getElementById('pun-img-url').value.trim();
if (urlVal) pun.image = urlVal;
// reuse the same task/param save logic as classes
const tasks = pun.tasks || {};
Object.keys(tasks).forEach(tk => {
const task = tasks[tk];
const params = task.parameters || {};
task.id = document.getElementById(`t-${tk}-id`)?.value ?? task.id;
task.isExam = document.getElementById(`t-${tk}-isExam`)?.value === 'true';
task.task = document.getElementById(`t-${tk}-task`)?.value ?? task.task;
task.tags = (document.getElementById(`t-${tk}-tags`)?.value || '').split(',').map(s=>s.trim()).filter(Boolean);
Object.keys(params).forEach(pk => {
const p = params[pk];
const valEl = document.getElementById(`p-${tk}-${pk}-val`);
const unitEl = document.getElementById(`p-${tk}-${pk}-unit`);
const timerEl = document.getElementById(`p-${tk}-${pk}-timer`);
const multEl = document.getElementById(`p-${tk}-${pk}-mult`);
const spawnEl = document.getElementById(`p-${tk}-${pk}-spawn`);
const autoEl = document.getElementById(`p-${tk}-${pk}-auto`);
const ctrEl = document.getElementById(`p-${tk}-${pk}-ctr`);
const hidEl = document.getElementById(`p-${tk}-${pk}-hid`);
const punEl = document.getElementById(`p-${tk}-${pk}-pun`);
const punMinEl= document.getElementById(`p-${tk}-${pk}-punMin`);
const punTierEl=document.getElementById(`p-${tk}-${pk}-punTier`);
if (valEl) p.value = parseFloat(valEl.value) || 0;
if (unitEl) p.unit = unitEl.value;
if (timerEl) p.timerInfo = timerEl.value;
if (multEl) p.applyMultiplier = multEl.value === 'true';
if (spawnEl) p.spawnTimer = spawnEl.value === 'true';
if (autoEl) p.startTimerAutomatically = autoEl.value === 'true';
if (ctrEl) p.provideCounter = ctrEl.value === 'true';
if (hidEl) p.hiddenTask = hidEl.value === 'true';
if (punEl) p.punishTime = punEl.value === 'true';
if (punMinEl) p.punishTimeMinutes = parseFloat(punMinEl.value) || 0;
if (punTierEl)p.punTier = punTierEl.value;
});
});
toast('Punishment saved!');
renderSidebar();
renderContent();
}
function previewPunishmentImageUrl() {
const url = document.getElementById('pun-img-url').value.trim();
const prev = document.getElementById('pun-img-preview');
if (!url || !prev) return;
if (prev.tagName === 'IMG') { prev.src = url; }
else { prev.outerHTML = `<img src="${url}" id="pun-img-preview" style="width:72px;height:88px;object-fit:cover;border-radius:6px;border:1px solid var(--border);flex-shrink:0">`; }
}
function previewPunishmentImageFile(id) {
const file = document.getElementById('pun-img-file').files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
data.punishments[id].image = e.target.result;
document.getElementById('pun-img-url').value = '';
const prev = document.getElementById('pun-img-preview');
if (prev) {
if (prev.tagName === 'IMG') { prev.src = e.target.result; }
else { prev.outerHTML = `<img src="${e.target.result}" id="pun-img-preview" style="width:72px;height:88px;object-fit:cover;border-radius:6px;border:1px solid var(--border);flex-shrink:0">`; }
}
toast('Image loaded!');
};
reader.readAsDataURL(file);
}
function renderGenericItem(el, section, id) {
const item = data[section][id] || {};
el.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:4px">
<div>
<div class="section-title">${esc(item.name || item.title || id)}</div>
<div class="section-subtitle">${section} editor · ${id}</div>
</div>
<button class="btn-danger" onclick="deleteItem('${section}','${id}')">Delete</button>
</div>
<div class="card">
<div class="card-body">
<div class="form-grid">
<div class="form-group">
<label>ID</label>
<input type="text" id="gi-id" value="${esc(item.id||id)}">
</div>
<div class="form-group">
<label>Name / Title</label>
<input type="text" id="gi-name" value="${esc(item.name||item.title||'')}">
</div>
<div class="form-group full">
<label>Description</label>
<textarea id="gi-desc">${esc(item.description||'')}</textarea>
</div>
</div>
</div>
</div>
<hr class="divider">
<div style="margin-bottom:16px">
<div style="font-family:'Space Mono',monospace;font-size:0.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px">Raw JSON preview</div>
<div class="json-preview">${esc(JSON.stringify(item, null, 2))}</div>
</div>
<button class="btn-primary" onclick="saveGenericItem('${section}','${id}')">Save</button>
`;
}
function saveGenericItem(section, id) {
const item = data[section][id];
item.id = document.getElementById('gi-id').value;
item.name = document.getElementById('gi-name').value;
item.description = document.getElementById('gi-desc').value;
toast('Saved!');
renderSidebar();
}
function deleteItem(section, id) {
if (!confirm('Delete this item? This cannot be undone.')) return;
delete data[section][id];
navigate(section, null);
}
// ─── SAVE FILE ───────────────────────────────────────────────────────────────
document.getElementById('save-btn').addEventListener('click', () => {
const json = JSON.stringify(data);
const blob = new Blob([json], { 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!');
});
// ─── UI HELPERS ──────────────────────────────────────────────────────────────
function toggleCard(header) {
const body = header.nextElementSibling;
const chevron = header.querySelector('.chevron');
if (body.classList.contains('collapsed')) {
body.classList.remove('collapsed');
chevron.classList.add('open');
} else {
body.classList.add('collapsed');
chevron.classList.remove('open');
}
}
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2200);
}
function esc(str) {
return String(str)
.replace(/&/g,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/"/g,'&quot;')
.replace(/'/g,'&#39;');
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
</script>
</body>
</html>