aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/static/admin.css2
-rw-r--r--internal/admin/static/dropeditor.js224
-rw-r--r--internal/admin/static/hazardeditor.js226
-rw-r--r--internal/admin/static/itemeditor.js11
-rw-r--r--internal/admin/static/mobeditor.js13
-rw-r--r--internal/admin/static/moduleeditor.js318
-rw-r--r--internal/admin/static/objecteditor.js11
-rw-r--r--internal/admin/static/techeditor.js191
-rw-r--r--internal/admin/templates/drops.html6
-rw-r--r--internal/admin/templates/hazards.html6
-rw-r--r--internal/admin/templates/modules.html6
-rw-r--r--internal/admin/templates/techs.html6
12 files changed, 1010 insertions, 10 deletions
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index b44adcd..8c3ee8e 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -272,6 +272,8 @@ g.ud-hover:hover text{font-weight:bold}
.obj-card-arrow{font-size:10px;width:14px;color:#888}
.obj-card-label{flex:1;color:var(--text)}
.obj-card-remove{padding:2px 6px;font-size:10px;min-width:auto}
+.obj-card-expand{padding:2px 5px;font-size:11px;min-width:auto;background:transparent;color:#888;border:1px solid transparent;border-radius:3px;cursor:pointer;font-family:monospace;line-height:1}
+.obj-card-expand:hover{color:var(--text);border-color:var(--border);background:rgba(255,255,255,.05)}
.obj-card-body{padding:10px 12px}
.obj-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:6px 8px}
.obj-field{display:flex;flex-direction:column;gap:2px;font-size:11px}
diff --git a/internal/admin/static/dropeditor.js b/internal/admin/static/dropeditor.js
new file mode 100644
index 0000000..1569a04
--- /dev/null
+++ b/internal/admin/static/dropeditor.js
@@ -0,0 +1,224 @@
+var dropData = null;
+var dropID = null;
+var expandedCards = {};
+var cardWidths = {};
+
+var DROP_SECTIONS = [
+ {
+ id: 'drops', label: 'Drops', always: true, path: 'drops', isArray: true,
+ fields: [
+ ['item_id', 'Item ID', 'search', '', '', null, 'items'],
+ ['table', 'Table', 'search', '', '', null, 'drops'],
+ ['weight', 'Weight', 'number', '', 'narrow'],
+ ['quantity', 'Quantity', 'number', '1', 'narrow'],
+ ['level', 'Level', 'number', '', 'narrow'],
+ ['xp', 'XP', 'number', '', 'narrow'],
+ ['depletes', 'Depletes', 'checkbox'],
+ ['success_message', 'Success Message', 'text', '', 'wide'],
+ ]
+ }
+];
+
+function initDropEditor() {
+ editorType = 'drops';
+ editorFields = [];
+ loadList();
+ if (window.location.hash) loadDropEditor(window.location.hash.substring(1));
+}
+
+function cardPath(sec) { return sec.path || ''; }
+
+function getVal(data, sec, key) {
+ return ced_getVal(data, sec, key);
+}
+
+function fieldID(sec, key, idx) {
+ return ced_fieldID(sec, key, idx);
+}
+
+function setupSearchFields() { ced_setupSearchFields(); }
+
+function collectFieldValue(el, type) { return ced_collectFieldValue(el, type); }
+
+function loadDropEditor(id) {
+ dropID = id;
+ currentID = id;
+ renderList('');
+ window.location.hash = id;
+ API.get('/api/drops/' + encodeURIComponent(id)).then(function(data) {
+ dropData = data;
+ renderDropEditor(id, data);
+ }).catch(function(e) {
+ $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+function loadItem(id) { loadDropEditor(id); }
+
+function renderDropEditor(id, data) {
+ var html = '<h2>Drop Table: ' + esc(id) + '</h2>';
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+ html += '<div class="obj-fields-wrap">';
+ DROP_SECTIONS.forEach(function(sec) {
+ html += renderCard(sec, data);
+ });
+ html += '</div>';
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
+ html += '</div>';
+
+ html += '<div class="btn-row">';
+ html += '<button class="btn btn-primary" onclick="saveDrop()">Save</button>';
+ html += '<button class="btn btn-danger" onclick="deleteDrop()">Delete</button>';
+ html += '</div>';
+
+ $('#editorMain').innerHTML = html;
+ setupSearchFields();
+}
+
+function renderCurrent() {
+ if (!dropData || !dropID) return;
+ renderDropEditor(dropID, dropData);
+}
+
+function renderCard(sec, data) {
+ var collapsed = expandedCards[sec.id] === false;
+ var isFull = cardWidths[sec.id] !== undefined ? cardWidths[sec.id] : (sec.fullWidth || false);
+ var fullClass = isFull ? ' obj-card-full' : '';
+ var h = '<div class="obj-card' + fullClass + '" data-card="' + sec.id + '">';
+ h += '<div class="obj-card-header" onclick="toggleCard(\'' + sec.id + '\')">';
+ h += '<span class="obj-card-arrow">' + (collapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<button class="btn btn-sm obj-card-expand" onclick="event.stopPropagation();toggleCardWidth(\'' + sec.id + '\')" title="' + (isFull ? 'Half width' : 'Full width') + '">' + (isFull ? '\u229F' : '\u229E') + '</button>';
+ h += '<span class="obj-card-label">' + sec.label + '</span>';
+ h += '</div>';
+ if (!collapsed) {
+ h += '<div class="obj-card-body">';
+ h += renderArraySection(sec, data);
+ h += '</div>';
+ }
+ h += '</div>';
+ return h;
+}
+
+function renderArraySection(sec, data) {
+ var arr = dropData[cardPath(sec)] || [];
+ var h = '';
+ arr.forEach(function(item, idx) {
+ h += '<div class="obj-sub-row" data-idx="' + idx + '">';
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderField(sec, f, data, idx);
+ });
+ h += '</div>';
+ h += '<button class="btn btn-sm btn-danger obj-sub-remove" onclick="removeArrayItem(\'' + sec.id + '\',' + idx + ')">X</button>';
+ h += '</div>';
+ });
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addArrayItem(\'' + sec.id + '\')">+ Add Entry</button>';
+ return h;
+}
+
+function renderField(sec, f, data, idx) {
+ return ced_renderField(sec, f, data, idx, null, null, cardPath, fieldID, getVal);
+}
+
+function toggleCard(id) {
+ expandedCards[id] = expandedCards[id] === false ? true : false;
+ renderCurrent();
+}
+
+function toggleCardWidth(id) {
+ cardWidths[id] = !cardWidths[id];
+ renderCurrent();
+}
+
+function addArrayItem(cardID) {
+ var sec = DROP_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec || !sec.isArray) return;
+ var arr = dropData[sec.path] || [];
+ var empty = {};
+ sec.fields.forEach(function(f) {
+ empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
+ });
+ arr.push(empty);
+ dropData[sec.path] = arr;
+ renderCurrent();
+}
+
+function removeArrayItem(cardID, idx) {
+ var sec = DROP_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec || !sec.isArray) return;
+ var arr = dropData[sec.path] || [];
+ arr.splice(idx, 1);
+ renderCurrent();
+}
+
+function saveDrop() {
+ if (!ced_validateFields(DROP_SECTIONS, fieldID, null)) return;
+
+ var out = {};
+
+ DROP_SECTIONS.forEach(function(sec) {
+ if (sec.isArray) {
+ var arr = [];
+ var card = document.querySelector('.obj-card[data-card="' + sec.id + '"]');
+ if (!card) return;
+ var rows = card.querySelectorAll('.obj-sub-row');
+ rows.forEach(function(row, idx) {
+ var item = {};
+ sec.fields.forEach(function(f) {
+ var fid = fieldID(sec, f[0], idx);
+ var el = document.getElementById(fid);
+ if (el) item[f[0]] = collectFieldValue(el, f[2]);
+ });
+ if (Object.keys(item).length > 0) arr.push(item);
+ });
+ if (arr.length > 0) out[sec.path] = arr;
+ return;
+ }
+
+ var sectionObj = {};
+ sec.fields.forEach(function(f) {
+ var fid = fieldID(sec, f[0]);
+ var el = document.getElementById(fid);
+ if (!el) return;
+ sectionObj[f[0]] = collectFieldValue(el, f[2]);
+ });
+
+ if (sec.always && sec.path) {
+ out[sec.path] = sectionObj;
+ } else if (sec.always) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ } else if (sec.path) {
+ out[sec.path] = sectionObj;
+ } else {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ }
+ });
+
+ ced_cleanOutput(out);
+ out.id = dropID;
+
+ API.put('/api/drops/' + encodeURIComponent(dropID), out).then(function(resp) {
+ notify('Drop table ' + dropID + ' saved', 'success');
+ updateUndoBar();
+ if (resp && resp._raw) dropData._raw = resp._raw;
+ }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
+}
+
+function deleteDrop() {
+ if (!confirm('Delete drop table "' + dropID + '"?')) return;
+ API.del('/api/drops/' + encodeURIComponent(dropID)).then(function() {
+ notify('Drop table ' + dropID + ' deleted', 'success');
+ updateUndoBar();
+ dropID = null;
+ currentID = null;
+ dropData = null;
+ $('#editorMain').innerHTML = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>';
+ loadList();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
diff --git a/internal/admin/static/hazardeditor.js b/internal/admin/static/hazardeditor.js
new file mode 100644
index 0000000..b07456e
--- /dev/null
+++ b/internal/admin/static/hazardeditor.js
@@ -0,0 +1,226 @@
+var hazardData = null;
+var hazardID = null;
+var expandedCards = {};
+var addedSections = {};
+var cardWidths = {};
+
+var HAZARD_SECTIONS = [
+ {
+ id: 'core', label: 'Core', always: true,
+ fields: [
+ ['name', 'Name', 'text'],
+ ['description', 'Description', 'textarea', '', 'wide'],
+ ['warn_message', 'Warn Message', 'text', '', 'wide'],
+ ['hit_message', 'Hit Message', 'text'],
+ ['miss_message', 'Miss Message', 'text'],
+ ['required_item', 'Required Item', 'text'],
+ ['speed', 'Speed', 'number', '', 'narrow'],
+ ]
+ },
+ {
+ id: 'combat', label: 'Combat',
+ detect: function(d) { return d.attack_type || d.attack || d.attack_bonus || d.max_hit; },
+ fields: [
+ ['attack_type', 'Attack Type', 'select', 'stab|slash|crush|ranged|science'],
+ ['attack', 'Attack', 'number', '', 'narrow'],
+ ['attack_bonus', 'Attack Bonus', 'number', '', 'narrow'],
+ ['max_hit', 'Max Hit', 'number', '', 'narrow'],
+ ]
+ }
+];
+
+function initHazardEditor() {
+ editorType = 'hazards';
+ editorFields = [];
+ loadList();
+ if (window.location.hash) loadHazardEditor(window.location.hash.substring(1));
+}
+
+function cardPath(sec) { return sec.path || ''; }
+
+function getVal(data, sec, key) {
+ return ced_getVal(data, sec, key);
+}
+
+function fieldID(sec, key, idx) {
+ return ced_fieldID(sec, key, idx);
+}
+
+function setupSearchFields() { ced_setupSearchFields(); }
+
+function collectFieldValue(el, type) { return ced_collectFieldValue(el, type); }
+
+function loadHazardEditor(id) {
+ hazardID = id;
+ currentID = id;
+ renderList('');
+ window.location.hash = id;
+ API.get('/api/hazards/' + encodeURIComponent(id)).then(function(data) {
+ hazardData = data;
+ renderHazardEditor(id, data);
+ }).catch(function(e) {
+ $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+function loadItem(id) { loadHazardEditor(id); }
+
+function renderHazardEditor(id, data) {
+ var html = '<h2>Hazard: ' + esc(id) + '</h2>';
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+ html += '<div class="obj-fields-wrap">';
+ HAZARD_SECTIONS.forEach(function(sec) {
+ if (!sec.always && sec.detect && !sec.detect(data) && !addedSections[sec.id]) return;
+ html += renderCard(sec, data);
+ });
+ html += '</div>';
+
+ html += '<div class="add-section-bar">';
+ html += '<select id="addSectionSelect">';
+ html += '<option value="">+ Add Section...</option>';
+ HAZARD_SECTIONS.forEach(function(sec) {
+ if (sec.always) return;
+ if (sec.detect && sec.detect(data)) return;
+ if (addedSections[sec.id]) return;
+ html += '<option value="' + sec.id + '">' + esc(sec.label) + '</option>';
+ });
+ html += '</select>';
+ html += '<button class="btn btn-primary btn-sm" onclick="addSection()">Add</button>';
+ html += '</div>';
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
+ html += '</div>';
+
+ html += '<div class="btn-row">';
+ html += '<button class="btn btn-primary" onclick="saveHazard()">Save</button>';
+ html += '<button class="btn btn-danger" onclick="deleteHazard()">Delete</button>';
+ html += '</div>';
+
+ $('#editorMain').innerHTML = html;
+ setupSearchFields();
+}
+
+function renderCurrent() {
+ if (!hazardData || !hazardID) return;
+ renderHazardEditor(hazardID, hazardData);
+}
+
+function renderCard(sec, data) {
+ var collapsed = expandedCards[sec.id] === false;
+ var isFull = cardWidths[sec.id] !== undefined ? cardWidths[sec.id] : (sec.fullWidth || false);
+ var fullClass = isFull ? ' obj-card-full' : '';
+ var h = '<div class="obj-card' + fullClass + '" data-card="' + sec.id + '">';
+ h += '<div class="obj-card-header" onclick="toggleCard(\'' + sec.id + '\')">';
+ h += '<span class="obj-card-arrow">' + (collapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<button class="btn btn-sm obj-card-expand" onclick="event.stopPropagation();toggleCardWidth(\'' + sec.id + '\')" title="' + (isFull ? 'Half width' : 'Full width') + '">' + (isFull ? '\u229F' : '\u229E') + '</button>';
+ h += '<span class="obj-card-label">' + sec.label + '</span>';
+ if (!sec.always) {
+ h += '<button class="btn btn-sm btn-danger obj-card-remove" onclick="event.stopPropagation();removeSection(\'' + sec.id + '\')">X</button>';
+ }
+ h += '</div>';
+ if (!collapsed) {
+ h += '<div class="obj-card-body">';
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderField(sec, f, data, -1);
+ });
+ h += '</div>';
+ h += '</div>';
+ }
+ h += '</div>';
+ return h;
+}
+
+function renderField(sec, f, data, idx) {
+ return ced_renderField(sec, f, data, idx, null, null, cardPath, fieldID, getVal);
+}
+
+function toggleCard(id) {
+ expandedCards[id] = expandedCards[id] === false ? true : false;
+ renderCurrent();
+}
+
+function toggleCardWidth(id) {
+ cardWidths[id] = !cardWidths[id];
+ renderCurrent();
+}
+
+function removeSection(id) {
+ var sec = HAZARD_SECTIONS.find(function(s) { return s.id === id; });
+ if (!sec) return;
+ if (sec.fields) sec.fields.forEach(function(f) { delete hazardData[f[0]]; });
+ delete addedSections[id];
+ renderCurrent();
+}
+
+function addSection() {
+ var sel = $('#addSectionSelect');
+ var id = sel.value;
+ if (!id) return;
+ var sec = HAZARD_SECTIONS.find(function(s) { return s.id === id; });
+ if (!sec) return;
+
+ if (id === 'combat') {
+ hazardData.attack_type = '';
+ hazardData.attack = 0;
+ hazardData.attack_bonus = 0;
+ hazardData.max_hit = 0;
+ }
+ addedSections[id] = true;
+ expandedCards[id] = true;
+ renderCurrent();
+}
+
+function saveHazard() {
+ if (!ced_validateFields(HAZARD_SECTIONS, fieldID, null)) return;
+
+ var out = {};
+ HAZARD_SECTIONS.forEach(function(sec) {
+ var sectionObj = {};
+ var hasValues = sec.always;
+
+ if (sec.fields) {
+ sec.fields.forEach(function(f) {
+ var fid = fieldID(sec, f[0]);
+ var el = document.getElementById(fid);
+ if (!el) return;
+ var val = collectFieldValue(el, f[2]);
+ if (val !== '' && val !== false && val !== 0 && val !== null) hasValues = true;
+ sectionObj[f[0]] = val;
+ });
+ }
+
+ if (sec.always) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ } else if (hasValues) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ }
+ });
+
+ ced_cleanOutput(out);
+ out.id = hazardID;
+
+ API.put('/api/hazards/' + encodeURIComponent(hazardID), out).then(function(resp) {
+ notify('Hazard ' + hazardID + ' saved', 'success');
+ updateUndoBar();
+ if (resp && resp._raw) hazardData._raw = resp._raw;
+ }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
+}
+
+function deleteHazard() {
+ if (!confirm('Delete hazard "' + hazardID + '"?')) return;
+ API.del('/api/hazards/' + encodeURIComponent(hazardID)).then(function() {
+ notify('Hazard ' + hazardID + ' deleted', 'success');
+ updateUndoBar();
+ hazardID = null;
+ currentID = null;
+ hazardData = null;
+ $('#editorMain').innerHTML = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>';
+ loadList();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
diff --git a/internal/admin/static/itemeditor.js b/internal/admin/static/itemeditor.js
index 215baf0..641f064 100644
--- a/internal/admin/static/itemeditor.js
+++ b/internal/admin/static/itemeditor.js
@@ -2,6 +2,7 @@ var itemData = null;
var itemID = null;
var expandedCards = {};
var addedSections = {};
+var cardWidths = {};
var SECTIONS = [
{
@@ -231,9 +232,12 @@ function renderCurrent() {
function renderCard(sec, data) {
var collapsed = expandedCards[sec.id] === false;
- var h = '<div class="obj-card" data-card="' + sec.id + '">';
+ var isFull = cardWidths[sec.id] !== undefined ? cardWidths[sec.id] : (sec.fullWidth || false);
+ var fullClass = isFull ? ' obj-card-full' : '';
+ var h = '<div class="obj-card' + fullClass + '" data-card="' + sec.id + '">';
h += '<div class="obj-card-header" onclick="toggleCard(\'' + sec.id + '\')">';
h += '<span class="obj-card-arrow">' + (collapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<button class="btn btn-sm obj-card-expand" onclick="event.stopPropagation();toggleCardWidth(\'' + sec.id + '\')" title="' + (isFull ? 'Half width' : 'Full width') + '">' + (isFull ? '\u229F' : '\u229E') + '</button>';
h += '<span class="obj-card-label">' + sec.label + '</span>';
if (!sec.always) {
h += '<button class="btn btn-sm btn-danger obj-card-remove" onclick="event.stopPropagation();removeSection(\'' + sec.id + '\')">X</button>';
@@ -868,6 +872,11 @@ function toggleCard(id) {
renderCurrent();
}
+function toggleCardWidth(id) {
+ cardWidths[id] = !cardWidths[id];
+ renderCurrent();
+}
+
function removeSection(id) {
var sec = SECTIONS.find(function(s) { return s.id === id; });
if (!sec) return;
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
index 4895c6f..4260e49 100644
--- a/internal/admin/static/mobeditor.js
+++ b/internal/admin/static/mobeditor.js
@@ -3,6 +3,7 @@ var mobData = null;
var mobID = null;
var expandedCards = {};
var addedSections = {};
+var cardWidths = {};
var MOB_SECTIONS = [
{
@@ -76,7 +77,7 @@ var MOB_SECTIONS = [
]
},
{
- id: 'talk', label: 'Talk', path: 'talk',
+ id: 'talk', label: 'Talk', path: 'talk', fullWidth: true,
detect: function(d) { return d.talk && d.talk.nodes; },
fields: []
},
@@ -206,9 +207,12 @@ function renderCurrent() {
function renderMobCard(sec, data) {
var collapsed = expandedCards[sec.id] === false;
- var h = '<div class="obj-card' + (sec.id === 'talk' ? ' obj-card-full' : '') + '" data-card="' + sec.id + '">';
+ var isFull = cardWidths[sec.id] !== undefined ? cardWidths[sec.id] : (sec.fullWidth || false);
+ var fullClass = isFull ? ' obj-card-full' : '';
+ var h = '<div class="obj-card' + fullClass + '" data-card="' + sec.id + '">';
h += '<div class="obj-card-header" onclick="toggleMobCard(\'' + sec.id + '\')">';
h += '<span class="obj-card-arrow">' + (collapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<button class="btn btn-sm obj-card-expand" onclick="event.stopPropagation();toggleMobCardWidth(\'' + sec.id + '\')" title="' + (isFull ? 'Half width' : 'Full width') + '">' + (isFull ? '\u229F' : '\u229E') + '</button>';
h += '<span class="obj-card-label">' + sec.label + '</span>';
if (!sec.always) {
h += '<button class="btn btn-sm btn-danger obj-card-remove" onclick="event.stopPropagation();removeMobSection(\'' + sec.id + '\')">X</button>';
@@ -555,6 +559,11 @@ function toggleMobCard(id) {
renderCurrent();
}
+function toggleMobCardWidth(id) {
+ cardWidths[id] = !cardWidths[id];
+ renderCurrent();
+}
+
function removeMobSection(id) {
var sec = MOB_SECTIONS.find(function(s) { return s.id === id; });
if (!sec) return;
diff --git a/internal/admin/static/moduleeditor.js b/internal/admin/static/moduleeditor.js
new file mode 100644
index 0000000..128148d
--- /dev/null
+++ b/internal/admin/static/moduleeditor.js
@@ -0,0 +1,318 @@
+var moduleData = null;
+var moduleID = null;
+var expandedCards = {};
+var addedSections = {};
+var cardWidths = {};
+
+var MODULE_SECTIONS = [
+ {
+ id: 'core', label: 'Core', always: true,
+ fields: [
+ ['name', 'Name', 'text'],
+ ['level', 'Level', 'number', '', 'narrow'],
+ ['max_hit', 'Max Hit', 'number', '', 'narrow'],
+ ['base_xp', 'Base XP', 'number', '', 'narrow'],
+ ['category', 'Category', 'select', 'combat|utility|enchant|processing|transport'],
+ ['element', 'Element', 'text'],
+ ['destination', 'Destination', 'number', '', 'narrow'],
+ ],
+ inline: [
+ {label:'Junk Cost', key:'junk_cost', type:'kv', valType:'number'},
+ ]
+ },
+ {
+ id: 'sequence', label: 'Sequence', path: 'sequence', isArray: true,
+ detect: function(d) { return d.sequence && Array.isArray(d.sequence); },
+ fields: [
+ ['message', 'Message', 'text', '', 'wide'],
+ ['delay', 'Delay', 'number', '', 'narrow'],
+ ]
+ }
+];
+
+function initModuleEditor() {
+ editorType = 'modules';
+ editorFields = [];
+ loadList();
+ if (window.location.hash) loadModuleEditor(window.location.hash.substring(1));
+}
+
+function cardPath(sec) { return sec.path || ''; }
+
+function getVal(data, sec, key) {
+ return ced_getVal(data, sec, key);
+}
+
+function fieldID(sec, key, idx) {
+ return ced_fieldID(sec, key, idx);
+}
+
+function setupSearchFields() { ced_setupSearchFields(); }
+
+function collectFieldValue(el, type) { return ced_collectFieldValue(el, type); }
+
+function loadModuleEditor(id) {
+ moduleID = id;
+ currentID = id;
+ renderList('');
+ window.location.hash = id;
+ API.get('/api/modules/' + encodeURIComponent(id)).then(function(data) {
+ moduleData = data;
+ renderModuleEditor(id, data);
+ }).catch(function(e) {
+ $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+function loadItem(id) { loadModuleEditor(id); }
+
+function renderModuleEditor(id, data) {
+ var html = '<h2>Module: ' + esc(id) + '</h2>';
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+ html += '<div class="obj-fields-wrap">';
+ MODULE_SECTIONS.forEach(function(sec) {
+ if (!sec.always && sec.detect && !sec.detect(data) && !addedSections[sec.id]) return;
+ html += renderCard(sec, data);
+ });
+ html += '</div>';
+
+ html += '<div class="add-section-bar">';
+ html += '<select id="addSectionSelect">';
+ html += '<option value="">+ Add Section...</option>';
+ MODULE_SECTIONS.forEach(function(sec) {
+ if (sec.always) return;
+ if (sec.detect && sec.detect(data)) return;
+ if (addedSections[sec.id]) return;
+ html += '<option value="' + sec.id + '">' + esc(sec.label) + '</option>';
+ });
+ html += '</select>';
+ html += '<button class="btn btn-primary btn-sm" onclick="addSection()">Add</button>';
+ html += '</div>';
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
+ html += '</div>';
+
+ html += '<div class="btn-row">';
+ html += '<button class="btn btn-primary" onclick="saveModule()">Save</button>';
+ html += '<button class="btn btn-danger" onclick="deleteModule()">Delete</button>';
+ html += '</div>';
+
+ $('#editorMain').innerHTML = html;
+ setupSearchFields();
+}
+
+function renderCurrent() {
+ if (!moduleData || !moduleID) return;
+ renderModuleEditor(moduleID, moduleData);
+}
+
+function renderCard(sec, data) {
+ var collapsed = expandedCards[sec.id] === false;
+ var isFull = cardWidths[sec.id] !== undefined ? cardWidths[sec.id] : (sec.fullWidth || false);
+ var fullClass = isFull ? ' obj-card-full' : '';
+ var h = '<div class="obj-card' + fullClass + '" data-card="' + sec.id + '">';
+ h += '<div class="obj-card-header" onclick="toggleCard(\'' + sec.id + '\')">';
+ h += '<span class="obj-card-arrow">' + (collapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<button class="btn btn-sm obj-card-expand" onclick="event.stopPropagation();toggleCardWidth(\'' + sec.id + '\')" title="' + (isFull ? 'Half width' : 'Full width') + '">' + (isFull ? '\u229F' : '\u229E') + '</button>';
+ h += '<span class="obj-card-label">' + sec.label + '</span>';
+ if (!sec.always) {
+ h += '<button class="btn btn-sm btn-danger obj-card-remove" onclick="event.stopPropagation();removeSection(\'' + sec.id + '\')">X</button>';
+ }
+ h += '</div>';
+ if (!collapsed) {
+ h += '<div class="obj-card-body">';
+
+ if (sec.isArray) {
+ h += renderArraySection(sec, data);
+ } else {
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderField(sec, f, data, -1);
+ });
+ h += '</div>';
+
+ if (sec.inline) {
+ sec.inline.forEach(function(il) {
+ if (il.type === 'kv') {
+ h += ced_renderKV(sec, il, data);
+ }
+ });
+ }
+ }
+ h += '</div>';
+ }
+ h += '</div>';
+ return h;
+}
+
+function renderArraySection(sec, data) {
+ var arr = moduleData[cardPath(sec)] || [];
+ var h = '';
+ arr.forEach(function(item, idx) {
+ h += '<div class="obj-sub-row" data-idx="' + idx + '">';
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderField(sec, f, data, idx);
+ });
+ h += '</div>';
+ h += '<button class="btn btn-sm btn-danger obj-sub-remove" onclick="removeArrayItem(\'' + sec.id + '\',' + idx + ')">X</button>';
+ h += '</div>';
+ });
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addArrayItem(\'' + sec.id + '\')">+ Add Step</button>';
+ return h;
+}
+
+function renderField(sec, f, data, idx) {
+ return ced_renderField(sec, f, data, idx, null, null, cardPath, fieldID, getVal);
+}
+
+function toggleCard(id) {
+ expandedCards[id] = expandedCards[id] === false ? true : false;
+ renderCurrent();
+}
+
+function toggleCardWidth(id) {
+ cardWidths[id] = !cardWidths[id];
+ renderCurrent();
+}
+
+function removeSection(id) {
+ var sec = MODULE_SECTIONS.find(function(s) { return s.id === id; });
+ if (!sec) return;
+ if (sec.path) {
+ delete moduleData[sec.path];
+ }
+ delete addedSections[id];
+ renderCurrent();
+}
+
+function addSection() {
+ var sel = $('#addSectionSelect');
+ var id = sel.value;
+ if (!id) return;
+ var sec = MODULE_SECTIONS.find(function(s) { return s.id === id; });
+ if (!sec) return;
+
+ if (sec.path && sec.isArray) {
+ moduleData[sec.path] = [];
+ } else if (sec.path) {
+ moduleData[sec.path] = {};
+ }
+ addedSections[id] = true;
+ expandedCards[id] = true;
+ renderCurrent();
+}
+
+function addArrayItem(cardID) {
+ var sec = MODULE_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec || !sec.isArray) return;
+ var arr = moduleData[sec.path] || [];
+ var empty = {};
+ sec.fields.forEach(function(f) {
+ empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
+ });
+ arr.push(empty);
+ moduleData[sec.path] = arr;
+ renderCurrent();
+}
+
+function removeArrayItem(cardID, idx) {
+ var sec = MODULE_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec || !sec.isArray) return;
+ var arr = moduleData[sec.path] || [];
+ arr.splice(idx, 1);
+ renderCurrent();
+}
+
+function saveModule() {
+ if (!ced_validateFields(MODULE_SECTIONS, fieldID, null)) return;
+
+ var out = {};
+ MODULE_SECTIONS.forEach(function(sec) {
+ if (sec.isArray) {
+ var arr = [];
+ var card = document.querySelector('.obj-card[data-card="' + sec.id + '"]');
+ if (!card) return;
+ var rows = card.querySelectorAll('.obj-sub-row');
+ rows.forEach(function(row, idx) {
+ var item = {};
+ sec.fields.forEach(function(f) {
+ var fid = fieldID(sec, f[0], idx);
+ var el = document.getElementById(fid);
+ if (el) item[f[0]] = collectFieldValue(el, f[2]);
+ });
+ if (Object.keys(item).length > 0) arr.push(item);
+ });
+ if (arr.length > 0) { out[sec.path] = arr; }
+ return;
+ }
+
+ var sectionObj = {};
+ var hasValues = sec.always;
+
+ if (sec.fields) {
+ sec.fields.forEach(function(f) {
+ var fid = fieldID(sec, f[0]);
+ var el = document.getElementById(fid);
+ if (!el) return;
+ var val = collectFieldValue(el, f[2]);
+ if (val !== '' && val !== false && val !== 0 && val !== null) hasValues = true;
+ sectionObj[f[0]] = val;
+ });
+ }
+
+ if (sec.inline) {
+ sec.inline.forEach(function(il) {
+ if (il.type === 'kv') {
+ var kvEl = document.querySelector('.obj-kv-list[data-sec="' + sec.id + '"][data-key="' + il.key + '"]');
+ if (kvEl) {
+ var map = {};
+ kvEl.querySelectorAll('.obj-kv-row').forEach(function(row) {
+ var keyEl = row.querySelector('.obj-kv-key');
+ var valEl = row.querySelector('.obj-kv-val');
+ if (keyEl && keyEl.value.trim() && valEl) {
+ map[keyEl.value.trim()] = parseFloat(valEl.value) || 0;
+ }
+ });
+ if (Object.keys(map).length > 0) { sectionObj[il.key] = map; hasValues = true; }
+ }
+ }
+ });
+ }
+
+ if (sec.always) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ } else if (hasValues && sec.path) {
+ out[sec.path] = sectionObj;
+ } else if (hasValues) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ }
+ });
+
+ ced_cleanOutput(out);
+ out.id = moduleID;
+
+ API.put('/api/modules/' + encodeURIComponent(moduleID), out).then(function(resp) {
+ notify('Module ' + moduleID + ' saved', 'success');
+ updateUndoBar();
+ if (resp && resp._raw) moduleData._raw = resp._raw;
+ }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
+}
+
+function deleteModule() {
+ if (!confirm('Delete module "' + moduleID + '"?')) return;
+ API.del('/api/modules/' + encodeURIComponent(moduleID)).then(function() {
+ notify('Module ' + moduleID + ' deleted', 'success');
+ updateUndoBar();
+ moduleID = null;
+ currentID = null;
+ moduleData = null;
+ $('#editorMain').innerHTML = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>';
+ loadList();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js
index d45c26e..3cbb37a 100644
--- a/internal/admin/static/objecteditor.js
+++ b/internal/admin/static/objecteditor.js
@@ -1,6 +1,7 @@
var objectData = null;
var objectID = null;
var expandedCards = {};
+var cardWidths = {};
var SECTIONS = [
{
@@ -220,9 +221,12 @@ function fieldIDSub(sec, subKey, idx, fieldKey) {
function renderCard(sec, data) {
var collapsed = expandedCards[sec.id] === false;
- var h = '<div class="obj-card" data-card="' + sec.id + '">';
+ var isFull = cardWidths[sec.id] !== undefined ? cardWidths[sec.id] : (sec.fullWidth || false);
+ var fullClass = isFull ? ' obj-card-full' : '';
+ var h = '<div class="obj-card' + fullClass + '" data-card="' + sec.id + '">';
h += '<div class="obj-card-header" onclick="toggleCard(\'' + sec.id + '\')">';
h += '<span class="obj-card-arrow">' + (collapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<button class="btn btn-sm obj-card-expand" onclick="event.stopPropagation();toggleCardWidth(\'' + sec.id + '\')" title="' + (isFull ? 'Half width' : 'Full width') + '">' + (isFull ? '\u229F' : '\u229E') + '</button>';
h += '<span class="obj-card-label">' + sec.label + '</span>';
if (!sec.always) {
h += '<button class="btn btn-sm btn-danger obj-card-remove" onclick="event.stopPropagation();removeSection(\'' + sec.id + '\')">X</button>';
@@ -517,6 +521,11 @@ function toggleCard(id) {
renderCurrent();
}
+function toggleCardWidth(id) {
+ cardWidths[id] = !cardWidths[id];
+ renderCurrent();
+}
+
function removeSection(id) {
var sec = SECTIONS.find(function(s) { return s.id === id; });
if (!sec) return;
diff --git a/internal/admin/static/techeditor.js b/internal/admin/static/techeditor.js
new file mode 100644
index 0000000..3d74254
--- /dev/null
+++ b/internal/admin/static/techeditor.js
@@ -0,0 +1,191 @@
+var techData = null;
+var techID = null;
+var expandedCards = {};
+var cardWidths = {};
+
+var TECH_SECTIONS = [
+ {
+ id: 'core', label: 'Core', always: true,
+ fields: [
+ ['name', 'Name', 'text'],
+ ['level', 'Level', 'number', '', 'narrow'],
+ ['drain_rate', 'Drain Rate', 'number', '', 'narrow'],
+ ['category', 'Category', 'text'],
+ ['group', 'Group', 'text'],
+ ]
+ },
+ {
+ id: 'effects', label: 'Effects', always: true, path: 'effects',
+ fields: [
+ ['accuracy_percent', 'Accuracy %', 'number', '', 'narrow'],
+ ['strength_percent', 'Strength %', 'number', '', 'narrow'],
+ ['defense_percent', 'Defense %', 'number', '', 'narrow'],
+ ['ranged_percent', 'Ranged %', 'number', '', 'narrow'],
+ ['science_percent', 'Science %', 'number', '', 'narrow'],
+ ['damage_reduction', 'Damage Reduction', 'number', '', 'narrow'],
+ ['hp_regen_multi', 'HP Regen Multi', 'number', '', 'narrow'],
+ ['preserve_drain', 'Preserve Drain', 'number', '', 'narrow'],
+ ['retribution_pct', 'Retribution %', 'number', '', 'narrow'],
+ ['protect_melee', 'Protect Melee', 'checkbox'],
+ ['protect_ranged', 'Protect Ranged', 'checkbox'],
+ ['protect_science', 'Protect Science', 'checkbox'],
+ ]
+ }
+];
+
+function initTechEditor() {
+ editorType = 'techs';
+ editorFields = [];
+ loadList();
+ if (window.location.hash) loadTechEditor(window.location.hash.substring(1));
+}
+
+function cardPath(sec) { return sec.path || ''; }
+
+function getVal(data, sec, key) {
+ return ced_getVal(data, sec, key);
+}
+
+function fieldID(sec, key, idx) {
+ return ced_fieldID(sec, key, idx);
+}
+
+function setupSearchFields() { ced_setupSearchFields(); }
+
+function collectFieldValue(el, type) { return ced_collectFieldValue(el, type); }
+
+function loadTechEditor(id) {
+ techID = id;
+ currentID = id;
+ renderList('');
+ window.location.hash = id;
+ API.get('/api/techs/' + encodeURIComponent(id)).then(function(data) {
+ techData = data;
+ renderTechEditor(id, data);
+ }).catch(function(e) {
+ $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+function loadItem(id) { loadTechEditor(id); }
+
+function renderTechEditor(id, data) {
+ var html = '<h2>Tech: ' + esc(id) + '</h2>';
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+ html += '<div class="obj-fields-wrap">';
+ TECH_SECTIONS.forEach(function(sec) {
+ html += renderCard(sec, data);
+ });
+ html += '</div>';
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
+ html += '</div>';
+
+ html += '<div class="btn-row">';
+ html += '<button class="btn btn-primary" onclick="saveTech()">Save</button>';
+ html += '<button class="btn btn-danger" onclick="deleteTech()">Delete</button>';
+ html += '</div>';
+
+ $('#editorMain').innerHTML = html;
+ setupSearchFields();
+}
+
+function renderCurrent() {
+ if (!techData || !techID) return;
+ renderTechEditor(techID, techData);
+}
+
+function renderCard(sec, data) {
+ var collapsed = expandedCards[sec.id] === false;
+ var isFull = cardWidths[sec.id] !== undefined ? cardWidths[sec.id] : (sec.fullWidth || false);
+ var fullClass = isFull ? ' obj-card-full' : '';
+ var h = '<div class="obj-card' + fullClass + '" data-card="' + sec.id + '">';
+ h += '<div class="obj-card-header" onclick="toggleCard(\'' + sec.id + '\')">';
+ h += '<span class="obj-card-arrow">' + (collapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<button class="btn btn-sm obj-card-expand" onclick="event.stopPropagation();toggleCardWidth(\'' + sec.id + '\')" title="' + (isFull ? 'Half width' : 'Full width') + '">' + (isFull ? '\u229F' : '\u229E') + '</button>';
+ h += '<span class="obj-card-label">' + sec.label + '</span>';
+ h += '</div>';
+ if (!collapsed) {
+ h += '<div class="obj-card-body">';
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderField(sec, f, data, -1);
+ });
+ h += '</div>';
+ h += '</div>';
+ }
+ h += '</div>';
+ return h;
+}
+
+function renderField(sec, f, data, idx) {
+ return ced_renderField(sec, f, data, idx, null, null, cardPath, fieldID, getVal);
+}
+
+function toggleCard(id) {
+ expandedCards[id] = expandedCards[id] === false ? true : false;
+ renderCurrent();
+}
+
+function toggleCardWidth(id) {
+ cardWidths[id] = !cardWidths[id];
+ renderCurrent();
+}
+
+function saveTech() {
+ if (!ced_validateFields(TECH_SECTIONS, fieldID, null)) return;
+
+ var out = {};
+ TECH_SECTIONS.forEach(function(sec) {
+ var sectionObj = {};
+ var hasValues = sec.always;
+
+ if (sec.fields) {
+ sec.fields.forEach(function(f) {
+ var fid = fieldID(sec, f[0]);
+ var el = document.getElementById(fid);
+ if (!el) return;
+ var val = collectFieldValue(el, f[2]);
+ if (val !== '' && val !== false && val !== 0 && val !== null) hasValues = true;
+ sectionObj[f[0]] = val;
+ });
+ }
+
+ if (sec.always && sec.path) {
+ out[sec.path] = sectionObj;
+ } else if (sec.always) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ } else if (hasValues && sec.path) {
+ out[sec.path] = sectionObj;
+ } else if (hasValues) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ }
+ });
+
+ ced_cleanOutput(out);
+ out.id = techID;
+
+ API.put('/api/techs/' + encodeURIComponent(techID), out).then(function(resp) {
+ notify('Tech ' + techID + ' saved', 'success');
+ updateUndoBar();
+ if (resp && resp._raw) techData._raw = resp._raw;
+ }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
+}
+
+function deleteTech() {
+ if (!confirm('Delete tech "' + techID + '"?')) return;
+ API.del('/api/techs/' + encodeURIComponent(techID)).then(function() {
+ notify('Tech ' + techID + ' deleted', 'success');
+ updateUndoBar();
+ techID = null;
+ currentID = null;
+ techData = null;
+ $('#editorMain').innerHTML = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>';
+ loadList();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
diff --git a/internal/admin/templates/drops.html b/internal/admin/templates/drops.html
index c040ef2..fb67c86 100644
--- a/internal/admin/templates/drops.html
+++ b/internal/admin/templates/drops.html
@@ -10,9 +10,9 @@
</div>
</div>
<script src="/static/editor.js"></script>
+<script src="/static/cardeditor.js"></script>
+<script src="/static/dropeditor.js"></script>
<script>
-initEditor('drops', [
- {key:'drops',label:'Drops (JSON array)',type:'textarea',parser:function(v){try{return JSON.parse(v)}catch(e){return []}}}
-]);
+initDropEditor();
</script>
{{end}}
diff --git a/internal/admin/templates/hazards.html b/internal/admin/templates/hazards.html
index 0d60df6..d7d4548 100644
--- a/internal/admin/templates/hazards.html
+++ b/internal/admin/templates/hazards.html
@@ -10,5 +10,9 @@
</div>
</div>
<script src="/static/editor.js"></script>
-<script>initEditor('hazards', [{key:'name',label:'Name',type:'text'},{key:'damage',label:'Damage',type:'number'},{key:'speed',label:'Speed',type:'number'},{key:'required_item',label:'Required Item',type:'text'},{key:'message',label:'Message',type:'text'},{key:'lethal',label:'Lethal',type:'checkbox'}]);</script>
+<script src="/static/cardeditor.js"></script>
+<script src="/static/hazardeditor.js"></script>
+<script>
+initHazardEditor();
+</script>
{{end}}
diff --git a/internal/admin/templates/modules.html b/internal/admin/templates/modules.html
index 0f8ca7c..e46af1b 100644
--- a/internal/admin/templates/modules.html
+++ b/internal/admin/templates/modules.html
@@ -10,5 +10,9 @@
</div>
</div>
<script src="/static/editor.js"></script>
-<script>initEditor('modules', [{key:'name',label:'Name',type:'text'},{key:'type',label:'Type',type:'text'},{key:'effect',label:'Effect',type:'text'},{key:'description',label:'Description',type:'textarea'}]);</script>
+<script src="/static/cardeditor.js"></script>
+<script src="/static/moduleeditor.js"></script>
+<script>
+initModuleEditor();
+</script>
{{end}}
diff --git a/internal/admin/templates/techs.html b/internal/admin/templates/techs.html
index 084d564..813d57b 100644
--- a/internal/admin/templates/techs.html
+++ b/internal/admin/templates/techs.html
@@ -10,5 +10,9 @@
</div>
</div>
<script src="/static/editor.js"></script>
-<script>initEditor('techs', [{key:'name',label:'Name',type:'text'},{key:'category',label:'Category',type:'text'},{key:'tier',label:'Tier',type:'number'},{key:'drain',label:'Drain Rate',type:'number'},{key:'bonus',label:'Bonus',type:'number'},{key:'description',label:'Description',type:'textarea'}]);</script>
+<script src="/static/cardeditor.js"></script>
+<script src="/static/techeditor.js"></script>
+<script>
+initTechEditor();
+</script>
{{end}}