diff options
| author | historia <[not public]> | 2026-07-01 02:21:42 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-01 02:21:42 -0400 |
| commit | 5f37dc9b6f6b79da04da70ae0c33ec8d02998587 (patch) | |
| tree | f7c309e36267a4c456fcf5bca800e614507a80af /internal/admin | |
| parent | fee376102192dc6f24297a7b11324636ace3a07d (diff) | |
| download | thehouseoficarus-5f37dc9b6f6b79da04da70ae0c33ec8d02998587.tar.gz | |
feat: qol improvements to items crud in admin gui
Diffstat (limited to 'internal/admin')
| -rw-r--r-- | internal/admin/api_items.go | 1 | ||||
| -rw-r--r-- | internal/admin/server.go | 4 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 17 | ||||
| -rw-r--r-- | internal/admin/static/editor.js | 11 | ||||
| -rw-r--r-- | internal/admin/static/itemeditor.js | 1557 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 13 | ||||
| -rw-r--r-- | internal/admin/static/objecteditor.js | 4 | ||||
| -rw-r--r-- | internal/admin/templates/items.html | 32 | ||||
| -rw-r--r-- | internal/admin/undo.go | 180 | ||||
| -rw-r--r-- | internal/admin/yaml_util.go | 2 |
10 files changed, 1698 insertions, 123 deletions
diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go index d5e707e..721a6f8 100644 --- a/internal/admin/api_items.go +++ b/internal/admin/api_items.go @@ -95,6 +95,7 @@ func (s *AdminServer) updateItem(w http.ResponseWriter, r *http.Request, id stri http.Error(w, `{"error":"failed to write item"}`, http.StatusInternalServerError) return } + m["_raw"] = string(newContent) s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("Update item %s", id), diff --git a/internal/admin/server.go b/internal/admin/server.go index df6d5d9..ff2bebd 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -403,7 +403,7 @@ func (s *AdminServer) doUndo(w http.ResponseWriter, r *http.Request) { return } s.rebuildAfterUndo(change) - writeJSON(w, map[string]any{"message": "Undid: " + change.Description}) + writeJSON(w, map[string]any{"message": "Undid: " + change.ShortDesc()}) } func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) { @@ -417,7 +417,7 @@ func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) { return } s.rebuildAfterUndo(change) - writeJSON(w, map[string]any{"message": "Redid: " + change.Description}) + writeJSON(w, map[string]any{"message": "Redid: " + change.ShortDesc()}) } func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) { diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index 6da338e..bdf9b60 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -11,6 +11,7 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100 .nav .undo-bar span{color:#888;flex:1;text-align:right;margin-right:8px} .layout{display:flex;height:calc(100vh - 38px)} .main{flex:1;overflow:auto;position:relative;user-select:none;-webkit-user-select:none} +#mapSvg{display:block} .panel{width:300px;background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:16px;flex-shrink:0;position:relative} .panel-resize-handle{position:absolute;left:-3px;top:0;bottom:0;width:6px;cursor:col-resize;z-index:5;background:transparent} .panel-resize-handle:hover{background:rgba(255,255,255,.06)} @@ -153,6 +154,7 @@ g.ud-hover:hover text{font-weight:bold} .panel-tab-content .mini-label{font-size:9px;color:#888;display:block;margin-bottom:1px;margin-top:3px} .panel-tab-content .mini-input{width:60px!important;display:inline-block;margin-right:4px} .obj-card{background:rgba(255,255,255,.02);border:1px solid var(--border);border-radius:5px;margin-bottom:10px;overflow:hidden;flex:1 1 calc(50% - 5px);min-width:340px;max-width:calc(50% - 5px)} +.obj-card.obj-card-full{flex:1 1 100%;max-width:100%} .obj-fields-wrap{display:flex;flex-wrap:wrap;gap:10px} .obj-card-header{display:flex;align-items:center;gap:8px;padding:8px 12px;background:rgba(15,52,96,.3);cursor:pointer;user-select:none;font-size:13px;font-weight:bold} .obj-card-header:hover{background:rgba(15,52,96,.5)} @@ -166,7 +168,7 @@ g.ud-hover:hover text{font-weight:bold} .obj-field input,.obj-field textarea,.obj-field select{padding:4px 6px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace;width:100%} .obj-field textarea{resize:vertical;min-height:28px} .obj-field.obj-wide{grid-column:1/-1} -.obj-field.obj-check{flex-direction:row;align-items:flex-end;gap:4px;padding-bottom:3px} +.obj-field.obj-check{flex-direction:row;align-items:center;gap:4px;padding-bottom:3px} .obj-field.obj-check input{width:auto} .obj-field.obj-check span{font-size:11px;color:var(--text)} .obj-color-row{display:flex;align-items:center;gap:4px} @@ -210,3 +212,16 @@ g.ud-hover:hover text{font-weight:bold} .obj-sub-row-line+.obj-sub-row-line{margin-top:4px} .obj-inline-compact .obj-card-grid{grid-template-columns:repeat(auto-fill,minmax(60px,1fr))!important} .obj-inline-compact .obj-narrow input{width:50px} +.ingredients-table{border:1px solid var(--border);border-radius:4px;overflow:hidden;margin-top:4px} +.ingredients-header-row{display:flex;gap:4px;padding:4px 6px;background:rgba(255,255,255,.03);font-size:9px;color:#888;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border)} +.ingredients-ingredient{border-bottom:1px solid rgba(255,255,255,.04)} +.ingredients-ingredient:last-child{border-bottom:none} +.ingredients-pair{display:flex;gap:4px;padding:3px 6px;align-items:center;border-top:1px solid rgba(255,255,255,.03)} +.ingredients-pair:first-child{border-top:none} +.ingredients-pair-add{padding:2px 6px 4px} +.ingredients-col{flex-shrink:0} +.ingredients-col-qty{width:48px;display:flex;align-items:center} +.ingredients-col-items{flex:1;min-width:0} +.ingredients-col-byproducts{flex:1;min-width:0} +.ingredients-col-x{width:26px;display:flex;align-items:center;justify-content:center} +.ingredients-col-num{width:24px;text-align:center;color:#888;font-size:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0} diff --git a/internal/admin/static/editor.js b/internal/admin/static/editor.js index 1ed0653..37bc797 100644 --- a/internal/admin/static/editor.js +++ b/internal/admin/static/editor.js @@ -59,7 +59,7 @@ function renderDirGroup(name, ids, filter) { displayIds = ids.filter(function(id) { return String(id).toLowerCase().indexOf(filter) >= 0; }); if (displayIds.length === 0) return ''; } - var expanded = isFiltering ? true : (collapsedDirs[name] === false || (!name && collapsedDirs[''] === undefined)); + var expanded = isFiltering ? true : (collapsedDirs[name] === true); var displayName = name || editorType.charAt(0).toUpperCase() + editorType.slice(1); var h = '<div class="dir-group">'; if (name) { @@ -79,7 +79,7 @@ function renderDirGroup(name, ids, filter) { } function toggleDir(name) { - collapsedDirs[name] = collapsedDirs[name] === false ? true : false; + collapsedDirs[name] = collapsedDirs[name] !== true; renderList(''); } @@ -206,6 +206,13 @@ function switchTab(e, tab) { tabs.forEach(function(t) { t.style.display = 'none'; }); var target = document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)); if (target) target.style.display = 'block'; + + if (tab === 'yaml' && currentID) { + API.get('/api/' + editorType + '/' + encodeURIComponent(currentID)).then(function(data) { + var pre = document.querySelector('#tabYaml pre'); + if (pre) pre.textContent = data._raw || JSON.stringify(data, null, 2); + }); + } } function getNested(obj, path) { 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 = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>'; + }); +} + +function renderItemEditor(id, data) { + var html = '<h2>Item: ' + 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">'; + + 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>'; + 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="saveItem()">Save</button>'; + html += '<button class="btn" onclick="duplicateItem()">Duplicate</button>'; + html += '<button class="btn btn-danger" onclick="deleteItem()">Delete Item</button>'; + html += '</div>'; + + $('#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 = '<div class="obj-card' + (sec.id === 'crafting' ? ' obj-card-full' : '') + '" data-card="' + sec.id + '">'; + h += '<div class="obj-card-header" onclick="toggleCard(\'' + sec.id + '\')">'; + h += '<span class="obj-card-arrow">' + (collapsed ? '▶' : '▼') + '</span>'; + 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 if (sec.id === 'core') { + h += renderCoreCard(data); + } else if (sec.id === 'equipment') { + h += renderEquipmentCard(data); + } else { + h += '<div class="obj-card-grid">'; + if (sec.fields) sec.fields.forEach(function(f) { + h += renderField(sec, f, data, -1); + }); + h += '</div>'; + + 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 += '<div class="obj-inline-group">'; + h += '<span class="obj-inline-label">' + esc(il.label) + '</span>'; + h += '<div style="display:flex;gap:8px;flex-wrap:nowrap;margin-top:4px">'; + il.fields.forEach(function(f) { + h += renderField(sec, f, data, -1, il.path); + }); + h += '</div>'; + h += '</div>'; + } + }); + } + + if (sec.subtables) { + sec.subtables.forEach(function(st) { + h += renderSubtable(sec, st, data); + }); + } + } + h += '</div>'; + } + h += '</div>'; + return h; +} + +function renderCoreCard(data) { + var sec = SECTIONS[0]; + var h = '<div class="obj-core-row">'; + 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 += '</div>'; + h += '<div class="obj-core-row" style="align-items:center">'; + h += renderField(sec, sec.fields[2], data, -1); + h += '<div style="display:flex;align-items:center;gap:6px">'; + h += renderField(sec, sec.fields[3], data, -1); + h += '</div>'; + h += '<div style="display:flex;gap:8px;margin-left:auto">'; + h += renderField(sec, sec.fields[4], data, -1); + h += renderField(sec, sec.fields[5], data, -1); + h += '</div>'; + h += '</div>'; + 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 += '<label class="obj-field obj-wide"><span>Description</span><textarea id="' + descFid + '" rows="6" style="min-height:56px">' + esc(dstr) + '</textarea></label>'; + 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 '<label class="' + cls + w + '">' + inner + '</label>'; + } + + if (type === 'select') { + var opts = hint ? hint.split('|') : []; + var inner = '<span>' + esc(label) + '</span><select id="' + fid + '">'; + opts.forEach(function(o) { + inner += '<option value="' + escAttr(o) + '"' + (String(val) === o ? ' selected' : '') + '>' + esc(o) + '</option>'; + }); + inner += '</select>'; + 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', '<span>' + esc(label) + '</span><div style="position:relative;width:100%"><input id="' + fid + '" value="' + escAttr(String(val)) + '" class="search-input" data-search-type="' + entity + '"' + ph + '><div class="search-results" style="display:none"></div></div>'); + } + var ph = hint ? ' placeholder="' + esc(hint) + '"' : ''; + return wrap('obj-field', '<span>' + esc(label) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '"' + ph + '>'); +} + +function renderEquipmentCard(data) { + var eq = data.equipment || {}; + var h = ''; + var isAmmo = eq.type === 'ammo'; + + // Row 1: type, slot + h += '<div class="obj-core-row">'; + 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 += '<input type="hidden" id="' + equipFieldID('slot') + '" value="ammo">'; + } + h += '</div>'; + + // Ammo: recoverable checkbox + if (isAmmo) { + h += '<label class="obj-field obj-check" style="margin-top:4px"><input type="checkbox" id="f_core_recoverable"' + (data.recoverable ? ' checked' : '') + '> <span>Recoverable</span></label>'; + } + + // 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 += '<div class="obj-core-row"' + (!isMelee && !isWeapon ? ' style="display:none"' : '') + '>'; + 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 += '</div>'; + + // 3 buttons row + var btns = []; + if (!eq.attack) btns.push('<button class="btn btn-sm obj-sub-add" style="flex:1" onclick="addEquipBlock(\'attack\')">+ Add Attack Stats</button>'); + if (!eq.defense) btns.push('<button class="btn btn-sm obj-sub-add" style="flex:1" onclick="addEquipBlock(\'defense\')">+ Add Defense Stats</button>'); + if (!eq.other) btns.push('<button class="btn btn-sm obj-sub-add" style="flex:1" onclick="addEquipBlock(\'other\')">+ Add Other Stats</button>'); + if (btns.length > 0) { + h += '<div style="display:flex;gap:6px;margin-top:10px">' + btns.join('') + '</div>'; + } + + // 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 += '<div style="margin-top:8px">'; + h += renderKV({id: 'equipment_req'}, {label:'Requirements', key:'requirements', type:'kv', valType:'number'}, data); + h += '</div>'; + + return h; +} + +function renderEquipStatBlock(label, blockKey, blockData, fields) { + var h = '<div class="obj-inline-group" style="margin-top:8px">'; + h += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">'; + h += '<span class="obj-inline-label" style="margin-bottom:0;flex:1">' + esc(label) + '</span>'; + h += '<button class="btn btn-sm btn-danger" onclick="removeEquipBlock(\'' + blockKey + '\')">X</button>'; + h += '</div>'; + h += '<div style="display:flex;gap:8px;flex-wrap:nowrap;margin-top:4px">'; + 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 += '<label class="obj-field' + extraCls + '"><span>' + esc(f[1]) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '"></label>'; + }); + h += '</div>'; + h += '</div>'; + 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 '<label class="' + cls + w + '"' + hiddenStyle + s + '>' + inner + '</label>'; + } + + if (type === 'checkbox') { + return wrap('obj-field obj-check', '<input type="checkbox" id="' + fid + '"' + (val ? ' checked' : '') + '> <span>' + esc(label) + '</span>'); + } + if (type === 'json') { + var jstr = typeof val === 'object' ? JSON.stringify(val) : String(val); + return wrap('obj-field', '<span>' + esc(label) + '</span><textarea id="' + fid + '" rows="2" placeholder="{}">' + esc(jstr) + '</textarea>'); + } + 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', '<span>' + esc(label) + '</span><textarea id="' + fid + '" rows="3">' + esc(dstr) + '</textarea>'); + } + if (type === 'select') { + var opts = hint ? hint.split('|') : []; + var inner = '<span>' + esc(label) + '</span><select id="' + fid + '">'; + opts.forEach(function(o) { + inner += '<option value="' + escAttr(o) + '"' + (String(val) === o ? ' selected' : '') + '>' + esc(o) + '</option>'; + }); + inner += '</select>'; + return wrap('obj-field', inner); + } + if (type === 'color') { + var swatch = '<span class="color-swatch" style="background:#' + escAttr(String(val) || '808080') + '"></span>'; + return wrap('obj-field color-field', '<span>' + esc(label) + '</span><div class="obj-color-row">' + swatch + '<input id="' + fid + '" value="' + escAttr(String(val)) + '" class="color-input"></div>'); + } + if (type === 'search') { + var entity = searchEntity || 'items'; + var ph = hint ? ' placeholder="' + esc(hint) + '"' : ' placeholder="Search ' + entity + '..."'; + return wrap('obj-field obj-search', '<span>' + esc(label) + '</span><div style="position:relative;width:100%"><input id="' + fid + '" value="' + escAttr(String(val)) + '" class="search-input" data-search-type="' + entity + '"' + ph + '><div class="search-results" style="display:none"></div></div>'); + } + var ph = hint ? ' placeholder="' + esc(hint) + '"' : ''; + return wrap('obj-field', '<span>' + esc(label) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '"' + ph + '>'); +} + +// ========================================================================= +// Array section rendering +// ========================================================================= + +function renderArraySection(sec, data) { + var arr = data[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 ' + esc(sec.label).slice(0,-1) + '</button>'; + 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 '<label class="obj-field obj-search' + extraCls + '"' + extraStyle + '><span>' + esc(f[1]) + '</span><div style="position:relative;width:100%"><input id="' + fid + '" value="' + escAttr(String(val)) + '" class="search-input" data-search-type="' + entity + '"' + ph + '><div class="search-results" style="display:none"></div></div></label>'; + } + if (f[2] === 'checkbox') { + return '<label class="obj-field obj-check' + extraCls + '"' + extraStyle + '><input type="checkbox" id="' + fid + '"' + (val ? ' checked' : '') + '> <span>' + esc(f[1]) + '</span></label>'; + } + if (f[2] === 'select') { + var opts = f[3] ? f[3].split('|') : []; + var sel = '<label class="obj-field' + extraCls + '"' + extraStyle + '><span>' + esc(f[1]) + '</span><select id="' + fid + '">'; + opts.forEach(function(o) { + sel += '<option value="' + escAttr(o) + '"' + (String(val) === o ? ' selected' : '') + '>' + esc(o || '(none)') + '</option>'; + }); + sel += '</select></label>'; + return sel; + } + return '<label class="obj-field' + extraCls + '"' + extraStyle + '><span>' + esc(f[1]) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '"></label>'; +} + +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 = '<div class="obj-subtable">'; + h += '<div class="obj-subtable-header">' + esc(st.label) + '</div>'; + arr.forEach(function(item, idx) { + h += '<div class="obj-sub-row" style="flex-direction:column;align-items:stretch">'; + + 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 += '<div class="obj-inline-group success-group" id="' + gid + '" style="margin:2px 0;padding:4px 8px' + (hasSuccess ? '' : ';display:none') + '">'; + h += '<div style="display:flex;align-items:center;margin-bottom:2px">'; + h += '<span class="obj-inline-label" style="margin-bottom:0;flex:1">Success</span>'; + h += '<button class="btn btn-sm btn-danger" style="padding:2px 6px;font-size:10px;min-width:auto" onclick="var g=document.getElementById(\'' + gid + '\');g.style.display=\'none\';var b=document.getElementById(\'' + gid + '_btn\');b.style.display=\'\'">X</button>'; + h += '</div>'; + h += '<div class="obj-sub-row-line">'; + renderGroup(group.fields); + h += '</div>'; + h += '</div>'; + h += '<button class="btn btn-sm obj-sub-add" id="' + gid + '_btn" style="margin:2px 0' + (hasSuccess ? ';display:none' : '') + '" onclick="var b=document.getElementById(\'' + gid + '_btn\');b.style.display=\'none\';var g=document.getElementById(\'' + gid + '\');g.style.display=\'\'">+ Add Success Rates</button>'; + } else { + h += '<div class="obj-inline-group" style="margin:2px 0;padding:4px 8px">'; + h += '<span class="obj-inline-label" style="margin-bottom:2px">' + esc(group.label) + '</span>'; + h += '<div class="obj-sub-row-line">'; + renderGroup(group.fields); + h += '</div>'; + h += '</div>'; + } + } else { + var rightStyle = (group && group.right) ? ' style="justify-content:flex-end"' : ''; + h += '<div class="obj-sub-row-line"' + rightStyle + '>'; + var keys = Array.isArray(group) ? group : group.fields; + renderGroup(keys); + h += '</div>'; + } + }); + } else { + h += '<div class="obj-sub-row-line">'; + 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 += '</div>'; + } + + 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 += '<button class="btn btn-sm obj-sub-add" style="margin-top:4px" onclick="addSubSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + idx + ',\'' + sst.key + '\')">+ Add Custom Delay/Message</button>'; + return; + } + } + h += renderSubSubtable(sec, st, idx, sst, item); + }); + } + + if (!st.single) { + h += '<button class="btn btn-sm btn-danger obj-sub-remove" style="align-self:flex-end;margin:6px 10px" onclick="removeSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + idx + ')">Remove Recipe</button>'; + } + h += '</div>'; + }); + if (!st.single || arr.length === 0) { + h += '<button class="btn btn-sm obj-sub-add" onclick="addSubRow(\'' + sec.id + '\',\'' + st.key + '\')">+ Add Recipe</button>'; + } + h += '</div>'; + 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 '<div class="obj-field obj-search" style="flex:1;min-width:0"><div style="position:relative;width:100%"><input id="' + fid + '" value="' + escAttr(String(val)) + '" class="search-input" data-search-type="' + entity + '" placeholder="' + escAttr(placeholder) + '"><div class="search-results" style="display:none"></div></div></div>'; +} + +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 = '<div class="ingredients-col ingredients-col-num"></div>' + + '<div class="ingredients-col ingredients-col-qty"></div>' + + '<div class="ingredients-col ingredients-col-items">' + renderConsumeSearchHTML(itemFid, '', 'items', 'Search items...') + '</div>' + + '<div class="ingredients-col ingredients-col-byproducts">' + renderConsumeSearchHTML(bypFid, '', 'items', '(optional)') + '</div>' + + '<div class="ingredients-col ingredients-col-x"><button class="btn btn-sm btn-danger" style="padding:2px 6px;font-size:10px;background:var(--danger);color:#fff;border:none;border-radius:3px;cursor:pointer" onclick="removeConsumePair(\'' + secID + '\',\'' + subKey + '\',' + rowIdx + ',\'' + subSubKey + '\',' + subIdx + ',' + pairIdx + ')">X</button></div>'; + 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 = '<div class="obj-subtable" style="margin:6px 0 6px 12px;border-left:2px solid var(--accent)">'; + if (!isIngredients) { + h += '<div class="obj-subtable-header">' + esc(sst.label) + '</div>'; + } + + if (isIngredients) { + h += '<div class="ingredients-table">'; + h += '<div class="ingredients-header-row">'; + h += '<div class="ingredients-col ingredients-col-num">#</div>'; + h += '<div class="ingredients-col ingredients-col-qty">Qty</div>'; + h += '<div class="ingredients-col ingredients-col-items">Items</div>'; + h += '<div class="ingredients-col ingredients-col-byproducts">Byproducts</div>'; + h += '<div class="ingredients-col ingredients-col-x"></div>'; + h += '</div>'; + + 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 += '<div class="ingredients-ingredient" data-click-pair="' + subIdx + '">'; + + 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 += '<div class="ingredients-pair" data-sec="' + sec.id + '" data-subkey="' + st.key + '" data-row="' + rowIdx + '" data-subsub="' + sst.key + '" data-subidx="' + subIdx + '" data-pair="' + p + '">'; + + if (p === 0) { + h += '<div class="ingredients-col ingredients-col-num">' + ingredientNum + '</div>'; + } else { + h += '<div class="ingredients-col ingredients-col-num"></div>'; + } + + if (p === 0) { + h += '<div class="ingredients-col ingredients-col-qty"><label class="obj-field obj-narrow"><input id="' + qtyFid + '" value="' + escAttr(String(qtyVal)) + '" style="width:36px"></label></div>'; + } else { + h += '<div class="ingredients-col ingredients-col-qty"></div>'; + } + + var itemVal = itemsArr[p] || ''; + var itemFid = ingredientPairID(sec, st.key, rowIdx, sst.key, subIdx, p, 'items'); + h += '<div class="ingredients-col ingredients-col-items">' + renderConsumeSearchHTML(itemFid, itemVal, 'items', 'Search items...') + '</div>'; + + var bypVal = bypArr[p] || ''; + var bypFid = ingredientPairID(sec, st.key, rowIdx, sst.key, subIdx, p, 'byproducts'); + h += '<div class="ingredients-col ingredients-col-byproducts">' + renderConsumeSearchHTML(bypFid, bypVal, 'items', '(optional)') + '</div>'; + + h += '<div class="ingredients-col ingredients-col-x"><button class="btn btn-sm btn-danger" style="padding:2px 6px;font-size:10px;background:var(--danger);color:#fff;border:none;border-radius:3px;cursor:pointer" onclick="removeConsumePair(\'' + sec.id + '\',\'' + st.key + '\',' + rowIdx + ',\'' + sst.key + '\',' + subIdx + ',' + p + ')">X</button></div>'; + + h += '</div>'; + } + + h += '<div class="ingredients-pair-add" data-sec="' + sec.id + '" data-subkey="' + st.key + '" data-row="' + rowIdx + '" data-subsub="' + sst.key + '" data-subidx="' + subIdx + '">'; + h += '<button class="btn btn-sm" style="padding:2px 8px;font-size:11px;background:var(--accent);color:var(--text);border:1px solid #1a5a8e;border-radius:3px;cursor:pointer;margin-left:76px" onclick="addConsumePair(\'' + sec.id + '\',\'' + st.key + '\',' + rowIdx + ',\'' + sst.key + '\',' + subIdx + ')">+ Add Alternative Item</button>'; + h += '</div>'; + + h += '</div>'; + }); + + h += '</div>'; + h += '<button class="btn btn-sm obj-sub-add" onclick="addSubSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + rowIdx + ',\'' + sst.key + '\')">+ Add New Ingredient</button>'; + } else { + arr.forEach(function(subItem, subIdx) { + h += '<div class="obj-sub-row" style="align-items:flex-start">'; + h += '<div class="obj-sub-row-line" style="flex:1">'; + 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 += '<label class="obj-field obj-search' + extraCls + '"><span>' + esc(f[1]) + '</span><div style="position:relative;width:100%"><input id="' + fid + '" value="' + escAttr(String(val)) + '" class="search-input" data-search-type="' + entity + '"' + ph + '><div class="search-results" style="display:none"></div></div></label>'; + } else if (f[2] === 'checkbox') { + h += '<label class="obj-field obj-check' + extraCls + '"><input type="checkbox" id="' + fid + '"' + (val ? ' checked' : '') + '> <span>' + esc(f[1]) + '</span></label>'; + } else { + h += '<label class="obj-field' + extraCls + '"><span>' + esc(f[1]) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '"></label>'; + } + }); + h += '</div>'; + h += '<button class="btn btn-sm btn-danger" style="align-self:flex-start;padding:2px 6px;font-size:10px;background:var(--danger);color:#fff;border:none;border-radius:3px;cursor:pointer;margin-top:2px" onclick="removeSubSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + rowIdx + ',\'' + sst.key + '\',' + subIdx + ')">X</button>'; + h += '</div>'; + }); + h += '<button class="btn btn-sm obj-sub-add" onclick="addSubSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + rowIdx + ',\'' + sst.key + '\')">+ Add Custom Delay/Message</button>'; + } + + h += '</div>'; + 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 '<label class="obj-field obj-wide"><span>' + esc(il.label) + '</span><input id="' + fid + '" value="' + escAttr(str) + '" placeholder="comma separated"></label>'; +} + +function renderKV(sec, il, data) { + var p = cardPath(sec); + var sectionRoot = p ? (itemData[p] || {}) : itemData; + var map = sectionRoot[il.key] || {}; + var h = '<div class="obj-inline-group">'; + h += '<span class="obj-inline-label">' + esc(il.label) + '</span>'; + h += '<div class="obj-kv-list" data-sec="' + sec.id + '" data-key="' + il.key + '">'; + Object.keys(map).forEach(function(k, idx) { + h += '<div class="obj-kv-row"><input value="' + escAttr(k) + '" placeholder="key" class="obj-kv-key">'; + h += '<input value="' + escAttr(String(map[k])) + '" placeholder="val" class="obj-kv-val">'; + h += '<button class="btn btn-sm btn-danger" onclick="this.parentElement.remove()">X</button></div>'; + }); + h += '</div>'; + h += '<button class="btn btn-sm obj-sub-add" onclick="addKVRow(\'' + sec.id + '\',\'' + il.key + '\')">+ Add</button>'; + h += '</div>'; + 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 = '<input value="" placeholder="key" class="obj-kv-key"><input value="0" placeholder="val" class="obj-kv-val"><button class="btn btn-sm btn-danger" onclick="this.parentElement.remove()">X</button>'; + 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 = '<option value="">none</option><option value="smelt">smelt</option>'; + } else if (t === 'pharmacy') { + if (sLbl) sLbl.style.display = ''; + subtypeSel.innerHTML = '<option value="">none</option><option value="clean">clean</option>'; + } 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 = '<div class="sr-item" style="color:#888">No results</div>'; + return; + } + results.style.display = 'block'; + results.innerHTML = items.map(function(r) { + return '<div class="sr-item" onclick="event.stopPropagation();selectSearchResult(this)">' + esc(r.id) + ' <span style="color:#aaa">' + esc(r.name || '') + '</span></div>'; + }).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 = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>'; + loadList(); + }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); +} + +// ========================================================================= +// Editor.js compatibility re-exports +// ========================================================================= + +function loadItem(id) { loadItemEditor(id); } diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 5bff9d7..e5d9a19 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -595,8 +595,10 @@ function renderItemsTab(spawns) { h += '<div class="entry-row" data-idx="' + i + '">'; h += '<div class="entry-fields">'; h += '<span class="entry-id">' + esc(spawn.id || '') + '</span>'; - h += '<label class="mini-label">Qty</label><input class="mini-input spawn-qty" type="number" value="' + (spawn.quantity || 0) + '" onchange="updateSpawn(' + i + ')">'; - h += '<label class="mini-label">Respawn</label><input class="mini-input spawn-respawn" type="number" value="' + (spawn.respawn_ticks || 0) + '" onchange="updateSpawn(' + i + ')">'; + h += '<div style="display:flex;gap:8px;align-items:center;">'; + h += '<div><label class="mini-label">Quantity</label><input class="mini-input spawn-qty" type="text" inputmode="numeric" pattern="[0-9]*" value="' + (spawn.quantity || 0) + '" onchange="updateSpawn(' + i + ')"></div>'; + h += '<div><label class="mini-label">Respawn</label><input class="mini-input spawn-respawn" type="text" inputmode="numeric" pattern="[0-9]*" value="' + (spawn.respawn_ticks || 0) + '" onchange="updateSpawn(' + i + ')"></div>'; + h += '</div>'; h += '</div>'; h += '<button class="btn btn-sm" onclick="openEditor(\'items\', \'' + escAttr(spawn.id || '') + '\')">Edit</button>'; h += '<button class="btn btn-sm btn-danger" onclick="removeSpawn(' + i + ')">X</button>'; @@ -666,7 +668,7 @@ function editExitCondition(dir) { overlay.className = 'cm-overlay exit-overlay'; var menu = document.createElement('div'); menu.className = 'cm-menu exit-modal'; - menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:420px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201'; + menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:520px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201'; var h = '<h3 style="margin-bottom:8px;font-size:13px">Exit: ' + esc(dir) + ' → #' + exit.room + '</h3>'; h += '<div class="form-group"><label>Set Flags (key=value, comma separated)</label><input id="exitFlags" value="' + escAttr(flagsStr) + '"></div>'; @@ -742,7 +744,7 @@ function renderConditionClauses(menu) { } else { h += '<input class="cond-val" value="' + escAttr(clause.value || '') + '" onchange="updateClauseData(this)" style="flex:1">'; } - h += '<label style="font-size:10px;color:#aaa;white-space:nowrap"><input type="checkbox" class="cond-not"' + (clause.not ? ' checked' : '') + ' onchange="updateClauseData(this)"> not</label>'; + h += '<label style="font-size:10px;color:#aaa;white-space:nowrap"><input type="checkbox" class="cond-not"' + (clause.not ? ' checked' : '') + ' onchange="updateClauseData(this)"> =Not True</label>'; h += '<button class="btn btn-sm btn-danger" onclick="removeConditionClause(this)" style="flex-shrink:0">X</button>'; h += '</div>'; }); @@ -833,7 +835,8 @@ function saveExitCondition() { } function buildSingleCondition(clause) { - var obj = {not: !!clause.not}; + var obj = {}; + if (clause.not) obj.not = true; if (clause.type === 'value') { var parts = (clause.value || '').split('=', 2); obj.value = {key: (parts[0] || '').trim(), value: (parts[1] || '').trim()}; diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js index 3e944c4..13a983e 100644 --- a/internal/admin/static/objecteditor.js +++ b/internal/admin/static/objecteditor.js @@ -588,7 +588,7 @@ function addArrayItem(cardID) { var arr = objectData[sec.path] || []; var empty = {}; sec.fields.forEach(function(f) { - empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' || f[2] === 'search' ? 0 : ''); + empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : ''); }); arr.push(empty); objectData[sec.path] = arr; @@ -612,7 +612,7 @@ function addSubRow(cardID, subKey) { 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' || f[2] === 'search' ? 0 : ''); + empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : ''); }); } arr.push(empty); diff --git a/internal/admin/templates/items.html b/internal/admin/templates/items.html index f8f3b66..4a74ff5 100644 --- a/internal/admin/templates/items.html +++ b/internal/admin/templates/items.html @@ -10,36 +10,8 @@ </div> </div> <script src="/static/editor.js"></script> +<script src="/static/itemeditor.js"></script> <script> -initEditor('items', [ - {key:'name',label:'Name',type:'text'}, - {key:'color',label:'Color',type:'color'}, - {key:'description',label:'Description',type:'textarea'}, - {key:'value',label:'Value',type:'number'}, - {key:'stackable',label:'Stackable',type:'checkbox'}, - {key:'equip_slot',label:'Equip Slot',type:'text'}, - {key:'weapon_type',label:'Weapon Type',type:'text'}, - {key:'attack_type',label:'Attack Type',type:'text'}, - {key:'speed',label:'Speed',type:'number'}, - {key:'tool_type',label:'Tool Type',type:'text'}, - {key:'tool_speed',label:'Tool Speed',type:'number'}, - {key:'burn_ticks',label:'Burn Ticks',type:'number'}, - {key:'fire_level',label:'Fire Level',type:'number'}, - {key:'fire_xp',label:'Fire XP',type:'number'}, - {key:'quality',label:'Quality',type:'number'}, - {key:'max_quality',label:'Max Quality',type:'number'}, - {key:'search_table',label:'Search Table',type:'text'}, - {key:'search_ticks',label:'Search Ticks',type:'number'}, - {key:'heal_value',label:'Heal Value',type:'number'}, - {key:'eat_message',label:'Eat Message',type:'text'}, - {key:'farm_patch_type',label:'Farm Patch',type:'text'}, - {key:'farm_level',label:'Farm Level',type:'number'}, - {key:'farm_stages',label:'Farm Stages',type:'number'}, - {key:'farm_product',label:'Farm Product',type:'text'}, - {key:'potion_effect',label:'Potion Effect',type:'text'}, - {key:'potion_bonus',label:'Potion Bonus',type:'number'}, - {key:'potion_duration',label:'Potion Duration',type:'number'}, - {key:'recoverable',label:'Recoverable',type:'checkbox'} -]); +initItemEditor(); </script> {{end}} diff --git a/internal/admin/undo.go b/internal/admin/undo.go index 4921ae5..bec3c35 100644 --- a/internal/admin/undo.go +++ b/internal/admin/undo.go @@ -2,6 +2,7 @@ package admin import ( "encoding/json" + "fmt" "log" "os" "path/filepath" @@ -27,6 +28,19 @@ type ChangeDesc struct { ExtraFiles []ExtraFile `json:"extra_files,omitempty"` } +func (c ChangeDesc) ShortDesc() string { + switch { + case c.IsCreate: + return fmt.Sprintf("Created %s", filepath.Base(c.FilePath)) + case c.IsDelete: + return fmt.Sprintf("Deleted %s", filepath.Base(c.FilePath)) + case c.NewFilePath != "": + return fmt.Sprintf("Renamed %s", filepath.Base(c.NewFilePath)) + default: + return fmt.Sprintf("Edited %s", filepath.Base(c.FilePath)) + } +} + type UndoInfo struct { CanUndo bool `json:"can_undo"` CanRedo bool `json:"can_redo"` @@ -52,6 +66,7 @@ func NewUndoStack(dataDir string) *UndoStack { filePath: filepath.Join(dataDir, ".admin_history.json"), } us.load() + us.redo = nil return us } @@ -65,6 +80,7 @@ func (us *UndoStack) Push(desc ChangeDesc) { us.history = us.history[len(us.history)-us.maxSize:] } us.save() + log.Printf("undo: push %s", desc.ShortDesc()) } func (us *UndoStack) Undo() *ChangeDesc { @@ -74,47 +90,16 @@ func (us *UndoStack) Undo() *ChangeDesc { return nil } last := us.history[len(us.history)-1] - us.history = us.history[:len(us.history)-1] - if last.NewFilePath != "" { - os.Remove(last.NewFilePath) - } - if last.IsDelete { - if err := os.WriteFile(last.FilePath, last.OldContent, 0644); err != nil { - log.Printf("undo: failed to restore deleted file %s: %v", last.FilePath, err) - return nil - } - for _, ef := range last.ExtraFiles { - if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil { - log.Printf("undo: failed to restore extra file %s: %v", ef.FilePath, err) - } - } - } else if last.IsCreate { - os.Remove(last.FilePath) - for _, ef := range last.ExtraFiles { - os.Remove(ef.FilePath) - } - } else { - if last.NewFilePath != "" { - if err := os.WriteFile(last.FilePath, last.OldContent, 0644); err != nil { - log.Printf("undo: failed to revert move %s: %v", last.FilePath, err) - return nil - } - } else { - if err := os.WriteFile(last.FilePath, last.OldContent, 0644); err != nil { - log.Printf("undo: failed to revert file %s: %v", last.FilePath, err) - return nil - } - } - for _, ef := range last.ExtraFiles { - if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil { - log.Printf("undo: failed to restore extra file %s: %v", ef.FilePath, err) - } - } + if err := us.applyUndo(last); err != nil { + log.Printf("undo: failed %s: %v", last.ShortDesc(), err) + return nil } + us.history = us.history[:len(us.history)-1] us.redo = append(us.redo, last) us.save() + log.Printf("undo: undid %s", last.ShortDesc()) return &last } @@ -125,57 +110,88 @@ func (us *UndoStack) Redo() *ChangeDesc { return nil } last := us.redo[len(us.redo)-1] + + if err := us.applyRedo(last); err != nil { + log.Printf("redo: failed %s: %v", last.ShortDesc(), err) + return nil + } + us.redo = us.redo[:len(us.redo)-1] + us.history = append(us.history, last) + us.save() + log.Printf("redo: redid %s", last.ShortDesc()) + return &last +} - if last.NewFilePath != "" { - os.Remove(last.FilePath) +func (us *UndoStack) applyUndo(c ChangeDesc) error { + if c.NewFilePath != "" { + os.Remove(c.NewFilePath) } - if last.IsCreate { - if last.NewFilePath != "" { - if err := os.WriteFile(last.NewFilePath, last.NewContent, 0644); err != nil { - log.Printf("redo: failed to recreate file %s: %v", last.NewFilePath, err) - return nil - } - } else { - if err := os.WriteFile(last.FilePath, last.NewContent, 0644); err != nil { - log.Printf("redo: failed to recreate file %s: %v", last.FilePath, err) - return nil - } + if c.IsDelete { + if c.OldContent == nil { + return fmt.Errorf("no backup content to restore for %s", c.FilePath) } - for _, ef := range last.ExtraFiles { - if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil { - log.Printf("redo: failed to create extra file %s: %v", ef.FilePath, err) - } + if err := os.WriteFile(c.FilePath, c.OldContent, 0644); err != nil { + return fmt.Errorf("restore deleted %s: %w", c.FilePath, err) } - } else if last.IsDelete { - os.Remove(last.FilePath) - for _, ef := range last.ExtraFiles { - if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil { - log.Printf("redo: failed to write extra file %s: %v", ef.FilePath, err) - } + for _, ef := range c.ExtraFiles { + os.WriteFile(ef.FilePath, ef.OldContent, 0644) + } + } else if c.IsCreate { + os.Remove(c.FilePath) + for _, ef := range c.ExtraFiles { + os.Remove(ef.FilePath) } } else { - if last.NewFilePath != "" { - if err := os.WriteFile(last.NewFilePath, last.NewContent, 0644); err != nil { - log.Printf("redo: failed to reapply move %s: %v", last.NewFilePath, err) - return nil - } - } else { - if err := os.WriteFile(last.FilePath, last.NewContent, 0644); err != nil { - log.Printf("redo: failed to reapply file %s: %v", last.FilePath, err) - return nil - } + target := c.FilePath + content := c.OldContent + if c.NewFilePath != "" { + target = c.FilePath } - for _, ef := range last.ExtraFiles { - if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil { - log.Printf("redo: failed to write extra file %s: %v", ef.FilePath, err) - } + if err := os.WriteFile(target, content, 0644); err != nil { + return fmt.Errorf("revert %s: %w", target, err) + } + for _, ef := range c.ExtraFiles { + os.WriteFile(ef.FilePath, ef.OldContent, 0644) } } + return nil +} - us.history = append(us.history, last) - us.save() - return &last +func (us *UndoStack) applyRedo(c ChangeDesc) error { + if c.NewFilePath != "" { + os.Remove(c.FilePath) + } + if c.IsCreate { + target := c.FilePath + if c.NewFilePath != "" { + target = c.NewFilePath + } + if err := os.WriteFile(target, c.NewContent, 0644); err != nil { + return fmt.Errorf("recreate %s: %w", target, err) + } + for _, ef := range c.ExtraFiles { + os.WriteFile(ef.FilePath, ef.NewContent, 0644) + } + } else if c.IsDelete { + os.Remove(c.FilePath) + for _, ef := range c.ExtraFiles { + os.WriteFile(ef.FilePath, ef.NewContent, 0644) + } + } else { + target := c.FilePath + content := c.NewContent + if c.NewFilePath != "" { + target = c.NewFilePath + } + if err := os.WriteFile(target, content, 0644); err != nil { + return fmt.Errorf("reapply %s: %w", target, err) + } + for _, ef := range c.ExtraFiles { + os.WriteFile(ef.FilePath, ef.NewContent, 0644) + } + } + return nil } func (us *UndoStack) Info() UndoInfo { @@ -187,11 +203,11 @@ func (us *UndoStack) Info() UndoInfo { } if len(us.history) > 0 { info.CanUndo = true - info.UndoDesc = us.history[len(us.history)-1].Description + info.UndoDesc = us.history[len(us.history)-1].ShortDesc() } if len(us.redo) > 0 { info.CanRedo = true - info.RedoDesc = us.redo[len(us.redo)-1].Description + info.RedoDesc = us.redo[len(us.redo)-1].ShortDesc() } return info } @@ -251,7 +267,9 @@ func (us *UndoStack) save() { log.Printf("undo: failed to marshal history: %v", err) return } - os.WriteFile(us.filePath, b, 0644) + if err := os.WriteFile(us.filePath, b, 0644); err != nil { + log.Printf("undo: failed to write history file: %v", err) + } } func (us *UndoStack) load() { @@ -259,6 +277,7 @@ func (us *UndoStack) load() { if err != nil { return } + log.Printf("undo: loaded history from %s", us.filePath) type extraEntry struct { FilePath string `json:"file_path"` OldContent string `json:"old_content"` @@ -280,6 +299,7 @@ func (us *UndoStack) load() { Redo []entry `json:"redo"` } if err := json.Unmarshal(data, &saveData); err != nil { + log.Printf("undo: failed to unmarshal history: %v", err) return } fromEntries := func(entries []entry) []ChangeDesc { @@ -308,5 +328,5 @@ func (us *UndoStack) load() { return changes } us.history = fromEntries(saveData.History) - us.redo = fromEntries(saveData.Redo) + log.Printf("undo: loaded %d history entries", len(us.history)) } diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go index 0bc2b4a..4d0a814 100644 --- a/internal/admin/yaml_util.go +++ b/internal/admin/yaml_util.go @@ -145,7 +145,7 @@ func listYAMLFilesDeep(dataDir, subdir string) ([]string, error) { func backupFile(path string) []byte { data, err := os.ReadFile(path) if err != nil { - log.Printf("backup: failed to read %s: %v", path, err) + log.Printf("backup: WARNING failed to read %s: %v (undo will not be able to restore this file)", path, err) return nil } return data |
