|
| 1 | +{{template "header" .}} |
| 2 | + |
| 3 | +<style> |
| 4 | + h1 { font-size: 1.5rem; margin-bottom: 0.25rem; } |
| 5 | + .subtitle { color: var(--color-text-muted); margin-bottom: 1.5rem; font-size: 0.9rem; } |
| 6 | + |
| 7 | + .page-layout { display: grid; grid-template-columns: 260px 1fr; gap: 1.25rem; align-items: start; } |
| 8 | + @media (max-width: 800px) { .page-layout { grid-template-columns: 1fr; } } |
| 9 | + |
| 10 | + .sidebar { background: var(--color-surface-white); border: 1px solid var(--color-border); border-radius: 6px; overflow: hidden; display: flex; flex-direction: column; } |
| 11 | + .sidebar-header { padding: 0.75rem 0.9rem; border-bottom: 1px solid var(--color-border-light); } |
| 12 | + .sidebar-header input[type="search"] { width: 100%; padding: 0.35rem 0.6rem; border: 1px solid var(--color-border-medium); border-radius: 4px; font-size: 0.85rem; } |
| 13 | + .entity-list { overflow-y: auto; max-height: 72vh; } |
| 14 | + .entity-row { |
| 15 | + padding: 0.5rem 0.9rem; cursor: pointer; border-bottom: 1px solid var(--color-border-faint); |
| 16 | + display: flex; align-items: baseline; gap: 0.5rem; font-size: 0.875rem; |
| 17 | + } |
| 18 | + .entity-row:last-child { border-bottom: none; } |
| 19 | + .entity-row:hover { background: var(--color-row-hover); } |
| 20 | + .entity-row.active { background: var(--color-primary); color: var(--color-surface-white); } |
| 21 | + .entity-id { font-size: 0.75rem; color: var(--color-text-secondary); font-family: monospace; flex-shrink: 0; } |
| 22 | + .entity-row.active .entity-id { color: var(--color-active-id); } |
| 23 | + .no-entities { padding: 1.5rem; text-align: center; color: var(--color-text-faint); font-size: 0.85rem; } |
| 24 | + .btn-new { margin: 0.6rem 0.9rem; padding: 0.4rem 0.9rem; border: none; border-radius: 4px; background: var(--color-success-text); color: var(--color-surface-white); font-size: 0.85rem; font-weight: 600; cursor: pointer; display: block; text-align: center; } |
| 25 | + .btn-new:hover { background: var(--color-btn-new-hover); } |
| 26 | + |
| 27 | + .editor-panel { background: var(--color-surface-white); border: 1px solid var(--color-border); border-radius: 6px; padding: 1.25rem; } |
| 28 | + .editor-placeholder { color: var(--color-text-placeholder); text-align: center; padding: 4rem 1rem; font-size: 0.9rem; } |
| 29 | + |
| 30 | + .status-bar { display: none; padding: 0.55rem 0.9rem; border-radius: 4px; font-size: 0.85rem; margin-bottom: 1rem; } |
| 31 | + .status-bar.success { background: var(--color-success-bg); color: var(--color-success-text); display: block; } |
| 32 | + .status-bar.error { background: var(--color-error-bg); color: var(--color-error-text); display: block; } |
| 33 | + |
| 34 | + .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem 1.25rem; } |
| 35 | + .form-grid .span2 { grid-column: 1 / -1; } |
| 36 | + .field label { display: block; font-size: 0.78rem; font-weight: 600; color: var(--color-text-muted); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.03em; } |
| 37 | + .field input[type="text"], .field textarea { |
| 38 | + width: 100%; padding: 0.4rem 0.6rem; border: 1px solid var(--color-border-medium); border-radius: 4px; |
| 39 | + font-size: 0.875rem; font-family: inherit; background: var(--color-surface-white); |
| 40 | + } |
| 41 | + .field input:focus, .field textarea:focus { outline: 2px solid var(--color-primary); outline-offset: 1px; border-color: transparent; } |
| 42 | + .field input[readonly] { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; } |
| 43 | + .field textarea { resize: vertical; min-height: 70px; } |
| 44 | + .field-hint { font-size: 0.75rem; color: var(--color-text-faint); margin-top: 0.2rem; } |
| 45 | + |
| 46 | + .section-title { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-subtle); padding: 0.75rem 0 0.4rem; border-bottom: 1px solid var(--color-border-light); margin-bottom: 0.75rem; grid-column: 1 / -1; } |
| 47 | + |
| 48 | + .slots-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 0.4rem; grid-column: 1 / -1; } |
| 49 | + .slot-item { |
| 50 | + display: flex; align-items: center; justify-content: center; |
| 51 | + gap: 0.4rem; font-size: 0.82rem; font-weight: 500; |
| 52 | + padding: 0.4rem 0.5rem; border-radius: 5px; cursor: pointer; |
| 53 | + border: 1px solid var(--color-border); background: var(--color-surface-alt); color: var(--color-text-tertiary); |
| 54 | + user-select: none; transition: background 0.12s, color 0.12s, border-color 0.12s; |
| 55 | + } |
| 56 | + .slot-item:hover { border-color: var(--color-text-placeholder); background: var(--color-border-faint); } |
| 57 | + .slot-item input[type="checkbox"] { display: none; } |
| 58 | + .slot-item.enabled { |
| 59 | + background: var(--color-primary); color: var(--color-surface-white); border-color: var(--color-primary); |
| 60 | + } |
| 61 | + |
| 62 | + .btn-row { display: flex; gap: 0.6rem; margin-top: 1.25rem; grid-column: 1 / -1; } |
| 63 | + .btn-save { padding: 0.45rem 1.2rem; background: var(--color-primary); color: var(--color-surface-white); border: none; border-radius: 4px; font-size: 0.875rem; font-weight: 600; cursor: pointer; } |
| 64 | + .btn-save:hover { background: var(--color-primary-hover); } |
| 65 | + .btn-delete { padding: 0.45rem 1rem; background: var(--color-surface-white); color: var(--color-error-text); border: 1px solid var(--color-btn-delete-border); border-radius: 4px; font-size: 0.875rem; font-weight: 600; cursor: pointer; } |
| 66 | + .btn-delete:hover { background: var(--color-error-bg); } |
| 67 | +</style> |
| 68 | +<h1>Professions</h1> |
| 69 | +<p class="subtitle">View, create, edit and delete profession definitions. See the <a href="/admin/skills-api">API reference</a>.</p> |
| 70 | + |
| 71 | +<div class="page-layout"> |
| 72 | + <div class="sidebar"> |
| 73 | + <div class="sidebar-header"> |
| 74 | + <input type="search" id="profSearch" placeholder="Search professions…" oninput="filterProfs()" /> |
| 75 | + </div> |
| 76 | + <div id="profList" class="entity-list"> |
| 77 | + <div class="no-entities">Loading…</div> |
| 78 | + </div> |
| 79 | + <button class="btn-new" onclick="newProf()">+ New Profession</button> |
| 80 | + </div> |
| 81 | + |
| 82 | + <div class="editor-panel"> |
| 83 | + <div id="editorPlaceholder" class="editor-placeholder">Select a profession to edit, or click "New Profession".</div> |
| 84 | + <div id="editorForm" style="display:none"> |
| 85 | + <div id="statusBar" class="status-bar"></div> |
| 86 | + <div class="form-grid"> |
| 87 | + <div style="grid-column:1/-1;display:flex;align-items:baseline;gap:0.75rem;margin-bottom:0.25rem"> |
| 88 | + <p class="section-title" id="editorTitle" style="margin:0;flex:1">Edit Profession</p> |
| 89 | + <button class="yaml-badge" id="yaml-view-btn" style="display:none" onclick="showYamlModal(this.dataset.url,'Profession YAML')">YAML</button> |
| 90 | + </div> |
| 91 | + |
| 92 | + <div class="field"> |
| 93 | + <label for="fProfId">Profession ID</label> |
| 94 | + <input type="text" id="fProfId" placeholder="treasure hunter" /> |
| 95 | + <div class="field-hint">Lowercase, spaces allowed. Immutable after creation.</div> |
| 96 | + </div> |
| 97 | + <div class="field"> |
| 98 | + <label for="fName">Name</label> |
| 99 | + <input type="text" id="fName" placeholder="Treasure Hunter" /> |
| 100 | + </div> |
| 101 | + |
| 102 | + <div class="field span2"> |
| 103 | + <label for="fDesc">Description</label> |
| 104 | + <textarea id="fDesc" rows="2" placeholder="A description of this profession."></textarea> |
| 105 | + </div> |
| 106 | + |
| 107 | + <div class="section-title">Skills</div> |
| 108 | + <div class="field-hint span2" style="grid-column:1/-1; margin-bottom:0.5rem;">Click a skill to toggle it. Highlighted skills belong to this profession.</div> |
| 109 | + <div class="slots-grid" id="skillsGrid"></div> |
| 110 | + |
| 111 | + <div class="btn-row"> |
| 112 | + <button class="btn-save" onclick="saveProf()">Save</button> |
| 113 | + <button class="btn-delete" id="btnDelete" onclick="deleteProf()" style="display:none">Delete</button> |
| 114 | + </div> |
| 115 | + </div> |
| 116 | + </div> |
| 117 | + </div> |
| 118 | +</div> |
| 119 | + |
| 120 | +<script> |
| 121 | +let profsData = {}; // id(string) -> Profession |
| 122 | +let allSkillIds = []; // sorted skill ids |
| 123 | +let activeProfId = null; |
| 124 | +let isNew = false; |
| 125 | + |
| 126 | +async function loadProfs() { |
| 127 | + const [res, skillsRes] = await AdminAPI.all([ |
| 128 | + AdminAPI.get('/admin/api/v1/professions'), |
| 129 | + AdminAPI.get('/admin/api/v1/skills'), |
| 130 | + ]); |
| 131 | + if (!res.ok) { console.error('Failed to load professions:', res.error); return; } |
| 132 | + profsData = (res.data && res.data.data) || {}; |
| 133 | + if (skillsRes.ok) { |
| 134 | + const skillsMap = (skillsRes.data && skillsRes.data.data) || {}; |
| 135 | + allSkillIds = Object.keys(skillsMap).sort(); |
| 136 | + buildSkillsGrid(); |
| 137 | + } |
| 138 | + renderList(); |
| 139 | + const hashId = decodeURIComponent(location.hash.slice(1)); |
| 140 | + if (hashId && profsData[hashId]) selectProf(hashId); |
| 141 | +} |
| 142 | + |
| 143 | +function filterProfs() { |
| 144 | + const q = document.getElementById('profSearch').value.toLowerCase(); |
| 145 | + document.querySelectorAll('.entity-row').forEach(row => { |
| 146 | + const name = (row.dataset.name || '').toLowerCase(); |
| 147 | + row.style.display = name.includes(q) ? '' : 'none'; |
| 148 | + }); |
| 149 | +} |
| 150 | + |
| 151 | +function renderList() { |
| 152 | + const list = document.getElementById('profList'); |
| 153 | + const ids = Object.keys(profsData).sort(); |
| 154 | + if (ids.length === 0) { |
| 155 | + list.innerHTML = '<div class="no-entities">No professions found.</div>'; |
| 156 | + return; |
| 157 | + } |
| 158 | + list.innerHTML = ids.map(id => { |
| 159 | + const p = profsData[id]; |
| 160 | + return `<div class="entity-row" data-id="${escAttr(id)}" data-name="${escAttr(id + ' ' + (p.Name || ''))}" |
| 161 | + onclick="selectProf('${escAttr(id)}')"> |
| 162 | + <span>${escHtml(p.Name || id)}</span> |
| 163 | + </div>`; |
| 164 | + }).join(''); |
| 165 | + if (activeProfId !== null) highlightRow(activeProfId); |
| 166 | +} |
| 167 | + |
| 168 | +function highlightRow(id) { |
| 169 | + document.querySelectorAll('.entity-row').forEach(r => r.classList.remove('active')); |
| 170 | + const row = document.querySelector(`.entity-row[data-id="${cssEsc(id)}"]`); |
| 171 | + if (row) row.classList.add('active'); |
| 172 | +} |
| 173 | + |
| 174 | +function selectProf(id) { |
| 175 | + activeProfId = id; |
| 176 | + isNew = false; |
| 177 | + location.hash = encodeURIComponent(id); |
| 178 | + highlightRow(id); |
| 179 | + populateForm(profsData[id]); |
| 180 | +} |
| 181 | + |
| 182 | +function newProf() { |
| 183 | + activeProfId = null; |
| 184 | + isNew = true; |
| 185 | + document.querySelectorAll('.entity-row').forEach(r => r.classList.remove('active')); |
| 186 | + populateForm(null); |
| 187 | +} |
| 188 | + |
| 189 | +function buildSkillsGrid() { |
| 190 | + const grid = document.getElementById('skillsGrid'); |
| 191 | + if (!grid) return; |
| 192 | + grid.innerHTML = ''; |
| 193 | + for (const id of allSkillIds) { |
| 194 | + const lbl = document.createElement('label'); |
| 195 | + lbl.className = 'slot-item'; |
| 196 | + lbl.setAttribute('onclick', 'toggleSkill(this)'); |
| 197 | + lbl.innerHTML = '<input type="checkbox" class="skill-cb" value="' + escAttr(id) + '" /> ' + escHtml(id); |
| 198 | + grid.appendChild(lbl); |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +function toggleSkill(label) { |
| 203 | + const cb = label.querySelector('.skill-cb'); |
| 204 | + cb.checked = !cb.checked; |
| 205 | + label.classList.toggle('enabled', cb.checked); |
| 206 | +} |
| 207 | + |
| 208 | +function populateForm(prof) { |
| 209 | + document.getElementById('editorPlaceholder').style.display = 'none'; |
| 210 | + document.getElementById('editorForm').style.display = ''; |
| 211 | + document.getElementById('statusBar').className = 'status-bar'; |
| 212 | + document.getElementById('statusBar').textContent = ''; |
| 213 | + document.getElementById('btnDelete').style.display = isNew ? 'none' : ''; |
| 214 | + document.getElementById('editorTitle').textContent = isNew ? 'New Profession' : `Edit: ${prof ? prof.Name : ''}`; |
| 215 | + |
| 216 | + const idInput = document.getElementById('fProfId'); |
| 217 | + idInput.readOnly = !isNew; |
| 218 | + |
| 219 | + var yamlBtn = document.getElementById('yaml-view-btn'); |
| 220 | + if (!isNew && activeProfId != null) { |
| 221 | + yamlBtn.dataset.url = '/admin/api/v1/professions/' + encodeURIComponent(activeProfId) + '/yaml'; |
| 222 | + yamlBtn.style.display = ''; |
| 223 | + } else { |
| 224 | + yamlBtn.style.display = 'none'; |
| 225 | + } |
| 226 | + |
| 227 | + const p = prof || {}; |
| 228 | + idInput.value = p.ProfessionId || ''; |
| 229 | + document.getElementById('fName').value = p.Name || ''; |
| 230 | + document.getElementById('fDesc').value = p.Description || ''; |
| 231 | + |
| 232 | + const selected = new Set((p.Skills || []).map(s => s.toLowerCase())); |
| 233 | + document.querySelectorAll('.skill-cb').forEach(cb => { |
| 234 | + cb.checked = selected.has(cb.value); |
| 235 | + cb.closest('.slot-item').classList.toggle('enabled', cb.checked); |
| 236 | + }); |
| 237 | +} |
| 238 | + |
| 239 | +function buildPayload() { |
| 240 | + const skills = []; |
| 241 | + document.querySelectorAll('.skill-cb:checked').forEach(cb => skills.push(cb.value)); |
| 242 | + return { |
| 243 | + ProfessionId: document.getElementById('fProfId').value.trim().toLowerCase(), |
| 244 | + Name: document.getElementById('fName').value.trim(), |
| 245 | + Description: document.getElementById('fDesc').value.trim(), |
| 246 | + Skills: skills, |
| 247 | + }; |
| 248 | +} |
| 249 | + |
| 250 | +async function saveProf() { |
| 251 | + const payload = buildPayload(); |
| 252 | + if (!payload.ProfessionId) { showStatus('error', 'Profession ID is required.'); return; } |
| 253 | + if (!payload.Name) { showStatus('error', 'Name is required.'); return; } |
| 254 | + if (payload.Skills.length === 0) { showStatus('error', 'Select at least one skill.'); return; } |
| 255 | + |
| 256 | + let res; |
| 257 | + if (isNew) { |
| 258 | + res = await AdminAPI.post('/admin/api/v1/professions', payload); |
| 259 | + if (res.ok && res.data && res.data.data) { |
| 260 | + const id = res.data.data.ProfessionId; |
| 261 | + profsData[id] = res.data.data; |
| 262 | + activeProfId = id; |
| 263 | + isNew = false; |
| 264 | + location.hash = encodeURIComponent(id); |
| 265 | + renderList(); |
| 266 | + populateForm(profsData[id]); |
| 267 | + showStatus('success', `Profession "${id}" created.`); |
| 268 | + } else { |
| 269 | + showStatus('error', res.error || 'Save failed.'); |
| 270 | + } |
| 271 | + } else { |
| 272 | + res = await AdminAPI.patch(`/admin/api/v1/professions/${encodeURIComponent(activeProfId)}`, payload); |
| 273 | + if (res.ok) { |
| 274 | + profsData[activeProfId] = (res.data && res.data.data) || payload; |
| 275 | + renderList(); |
| 276 | + highlightRow(activeProfId); |
| 277 | + document.getElementById('editorTitle').textContent = `Edit: ${payload.Name}`; |
| 278 | + showStatus('success', `Profession "${activeProfId}" saved.`); |
| 279 | + } else { |
| 280 | + showStatus('error', res.error || 'Save failed.'); |
| 281 | + } |
| 282 | + } |
| 283 | +} |
| 284 | + |
| 285 | +async function deleteProf() { |
| 286 | + if (activeProfId === null) return; |
| 287 | + if (!confirm(`Delete profession "${activeProfId}"? This cannot be undone.`)) return; |
| 288 | + const res = await AdminAPI.delete(`/admin/api/v1/professions/${encodeURIComponent(activeProfId)}`); |
| 289 | + if (res.ok) { |
| 290 | + delete profsData[activeProfId]; |
| 291 | + activeProfId = null; |
| 292 | + history.replaceState(null, '', location.pathname + location.search); |
| 293 | + renderList(); |
| 294 | + document.getElementById('editorForm').style.display = 'none'; |
| 295 | + document.getElementById('editorPlaceholder').style.display = ''; |
| 296 | + } else { |
| 297 | + showStatus('error', res.error || 'Delete failed.'); |
| 298 | + } |
| 299 | +} |
| 300 | + |
| 301 | +function showStatus(type, msg) { |
| 302 | + const bar = document.getElementById('statusBar'); |
| 303 | + bar.className = 'status-bar ' + type; |
| 304 | + bar.textContent = msg; |
| 305 | + if (type === 'success') setTimeout(() => { bar.className = 'status-bar'; }, 3000); |
| 306 | +} |
| 307 | + |
| 308 | +function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); } |
| 309 | +function escAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); } |
| 310 | +function cssEsc(s) { return String(s).replace(/"/g,'\\"'); } |
| 311 | + |
| 312 | +loadProfs(); |
| 313 | +</script> |
| 314 | + |
| 315 | +{{template "footer" .}} |
0 commit comments