Files
code/eggdrop-config-generator.html
2026-04-11 13:14:37 +02:00

1404 lines
45 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eggdrop Config Generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0d12;
--surface: #111620;
--panel: #161c28;
--border: #1e2a3a;
--accent: #00c8ff;
--accent2: #ff6b35;
--accent3: #39ff80;
--text: #c8d8e8;
--muted: #4a6070;
--comment: #3a5a40;
--comment-t: #5aaa60;
--danger: #ff4455;
--mono: 'Share Tech Mono', monospace;
--sans: 'Exo 2', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
min-height: 100vh;
overflow-x: hidden;
}
/* Scanline overlay */
body::before {
content: '';
position: fixed; inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,200,255,0.015) 2px,
rgba(0,200,255,0.015) 4px
);
pointer-events: none;
z-index: 9999;
}
/* ── HEADER ── */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 32px;
display: flex;
align-items: center;
gap: 20px;
height: 64px;
position: sticky; top: 0; z-index: 100;
}
.logo {
font-family: var(--mono);
font-size: 20px;
color: var(--accent);
letter-spacing: 2px;
white-space: nowrap;
}
.logo span { color: var(--accent2); }
.header-status {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
flex: 1;
}
.header-status.ok { color: var(--accent3); }
.header-status.err { color: var(--danger); }
.btn {
font-family: var(--mono);
font-size: 12px;
padding: 8px 18px;
border: 1px solid;
cursor: pointer;
transition: all .15s;
text-transform: uppercase;
letter-spacing: 1px;
background: transparent;
}
.btn-primary {
border-color: var(--accent);
color: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent);
color: var(--bg);
}
.btn-success {
border-color: var(--accent3);
color: var(--accent3);
}
.btn-success:hover:not(:disabled) {
background: var(--accent3);
color: var(--bg);
}
.btn:disabled { opacity: .35; cursor: not-allowed; }
/* ── LAYOUT ── */
.shell {
display: flex;
height: calc(100vh - 64px);
}
/* ── SIDEBAR ── */
nav.sidebar {
width: 240px;
min-width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 12px 0;
}
.nav-section {
padding: 6px 16px 2px;
font-family: var(--mono);
font-size: 9px;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
margin-top: 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
cursor: pointer;
font-size: 12px;
color: var(--text);
border-left: 2px solid transparent;
transition: all .12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-item:hover { background: var(--panel); color: var(--accent); }
.nav-item.active {
border-left-color: var(--accent);
background: var(--panel);
color: var(--accent);
}
.nav-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--muted);
flex-shrink: 0;
}
.nav-item.active .nav-dot { background: var(--accent); }
/* ── MAIN ── */
main {
flex: 1;
overflow-y: auto;
padding: 32px;
}
/* ── LOADING/ERROR ── */
#loading, #error-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 16px;
font-family: var(--mono);
color: var(--muted);
}
#error-panel { color: var(--danger); }
.spinner {
width: 40px; height: 40px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── SECTIONS ── */
.section-block {
display: none;
animation: fadeIn .2s ease;
}
.section-block.visible { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; } }
.section-title {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--accent);
border-bottom: 1px solid var(--border);
padding-bottom: 10px;
margin-bottom: 24px;
}
/* ── FIELD CARD ── */
.field-card {
background: var(--panel);
border: 1px solid var(--border);
margin-bottom: 16px;
transition: border-color .15s;
}
.field-card:hover { border-color: #2a3a50; }
.field-header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.field-key {
font-family: var(--mono);
font-size: 13px;
color: var(--accent);
flex: 1;
}
.toggle-wrap {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--muted);
font-family: var(--mono);
}
.toggle {
position: relative;
width: 32px; height: 18px;
flex-shrink: 0;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0;
background: var(--border);
cursor: pointer;
transition: .2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 12px; height: 12px;
left: 3px; top: 3px;
background: var(--muted);
transition: .2s;
}
.toggle input:checked + .toggle-slider { background: rgba(0,200,255,.25); }
.toggle input:checked + .toggle-slider::before {
transform: translateX(14px);
background: var(--accent);
}
.field-body { padding: 12px 14px; }
.field-comment {
font-family: var(--mono);
font-size: 11px;
color: var(--comment-t);
background: rgba(58,90,64,.15);
border-left: 2px solid var(--comment);
padding: 8px 10px;
margin-bottom: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.field-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--mono);
font-size: 13px;
padding: 8px 10px;
outline: none;
transition: border-color .15s;
}
.field-input:focus { border-color: var(--accent); }
.field-input:disabled { opacity: .4; cursor: not-allowed; }
textarea.field-input {
resize: vertical;
min-height: 80px;
}
select.field-input {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M0 0l6 8 6-8z' fill='%2300c8ff'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
}
/* bool radio */
.bool-group {
display: flex;
gap: 12px;
}
.bool-opt {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-family: var(--mono);
font-size: 13px;
padding: 6px 14px;
border: 1px solid var(--border);
background: var(--bg);
transition: all .12s;
}
.bool-opt input { accent-color: var(--accent); }
.bool-opt:has(input:checked) {
border-color: var(--accent);
color: var(--accent);
background: rgba(0,200,255,.07);
}
.bool-opt:has(input:disabled) { opacity: .4; cursor: not-allowed; }
/* dynamic list (servers, logfiles, channels, scripts) */
.dyn-list { display: flex; flex-direction: column; gap: 6px; }
.dyn-item {
display: flex;
gap: 6px;
align-items: center;
}
.dyn-item .field-input { flex: 1; }
.dyn-remove {
font-family: var(--mono);
font-size: 14px;
color: var(--danger);
background: none;
border: 1px solid var(--danger);
width: 28px; height: 28px;
cursor: pointer;
flex-shrink: 0;
line-height: 1;
transition: all .12s;
}
.dyn-remove:hover { background: var(--danger); color: var(--bg); }
.dyn-add {
align-self: flex-start;
margin-top: 4px;
font-family: var(--mono);
font-size: 11px;
color: var(--accent3);
background: none;
border: 1px solid var(--accent3);
padding: 4px 12px;
cursor: pointer;
letter-spacing: 1px;
transition: all .12s;
}
.dyn-add:hover { background: var(--accent3); color: var(--bg); }
/* chanset flags grid */
.flags-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 6px;
}
.flag-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border: 1px solid var(--border);
background: var(--bg);
font-family: var(--mono);
font-size: 12px;
cursor: pointer;
transition: all .12s;
}
.flag-item:has(input:checked) {
border-color: var(--accent3);
color: var(--accent3);
background: rgba(57,255,128,.07);
}
.flag-item input { accent-color: var(--accent3); }
/* ── PREVIEW PANEL ── */
#preview-panel {
position: fixed;
inset: 0;
background: rgba(0,0,0,.8);
z-index: 200;
display: none;
align-items: center;
justify-content: center;
}
#preview-panel.open { display: flex; }
.preview-box {
width: 80vw; max-width: 900px;
height: 80vh;
background: var(--surface);
border: 1px solid var(--accent);
display: flex;
flex-direction: column;
}
.preview-head {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
gap: 12px;
}
.preview-head h2 {
font-family: var(--mono);
font-size: 13px;
color: var(--accent);
flex: 1;
letter-spacing: 2px;
text-transform: uppercase;
}
.preview-body {
flex: 1;
overflow: auto;
padding: 16px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.7;
color: var(--text);
white-space: pre;
}
.preview-close {
background: none;
border: 1px solid var(--muted);
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
padding: 6px 14px;
cursor: pointer;
}
.preview-close:hover { border-color: var(--danger); color: var(--danger); }
/* scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--surface); }
::-webkit-scrollbar-thumb { background: var(--border); }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
</style>
</head>
<body>
<header>
<div class="logo">EGG<span>DROP</span> CONF</div>
<div class="header-status" id="hdr-status">Fetching config from eggheads/eggdrop…</div>
<button class="btn btn-primary" id="btn-preview" disabled>Preview</button>
<button class="btn btn-success" id="btn-download" disabled>Download .conf</button>
</header>
<div class="shell">
<nav class="sidebar" id="sidebar"></nav>
<main id="main">
<div id="loading"><div class="spinner"></div><span>Loading upstream config…</span></div>
<div id="error-panel" style="display:none"></div>
<div id="sections-wrap"></div>
</main>
</div>
<!-- Preview modal -->
<div id="preview-panel">
<div class="preview-box">
<div class="preview-head">
<h2>eggdrop.conf preview</h2>
<button class="btn btn-success" id="btn-dl-from-preview">Download</button>
<button class="preview-close" id="btn-close-preview">✕ Close</button>
</div>
<div class="preview-body" id="preview-body"></div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════
// PARSER — reads raw eggdrop.conf text into a schema
// ═══════════════════════════════════════════════════════
const CONF_URL = 'https://raw.githubusercontent.com/eggheads/eggdrop/refs/heads/develop/eggdrop.conf';
// Sections we treat as dynamic lists or special blocks
const DYNAMIC_KEYS = {
'server add': 'servers',
'logfile': 'logfiles',
'channel add': 'channels',
'source': 'scripts',
};
// Known boolean (0/1) keys
const BOOL_KEYS = new Set([
'prefer-ipv6','raw-log','log-time','keep-all-logs','quiet-save',
'protect-telnet','dcc-sanitycheck','require-p','open-telnets',
'stealth-telnets','use-telnet-banner','paranoid-telnet-flood',
'share-unlinks','remote-boots','force-expire','share-greet',
'use-info','allow-ps','allow-dk-cmds','cidr-support','show-uname',
'must-be-owner','keep-nick','quiet-reject','lowercase-ctcp',
'sasl','account-notify','extended-join','invite-notify',
'message-tags','account-tag','console-autosave','force-channel',
'info-party','notify-users','notify-onjoin','allow-fwd',
'upload-to-pwd','blowfish-use-mode',
]);
// Keys that are file paths
const PATH_KEYS = new Set([
'userfile','chanfile','notefile','help-path','text-path','motd',
'telnet-banner','mod-path','files-path','incoming-path','filedb-path',
'ssl-privatekey','ssl-certificate','ssl-capath','ssl-cafile',
'ssl-dhparam','sasl-ecdsa-key',
]);
// The default-chanset flags
const CHANSET_FLAGS = [
'autoop','autovoice','autohalfop','bitch','cycle','dontkickops',
'dynamicbans','dynamicexempts','dynamicinvites','enforcebans',
'greet','inactive','nodesynch','protectfriends','protecthalfops',
'protectops','revenge','revengebot','secret','seen','shared',
'static','statuslog','userbans','userexempts','userinvites',
];
function parseConf(raw) {
const lines = raw.split('\n');
const sections = [];
let currentSection = null;
let pendingComments = [];
let i = 0;
function ensureSection(name) {
if (!currentSection || currentSection.name !== name) {
currentSection = { name, fields: [], rawLines: [] };
sections.push(currentSection);
}
}
function detectSectionName(line) {
// ##### FOO BAR ##### or #### FOO BAR ####
const m = line.match(/^#+\s+([A-Z][A-Z /\-]+?)\s+#+\s*$/);
return m ? m[1].trim() : null;
}
// We'll also keep a "raw template" per section for output reconstruction
// Instead, we store rawLines on each section that we'll reconstruct at download time
ensureSection('BASIC');
while (i < lines.length) {
const line = lines[i];
// Section header?
const secName = detectSectionName(line);
if (secName) {
// flush pending comments into a raw comment field
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
ensureSection(secName);
currentSection.fields.push({ type: 'section-header', raw: line });
i++; continue;
}
// Blank line — keep as raw spacer
if (line.trim() === '') {
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
currentSection.fields.push({ type: 'blank' });
i++; continue;
}
// die "..." line — mark as safety-die
if (/^die\s+"/.test(line.trim())) {
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
currentSection.fields.push({ type: 'die', raw: line });
i++; continue;
}
// if { ... } die line
if (/^if\s+\{.*\}\s*\{/.test(line.trim())) {
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
currentSection.fields.push({ type: 'die', raw: line });
i++; continue;
}
// bind evnt / proc block — collect until we see closing brace line
if (/^bind\s+evnt/.test(line.trim()) || /^proc\s+evnt:/.test(line.trim())) {
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
const block = [line];
i++;
while (i < lines.length) {
block.push(lines[i]);
if (lines[i].trim() === '}') { i++; break; }
i++;
}
currentSection.fields.push({ type: 'tcl-block', lines: block });
continue;
}
// unbind / addlang / loadhelp — verbatim directives
if (/^(unbind|addlang|loadhelp|pysource)\s/.test(line.trim())) {
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
currentSection.fields.push({ type: 'verbatim', raw: line });
i++; continue;
}
// loadmodule X
if (/^loadmodule\s+\w+/.test(line.trim())) {
const mod = line.trim().match(/^loadmodule\s+(\S+)/)[1];
currentSection.fields.push({
type: 'module',
key: 'loadmodule',
module: mod,
comment: pendingComments.join('\n'),
active: true,
raw: line,
});
pendingComments = [];
i++; continue;
}
if (/^#loadmodule\s+\w+/.test(line.trim())) {
const mod = line.trim().match(/^#loadmodule\s+(\S+)/)[1];
currentSection.fields.push({
type: 'module',
key: 'loadmodule',
module: mod,
comment: pendingComments.join('\n'),
active: false,
raw: line,
});
pendingComments = [];
i++; continue;
}
// server add ... (active or commented)
const serverMatch = line.match(/^#?\s*server\s+add\s+(.+)$/);
if (serverMatch) {
const commented = line.trimStart().startsWith('#');
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
// Find or create the servers dynamic field
let srv = currentSection.fields.find(f => f.type === 'servers');
if (!srv) {
srv = { type: 'servers', entries: [], comment: '' };
currentSection.fields.push(srv);
}
const parts = serverMatch[1].trim().split(/\s+/);
srv.entries.push({ host: parts[0], port: parts[1]||'6667', password: parts[2]||'', active: !commented });
i++; continue;
}
// logfile ... (active)
const logMatch = line.match(/^(#?)logfile\s+(\S+)\s+(\S+)\s+"([^"]+)"/);
if (logMatch) {
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
let lf = currentSection.fields.find(f => f.type === 'logfiles');
if (!lf) {
lf = { type: 'logfiles', entries: [], comment: '' };
currentSection.fields.push(lf);
}
lf.entries.push({
flags: logMatch[2], channel: logMatch[3], file: logMatch[4],
active: !logMatch[1],
});
i++; continue;
}
// channel add #chan
const chanMatch = line.match(/^(#?)channel\s+add\s+(\S+)/);
if (chanMatch) {
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
let cl = currentSection.fields.find(f => f.type === 'channels');
if (!cl) {
cl = { type: 'channels', entries: [], comment: '' };
currentSection.fields.push(cl);
}
cl.entries.push({ name: chanMatch[2], active: !chanMatch[1] });
i++; continue;
}
// source scripts/...
const srcMatch = line.match(/^(#?)source\s+(.+)$/);
if (srcMatch) {
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
let sl = currentSection.fields.find(f => f.type === 'scripts');
if (!sl) {
sl = { type: 'scripts', entries: [], comment: '' };
currentSection.fields.push(sl);
}
sl.entries.push({ path: srcMatch[2].trim(), active: !srcMatch[1] });
i++; continue;
}
// set default-chanset { ... } (multi-line block)
if (/^set\s+default-chanset\s+\{/.test(line.trim())) {
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
const block = [line];
let flags = {};
// parse inline
let allText = line;
if (!line.includes('}')) {
i++;
while (i < lines.length) {
block.push(lines[i]);
allText += ' ' + lines[i];
if (lines[i].includes('}')) { i++; break; }
i++;
}
} else { i++; }
// parse +/- flags
const flagRx = /([+-])(\w+)/g;
let m;
while ((m = flagRx.exec(allText)) !== null) {
flags[m[2]] = m[1] === '+';
}
currentSection.fields.push({ type: 'chanset', flags, rawBlock: block });
continue;
}
// set key "value" or set key value (active)
const setMatch = line.match(/^set\s+(\S+)\s+(.*)/);
if (setMatch) {
const key = setMatch[1];
let val = setMatch[2].trim();
// strip surrounding quotes
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith('{') && val.endsWith('}'))) {
val = val.slice(1, -1);
}
const fieldType = inferType(key, val);
currentSection.fields.push({
type: 'setting',
key,
value: val,
active: true,
comment: pendingComments.join('\n'),
fieldType,
});
pendingComments = [];
i++; continue;
}
// #set key ... (commented-out setting)
const csetMatch = line.match(/^#set\s+(\S+)\s+(.*)/);
if (csetMatch) {
const key = csetMatch[1];
let val = csetMatch[2].trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith('{') && val.endsWith('}'))) {
val = val.slice(1, -1);
}
const fieldType = inferType(key, val);
currentSection.fields.push({
type: 'setting',
key,
value: val,
active: false,
comment: pendingComments.join('\n'),
fieldType,
});
pendingComments = [];
i++; continue;
}
// Comment line — accumulate
if (line.trim().startsWith('#')) {
pendingComments.push(line);
i++; continue;
}
// Anything else — verbatim
if (pendingComments.length) {
currentSection.fields.push({ type: 'raw-comment', lines: [...pendingComments] });
pendingComments = [];
}
currentSection.fields.push({ type: 'verbatim', raw: line });
i++;
}
if (pendingComments.length) {
if (currentSection) currentSection.fields.push({ type: 'raw-comment', lines: pendingComments });
}
return sections;
}
function inferType(key, val) {
if (BOOL_KEYS.has(key)) return 'bool';
if (PATH_KEYS.has(key)) return 'path';
if (/^\d+$/.test(val) && !key.includes('format') && !key.includes('suffix')) return 'int';
// flood rates like 3:10
if (/^\d+:\d+$/.test(val)) return 'rate';
return 'string';
}
// ═══════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════
let SCHEMA = []; // parsed sections
let DOM_IDS = {}; // key => element id for reading values
// ═══════════════════════════════════════════════════════
// RENDER UI
// ═══════════════════════════════════════════════════════
function renderUI(sections) {
const sidebar = document.getElementById('sidebar');
const wrap = document.getElementById('sections-wrap');
sidebar.innerHTML = '';
wrap.innerHTML = '';
sections.forEach((sec, si) => {
// Only add sections that have something interactive
const hasInteractive = sec.fields.some(f =>
['setting','module','servers','logfiles','channels','scripts','chanset'].includes(f.type)
);
if (!hasInteractive) return;
// Sidebar item
const navItem = document.createElement('div');
navItem.className = 'nav-item' + (si === 0 ? ' active' : '');
navItem.dataset.secid = si;
navItem.innerHTML = `<span class="nav-dot"></span>${sec.name}`;
navItem.addEventListener('click', () => showSection(si));
sidebar.appendChild(navItem);
// Section block
const block = document.createElement('div');
block.className = 'section-block' + (si === 0 ? ' visible' : '');
block.id = `sec-${si}`;
block.innerHTML = `<div class="section-title">// ${sec.name}</div>`;
sec.fields.forEach((field, fi) => {
const el = renderField(field, si, fi);
if (el) block.appendChild(el);
});
wrap.appendChild(block);
});
}
let _uid = 0;
function uid() { return 'f' + (_uid++); }
function renderField(field, si, fi) {
switch (field.type) {
case 'setting': return renderSetting(field, si, fi);
case 'module': return renderModule(field, si, fi);
case 'servers': return renderServers(field, si, fi);
case 'logfiles':return renderLogfiles(field, si, fi);
case 'channels':return renderChannels(field, si, fi);
case 'scripts': return renderScripts(field, si, fi);
case 'chanset': return renderChanset(field, si, fi);
default: return null;
}
}
function commentText(raw) {
return raw.split('\n')
.map(l => l.replace(/^#+\s?/, ''))
.join('\n')
.trim();
}
function renderSetting(field, si, fi) {
const id = uid();
field._id = id;
const card = document.createElement('div');
card.className = 'field-card';
const toggleId = uid();
card.innerHTML = `
<div class="field-header">
<span class="field-key">set ${field.key}</span>
<label class="toggle-wrap">
<label class="toggle">
<input type="checkbox" id="tog-${id}" ${field.active ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
<span>enabled</span>
</label>
</div>
<div class="field-body" id="body-${id}">
${field.comment ? `<div class="field-comment">${escHtml(commentText(field.comment))}</div>` : ''}
${renderInput(field, id)}
</div>
`;
// wire toggle
const tog = card.querySelector(`#tog-${id}`);
const body = card.querySelector(`#body-${id}`);
function applyEnabled(en) {
body.querySelectorAll('input:not([type=checkbox]),textarea,select').forEach(el => el.disabled = !en);
body.querySelectorAll('.bool-opt').forEach(el => el.style.opacity = en ? '' : '.4');
}
applyEnabled(field.active);
tog.addEventListener('change', () => { field.active = tog.checked; applyEnabled(tog.checked); });
return card;
}
function renderInput(field, id) {
const v = escHtml(field.value);
if (field.fieldType === 'bool') {
return `<div class="bool-group">
<label class="bool-opt"><input type="radio" name="${id}" value="1" ${field.value==='1'?'checked':''}> 1 (on)</label>
<label class="bool-opt"><input type="radio" name="${id}" value="0" ${field.value!=='1'?'checked':''}> 0 (off)</label>
</div>`;
}
if (field.key === 'net-type') {
const nets = ['EFnet','IRCnet','Undernet','DALnet','Libera','freenode','QuakeNet','Rizon','Twitch','Other'];
return `<select class="field-input" id="inp-${id}">` +
nets.map(n => `<option ${field.value===n?'selected':''}>${n}</option>`).join('') +
'</select>';
}
if (field.key === 'blowfish-use-mode') {
return `<div class="bool-group">
<label class="bool-opt"><input type="radio" name="${id}" value="cbc" ${field.value==='cbc'?'checked':''}> cbc</label>
<label class="bool-opt"><input type="radio" name="${id}" value="ecb" ${field.value!=='cbc'?'checked':''}> ecb</label>
</div>`;
}
if (field.key === 'console') {
return `<input class="field-input" id="inp-${id}" value="${v}" placeholder="mkcoblxs" style="width:200px">`;
}
if (field.key === 'default-chanset') return ''; // handled separately
if (field.key.endsWith('-flood') || field.key.startsWith('default-flood') || field.key === 'telnet-flood' || field.key === 'default-aop-delay') {
return `<input class="field-input" id="inp-${id}" value="${v}" placeholder="N:N" style="width:160px">`;
}
if (field.fieldType === 'int') {
return `<input class="field-input" type="number" id="inp-${id}" value="${v}" style="width:160px">`;
}
// long string
if (field.value && field.value.length > 60) {
return `<textarea class="field-input" id="inp-${id}">${v}</textarea>`;
}
return `<input class="field-input" id="inp-${id}" value="${v}">`;
}
function renderModule(field, si, fi) {
const id = uid();
field._id = id;
const card = document.createElement('div');
card.className = 'field-card';
card.innerHTML = `
<div class="field-header">
<span class="field-key">loadmodule ${field.module}</span>
<label class="toggle-wrap">
<label class="toggle">
<input type="checkbox" id="tog-${id}" ${field.active ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
<span>load</span>
</label>
</div>
${field.comment ? `<div class="field-body"><div class="field-comment">${escHtml(commentText(field.comment))}</div></div>` : ''}
`;
const tog = card.querySelector(`#tog-${id}`);
tog.addEventListener('change', () => { field.active = tog.checked; });
return card;
}
function renderServers(field, si, fi) {
field._id = 'servers-' + si;
const card = document.createElement('div');
card.className = 'field-card';
card.innerHTML = `
<div class="field-header"><span class="field-key">server add <span style="color:var(--muted);font-size:11px">— IRC server list</span></span></div>
<div class="field-body">
<div class="field-comment">Add servers to connect to. Prefix port with + for SSL. Password is optional.</div>
<div class="dyn-list" id="srv-list-${si}"></div>
<button class="dyn-add" id="srv-add-${si}">+ Add server</button>
</div>`;
const list = card.querySelector(`#srv-list-${si}`);
function addRow(entry) {
const row = document.createElement('div');
row.className = 'dyn-item';
row.innerHTML = `
<input class="field-input" placeholder="server.host.name" value="${escHtml(entry.host)}" style="flex:3" data-role="host">
<input class="field-input" placeholder="port" value="${escHtml(entry.port)}" style="flex:1;max-width:90px" data-role="port">
<input class="field-input" placeholder="password (opt)" value="${escHtml(entry.password)}" style="flex:2" data-role="pass">
<button class="dyn-remove" title="Remove">✕</button>`;
row.querySelector('.dyn-remove').addEventListener('click', () => row.remove());
list.appendChild(row);
}
field.entries.forEach(e => addRow(e));
card.querySelector(`#srv-add-${si}`).addEventListener('click', () =>
addRow({ host: '', port: '6667', password: '' })
);
return card;
}
function renderLogfiles(field, si, fi) {
field._id = 'logfiles-' + si;
const card = document.createElement('div');
card.className = 'field-card';
card.innerHTML = `
<div class="field-header"><span class="field-key">logfile <span style="color:var(--muted);font-size:11px">— log targets</span></span></div>
<div class="field-body">
<div class="field-comment">Flags: b=botlink c=commands d=debug j=joins k=kicks m=msgs o=misc p=public r=raw-in s=server v=raw-out</div>
<div class="dyn-list" id="lf-list-${si}"></div>
<button class="dyn-add" id="lf-add-${si}">+ Add logfile</button>
</div>`;
const list = card.querySelector(`#lf-list-${si}`);
function addRow(e) {
const row = document.createElement('div');
row.className = 'dyn-item';
row.innerHTML = `
<input class="field-input" placeholder="flags e.g. mco" value="${escHtml(e.flags)}" style="flex:1;max-width:90px" data-role="flags">
<input class="field-input" placeholder="channel (*=all)" value="${escHtml(e.channel)}" style="flex:1;max-width:120px" data-role="channel">
<input class="field-input" placeholder="logs/file.log" value="${escHtml(e.file)}" style="flex:3" data-role="file">
<button class="dyn-remove">✕</button>`;
row.querySelector('.dyn-remove').addEventListener('click', () => row.remove());
list.appendChild(row);
}
field.entries.forEach(e => addRow(e));
card.querySelector(`#lf-add-${si}`).addEventListener('click', () =>
addRow({ flags: 'mco', channel: '*', file: 'logs/eggdrop.log' })
);
return card;
}
function renderChannels(field, si, fi) {
field._id = 'channels-' + si;
const card = document.createElement('div');
card.className = 'field-card';
card.innerHTML = `
<div class="field-header"><span class="field-key">channel add <span style="color:var(--muted);font-size:11px">— channels to join</span></span></div>
<div class="field-body">
<div class="field-comment">Channels to join on startup. You can also add channels via partyline with .+chan</div>
<div class="dyn-list" id="ch-list-${si}"></div>
<button class="dyn-add" id="ch-add-${si}">+ Add channel</button>
</div>`;
const list = card.querySelector(`#ch-list-${si}`);
function addRow(e) {
const row = document.createElement('div');
row.className = 'dyn-item';
row.innerHTML = `
<input class="field-input" placeholder="#channel" value="${escHtml(e.name)}">
<button class="dyn-remove">✕</button>`;
row.querySelector('.dyn-remove').addEventListener('click', () => row.remove());
list.appendChild(row);
}
field.entries.forEach(e => addRow(e));
card.querySelector(`#ch-add-${si}`).addEventListener('click', () =>
addRow({ name: '#channel' })
);
return card;
}
function renderScripts(field, si, fi) {
field._id = 'scripts-' + si;
const card = document.createElement('div');
card.className = 'field-card';
card.innerHTML = `
<div class="field-header"><span class="field-key">source <span style="color:var(--muted);font-size:11px">— Tcl scripts to load</span></span></div>
<div class="field-body">
<div class="dyn-list" id="sc-list-${si}"></div>
<button class="dyn-add" id="sc-add-${si}">+ Add script</button>
</div>`;
const list = card.querySelector(`#sc-list-${si}`);
function addRow(e) {
const row = document.createElement('div');
row.className = 'dyn-item';
const chkId = uid();
row.innerHTML = `
<label class="toggle-wrap" style="flex-shrink:0">
<label class="toggle"><input type="checkbox" id="${chkId}" ${e.active?'checked':''}><span class="toggle-slider"></span></label>
<span style="font-size:11px">load</span>
</label>
<input class="field-input" placeholder="scripts/myscript.tcl" value="${escHtml(e.path)}">
<button class="dyn-remove">✕</button>`;
row.querySelector('.dyn-remove').addEventListener('click', () => row.remove());
list.appendChild(row);
}
field.entries.forEach(e => addRow(e));
card.querySelector(`#sc-add-${si}`).addEventListener('click', () =>
addRow({ path: 'scripts/', active: true })
);
return card;
}
function renderChanset(field, si, fi) {
field._id = 'chanset-' + si;
const card = document.createElement('div');
card.className = 'field-card';
const flagsHtml = CHANSET_FLAGS.map(f => `
<label class="flag-item">
<input type="checkbox" name="cs-${f}" ${field.flags[f] ? 'checked' : ''}> ${f}
</label>`).join('');
card.innerHTML = `
<div class="field-header"><span class="field-key">set default-chanset <span style="color:var(--muted);font-size:11px">— default channel flags</span></span></div>
<div class="field-body">
<div class="field-comment">These flags apply to newly added channels by default.</div>
<div class="flags-grid" id="cs-grid-${si}">${flagsHtml}</div>
</div>`;
return card;
}
function showSection(si) {
document.querySelectorAll('.section-block').forEach(b => b.classList.remove('visible'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
const block = document.getElementById(`sec-${si}`);
if (block) block.classList.add('visible');
document.querySelectorAll(`.nav-item[data-secid="${si}"]`).forEach(n => n.classList.add('active'));
}
// ═══════════════════════════════════════════════════════
// GENERATOR — reads DOM back into .conf text
// ═══════════════════════════════════════════════════════
function generateConf(sections) {
const out = [];
sections.forEach(sec => {
sec.fields.forEach(field => {
switch (field.type) {
case 'blank':
out.push('');
break;
case 'raw-comment':
field.lines.forEach(l => out.push(l));
break;
case 'section-header':
out.push(field.raw);
break;
case 'die':
// Replace die lines with a comment
out.push(`# [config generator] die line removed — config is ready to use`);
break;
case 'verbatim':
out.push(field.raw);
break;
case 'tcl-block':
field.lines.forEach(l => out.push(l));
break;
case 'setting': {
// Write comment lines
if (field.comment) field.comment.split('\n').forEach(l => out.push(l));
const val = readSettingValue(field);
const prefix = field.active ? '' : '#';
if (field.fieldType === 'bool' || field.fieldType === 'int' || field.fieldType === 'rate') {
out.push(`${prefix}set ${field.key} ${val}`);
} else if (val.includes('\n') || (val.includes(' ') && !val.startsWith('$'))) {
out.push(`${prefix}set ${field.key} "${val}"`);
} else {
out.push(`${prefix}set ${field.key} "${val}"`);
}
break;
}
case 'module': {
if (field.comment) field.comment.split('\n').forEach(l => out.push(l));
out.push(`${field.active ? '' : '#'}loadmodule ${field.module}`);
break;
}
case 'servers': {
const rows = getServerRows(field._id.replace('servers-', ''));
if (rows.length === 0) {
out.push(`# server add <host> <port> [password]`);
} else {
rows.forEach(r => out.push(`server add ${r.host} ${r.port}${r.password ? ' ' + r.password : ''}`));
}
break;
}
case 'logfiles': {
const rows = getLogRows(field._id.replace('logfiles-', ''));
rows.forEach(r => out.push(`logfile ${r.flags} ${r.channel} "${r.file}"`));
break;
}
case 'channels': {
const rows = getChanRows(field._id.replace('channels-', ''));
rows.forEach(r => out.push(`channel add ${r}`));
break;
}
case 'scripts': {
const rows = getScriptRows(field._id.replace('scripts-', ''));
rows.forEach(r => out.push(`${r.active ? '' : '#'}source ${r.path}`));
break;
}
case 'chanset': {
const flags = readChansetFlags(field._id.replace('chanset-', ''));
out.push('set default-chanset {');
const names = Object.keys(flags);
for (let i = 0; i < names.length; i += 2) {
const a = names[i], va = flags[a] ? '+' : '-';
const b = names[i+1], vb = b ? (flags[b] ? '+' : '-') : null;
if (vb !== null) {
out.push(`\t${va}${a.padEnd(20)} ${vb}${b}`);
} else {
out.push(`\t${va}${a}`);
}
}
out.push('}');
break;
}
}
});
});
return out.join('\n');
}
// ── value readers ──
function readSettingValue(field) {
if (field.fieldType === 'bool' || field.key === 'blowfish-use-mode') {
const checked = document.querySelector(`input[name="f${field._id.slice(1)}"]:checked`)
|| document.querySelector(`input[name="${field._id}"]:checked`);
// fallback: find radio by scanning
const radios = document.querySelectorAll(`input[name="${field._id}"]`);
if (radios.length) {
for (const r of radios) { if (r.checked) return r.value; }
}
return field.value;
}
const el = document.getElementById(`inp-${field._id}`);
return el ? el.value : field.value;
}
function getServerRows(si) {
const list = document.getElementById(`srv-list-${si}`);
if (!list) return [];
return Array.from(list.querySelectorAll('.dyn-item')).map(row => ({
host: row.querySelector('[data-role=host]').value.trim(),
port: row.querySelector('[data-role=port]').value.trim(),
password: row.querySelector('[data-role=pass]').value.trim(),
})).filter(r => r.host);
}
function getLogRows(si) {
const list = document.getElementById(`lf-list-${si}`);
if (!list) return [];
return Array.from(list.querySelectorAll('.dyn-item')).map(row => ({
flags: row.querySelector('[data-role=flags]').value.trim(),
channel: row.querySelector('[data-role=channel]').value.trim(),
file: row.querySelector('[data-role=file]').value.trim(),
})).filter(r => r.file);
}
function getChanRows(si) {
const list = document.getElementById(`ch-list-${si}`);
if (!list) return [];
return Array.from(list.querySelectorAll('.dyn-item input')).map(i => i.value.trim()).filter(Boolean);
}
function getScriptRows(si) {
const list = document.getElementById(`sc-list-${si}`);
if (!list) return [];
return Array.from(list.querySelectorAll('.dyn-item')).map(row => ({
active: row.querySelector('input[type=checkbox]').checked,
path: row.querySelector('input[type=text], input:not([type=checkbox])').value.trim(),
})).filter(r => r.path);
}
function readChansetFlags(si) {
const grid = document.getElementById(`cs-grid-${si}`);
const result = {};
if (!grid) {
CHANSET_FLAGS.forEach(f => result[f] = false);
return result;
}
CHANSET_FLAGS.forEach(f => {
const cb = grid.querySelector(`input[name="cs-${f}"]`);
result[f] = cb ? cb.checked : false;
});
return result;
}
// ── helpers ──
function escHtml(s) {
return String(s)
.replace(/&/g,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
function download(text, filename) {
const blob = new Blob([text], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
}
// ═══════════════════════════════════════════════════════
// BOOTSTRAP
// ═══════════════════════════════════════════════════════
async function init() {
const status = document.getElementById('hdr-status');
try {
const resp = await fetch(CONF_URL);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const raw = await resp.text();
SCHEMA = parseConf(raw);
document.getElementById('loading').style.display = 'none';
renderUI(SCHEMA);
// show first visible section
const first = document.querySelector('.nav-item');
if (first) showSection(parseInt(first.dataset.secid));
status.textContent = `✓ Loaded ${SCHEMA.length} sections from eggheads/eggdrop`;
status.className = 'header-status ok';
document.getElementById('btn-preview').disabled = false;
document.getElementById('btn-download').disabled = false;
} catch(e) {
document.getElementById('loading').style.display = 'none';
const ep = document.getElementById('error-panel');
ep.style.display = 'flex';
ep.innerHTML = `<div>Failed to fetch config</div><div style="font-size:11px">${e.message}</div>
<div style="font-size:11px;color:var(--muted)">Check CORS — you may need to open this file via a local server</div>`;
status.textContent = '✗ Fetch failed';
status.className = 'header-status err';
}
}
document.getElementById('btn-preview').addEventListener('click', () => {
const text = generateConf(SCHEMA);
document.getElementById('preview-body').textContent = text;
document.getElementById('preview-panel').classList.add('open');
});
document.getElementById('btn-close-preview').addEventListener('click', () =>
document.getElementById('preview-panel').classList.remove('open')
);
document.getElementById('preview-panel').addEventListener('click', e => {
if (e.target === document.getElementById('preview-panel'))
document.getElementById('preview-panel').classList.remove('open');
});
document.getElementById('btn-download').addEventListener('click', () =>
download(generateConf(SCHEMA), 'eggdrop.conf')
);
document.getElementById('btn-dl-from-preview').addEventListener('click', () =>
download(generateConf(SCHEMA), 'eggdrop.conf')
);
init();
</script>
</body>
</html>