1957 lines
76 KiB
HTML
1957 lines
76 KiB
HTML
<!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,'&')
|
||
.replace(/</g,'<')
|
||
.replace(/>/g,'>')
|
||
.replace(/"/g,'"')
|
||
.replace(/'/g,''');
|
||
}
|
||
|
||
function capitalize(s) {
|
||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|