diff options
Diffstat (limited to 'internal/admin/static/cardeditor.js')
| -rw-r--r-- | internal/admin/static/cardeditor.js | 452 |
1 files changed, 452 insertions, 0 deletions
diff --git a/internal/admin/static/cardeditor.js b/internal/admin/static/cardeditor.js new file mode 100644 index 0000000..0235894 --- /dev/null +++ b/internal/admin/static/cardeditor.js @@ -0,0 +1,452 @@ +// cardeditor.js — Shared utilities for card-based entity editors. +// Used by mobeditor, objecteditor, itemeditor, and future editors. +// Prefix all exports with ced_ to avoid collisions with editor.js globals. + +// ---- Path helpers ---- + +function ced_cardPath(sec) { + return sec.path || ''; +} + +function ced_getVal(data, sec, key) { + var p = ced_cardPath(sec); + if (sec.isArray || p === 'on_look') { + 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; +} + +// ---- Field ID helpers ---- + +function ced_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 ced_fieldIDSub(sec, subKey, idx, fieldKey) { + return 'f_' + sec.id + '_' + subKey + '_' + idx + '_' + fieldKey; +} + +// ---- Value collector ---- + +function ced_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; +} + +// ---- Tags rendering (comma-separated string arrays) ---- + +function ced_renderTags(sec, il, data, getter) { + var sectionRoot = data[ced_cardPath(sec)] || data; + var arr = getter ? getter(sectionRoot, il) : (sectionRoot[il.key] || []); + var str = Array.isArray(arr) ? arr.join(', ') : String(arr || ''); + var fid = ced_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>'; +} + +// ---- KV rendering (key=value map) ---- + +function ced_renderKV(sec, il, data, getter) { + var sectionRoot = data[ced_cardPath(sec)] || data; + var map = getter ? getter(sectionRoot, il) : (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="ced_addKVRow(\'' + sec.id + '\',\'' + il.key + '\')">+ Add</button>'; + h += '</div>'; + return h; +} + +function ced_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 ced_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 = ''; results._selectedIndex = -1; 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();ced_selectSearchResult(this)">' + esc(r.id) + ' <span style="color:#aaa">' + esc(r.name || '') + '</span></div>'; + }).join(''); + results._items = items; + results._selectedIndex = -1; + }).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); + }); + + input.addEventListener('keydown', function(e) { + if (results.style.display === 'none' || !results.children.length) return; + if (results._selectedIndex === undefined) results._selectedIndex = -1; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + results._selectedIndex = Math.min(results._selectedIndex + 1, results.children.length - 1); + ced_updateSearchHighlight(results); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + results._selectedIndex = Math.max(results._selectedIndex - 1, 0); + ced_updateSearchHighlight(results); + } else if (e.key === 'Enter') { + if (results._selectedIndex >= 0) { + e.preventDefault(); + ced_selectSearchResult(results.children[results._selectedIndex]); + } + } else if (e.key === 'Escape') { + results.style.display = 'none'; + results._selectedIndex = -1; + } + }); + }); + + 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 ced_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 = ''; + } +} + +function ced_updateSearchHighlight(results) { + for (var i = 0; i < results.children.length; i++) { + results.children[i].classList.toggle('active', i === results._selectedIndex); + if (i === results._selectedIndex) results.children[i].scrollIntoView({block: 'nearest'}); + } +} + +// ---- Generic field renderer (used by all card-based editors) ---- + +function ced_renderField(sec, f, data, idx, subPath, optStyle, cardPathFn, fieldIDFn, getValFn) { + 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 = fieldIDFn(sec, fp, idx); + var val; + + if (sec.isArray && idx >= 0) { + var arr = data[cardPathFn(sec)] || []; + var item = arr[idx] || {}; + val = item[key]; + } else if (subPath) { + var sectionRoot = data[cardPathFn(sec)] || {}; + var subObj = sectionRoot[subPath] || {}; + val = subObj[key]; + } else { + val = getValFn(data, sec, key); + } + val = val === undefined || val === null ? '' : val; + + var hiddenStyle = ''; + if (showIf) { + var sectionData = data[cardPathFn(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 + '>'); +} + +// ---- Subtable row add/remove ---- + +function ced_addSubRow(sec, subKey, dataRef, cardPathFn) { + var p = cardPathFn(sec); + var sectionRoot = p ? (dataRef[p] || {}) : dataRef; + 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; +} + +function ced_removeSubRow(sec, subKey, idx, dataRef, cardPathFn) { + var p = cardPathFn(sec); + var sectionRoot = p ? (dataRef[p] || {}) : dataRef; + 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); +} + +// ---- Field save helpers ---- + +function ced_saveSectionFields(sec, sectionObj, cardPathFn, fieldIDFn) { + var hasValues = sec.always; + sec.fields.forEach(function(f) { + var fid = fieldIDFn(sec, f[0]); + var el = document.getElementById(fid); + if (!el) return; + var val = ced_collectFieldValue(el, f[2]); + if (val !== '' && val !== false && val !== 0 && val !== null) hasValues = true; + sectionObj[f[0]] = val; + }); + return hasValues; +} + +function ced_saveSubtable(sec, st, sectionObj, cardPathFn, fieldIDSubFn) { + var items = []; + for (var i = 0; ; i++) { + var item = {}; + var found = false; + st.fields.forEach(function(f) { + var fid = fieldIDSubFn(sec, st.key, i, f[0]); + var el = document.getElementById(fid); + if (el) found = true; + if (el) item[f[0]] = ced_collectFieldValue(el, f[2]); + }); + if (!found) break; + if (Object.keys(item).length > 0) items.push(item); + } + if (st.single) { + if (items.length > 0) { sectionObj[st.key] = items[0]; return true; } + } else { + if (items.length > 0) { sectionObj[st.key] = items; return true; } + } + return false; +} + +function ced_saveInlineFields(sec, sectionObj, cardPathFn, fieldIDFn) { + var hasValues = false; + sec.inline.forEach(function(il) { + if (il.type === 'tags') { + var fid = fieldIDFn(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 = fieldIDFn(sec, fp); + var el = document.getElementById(fid); + if (el) sHas = true; + if (el) ssec[f[0]] = ced_collectFieldValue(el, f[2]); + }); + if (sHas) { sectionObj[il.path] = ssec; hasValues = true; } + } + }); + return hasValues; +} + +// ---- Validation ---- + +function ced_validateFields(SECTIONS, fieldIDFn, fieldIDSubFn) { + var errors = []; + document.querySelectorAll('.obj-field-error').forEach(function(field) { + field.classList.remove('obj-field-error'); + }); + + document.querySelectorAll('input[id][type="text"], input[id]:not([type])').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.+)$/); + + 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 (baseMatch) { + var fieldKey = baseMatch[2]; + var nf = sec.fields ? sec.fields.find(function(f) { + var fid = fieldIDFn(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 = fieldIDFn(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; +} + +// ---- Output cleanup ---- + +function ced_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)) { + ced_cleanOutput(val); + if (Object.keys(val).length === 0) delete obj[key]; + } else if (Array.isArray(val) && val.length === 0) { + delete obj[key]; + } + } +} |
