From 5f37dc9b6f6b79da04da70ae0c33ec8d02998587 Mon Sep 17 00:00:00 2001
From: historia <[not public]>
Date: Wed, 1 Jul 2026 02:21:42 -0400
Subject: feat: qol improvements to items crud in admin gui
---
internal/admin/static/itemeditor.js | 1557 +++++++++++++++++++++++++++++++++++
1 file changed, 1557 insertions(+)
create mode 100644 internal/admin/static/itemeditor.js
(limited to 'internal/admin/static/itemeditor.js')
diff --git a/internal/admin/static/itemeditor.js b/internal/admin/static/itemeditor.js
new file mode 100644
index 0000000..d02e7ab
--- /dev/null
+++ b/internal/admin/static/itemeditor.js
@@ -0,0 +1,1557 @@
+var itemData = null;
+var itemID = null;
+var expandedCards = {};
+var addedSections = {};
+
+var SECTIONS = [
+ {
+ id: 'core', label: 'Core', always: true,
+ fields: [
+ ['name', 'Name', 'text', 'Copper Ore'],
+ ['color', 'Color', 'color', 'B2'],
+ ['value', 'Value', 'number', '0'],
+ ['stackable', 'Stackable', 'checkbox'],
+ ['quality', 'Quality', 'number', ''],
+ ['max_quality', 'Max Quality', 'number', ''],
+ ['aliases', 'Aliases', 'text', 'comma separated'],
+ ['description', 'Description', 'textarea', 'A chunk of copper ore.'],
+ ]
+ },
+ {
+ id: 'equipment', label: 'Equipment', path: 'equipment',
+ detect: function(d) { return d.equipment; },
+ fields: []
+ },
+ {
+ id: 'tool', label: 'Tool', path: 'tool',
+ detect: function(d) { return d.tool; },
+ fields: [
+ ['type', 'Type', 'text', 'pickaxe, axe, hammer'],
+ ['speed', 'Speed', 'number', '1.0', 'narrow'],
+ ]
+ },
+ {
+ id: 'firemaking', label: 'Firemaking',
+ detect: function(d) { return d.burn_ticks || d.fire_level || d.fire_xp; },
+ fields: [
+ ['burn_ticks', 'Burn Ticks', 'number', '100', 'narrow'],
+ ['fire_level', 'Fire Level', 'number', '1', 'narrow'],
+ ['fire_xp', 'Fire XP', 'number', '40', 'narrow'],
+ ]
+ },
+ {
+ id: 'crafting', label: 'Crafting',
+ detect: function(d) { return d.craft; },
+ fields: [],
+ subtables: [
+ {
+ label: 'Recipes to Craft This Item', key: 'craft',
+ fields: [
+ ['type', 'Type', 'select', 'smithing|cooking|crafting|fletching|combine|pharmacy|construction'],
+ ['subtype', 'Subtype', 'select', '|smelt|clean'],
+ ['level', 'Level', 'number', '', 'narrow'],
+ ['xp', 'XP', 'number', '', 'narrow'],
+ ['wait', 'Wait', 'number', '', 'narrow'],
+ ['output_qty', 'Output Qty', 'number', '', 'narrow'],
+ ['station', 'Station', 'select', '|anvil|cooking_range|fire|furnace|pottery_oven|pottery_wheel|spinning_wheel|workbench'],
+ ['tool', 'Tool', 'select', '|pickaxe|axe|fire|chisel|hammer|knife|needle|rake|saw|shears|spade|watering_can|fishing_rod|fly_rod|harpoon|small_net|big_net|lobster_pot'],
+ ['fail', 'Fail Item', 'search', '', '', null, 'items'],
+ ['success_base', 'Base', 'number', '', 'narrow'],
+ ['success_per_level', 'Per Lvl', 'number', '', 'narrow'],
+ ['success_cap', 'Cap', 'number', '', 'narrow'],
+ ['start_message', 'Start Message', 'text', '', 'wide'],
+ ['message', 'Message', 'text', '', 'wide'],
+ ['fail_message', 'Fail Message', 'text', '', 'wide', function(item) { return item.type === 'cooking' || item.subtype === 'smelt'; }],
+ ['end_message', 'End Message', 'text', '', 'wide'],
+ ],
+ fieldGroups: [
+ ['type', 'subtype', 'level', 'xp', 'wait', 'output_qty'],
+ {fields: ['station', 'tool'], right: true},
+ {label: 'Success', fields: ['success_base', 'success_per_level', 'success_cap', 'fail']},
+ ['start_message', 'message', 'fail_message', 'end_message'],
+ ],
+ subSubtables: [
+ {label:'Ingredients', key:'ingredients', fields:[
+ ['quantity', 'Qty', 'number', '1', 'narrow'],
+ ]},
+ {label:'Steps', key:'steps', fields:[
+ ['tick', 'Ticks', 'number', '', 'narrow'],
+ ['message', 'Message', 'text', '', 'wide'],
+ ]},
+ ],
+ }
+ ]
+ },
+ {
+ id: 'search', label: 'Search',
+ detect: function(d) { return d.search_table || d.search_ticks; },
+ fields: [
+ ['search_table', 'Search Table', 'search', '', '', null, 'drops'],
+ ['search_ticks', 'Search Ticks', 'number', '', 'narrow'],
+ ['search_message', 'Search Message', 'text', 'You search...', 'wide'],
+ ]
+ },
+ {
+ id: 'food', label: 'Food',
+ detect: function(d) { return d.heal_value || d.eat_message; },
+ fields: [
+ ['heal_value', '+HP', 'number', '', 'narrow'],
+ ['eat_message', 'Eat Message', 'text', 'You eat the...', 'wide'],
+ ]
+ },
+ {
+ id: 'farming', label: 'Farming',
+ detect: function(d) { return d.farm_patch_type; },
+ fields: [
+ ['farm_patch_type', 'Patch Type', 'select', 'herb|allotment|flower|bush|tree'],
+ ['farm_level', 'Farm Level', 'number', '', 'narrow'],
+ ['farm_plant_xp', 'Plant XP', 'number', '', 'narrow'],
+ ['farm_harvest_xp', 'Harvest XP', 'number', '', 'narrow'],
+ ['farm_stages', 'Stages', 'number', '', 'narrow'],
+ ['farm_product', 'Product', 'search', '', '', null, 'items'],
+ ['farm_min_yield', 'Min Yield', 'number', '', 'narrow'],
+ ['farm_max_yield', 'Max Yield', 'number', '', 'narrow'],
+ ]
+ },
+ {
+ id: 'potions', label: 'Potions',
+ detect: function(d) { return d.potion_effect; },
+ fields: [
+ ['potion_effect', 'Effect', 'text', 'strength, accuracy, heal'],
+ ['potion_bonus', 'Bonus', 'number', '', 'narrow'],
+ ['potion_duration', 'Duration', 'number', '', 'narrow'],
+ ]
+ },
+];
+
+function initItemEditor() {
+ editorType = 'items';
+ editorFields = [];
+ loadList();
+ if (window.location.hash) loadItemEditor(window.location.hash.substring(1));
+}
+
+function cardPath(sec) {
+ return sec.path || '';
+}
+
+function getVal(data, sec, key) {
+ var p = cardPath(sec);
+ if (sec.isArray) {
+ return data[p] && data[p][key] !== undefined ? data[p][key] : '';
+ }
+ var fp = p ? p + '.' + key : key;
+ var v = getNested(data, fp);
+ return v === undefined || v === null ? '' : v;
+}
+
+function fieldID(sec, key, idx) {
+ if (idx !== undefined && idx >= 0) return 'f_' + sec.id + '_' + idx + '_' + key.replace(/\./g, '_');
+ return 'f_' + sec.id + '_' + key.replace(/\./g, '_');
+}
+
+function fieldIDSub(sec, subKey, idx, fieldKey) {
+ return 'f_' + sec.id + '_' + subKey + '_' + idx + '_' + fieldKey;
+}
+
+function fieldIDSubSub(sec, subKey, rowIdx, subSubKey, subSubIdx, fieldKey) {
+ return 'f_' + sec.id + '_' + subKey + '_' + rowIdx + '__' + subSubKey + '_' + subSubIdx + '_' + fieldKey;
+}
+
+function loadItemEditor(id) {
+ itemID = id;
+ currentID = id;
+ renderList('');
+ window.location.hash = id;
+ API.get('/api/items/' + encodeURIComponent(id)).then(function(data) {
+ itemData = data;
+ renderItemEditor(id, data);
+ }).catch(function(e) {
+ $('#editorMain').innerHTML = '
Failed to load: ' + esc(e.message) + '
';
+ });
+}
+
+function renderItemEditor(id, data) {
+ var html = 'Item: ' + esc(id) + '
';
+ html += '';
+ html += '
';
+
+ html += '';
+ html += '
';
+
+ SECTIONS.forEach(function(sec) {
+ if (!sec.always && sec.detect && !sec.detect(data) && !addedSections[sec.id]) return;
+ html += renderCard(sec, data);
+ });
+
+ html += '
';
+
+ html += '
';
+ html += '';
+ html += '';
+ html += '
';
+ html += '
';
+
+ html += '';
+ html += '
' + esc(data._raw || JSON.stringify(data, null, 2)) + '
';
+ html += '
';
+
+ html += '';
+ html += '';
+ html += '';
+ html += '';
+ html += '
';
+
+ $('#editorMain').innerHTML = html;
+ setupSearchFields();
+ setupCraftTypeChange();
+ setTimeout(function() {
+ document.querySelectorAll('.color-field').forEach(function(el) { bindColorField(el); });
+ }, 50);
+}
+
+function renderCurrent() {
+ if (!itemData || !itemID) return;
+ renderItemEditor(itemID, itemData);
+}
+
+// =========================================================================
+// Card rendering
+// =========================================================================
+
+function renderCard(sec, data) {
+ var collapsed = expandedCards[sec.id] === false;
+ var h = '';
+ h += '';
+ if (!collapsed) {
+ h += '
';
+
+ if (sec.isArray) {
+ h += renderArraySection(sec, data);
+ } else if (sec.id === 'core') {
+ h += renderCoreCard(data);
+ } else if (sec.id === 'equipment') {
+ h += renderEquipmentCard(data);
+ } else {
+ h += '
';
+ if (sec.fields) sec.fields.forEach(function(f) {
+ h += renderField(sec, f, data, -1);
+ });
+ h += '
';
+
+ if (sec.inline) {
+ sec.inline.forEach(function(il) {
+ if (il.type === 'tags') {
+ h += renderTags(sec, il, data);
+ } else if (il.type === 'kv') {
+ h += renderKV(sec, il, data);
+ } else {
+ h += '
';
+ h += '
' + esc(il.label) + '';
+ h += '
';
+ il.fields.forEach(function(f) {
+ h += renderField(sec, f, data, -1, il.path);
+ });
+ h += '
';
+ h += '
';
+ }
+ });
+ }
+
+ if (sec.subtables) {
+ sec.subtables.forEach(function(st) {
+ h += renderSubtable(sec, st, data);
+ });
+ }
+ }
+ h += '
';
+ }
+ h += '
';
+ return h;
+}
+
+function renderCoreCard(data) {
+ var sec = SECTIONS[0];
+ var h = '';
+ h += renderField(sec, sec.fields[0], data, -1, null, 'flex:1');
+ h += renderField(sec, sec.fields[1], data, -1);
+ h += renderField(sec, sec.fields[6], data, -1, null, 'flex:1');
+ h += '
';
+ h += '';
+ h += renderField(sec, sec.fields[2], data, -1);
+ h += '
';
+ h += renderField(sec, sec.fields[3], data, -1);
+ h += '
';
+ h += '
';
+ h += renderField(sec, sec.fields[4], data, -1);
+ h += renderField(sec, sec.fields[5], data, -1);
+ h += '
';
+ h += '
';
+ var descFid = fieldID(sec, 'description', -1);
+ var descVal = getVal(itemData, sec, 'description');
+ var dstr = typeof descVal === 'object' ? (Array.isArray(descVal) ? descVal.map(function(v){return v.text||'';}).join('\n') : JSON.stringify(descVal)) : String(descVal);
+ h += '';
+ return h;
+}
+
+// =========================================================================
+// Equipment card (custom render with 3 stat blocks)
+// =========================================================================
+
+var EQUIP_STAT_FIELDS = {
+ attack: [
+ ['stab', 'Stab', 'number', '', 'narrow'],
+ ['slash', 'Slash', 'number', '', 'narrow'],
+ ['crush', 'Crush', 'number', '', 'narrow'],
+ ['ranged', 'Ranged', 'number', '', 'narrow'],
+ ['science', 'Science', 'number', '', 'narrow'],
+ ],
+ defense: [
+ ['stab', 'Stab', 'number', '', 'narrow'],
+ ['slash', 'Slash', 'number', '', 'narrow'],
+ ['crush', 'Crush', 'number', '', 'narrow'],
+ ['ranged', 'Ranged', 'number', '', 'narrow'],
+ ['science', 'Science', 'number', '', 'narrow'],
+ ],
+ other: [
+ ['strength', 'Strength', 'number', '', 'narrow'],
+ ['ranged', 'Ranged', 'number', '', 'narrow'],
+ ['science', 'Science', 'number', '', 'narrow'],
+ ['technology', 'Tech', 'number', '', 'narrow'],
+ ],
+};
+
+function equipFieldID(key) {
+ return 'f_equipment_' + key;
+}
+
+function renderEquipField(key, label, type, hint, val, wide, searchEntity) {
+ var fid = equipFieldID(key);
+ val = val === undefined || val === null ? '' : val;
+
+ function wrap(cls, inner) {
+ var w = (wide === 'wide') ? ' obj-wide' : '';
+ if (wide === 'narrow') w = ' obj-narrow';
+ return '';
+ }
+
+ if (type === 'select') {
+ var opts = hint ? hint.split('|') : [];
+ var inner = '' + esc(label) + '';
+ return wrap('obj-field', inner);
+ }
+ if (type === 'search') {
+ var entity = searchEntity || 'items';
+ var ph = hint ? ' placeholder="' + esc(hint) + '"' : ' placeholder="Search ' + entity + '..."';
+ return wrap('obj-field obj-search', '' + esc(label) + '');
+ }
+ var ph = hint ? ' placeholder="' + esc(hint) + '"' : '';
+ return wrap('obj-field', '' + esc(label) + '');
+}
+
+function renderEquipmentCard(data) {
+ var eq = data.equipment || {};
+ var h = '';
+ var isAmmo = eq.type === 'ammo';
+
+ // Row 1: type, slot
+ h += '';
+ h += renderEquipField('type', 'Type', 'select', 'melee_weapon|ranged_weapon|tech_weapon|armor|tool|ammo', eq.type);
+ if (!isAmmo) {
+ h += renderEquipField('slot', 'Slot', 'select', 'head|neck|torso|legs|hands|feet|back|ammo|main_hand|off_hand|ring', eq.slot);
+ } else {
+ h += '';
+ }
+ h += '
';
+
+ // Ammo: recoverable checkbox
+ if (isAmmo) {
+ h += '';
+ }
+
+ // Row 2: attack_type, speed, junk
+ var isMelee = eq.type === 'melee_weapon';
+ var isWeapon = ['melee_weapon', 'ranged_weapon', 'tech_weapon'].indexOf(eq.type) >= 0;
+ h += '';
+ if (isMelee) {
+ h += renderEquipField('attack_type', 'Attack Type', 'select', 'stab|slash|crush', eq.attack_type);
+ }
+ if (isWeapon) {
+ h += renderEquipField('speed', 'Speed', 'number', '', eq.speed, 'narrow');
+ }
+ h += renderEquipField('junk', 'Junk', 'search', '', eq.junk, '', 'items');
+ h += '
';
+
+ // 3 buttons row
+ var btns = [];
+ if (!eq.attack) btns.push('');
+ if (!eq.defense) btns.push('');
+ if (!eq.other) btns.push('');
+ if (btns.length > 0) {
+ h += '' + btns.join('') + '
';
+ }
+
+ // Stat blocks
+ if (eq.attack) h += renderEquipStatBlock('Attack Stats', 'attack', eq.attack, EQUIP_STAT_FIELDS.attack);
+ if (eq.defense) h += renderEquipStatBlock('Defense Stats', 'defense', eq.defense, EQUIP_STAT_FIELDS.defense);
+ if (eq.other) h += renderEquipStatBlock('Other Stats', 'other', eq.other, EQUIP_STAT_FIELDS.other);
+
+ // Requirements KV (at root level, stored in itemData.requirements)
+ h += '';
+ h += renderKV({id: 'equipment_req'}, {label:'Requirements', key:'requirements', type:'kv', valType:'number'}, data);
+ h += '
';
+
+ return h;
+}
+
+function renderEquipStatBlock(label, blockKey, blockData, fields) {
+ var h = '';
+ h += '
';
+ h += '' + esc(label) + '';
+ h += '';
+ h += '
';
+ h += '
';
+ fields.forEach(function(f) {
+ var fid = 'f_equipment_' + blockKey + '_' + f[0];
+ var val = blockData[f[0]];
+ val = val === undefined || val === null ? '' : val;
+ var extraCls = f[4] === 'narrow' ? ' obj-narrow' : '';
+ h += '';
+ });
+ h += '
';
+ h += '
';
+ return h;
+}
+
+function addEquipBlock(blockKey) {
+ if (!itemData.equipment) itemData.equipment = {};
+ var empty = {};
+ EQUIP_STAT_FIELDS[blockKey].forEach(function(f) { empty[f[0]] = 0; });
+ itemData.equipment[blockKey] = empty;
+ renderCurrent();
+}
+
+function removeEquipBlock(blockKey) {
+ if (itemData.equipment) delete itemData.equipment[blockKey];
+ renderCurrent();
+}
+
+// =========================================================================
+// Generic field render (unchanged)
+// =========================================================================
+
+function renderField(sec, f, data, idx, subPath, optStyle) {
+ var key = f[0], label = f[1], type = f[2], hint = f[3], wide = f[4], showIf = f[5], searchEntity = f[6];
+ var fp = subPath ? subPath + '.' + key : key;
+ var fid = fieldID(sec, fp, idx);
+ var val;
+
+ if (sec.isArray && idx >= 0) {
+ var arr = itemData[cardPath(sec)] || [];
+ var item = arr[idx] || {};
+ val = item[key];
+ } else if (subPath) {
+ var sectionRoot = itemData[cardPath(sec)] || {};
+ var subObj = sectionRoot[subPath] || {};
+ val = subObj[key];
+ } else {
+ val = getVal(itemData, sec, key);
+ }
+ val = val === undefined || val === null ? '' : val;
+
+ var hiddenStyle = '';
+ if (showIf) {
+ var sectionData = itemData[cardPath(sec)] || {};
+ if (!showIf(sectionData)) hiddenStyle = ' style="display:none"';
+ }
+
+ function wrap(cls, inner) {
+ var w = (wide === 'wide') ? ' obj-wide' : '';
+ if (wide === 'narrow') w = ' obj-narrow';
+ var s = optStyle ? ' style="' + optStyle + '"' : '';
+ return '';
+ }
+
+ if (type === 'checkbox') {
+ return wrap('obj-field obj-check', ' ' + esc(label) + '');
+ }
+ if (type === 'json') {
+ var jstr = typeof val === 'object' ? JSON.stringify(val) : String(val);
+ return wrap('obj-field', '' + esc(label) + '');
+ }
+ if (type === 'textarea') {
+ var dstr = typeof val === 'object' ? (Array.isArray(val) ? val.map(function(v){return v.text||'';}).join('\n') : JSON.stringify(val)) : String(val);
+ return wrap('obj-field', '' + esc(label) + '');
+ }
+ if (type === 'select') {
+ var opts = hint ? hint.split('|') : [];
+ var inner = '' + esc(label) + '';
+ return wrap('obj-field', inner);
+ }
+ if (type === 'color') {
+ var swatch = '';
+ return wrap('obj-field color-field', '' + esc(label) + '' + swatch + '
');
+ }
+ if (type === 'search') {
+ var entity = searchEntity || 'items';
+ var ph = hint ? ' placeholder="' + esc(hint) + '"' : ' placeholder="Search ' + entity + '..."';
+ return wrap('obj-field obj-search', '' + esc(label) + '');
+ }
+ var ph = hint ? ' placeholder="' + esc(hint) + '"' : '';
+ return wrap('obj-field', '' + esc(label) + '');
+}
+
+// =========================================================================
+// Array section rendering
+// =========================================================================
+
+function renderArraySection(sec, data) {
+ var arr = data[cardPath(sec)] || [];
+ var h = '';
+ arr.forEach(function(item, idx) {
+ h += '';
+ h += '
';
+ sec.fields.forEach(function(f) {
+ h += renderField(sec, f, data, idx);
+ });
+ h += '
';
+ h += '
';
+ h += '
';
+ });
+ h += '';
+ return h;
+}
+
+// =========================================================================
+// Subtable rendering
+// =========================================================================
+
+function renderSubFieldHTML(sec, stKey, idx, f, val) {
+ var fid = fieldIDSub(sec, stKey, idx, f[0]);
+ val = val === undefined || val === null ? '' : val;
+ var extraCls = '';
+ var extraStyle = '';
+ if (f[4] === 'narrow') extraCls = ' obj-narrow';
+ else if (f[4] === 'grow') extraCls = ' obj-grow';
+ if (f[4] === 'wide') extraStyle = ' style="flex:1 1 100%"';
+ if (f[2] === 'search') {
+ var entity = f[6] || 'items';
+ var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."';
+ return '';
+ }
+ if (f[2] === 'checkbox') {
+ return '';
+ }
+ if (f[2] === 'select') {
+ var opts = f[3] ? f[3].split('|') : [];
+ var sel = '';
+ return sel;
+ }
+ return '';
+}
+
+function renderSubtable(sec, st, data) {
+ var p = cardPath(sec);
+ var sectionRoot = p ? (itemData[p] || {}) : itemData;
+ var raw = sectionRoot[st.key];
+ var arr;
+ if (st.single) {
+ arr = raw ? [raw] : [];
+ } else if (Array.isArray(raw)) {
+ arr = raw;
+ } else if (raw && typeof raw === 'object') {
+ arr = [raw];
+ } else {
+ arr = [];
+ }
+ var hasSubSubtables = st.subSubtables && st.subSubtables.length > 0;
+ var h = '';
+ h += '';
+ arr.forEach(function(item, idx) {
+ h += '
';
+
+ function renderGroup(fieldKeys) {
+ fieldKeys.forEach(function(fieldKey) {
+ var f = st.fields.find(function(sf) { return sf[0] === fieldKey; });
+ if (!f) return;
+ if (f[5] && !f[5](item)) return;
+ var val = item[f[0]];
+ h += renderSubFieldHTML(sec, st.key, idx, f, val);
+ });
+ }
+
+ if (st.fieldGroups) {
+ st.fieldGroups.forEach(function(group) {
+ if (group.label) {
+ if (group.label === 'Success') {
+ var hasSuccess = !!(item.fail || item.success_base || item.success_per_level || item.success_cap);
+ var gid = 'sg_' + sec.id + '_' + st.key + '_' + idx;
+ h += '
';
+ h += '
';
+ h += 'Success';
+ h += '';
+ h += '
';
+ h += '
';
+ renderGroup(group.fields);
+ h += '
';
+ h += '
';
+ h += '
';
+ } else {
+ h += '
';
+ h += '
' + esc(group.label) + '';
+ h += '
';
+ renderGroup(group.fields);
+ h += '
';
+ h += '
';
+ }
+ } else {
+ var rightStyle = (group && group.right) ? ' style="justify-content:flex-end"' : '';
+ h += '
';
+ var keys = Array.isArray(group) ? group : group.fields;
+ renderGroup(keys);
+ h += '
';
+ }
+ });
+ } else {
+ h += '
';
+ st.fields.forEach(function(f) {
+ if (f[5] && !f[5](item)) return;
+ var val = item[f[0]];
+ h += renderSubFieldHTML(sec, st.key, idx, f, val);
+ });
+ h += '
';
+ }
+
+ if (hasSubSubtables) {
+ st.subSubtables.forEach(function(sst) {
+ if (sst.key === 'steps') {
+ var stepsArr = item[sst.key];
+ if (!stepsArr || !Array.isArray(stepsArr) || stepsArr.length === 0) {
+ h += '
';
+ return;
+ }
+ }
+ h += renderSubSubtable(sec, st, idx, sst, item);
+ });
+ }
+
+ if (!st.single) {
+ h += '
';
+ }
+ h += '
';
+ });
+ if (!st.single || arr.length === 0) {
+ h += '
';
+ }
+ h += '
';
+ return h;
+}
+
+function ingredientPairID(sec, subKey, rowIdx, subSubKey, subIdx, pairIdx, fieldKey) {
+ return 'f_' + sec.id + '_' + subKey + '_' + rowIdx + '__' + subSubKey + '_' + subIdx + '_' + fieldKey + '_' + pairIdx;
+}
+
+function renderConsumeSearchHTML(fid, val, entity, placeholder) {
+ val = val === undefined || val === null ? '' : val;
+ return '';
+}
+
+function addConsumePair(secID, subKey, rowIdx, subSubKey, subIdx) {
+ var sec = SECTIONS.find(function(s) { return s.id === secID; });
+ var existing = document.querySelectorAll('.ingredients-pair[data-sec="' + secID + '"][data-subkey="' + subKey + '"][data-row="' + rowIdx + '"][data-subsub="' + subSubKey + '"][data-subidx="' + subIdx + '"]');
+ var pairIdx = existing.length;
+ var btnRow = document.querySelector('.ingredients-pair-add[data-sec="' + secID + '"][data-subkey="' + subKey + '"][data-row="' + rowIdx + '"][data-subsub="' + subSubKey + '"][data-subidx="' + subIdx + '"]');
+ var itemFid = ingredientPairID(sec, subKey, rowIdx, subSubKey, subIdx, pairIdx, 'items');
+ var bypFid = ingredientPairID(sec, subKey, rowIdx, subSubKey, subIdx, pairIdx, 'byproducts');
+ var row = document.createElement('div');
+ row.className = 'ingredients-pair';
+ row.setAttribute('data-sec', secID);
+ row.setAttribute('data-subkey', subKey);
+ row.setAttribute('data-row', rowIdx);
+ row.setAttribute('data-subsub', subSubKey);
+ row.setAttribute('data-subidx', subIdx);
+ row.setAttribute('data-pair', pairIdx);
+ row.innerHTML = ''
+ + ''
+ + '' + renderConsumeSearchHTML(itemFid, '', 'items', 'Search items...') + '
'
+ + '' + renderConsumeSearchHTML(bypFid, '', 'items', '(optional)') + '
'
+ + '';
+ btnRow.parentNode.insertBefore(row, btnRow);
+ setupSearchFields();
+}
+
+function removeConsumePair(secID, subKey, rowIdx, subSubKey, subIdx, pairIdx) {
+ var el = document.querySelector('.ingredients-pair[data-sec="' + secID + '"][data-subkey="' + subKey + '"][data-row="' + rowIdx + '"][data-subsub="' + subSubKey + '"][data-subidx="' + subIdx + '"][data-pair="' + pairIdx + '"]');
+ if (!el) return;
+ var ingredient = el.parentElement;
+ var remaining = ingredient.querySelectorAll('.ingredients-pair');
+ if (remaining.length <= 1) {
+ ingredient.remove();
+ } else {
+ el.remove();
+ }
+}
+
+function renderSubSubtable(sec, st, rowIdx, sst, parentItem) {
+ var arr = parentItem[sst.key] || [];
+ var isIngredients = sst.key === 'ingredients';
+ var h = '';
+ if (!isIngredients) {
+ h += '';
+ }
+
+ if (isIngredients) {
+ h += '
';
+ h += '';
+
+ var ingredientNum = 0;
+ arr.forEach(function(subItem, subIdx) {
+ ingredientNum++;
+ var itemsArr = Array.isArray(subItem.items) ? subItem.items : (subItem.items ? [subItem.items] : ['']);
+ var bypArr = Array.isArray(subItem.byproducts) ? subItem.byproducts : [];
+ var pairs = Math.max(itemsArr.length, bypArr.length, 1);
+
+ h += '
';
+
+ var qtyVal = subItem.quantity !== undefined && subItem.quantity !== null ? subItem.quantity : '';
+ var qtyFid = fieldIDSubSub(sec, st.key, rowIdx, sst.key, subIdx, 'quantity');
+
+ for (var p = 0; p < pairs; p++) {
+ h += '
';
+
+ if (p === 0) {
+ h += '
' + ingredientNum + '
';
+ } else {
+ h += '
';
+ }
+
+ if (p === 0) {
+ h += '
';
+ } else {
+ h += '
';
+ }
+
+ var itemVal = itemsArr[p] || '';
+ var itemFid = ingredientPairID(sec, st.key, rowIdx, sst.key, subIdx, p, 'items');
+ h += '
' + renderConsumeSearchHTML(itemFid, itemVal, 'items', 'Search items...') + '
';
+
+ var bypVal = bypArr[p] || '';
+ var bypFid = ingredientPairID(sec, st.key, rowIdx, sst.key, subIdx, p, 'byproducts');
+ h += '
' + renderConsumeSearchHTML(bypFid, bypVal, 'items', '(optional)') + '
';
+
+ h += '
';
+
+ h += '
';
+ }
+
+ h += '
';
+ h += '';
+ h += '
';
+
+ h += '
';
+ });
+
+ h += '
';
+ h += '
';
+ } else {
+ arr.forEach(function(subItem, subIdx) {
+ h += '
';
+ h += '
';
+ sst.fields.forEach(function(f) {
+ var fid = fieldIDSubSub(sec, st.key, rowIdx, sst.key, subIdx, f[0]);
+ var val = subItem[f[0]];
+ val = val === undefined || val === null ? '' : val;
+ var extraCls = '';
+ if (f[4] === 'narrow') extraCls = ' obj-narrow';
+ if (f[2] === 'search') {
+ var entity = f[6] || 'items';
+ var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."';
+ h += '';
+ } else if (f[2] === 'checkbox') {
+ h += '';
+ } else {
+ h += '';
+ }
+ });
+ h += '
';
+ h += '
';
+ h += '
';
+ });
+ h += '
';
+ }
+
+ h += '
';
+ return h;
+}
+
+// =========================================================================
+// Tags / KV rendering
+// =========================================================================
+
+function renderTags(sec, il, data) {
+ var p = cardPath(sec);
+ var sectionRoot = p ? (itemData[p] || {}) : itemData;
+ var arr = sectionRoot[il.key] || [];
+ var str = Array.isArray(arr) ? arr.join(', ') : String(arr || '');
+ var fid = fieldID(sec, il.key);
+ return '';
+}
+
+function renderKV(sec, il, data) {
+ var p = cardPath(sec);
+ var sectionRoot = p ? (itemData[p] || {}) : itemData;
+ var map = sectionRoot[il.key] || {};
+ var h = '';
+ h += '
' + esc(il.label) + '';
+ h += '
';
+ h += '
';
+ h += '
';
+ return h;
+}
+
+// =========================================================================
+// Card toggle / section add-remove
+// =========================================================================
+
+function toggleCard(id) {
+ expandedCards[id] = expandedCards[id] === false ? true : false;
+ renderCurrent();
+}
+
+function removeSection(id) {
+ var sec = SECTIONS.find(function(s) { return s.id === id; });
+ if (!sec) return;
+ if (sec.path) {
+ delete itemData[sec.path];
+ } else {
+ if (sec.fields) sec.fields.forEach(function(f) { delete itemData[f[0]]; });
+ if (sec.inline) sec.inline.forEach(function(il) { if (il.key) delete itemData[il.key]; });
+ if (sec.subtables) sec.subtables.forEach(function(st) { delete itemData[st.key]; });
+ }
+ delete addedSections[id];
+ renderCurrent();
+}
+
+function addSection() {
+ var sel = $('#addSectionSelect');
+ var id = sel.value;
+ if (!id) return;
+ var sec = SECTIONS.find(function(s) { return s.id === id; });
+ if (!sec) return;
+
+ if (sec.path) {
+ if (sec.id === 'equipment') {
+ itemData.equipment = {type:'', slot:'', attack_type:'', speed:0, junk:''};
+ } else {
+ itemData[sec.path] = {};
+ }
+ }
+ addedSections[id] = true;
+ expandedCards[id] = true;
+ renderCurrent();
+}
+
+// =========================================================================
+// Array item add/remove
+// =========================================================================
+
+function addArrayItem(cardID) {
+ var sec = SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec || !sec.isArray) return;
+ var arr = itemData[sec.path] || [];
+ var empty = {};
+ sec.fields.forEach(function(f) {
+ empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
+ });
+ arr.push(empty);
+ itemData[sec.path] = arr;
+ renderCurrent();
+}
+
+function removeArrayItem(cardID, idx) {
+ var sec = SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec || !sec.isArray) return;
+ var arr = itemData[sec.path] || [];
+ arr.splice(idx, 1);
+ renderCurrent();
+}
+
+// =========================================================================
+// Subtable row add/remove
+// =========================================================================
+
+function addSubRow(cardID, subKey) {
+ var sec = SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec) return;
+ var p = cardPath(sec);
+ var sectionRoot = p ? (itemData[p] || {}) : itemData;
+ var raw = sectionRoot[subKey];
+ var arr;
+ if (Array.isArray(raw)) {
+ arr = raw;
+ } else if (raw && typeof raw === 'object') {
+ arr = [raw];
+ } else {
+ arr = [];
+ }
+ var empty = {};
+ var st = (sec.subtables || []).find(function(s) { return s.key === subKey; });
+ if (st) {
+ st.fields.forEach(function(f) {
+ empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
+ });
+ }
+ arr.push(empty);
+ sectionRoot[subKey] = arr;
+ if (sectionRoot === itemData) { delete itemData._raw; delete itemData._path; }
+ renderCurrent();
+}
+
+function removeSubRow(cardID, subKey, idx) {
+ var sec = SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec) return;
+ var p = cardPath(sec);
+ var sectionRoot = p ? (itemData[p] || {}) : itemData;
+ var raw = sectionRoot[subKey];
+ var arr;
+ if (Array.isArray(raw)) {
+ arr = raw;
+ } else if (raw && typeof raw === 'object') {
+ arr = [raw];
+ } else {
+ arr = [];
+ }
+ arr.splice(idx, 1);
+ sectionRoot[subKey] = arr;
+ renderCurrent();
+}
+
+// =========================================================================
+// Sub-subtable row add/remove
+// =========================================================================
+
+function addSubSubRow(cardID, subKey, rowIdx, subSubKey) {
+ try {
+ var sec = SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec) { console.warn('addSubSubRow: section not found', cardID); return; }
+ var p = cardPath(sec);
+ var sectionRoot = p ? (itemData[p] || {}) : itemData;
+ var arr = sectionRoot[subKey] || [];
+ if (rowIdx >= arr.length) { console.warn('addSubSubRow: row index out of range', rowIdx, arr.length); return; }
+ if (!arr[rowIdx]) { arr[rowIdx] = {}; }
+ var subArr = arr[rowIdx][subSubKey] || [];
+ var empty = {};
+ var st = (sec.subtables || []).find(function(s) { return s.key === subKey; });
+ if (st && st.subSubtables) {
+ var sst = st.subSubtables.find(function(ss) { return ss.key === subSubKey; });
+ if (sst) {
+ sst.fields.forEach(function(f) {
+ empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
+ });
+ }
+ }
+ subArr.push(empty);
+ arr[rowIdx][subSubKey] = subArr;
+ sectionRoot[subKey] = arr;
+ renderCurrent();
+ } catch(e) {
+ console.error('addSubSubRow error:', e);
+ notify('Failed to add row: ' + e.message, 'error');
+ }
+}
+
+function removeSubSubRow(cardID, subKey, rowIdx, subSubKey, subSubIdx) {
+ var sec = SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec) return;
+ var p = cardPath(sec);
+ var sectionRoot = p ? (itemData[p] || {}) : itemData;
+ var arr = sectionRoot[subKey] || [];
+ if (rowIdx >= arr.length) return;
+ var subArr = arr[rowIdx][subSubKey] || [];
+ subArr.splice(subSubIdx, 1);
+ renderCurrent();
+}
+
+// =========================================================================
+// KV row add
+// =========================================================================
+
+function addKVRow(cardID, subKey) {
+ var el = document.querySelector('.obj-kv-list[data-sec="' + cardID + '"][data-key="' + subKey + '"]');
+ if (!el) return;
+ var row = document.createElement('div');
+ row.className = 'obj-kv-row';
+ row.innerHTML = '';
+ el.appendChild(row);
+}
+
+// =========================================================================
+// Search autocomplete
+// =========================================================================
+
+function setupCraftTypeChange() {
+ document.querySelectorAll('.obj-subtable select[id$="_type"]').forEach(function(typeSelect) {
+ var id = typeSelect.id;
+ var subtypeSel = document.getElementById(id.replace(/_type$/, '_subtype'));
+ var levelField = document.getElementById(id.replace(/_type$/, '_level'));
+ var xpField = document.getElementById(id.replace(/_type$/, '_xp'));
+ var stationField = document.getElementById(id.replace(/_type$/, '_station'));
+
+ function update() {
+ var t = typeSelect.value;
+
+ [levelField, xpField].forEach(function(el) {
+ if (!el) return;
+ var lbl = el.closest('.obj-field');
+ if (lbl) lbl.style.display = t === 'combine' ? 'none' : '';
+ });
+
+ if (stationField) {
+ var sLbl = stationField.closest('.obj-field');
+ if (sLbl) sLbl.style.display = (t === 'fletching' || t === 'combine' || t === 'pharmacy') ? 'none' : '';
+ }
+
+ if (subtypeSel) {
+ var sLbl = subtypeSel.closest('.obj-field');
+ if (t === 'smithing') {
+ if (sLbl) sLbl.style.display = '';
+ subtypeSel.innerHTML = '';
+ } else if (t === 'pharmacy') {
+ if (sLbl) sLbl.style.display = '';
+ subtypeSel.innerHTML = '';
+ } else {
+ if (sLbl) sLbl.style.display = 'none';
+ subtypeSel.value = '';
+ }
+ }
+ }
+
+ typeSelect.addEventListener('change', update);
+ update();
+ });
+}
+
+function setupSearchFields() {
+ document.querySelectorAll('.search-input').forEach(function(input) {
+ if (input._searchSetup) return;
+ input._searchSetup = true;
+ var entityType = input.getAttribute('data-search-type') || 'items';
+ var results = input.parentElement.querySelector('.search-results');
+ if (!results) return;
+
+ results._targetInput = input;
+
+ input.addEventListener('input', function() {
+ var val = input.value.trim();
+ if (!val) { results.style.display = 'none'; results.innerHTML = ''; return; }
+ API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) {
+ var items = [];
+ if (entityType === 'items' && data.items) items = data.items;
+ if (entityType === 'objects' && data.objects) items = data.objects;
+ if (entityType === 'mobs' && data.mobs) items = data.mobs;
+ if (entityType === 'drops' && data.drops) items = data.drops;
+ if (items.length === 0) {
+ results.style.display = 'block';
+ results.innerHTML = 'No results
';
+ return;
+ }
+ results.style.display = 'block';
+ results.innerHTML = items.map(function(r) {
+ return '' + esc(r.id) + ' ' + esc(r.name || '') + '
';
+ }).join('');
+ results._items = items;
+ }).catch(function() {
+ results.style.display = 'none';
+ });
+ });
+
+ input.addEventListener('focus', function() {
+ if (input.value.trim()) {
+ input.dispatchEvent(new Event('input'));
+ }
+ });
+
+ input.addEventListener('blur', function() {
+ setTimeout(function() { results.style.display = 'none'; }, 200);
+ });
+ });
+
+ document.addEventListener('click', function(e) {
+ if (!e.target.closest('.search-results') && !e.target.closest('.search-input')) {
+ document.querySelectorAll('.search-results').forEach(function(r) { r.style.display = 'none'; });
+ }
+ });
+}
+
+function selectSearchResult(el) {
+ var results = el.parentElement;
+ var idx = Array.prototype.indexOf.call(results.children, el);
+ if (results._items && results._items[idx] && results._targetInput) {
+ results._targetInput.value = results._items[idx].id;
+ results._targetInput.dispatchEvent(new Event('input'));
+ results.style.display = 'none';
+ results.innerHTML = '';
+ }
+}
+
+// =========================================================================
+// Validation
+// =========================================================================
+
+function validateFields() {
+ var errors = [];
+ document.querySelectorAll('.obj-field-error').forEach(function(field) {
+ field.classList.remove('obj-field-error');
+ });
+
+ document.querySelectorAll('input[id]:not([type="checkbox"])').forEach(function(input) {
+ var id = input.id;
+ if (!id) return;
+ var labelEl = input.closest('.obj-field');
+ if (!labelEl) return;
+
+ if (input.classList.contains('search-input') || input.classList.contains('color-input')) return;
+
+ var isNumber = false;
+ var labelText = '';
+
+ var fieldDiv = labelEl.closest('[data-card]');
+ var cardID = fieldDiv ? fieldDiv.getAttribute('data-card') : '';
+ var sec = SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec) return;
+
+ var baseMatch = id.match(/^f_(\w+)_(\w+)$/);
+ var subMatch = id.match(/^f_(\w+)_(\w+)_(\d+)_(\w.+)$/);
+ var subSubMatch = id.match(/^f_(\w+)_(\w+)_(\d+)__(\w+)_(\d+)_(\w+)$/);
+ var equipStatMatch = id.match(/^f_equipment_(\w+)_(\w+)$/);
+
+ if (subSubMatch) {
+ var ssSecId = subSubMatch[1], ssSubKey = subSubMatch[2];
+ var ssSubSubKey = subSubMatch[4], ssFieldKey = subSubMatch[6];
+ var st = (sec.subtables || []).find(function(s) { return s.key === ssSubKey; });
+ if (st && st.subSubtables) {
+ var sst = st.subSubtables.find(function(ss) { return ss.key === ssSubSubKey; });
+ if (sst) {
+ var nf = sst.fields.find(function(f) { return f[0] === ssFieldKey && f[2] === 'number'; });
+ if (nf) { isNumber = true; labelText = nf[1]; }
+ }
+ }
+ } else if (subMatch) {
+ var subKey = subMatch[2], fieldKey = subMatch[4];
+ var st = (sec.subtables || []).find(function(s) { return s.key === subKey; });
+ if (st) {
+ var nf = st.fields.find(function(f) { return f[0] === fieldKey && f[2] === 'number'; });
+ if (nf) { isNumber = true; labelText = nf[1]; }
+ }
+ } else if (equipStatMatch) {
+ var blockKey = equipStatMatch[1], statField = equipStatMatch[2];
+ var fields = EQUIP_STAT_FIELDS[blockKey];
+ if (fields) {
+ var nf = fields.find(function(f) { return f[0] === statField; });
+ if (nf) { isNumber = true; labelText = nf[1]; }
+ }
+ } else if (baseMatch) {
+ var fieldKey = baseMatch[2];
+ var nf = sec.fields ? sec.fields.find(function(f) {
+ var fid = fieldID(sec, f[0], -1);
+ return fid === id && f[2] === 'number';
+ }) : null;
+ if (!nf && sec.inline) {
+ sec.inline.forEach(function(il) {
+ if (il.fields) {
+ var inf = il.fields.find(function(f) {
+ var fid = fieldID(sec, il.path + '.' + f[0], -1);
+ return fid === id && f[2] === 'number';
+ });
+ if (inf) nf = inf;
+ }
+ });
+ }
+ if (nf) { isNumber = true; labelText = nf[1]; }
+ }
+
+ if (isNumber) {
+ var rawVal = input.value.trim();
+ if (rawVal !== '' && !/^-?\d*\.?\d*$/.test(rawVal)) {
+ labelEl.classList.add('obj-field-error');
+ input.focus();
+ input.select();
+ labelText = labelText || (labelEl.querySelector('span') || {}).textContent || '';
+ errors.push('Invalid value in "' + esc(labelText) + '": "' + esc(rawVal) + '"');
+ }
+ }
+ });
+
+ if (errors.length > 0) {
+ notify(errors.join('; '), 'error');
+ return false;
+ }
+ return true;
+}
+
+// =========================================================================
+// Field value collector
+// =========================================================================
+
+function collectFieldValue(el, type) {
+ if (type === 'checkbox') return el.checked;
+ if (type === 'select') return el.value;
+ if (type === 'search') return el.value;
+ if (type === 'number') { var n = parseFloat(el.value); return isNaN(n) ? 0 : n; }
+ if (type === 'json') {
+ var raw = el.value.trim();
+ if (!raw) return null;
+ try { return JSON.parse(raw); } catch(e) { return raw; }
+ }
+ if (type === 'textarea') return el.value;
+ return el.value;
+}
+
+// =========================================================================
+// Save
+// =========================================================================
+
+function saveItem() {
+ if (!validateFields()) return;
+
+ var out = {};
+
+ 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;
+ }
+
+ if (sec.id === 'equipment') {
+ var eq = {};
+ var hasEq = false;
+
+ ['type', 'slot', 'attack_type', 'junk'].forEach(function(k) {
+ var el = document.getElementById('f_equipment_' + k);
+ if (!el) return;
+ var val = collectFieldValue(el, k === 'type' || k === 'slot' ? 'select' : (k === 'junk' ? 'search' : 'text'));
+ if (val !== '' && val !== false && val !== 0 && val !== null) hasEq = true;
+ eq[k] = val;
+ });
+
+ if (eq.type === 'ammo') eq.slot = 'ammo';
+
+ var speedEl = document.getElementById('f_equipment_speed');
+ if (speedEl) {
+ var spd = parseFloat(speedEl.value);
+ if (!isNaN(spd) && spd !== 0) { eq.speed = spd; hasEq = true; }
+ }
+
+ EQUIP_STAT_FIELDS.attack.forEach(function(f) { saveEquipBlock(eq, 'attack', f[0]); });
+ EQUIP_STAT_FIELDS.defense.forEach(function(f) { saveEquipBlock(eq, 'defense', f[0]); });
+ EQUIP_STAT_FIELDS.other.forEach(function(f) { saveEquipBlock(eq, 'other', f[0]); });
+
+ if (hasEq && Object.keys(eq).length > 0) out.equipment = eq;
+
+ var recovEl = document.getElementById('f_core_recoverable');
+ if (recovEl) out.recoverable = recovEl.checked;
+
+ var kvEl = document.querySelector('.obj-kv-list[data-sec="equipment_req"][data-key="requirements"]');
+ 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) out.requirements = map;
+ }
+ 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.subtables) {
+ sec.subtables.forEach(function(st) {
+ var items = [];
+ for (var i = 0; ; i++) {
+ var item = {};
+ var found = false;
+ st.fields.forEach(function(f) {
+ var fid = fieldIDSub(sec, st.key, i, f[0]);
+ var el = document.getElementById(fid);
+ if (el) found = true;
+ if (el) item[f[0]] = collectFieldValue(el, f[2]);
+ });
+ if (!found) break;
+
+ if (item.success_base !== undefined || item.success_per_level !== undefined || item.success_cap !== undefined) {
+ var sBase = Number(item.success_base) || 0;
+ var sPer = Number(item.success_per_level) || 0;
+ var sCap = Number(item.success_cap) || 0;
+ delete item.success_base;
+ delete item.success_per_level;
+ delete item.success_cap;
+ if (sBase || sPer || sCap) {
+ item.success = {base: sBase, per_level: sPer, cap: sCap};
+ }
+ }
+
+ if (item.station !== undefined && typeof item.station === 'string') {
+ item.station = item.station.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; });
+ }
+
+ if (st.subSubtables) {
+ st.subSubtables.forEach(function(sst) {
+ var isIngredients = sst.key === 'ingredients';
+ var subItems = [];
+ for (var j = 0; ; j++) {
+ var subItem = {};
+ var subFound = false;
+ sst.fields.forEach(function(f) {
+ var fid = fieldIDSubSub(sec, st.key, i, sst.key, j, f[0]);
+ var el = document.getElementById(fid);
+ if (el) subFound = true;
+ if (el) subItem[f[0]] = collectFieldValue(el, f[2]);
+ });
+ if (!subFound) break;
+
+ if (isIngredients) {
+ var itemsArr = [];
+ var bypArr = [];
+ for (var k = 0; ; k++) {
+ var itemFid = ingredientPairID(sec, st.key, i, sst.key, j, k, 'items');
+ var itemEl = document.getElementById(itemFid);
+ if (!itemEl) break;
+ var itemVal = itemEl.value.trim();
+ itemsArr.push(itemVal);
+
+ var bypFid = ingredientPairID(sec, st.key, i, sst.key, j, k, 'byproducts');
+ var bypEl = document.getElementById(bypFid);
+ var bypVal = bypEl ? bypEl.value.trim() : '';
+ bypArr.push(bypVal);
+ }
+ var hasItems = itemsArr.some(function(v) { return v !== ''; });
+ if (hasItems) {
+ subItem.items = itemsArr.filter(function(v) { return v !== ''; });
+ if (bypArr.some(function(v) { return v !== ''; })) subItem.byproducts = bypArr;
+ }
+ if (!subItem.items) { subFound = false; }
+ } else {
+ if (subItem.items !== undefined && typeof subItem.items === 'string') {
+ subItem.items = subItem.items.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; });
+ }
+ if (subItem.byproducts !== undefined && typeof subItem.byproducts === 'string') {
+ subItem.byproducts = subItem.byproducts.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; });
+ }
+ }
+
+ if (Object.keys(subItem).length > 0) subItems.push(subItem);
+ }
+ if (subItems.length > 0) item[sst.key] = subItems;
+ });
+ }
+
+ if (Object.keys(item).length > 0) items.push(item);
+ }
+ if (st.single) {
+ if (items.length > 0) { sectionObj[st.key] = items[0]; hasValues = true; }
+ } else {
+ if (items.length > 0) { sectionObj[st.key] = items; hasValues = true; }
+ }
+ });
+ }
+
+ if (sec.inline) {
+ sec.inline.forEach(function(il) {
+ if (il.type === 'tags') {
+ var fid = fieldID(sec, il.key);
+ var el = document.getElementById(fid);
+ if (el && el.value.trim()) {
+ sectionObj[il.key] = el.value.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; });
+ hasValues = true;
+ }
+ } else 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; }
+ }
+ } else {
+ var ssec = {};
+ var sHas = false;
+ il.fields.forEach(function(f) {
+ var fp = il.path + '.' + f[0];
+ var fid = fieldID(sec, fp);
+ var el = document.getElementById(fid);
+ if (el) sHas = true;
+ if (el) ssec[f[0]] = collectFieldValue(el, f[2]);
+ });
+ if (sHas) { sectionObj[il.path] = ssec; 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 && !sec.path) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ }
+ });
+
+ cleanOutput(out);
+ out.id = itemID;
+
+ API.put('/api/items/' + encodeURIComponent(itemID), out).then(function(resp) {
+ notify('Item ' + itemID + ' saved', 'success');
+ updateUndoBar();
+ if (resp && resp._raw) itemData._raw = resp._raw;
+ }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
+}
+
+function saveEquipBlock(eq, blockKey, fieldKey) {
+ var fid = 'f_equipment_' + blockKey + '_' + fieldKey;
+ var el = document.getElementById(fid);
+ if (!el) return;
+ var val = parseFloat(el.value);
+ if (isNaN(val)) return;
+ if (!eq[blockKey]) eq[blockKey] = {};
+ eq[blockKey][fieldKey] = val;
+}
+
+function cleanOutput(obj) {
+ for (var key in obj) {
+ var val = obj[key];
+ if (val === '' || val === null || val === undefined) {
+ delete obj[key];
+ } else if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
+ cleanOutput(val);
+ if (Object.keys(val).length === 0) delete obj[key];
+ } else if (Array.isArray(val) && val.length === 0) {
+ delete obj[key];
+ }
+ }
+}
+
+// =========================================================================
+// Duplicate
+// =========================================================================
+
+function duplicateItem() {
+ var baseID = itemID + '_copy';
+ var newID = baseID;
+ var counter = 2;
+ while (true) {
+ var exists = false;
+ for (var i = 0; i < allIDs.length; i++) {
+ if (allIDs[i] === newID) { exists = true; break; }
+ }
+ if (!exists) break;
+ newID = baseID + '_' + counter;
+ counter++;
+ }
+
+ var copy = JSON.parse(JSON.stringify(itemData));
+ delete copy._raw;
+ delete copy._path;
+ copy.id = newID;
+
+ API.post('/api/items', {id: newID}).then(function() {
+ return API.put('/api/items/' + encodeURIComponent(newID), copy);
+ }).then(function() {
+ notify('Duplicated as ' + newID, 'success');
+ updateUndoBar();
+ loadList();
+ loadItem(newID);
+ }).catch(function(e) { notify('Duplicate failed: ' + e.message, 'error'); });
+}
+
+// =========================================================================
+// Delete
+// =========================================================================
+
+function deleteItem() {
+ if (!confirm('Delete item "' + itemID + '"?')) return;
+ API.del('/api/items/' + encodeURIComponent(itemID)).then(function() {
+ notify('Item ' + itemID + ' deleted', 'success');
+ updateUndoBar();
+ itemID = null;
+ currentID = null;
+ itemData = null;
+ $('#editorMain').innerHTML = 'Deleted
';
+ loadList();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
+
+// =========================================================================
+// Editor.js compatibility re-exports
+// =========================================================================
+
+function loadItem(id) { loadItemEditor(id); }
--
cgit v1.2.3