From 94823b52168b44894fb5e9c960358ed02ebb122b Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sun, 5 Jul 2026 22:35:21 -0400 Subject: feat: courses gui overhaul, add course tab to map, add hidden exits to give courses structure --- internal/admin/static/admin.css | 14 +- internal/admin/static/map.js | 535 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 545 insertions(+), 4 deletions(-) (limited to 'internal/admin/static') diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index 1f6aeec..376bf23 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -281,9 +281,9 @@ g.ud-hover:hover text{font-weight:bold} .panel-tab-content .section-add-btn:hover{background:var(--hover)} .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;break-inside:avoid;display:inline-block;width:100%} -.obj-card.obj-card-full{column-span:all} -.obj-fields-wrap{column-count:2;column-gap:10px} +.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)} .obj-card-arrow{font-size:10px;width:14px;color:#888} @@ -355,3 +355,11 @@ g.ud-hover:hover text{font-weight:bold} .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} + +/* Course tab (map side panel) */ +.course-header{border-bottom:1px solid var(--border);padding-bottom:6px;margin-bottom:8px} +.course-obstacle-section,.course-phases-section{margin-top:10px;border-top:1px solid var(--border);padding-top:6px} +.section-divider{font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px} +.course-phase .phase-msg{font-family:monospace} +.course-phase .mini-input{width:auto!important;flex:1} +.course-details-card{margin-top:10px} diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 740edf3..f0b4d87 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -200,6 +200,12 @@ function renderMap(data) { if (!fr || !tr) return; var x1 = fr.x * CELL + CELL/2, y1 = fr.y * CELL + CELL/2; var x2 = tr.x * CELL + CELL/2, y2 = tr.y * CELL + CELL/2; + if (l.hidden) { + // Hidden course-link exit: distinct color (map_course, ~#1B6FC0) and + // tighter dashed arrow style. + g += ''; + return; + } var c = blendColors(fr.color, tr.color).replace('#',''); g += ''; }); @@ -370,6 +376,7 @@ function selectRoom(id) { API.get('/api/rooms/' + id).then(function(data) { renderSidePanel(data); + loadRoomCourseData(id); }).catch(function(e) { notify('Failed to load room: '+e.message, 'error'); }); @@ -402,6 +409,7 @@ function renderSidePanel(data) { html += ''; html += ''; html += ''; + html += ''; html += ''; html += '
'; @@ -412,6 +420,16 @@ function renderSidePanel(data) { else if (currentPanelTab === 4) html += renderMobsTab(mobs); else if (currentPanelTab === 5) html += renderItemsTab(spawns); else if (currentPanelTab === 6) html += renderHazardTab(r); + else if (currentPanelTab === 7) { + if (currentRoomData.course === undefined) { + html += renderCourseTabLoading(); + loadRoomCourseData(r.id); + } else if (currentRoomData.course === null) { + html += renderCourseTabEmpty(); + } else { + html += renderCourseTab(); + } + } html += '
'; panel.innerHTML = html; @@ -477,6 +495,489 @@ function renderHazardTab(r) { return html; } +// ---- Course tab --------------------------------------------------------- + +var OBSTACLE_VERBS = ['scramble','jump','swing','balance','climb','crawl','vault','leap','slide']; + +function loadRoomCourseData(id) { + if (!currentRoomData) return; + // Mark as "fetching" (undefined) so switchPanelTab knows to show Loading + // rather than the empty state, and so re-clicking the tab doesn't refetch. + if (currentRoomData.course === undefined) { + // first time for this room + } else { + currentRoomData.course = undefined; + } + API.get('/api/rooms/' + id + '/course').then(function(c) { + currentRoomData.course = c; + _coursePhases = null; + if (currentPanelTab === 7 && selectedRoom === id) { + var content = document.querySelector('.panel-tab-content'); + if (content) content.innerHTML = renderCourseTab(); + } + }).catch(function() { + if (currentRoomData) currentRoomData.course = null; + }); +} + +function renderCourseTabLoading() { + return '

Loading course...

'; +} + +var _courseDetailsCollapsed = true; + +function renderCourseTab() { + var cd = currentRoomData && currentRoomData.course ? currentRoomData.course : null; + if (!cd || cd === null) { + return renderCourseTabEmpty(); + } + var h = ''; + var obstacle = cd.obstacle || {}; + var idx = cd.obstacle_index, total = cd.total_obstacles; + var course = cd.course || {}; + var prevRoom = idx > 0 ? (course.obstacles && course.obstacles[idx-1] && course.obstacles[idx-1].room_id) : 0; + var nextRoom = idx < total - 1 ? (course.obstacles && course.obstacles[idx+1] && course.obstacles[idx+1].room_id) : 0; + var roomID = currentRoomData.room.id; + + // Header: Step N, Room #X (This Obstacle) + h += '
'; + h += '

Step ' + (idx + 1) + ', Room #' + roomID + '

'; + h += '
(This Obstacle) — ' + esc(cd.course_name || cd.course_id || '') + '
'; + // Prev/Next buttons row + h += '
'; + if (prevRoom) { + h += ''; + } + if (nextRoom) { + h += ''; + } + if (!prevRoom && !nextRoom) { + h += 'Single-step course'; + } + h += '
'; + h += '
'; + + // Obstacle details + h += '
'; + h += '
'; + h += '
'; + h += '
'; + var fd = obstacle.fail_damage || [0,0]; + var fdMin = Array.isArray(fd) ? fd[0] : 0; + var fdMax = Array.isArray(fd) ? fd[1] : 0; + h += ''; + h += ''; + h += '
'; + h += '
'; + var fcVal = (obstacle.fail_chance !== undefined && obstacle.fail_chance !== null) ? obstacle.fail_chance : ''; + h += ''; + h += '
Derived: 30% at req level, -1% per level above, clamp 5-60%. A number here overrides it.
'; + h += '
'; + h += '
'; + + // Phases section + h += '
'; + h += '
Phases (messages)
'; + h += '
'; + h += ''; + h += '
'; + + h += '
'; + h += ''; + h += '
'; + + // Collapsible course details (starts collapsed) + h += '
'; + h += '
'; + h += '' + (_courseDetailsCollapsed ? '▶' : '▼') + ''; + h += 'Course Details'; + h += '
'; + if (!_courseDetailsCollapsed) { + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + } + h += '
'; + + setTimeout(function() { renderCoursePhases(); }, 0); + return h; +} + +function courseToggleDetails() { + _courseDetailsCollapsed = !_courseDetailsCollapsed; + if (currentPanelTab === 7 && currentRoomData) { + var content = document.querySelector('.panel-tab-content'); + if (content) content.innerHTML = renderCourseTab(); + } +} + +var HORIZONTAL_DIRS = ['north','south','east','west','northeast','northwest','southeast','southwest']; + +function renderCourseTabEmpty() { + var h = '

This room is not part of any course.

'; + h += '
'; + h += '
'; + h += ''; + h += ''; + return h; +} + +// ---- phases ------------------------------------------------------------- + +function getCoursePhases() { + var c = currentRoomData && currentRoomData.course; + if (!c) return []; + var obs = c.obstacle || {}; + var phases = obs.phases; + if (!Array.isArray(phases)) { + // build from legacy messages form + var msgs = obs.messages || []; + var tpp = obs.ticks_per_phase || 0; + phases = msgs.map(function(m, i) { + return { message: m, delay: i === 0 ? 0 : tpp, fail_check: (i === 1) }; + }); + } + return phases; +} + +var _coursePhases = null; + +function ensureCoursePhasesLoaded() { + if (_coursePhases === null) { + _coursePhases = getCoursePhases().slice(); + } +} + +function renderCoursePhases() { + ensureCoursePhasesLoaded(); + var container = $('#coursePhases'); + if (!container) return; + var h = ''; + _coursePhases.forEach(function(p, i) { + h += '
'; + h += '
'; + h += ''; + h += '
'; + h += ''; + h += ''; + h += '
'; + h += '
'; + h += ''; + h += '
'; + }); + container.innerHTML = h; +} + +function courseAddPhase() { + ensureCoursePhasesLoaded(); + _coursePhases.push({ message: '', delay: 0, fail_check: false }); + renderCoursePhases(); + courseAutoSave(); +} + +function courseRemovePhase(i) { + ensureCoursePhasesLoaded(); + _coursePhases.splice(i, 1); + renderCoursePhases(); + courseAutoSave(); +} + +function courseUpdatePhase(i) { + ensureCoursePhasesLoaded(); + if (i < 0 || i >= _coursePhases.length) return; + var row = document.querySelector('.course-phase[data-idx="' + i + '"]'); + if (!row) return; + _coursePhases[i].message = row.querySelector('.phase-msg').value; + _coursePhases[i].delay = parseFloat(row.querySelector('.phase-delay').value) || 0; + courseAutoSave(); +} + +function courseSetFailCheck(i) { + ensureCoursePhasesLoaded(); + _coursePhases.forEach(function(p, j) { p.fail_check = (j === i); }); + renderCoursePhases(); + courseAutoSave(); +} + +// ---- save / detach ------------------------------------------------------ + +function courseSyncPhasesFromDOM() { + ensureCoursePhasesLoaded(); + var rows = document.querySelectorAll('.course-phase'); + rows.forEach(function(row) { + var i = parseInt(row.getAttribute('data-idx')); + if (i < 0 || i >= _coursePhases.length) return; + var msgEl = row.querySelector('.phase-msg'); + var delayEl = row.querySelector('.phase-delay'); + if (msgEl) _coursePhases[i].message = msgEl.value; + if (delayEl) _coursePhases[i].delay = parseFloat(delayEl.value) || 0; + }); +} + +var _courseSaveTimer = null; + +function courseAutoSave() { + if (_courseSaveTimer) clearTimeout(_courseSaveTimer); + _courseSaveTimer = setTimeout(function() { courseSave(true); }, 600); +} + +function courseSave(silent) { + if (!currentRoomData || !currentRoomData.course) { if (!silent) notify('No course loaded', 'error'); return; } + var c = currentRoomData.course; + var course = c.course || {}; + var courseID = c.course_id; + if (!courseID) { if (!silent) notify('Missing course id', 'error'); return; } + + courseSyncPhasesFromDOM(); + + // Course-level fields (may be absent if the details section is collapsed; + // fall back to existing course values). + var nameEl = document.querySelector('#courseName'); + var rlEl = document.querySelector('#courseRequiredLevel'); + var srEl = document.querySelector('#courseStartRoom'); + var cxEl = document.querySelector('#courseCompletionXP'); + var name = nameEl ? nameEl.value : (course.name || ''); + var reqLevel = rlEl ? (parseInt(rlEl.value) || 0) : (course.required_level || 0); + var startRoom = srEl ? (parseInt(srEl.value) || 0) : (course.start_room || 0); + var completionXP = cxEl ? (parseInt(cxEl.value) || 0) : (course.completion_xp || 0); + + var verbEl = document.querySelector('#courseObstacleVerb'); + var xpEl = document.querySelector('#courseObstacleXP'); + var fminEl = document.querySelector('#courseObstacleFailMin'); + var fmaxEl = document.querySelector('#courseObstacleFailMax'); + var fcEl = document.querySelector('#courseObstacleFailChance'); + var verb = verbEl ? verbEl.value : (c.obstacle && c.obstacle.verb) || 'climb'; + var xp = xpEl ? (parseInt(xpEl.value) || 0) : (c.obstacle && c.obstacle.xp) || 0; + var failMin = fminEl ? (parseInt(fminEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[0] : 0); + var failMax = fmaxEl ? (parseInt(fmaxEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[1] : 0); + var fcStr = fcEl ? fcEl.value : (c.obstacle && (c.obstacle.fail_chance !== undefined && c.obstacle.fail_chance !== null) ? String(c.obstacle.fail_chance) : ''); + var fc = fcStr === '' ? null : parseFloat(fcStr); + if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; } + + var obstacles = (course.obstacles || []).slice(); + var newObs = { + room_id: currentRoomData.room.id, + verb: verb, + xp: xp, + fail_damage: [failMin, failMax] + }; + if (fc !== null) newObs.fail_chance = fc; + newObs.phases = (_coursePhases || []).map(function(p) { + var ph = { message: p.message || '', delay: p.delay || 0 }; + if (p.fail_check) ph.fail_check = true; + return ph; + }); + obstacles[c.obstacle_index] = newObs; + + var body = { + name: name, + required_level: reqLevel, + start_room: startRoom, + completion_xp: completionXP, + obstacles: obstacles + }; + + API.put('/api/courses/' + encodeURIComponent(courseID), body).then(function() { + if (!silent) notify('Course ' + courseID + ' saved', 'success'); + updateUndoBar(); + // Refresh the cached course data WITHOUT re-rendering the panel, so the + // builder's cursor isn't disrupted (auto-save uses silent=true). + API.get('/api/rooms/' + currentRoomData.room.id + '/course').then(function(c) { + currentRoomData.course = c; + _coursePhases = null; + }).catch(function() {}); + }).catch(function(e) { if (!silent) notify('Course save failed: ' + extractError(e), 'error'); }); +} + +function courseDetach() { + if (!currentRoomData || !currentRoomData.course) return; + var c = currentRoomData.course; + var total = c.total_obstacles || 1; + var msg = 'Remove room #' + currentRoomData.room.id + ' from course ' + c.course_id + '?'; + if (total <= 1) msg = 'This is the last obstacle of course ' + c.course_id + '. Removing it will DELETE the course. Continue?'; + if (!confirm(msg)) return; + fetch('/api/rooms/' + currentRoomData.room.id + '/courses/detach', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: '{}', + credentials: 'same-origin' + }).then(function(r) { return r.json(); }).then(function(res) { + if (res.error) { notify(res.error, 'error'); return; } + notify(res.deleted ? ('Course ' + c.course_id + ' deleted') : ('Room removed from ' + c.course_id), 'success'); + updateUndoBar(); + _coursePhases = null; + currentRoomData.course = null; + loadRoomCourseData(currentRoomData.room.id); + var content = document.querySelector('.panel-tab-content'); + if (content && currentPanelTab === 7) content.innerHTML = renderCourseTab(); + }).catch(function(e) { notify('Detach failed: ' + e.message, 'error'); }); +} + +// ---- attach / new course ------------------------------------------------ + +function addRoomToCourse(courseID) { + if (!currentRoomData || !currentRoomData.room) return; + var roomID = currentRoomData.room.id; + var dirSel = document.querySelector('#courseAttachDir'); + var dir = dirSel ? dirSel.value : ''; + fetch('/api/rooms/' + roomID + '/courses/attach', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ course_id: courseID, after_index: 999, direction: dir }), + credentials: 'same-origin' + }).then(function(r) { return r.json(); }).then(function(res) { + if (res.error) { notify(res.error, 'error'); return; } + notify('Added room #' + roomID + ' to course ' + courseID, 'success'); + updateUndoBar(); + _coursePhases = null; + loadRoomCourseData(roomID); + }).catch(function(e) { notify('Attach failed: ' + e.message, 'error'); }); +} + +function searchCourses(input) { + searchAttachShow(input, function(r) { addRoomToCourse(r.id); }); +} + +function searchAttachShow(input, onSelect) { + var val = input.value.trim(); + var results = input.parentElement.querySelector('.search-results'); + if (!val) { results.style.display = 'none'; results.innerHTML = ''; return; } + API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) { + var items = data.courses || []; + if (items.length === 0) { + results.style.display = 'block'; + results.innerHTML = '
No results
'; + return; + } + results.style.display = 'block'; + results.innerHTML = items.map(function(r) { + return '
' + esc(r.id) + ' ' + esc(r.name || '') + '
'; + }).join(''); + results._items = items; + results._onSelect = onSelect; + }).catch(function() { results.style.display = 'none'; }); +} + +function selectCourseResult(el) { + var results = el.parentElement; + var idx = Array.prototype.indexOf.call(results.children, el); + if (results._items && results._items[idx] && results._onSelect) { + results._onSelect(results._items[idx]); + results.style.display = 'none'; + results.innerHTML = ''; + var input = results.parentElement.querySelector('.search-input'); + if (input) input.value = ''; + } +} + +function courseShowNew() { + if (!currentRoomData || !currentRoomData.room) return; + var roomID = currentRoomData.room.id; + + var overlay = document.createElement('div'); + 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:520px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201'; + + var h = '

New Course (room #' + roomID + ' = step 1)

'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += '
'; + + menu.innerHTML = h; + document.body.appendChild(overlay); + document.body.appendChild(menu); + overlay.onclick = courseCloseModal; +} + +function courseCloseModal() { + var ov = document.querySelector('.exit-overlay'); + if (ov) ov.remove(); + var m = document.querySelector('.exit-modal'); + if (m) m.remove(); +} + +function courseCreateConfirm() { + if (!currentRoomData || !currentRoomData.room) return; + var roomID = currentRoomData.room.id; + var id = val('#newCourseID'); + if (!id) { notify('Course ID required', 'error'); return; } + var name = val('#newCourseName'); + var reqLevel = parseInt(val('#newCourseReqLevel')) || 0; + var startRoom = parseInt(val('#newCourseStartRoom')) || 0; + var completionXP = parseInt(val('#newCourseCompletionXP')) || 0; + var verb = sel('#newCourseVerb'); + var xp = parseInt(val('#newCourseXP')) || 0; + var failMin = parseInt(val('#newCourseFailMin')) || 0; + var failMax = parseInt(val('#newCourseFailMax')) || 0; + var fcStr = val('#newCourseFailChance'); + var fc = fcStr === '' ? null : parseFloat(fcStr); + if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; } + var msg = val('#newCourseMsg') || ''; + + var obstacle = { + room_id: roomID, + verb: verb, + xp: xp, + fail_damage: [failMin, failMax], + phases: [{ message: msg, delay: 0 }] + }; + if (fc !== null) obstacle.fail_chance = fc; + + var body = { + id: id, + name: name, + required_level: reqLevel, + start_room: startRoom, + completion_xp: completionXP, + obstacles: [obstacle] + }; + + API.post('/api/courses', body).then(function(res) { + if (res.error) { notify(res.error, 'error'); return; } + notify('Course ' + id + ' created', 'success'); + updateUndoBar(); + courseCloseModal(); + _coursePhases = null; + loadRoomCourseData(roomID); + }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); }); +} + +// tiny helpers used by the course tab +function val(sel) { var el = document.querySelector(sel); return el ? el.value : ''; } +function sel(sel) { var el = document.querySelector(sel); return el ? el.value : ''; } + function searchHazards(input) { searchAndShow(input, 'hazards', function(r) { addHazard(r.id); }); } @@ -528,6 +1029,17 @@ function switchPanelTab(idx) { else if (idx === 4) html = renderMobsTab(mobs); else if (idx === 5) html = renderItemsTab(spawns); else if (idx === 6) html = renderHazardTab(r); + else if (idx === 7) { + _coursePhases = null; + if (currentRoomData.course === undefined) { + html = renderCourseTabLoading(); + loadRoomCourseData(r.id); + } else if (currentRoomData.course === null) { + html = renderCourseTabEmpty(); + } else { + html = renderCourseTab(); + } + } var content = document.querySelector('.panel-tab-content'); if (content) content.innerHTML = html; var tabs = document.querySelectorAll('.panel-tab'); @@ -646,10 +1158,12 @@ function renderExitsSection(r) { var bmsg = (typeof exit === 'object' && exit.blocked_message) ? exit.blocked_message : ''; var setFlags = (typeof exit === 'object' && exit.set_flags) ? exit.set_flags : null; var setPlayerFlags = (typeof exit === 'object' && exit.set_player_flags) ? exit.set_player_flags : null; - var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags); + var hidden = (typeof exit === 'object' && exit.hidden) ? exit.hidden : false; + var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden); h += '
'; h += '' + d + ''; h += '# ' + target + ''; + h += ''; if (bmsg) h += 'Blocked: ' + esc(bmsg) + ''; if (cond) h += 'Conditional'; h += ''; @@ -679,6 +1193,7 @@ function editExitCondition(dir) { var bmsg = exit.blocked_message || ''; var setFlags = exit.set_flags || null; var setPlayerFlags = exit.set_player_flags || null; + var hidden = exit.hidden || false; var flagsStr = ''; if (setFlags) flagsStr = Object.keys(setFlags).map(function(k) { return k + '=' + setFlags[k]; }).join(', '); @@ -697,6 +1212,7 @@ function editExitCondition(dir) { h += '
'; h += '
'; h += '
'; + h += '
'; h += '
'; h += ''; @@ -834,6 +1350,8 @@ function saveExitCondition() { var bmsg = document.getElementById('exitBmsg'); exit.blocked_message = (bmsg && bmsg.value) ? bmsg.value : ''; + var hiddenEl = document.getElementById('exitHidden'); + exit.hidden = hiddenEl ? hiddenEl.checked : false; var flagsStr = document.getElementById('exitFlags'); var pflagsStr = document.getElementById('exitPFlags'); exit.set_flags = parseFlagInput(flagsStr ? flagsStr.value : ''); @@ -928,6 +1446,21 @@ function deleteExit(dir, target) { }).catch(function(e) { notify('Delete exit failed: ' + extractError(e), 'error'); }); } +function toggleExitHidden(dir) { + if (!currentRoomData || !currentRoomData.room) return; + var exits = currentRoomData.room.exits || {}; + var exit = exits[dir]; + if (!exit) return; + if (typeof exit !== 'object') { + exit = {room: exit}; + exits[dir] = exit; + currentRoomData.room.exits = exits; + } + exit.hidden = !exit.hidden; + autoSave(); + reRenderPanel(); +} + function extractError(e) { var msg = e.message || 'unknown error'; try { var j = JSON.parse(msg); if (j.error) msg = j.error; } catch(_) {} -- cgit v1.2.3