1/* ZnoteX Admin Control Panel - day/night theme, menu search, side sections. */
2(function () {
3 'use strict';
4
5 var root = document.documentElement;
6 var backdrop = document.getElementById('acpBackdrop');
7 var burger = document.getElementById('acpBurger');
8 var filter = document.getElementById('acpFilter');
9 var nav = document.getElementById('acpNav');
10 var themeBtn = document.getElementById('acpTheme');
11 var MOBILE = '(max-width: 840px)';
12
13 function store(key, value) {
14 try { localStorage.setItem('acp.' + key, value); } catch (e) {}
15 }
16
17 function isMobile() {
18 return window.matchMedia(MOBILE).matches;
19 }
20
21 function openDrawer(open) {
22 root.classList.toggle('acp-nav-open', open);
23 if (backdrop) { backdrop.hidden = !open; }
24 }
25
26 if (burger) {
27 burger.addEventListener('click', function () {
28 if (isMobile()) {
29 openDrawer(!root.classList.contains('acp-nav-open'));
30 return;
31 }
32
33 var closed = root.classList.toggle('acp-sidebar-closed');
34 store('sidebar_closed', closed ? '1' : '0');
35 });
36 }
37 if (backdrop) {
38 backdrop.addEventListener('click', function () { openDrawer(false); });
39 }
40 document.addEventListener('keydown', function (e) {
41 if (e.key === 'Escape') { openDrawer(false); }
42 });
43 window.addEventListener('resize', function () {
44 if (!isMobile()) { openDrawer(false); }
45 });
46
47 if (nav) {
48 Array.prototype.slice.call(nav.querySelectorAll('.acp-nav-group')).forEach(function (group) {
49 var button = group.querySelector('.acp-nav-group-label');
50 var active = group.querySelector('.acp-nav-link.is-active');
51 group.classList.toggle('is-open', !!active);
52 if (!active && group === nav.querySelector('.acp-nav-group')) {
53 group.classList.add('is-open');
54 }
55 if (button) {
56 button.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
57 button.addEventListener('click', function () {
58 var open = group.classList.toggle('is-open');
59 button.setAttribute('aria-expanded', open ? 'true' : 'false');
60 });
61 }
62 });
63
64 // Same collapse behaviour, one level down: a plugin's own sub-list of
65 // pages inside a group. Already open when one of its pages is the
66 // current one (server-rendered with is-open), closed otherwise.
67 Array.prototype.slice.call(nav.querySelectorAll('.acp-nav-plugin')).forEach(function (cluster) {
68 var button = cluster.querySelector('.acp-nav-plugin-label');
69 if (!button) { return; }
70 button.addEventListener('click', function () {
71 var open = cluster.classList.toggle('is-open');
72 button.setAttribute('aria-expanded', open ? 'true' : 'false');
73 });
74 });
75 }
76
77 if (filter && nav) {
78 var links = Array.prototype.slice.call(nav.querySelectorAll('.acp-nav-link'));
79 var clusters = Array.prototype.slice.call(nav.querySelectorAll('.acp-nav-plugin'));
80 var groups = Array.prototype.slice.call(nav.querySelectorAll('.acp-nav-group'));
81 var noMatch = nav.querySelector('.acp-nav-nomatch');
82
83 function applyFilter() {
84 var q = filter.value.trim().toLowerCase();
85 var hits = 0;
86
87 links.forEach(function (link) {
88 var match = q === '' || (link.getAttribute('data-title') || '').indexOf(q) !== -1;
89 link.parentNode.hidden = !match;
90 if (match) { hits++; }
91 });
92
93 // A plugin's own <li> never matches .acp-nav-link (it is the
94 // cluster wrapper, not a page), so it needs its own visibility
95 // pass based on whether any page inside it just matched above.
96 clusters.forEach(function (cluster) {
97 var visible = cluster.querySelectorAll('.acp-nav-link:not([hidden])').length > 0;
98 cluster.hidden = !visible;
99 if (q !== '' && visible) {
100 cluster.classList.add('is-open');
101 var pbutton = cluster.querySelector('.acp-nav-plugin-label');
102 if (pbutton) { pbutton.setAttribute('aria-expanded', 'true'); }
103 }
104 });
105
106 groups.forEach(function (group) {
107 var visible = group.querySelectorAll('li:not([hidden])').length > 0;
108 group.hidden = !visible;
109 if (q !== '' && visible) {
110 group.classList.add('is-open');
111 var button = group.querySelector('.acp-nav-group-label');
112 if (button) { button.setAttribute('aria-expanded', 'true'); }
113 }
114 });
115
116 if (noMatch) { noMatch.hidden = hits > 0; }
117 }
118
119 filter.addEventListener('input', applyFilter);
120 filter.addEventListener('keydown', function (e) {
121 if (e.key === 'Escape') {
122 filter.value = '';
123 applyFilter();
124 }
125 if (e.key === 'Enter') {
126 var first = nav.querySelector('li:not([hidden]) .acp-nav-link');
127 if (first) { window.location.href = first.href; }
128 }
129 });
130 }
131
132 function syncTheme() {
133 if (!themeBtn) { return; }
134 var dark = root.getAttribute('data-acp-theme') === 'dark';
135 var icon = themeBtn.querySelector('.fa');
136 var label = themeBtn.querySelector('span');
137 if (icon) { icon.className = 'fa ' + (dark ? 'fa-sun-o' : 'fa-moon-o'); }
138 if (label) { label.textContent = dark ? 'Day' : 'Night'; }
139 themeBtn.title = dark ? 'Switch to day mode' : 'Switch to night mode';
140 }
141
142 if (themeBtn) {
143 themeBtn.addEventListener('click', function () {
144 var dark = root.getAttribute('data-acp-theme') === 'dark';
145 root.setAttribute('data-acp-theme', dark ? 'light' : 'dark');
146 store('theme', dark ? 'light' : 'dark');
147 syncTheme();
148 });
149 syncTheme();
150 }
151
152 var shopPreview = document.getElementById('shopOfferPreview');
153 var shopType = document.getElementById('type');
154 var shopItem = document.getElementById('itemid');
155 var shopCount = document.getElementById('count');
156
157 if (shopPreview && shopType && shopItem && shopCount) {
158 function image(src, className) {
159 var img = document.createElement('img');
160 img.src = src;
161 img.className = className;
162 img.alt = '';
163 return img;
164 }
165
166 function outfitUrl(outfitId, mountId) {
167 var server = shopPreview.getAttribute('data-outfit-server') || '';
168 var count = parseInt(shopCount.value, 10) || 0;
169 if (!server || !outfitId) { return ''; }
170
171 var url = server + '?id=' + encodeURIComponent(outfitId)
172 + '&addons=' + encodeURIComponent(count)
173 + '&head=78&body=68&legs=58&feet=76&direction=2';
174
175 if (mountId) {
176 url += '&mount=' + encodeURIComponent(mountId);
177 }
178
179 return url;
180 }
181
182 function updateShopPreview() {
183 var type = parseInt(shopType.value, 10) || 0;
184 var raw = shopItem.value.trim();
185 var template = shopPreview.getAttribute('data-item-template') || '';
186
187 shopPreview.innerHTML = '';
188
189 if (type === 5) {
190 var pair = raw.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/);
191 if (pair) {
192 shopPreview.appendChild(image(outfitUrl(pair[1], ''), 'acp-shop-preview-img'));
193 shopPreview.appendChild(image(outfitUrl(pair[2], ''), 'acp-shop-preview-img'));
194 return;
195 }
196 } else if (type === 6) {
197 if (/^\d+$/.test(raw)) {
198 shopPreview.appendChild(image(outfitUrl(128, raw), 'acp-shop-preview-img'));
199 return;
200 }
201 } else if (/^\d+$/.test(raw) && template) {
202 shopPreview.appendChild(image(template.replace('{id}', raw), 'acp-shop-preview-item'));
203 return;
204 }
205
206 var muted = document.createElement('span');
207 muted.className = 'is-muted';
208 muted.appendChild(document.createTextNode('Preview'));
209 shopPreview.appendChild(muted);
210 }
211
212 shopType.addEventListener('change', updateShopPreview);
213 shopItem.addEventListener('input', updateShopPreview);
214 shopCount.addEventListener('input', updateShopPreview);
215 updateShopPreview();
216 }
217
218 document.addEventListener('submit', function (e) {
219 var form = e.target;
220 var message = form.getAttribute('data-confirm');
221 if (message && !window.confirm(message)) {
222 e.preventDefault();
223 }
224 });
225})();
226
227/*
228 * Click-to-sort for any <table class="acp-table" data-sortable>. Sorts the
229 * rows currently in the DOM - on a paginated list that is only the current
230 * page, which is fine: the point is letting an admin re-order what is on
231 * screen (highest points first, most recent first), not a full-dataset sort.
232 */
233(function () {
234 function cellText(row, index) {
235 var cell = row.children[index];
236 if (!cell) return '';
237 return (cell.getAttribute('data-sort-value') || cell.textContent || '').trim();
238 }
239
240 function looksNumeric(value) {
241 return value !== '' && !isNaN(parseFloat(value.replace(/[^0-9.\-]/g, ''))) && /^[\s0-9.,\-]+$/.test(value);
242 }
243
244 Array.prototype.slice.call(document.querySelectorAll('table.acp-table[data-sortable]')).forEach(function (table) {
245 var thead = table.querySelector('thead');
246 var tbody = table.querySelector('tbody');
247 if (!thead || !tbody) return;
248
249 var headers = Array.prototype.slice.call(thead.querySelectorAll('th'));
250
251 headers.forEach(function (th, index) {
252 if (th.textContent.trim() === '') return; // icon/action-only columns stay unsortable
253
254 th.classList.add('acp-th-sortable');
255 th.setAttribute('role', 'button');
256 th.setAttribute('tabindex', '0');
257
258 var sort = function () {
259 var dir = th.getAttribute('data-sort-dir') === 'asc' ? 'desc' : 'asc';
260 headers.forEach(function (h) {
261 h.removeAttribute('data-sort-dir');
262 h.classList.remove('is-sorted-asc', 'is-sorted-desc');
263 });
264 th.setAttribute('data-sort-dir', dir);
265 th.classList.add(dir === 'asc' ? 'is-sorted-asc' : 'is-sorted-desc');
266
267 var rows = Array.prototype.slice.call(tbody.querySelectorAll(':scope > tr'));
268 var numeric = rows.length > 0 && looksNumeric(cellText(rows[0], index));
269
270 rows.sort(function (a, b) {
271 var av = cellText(a, index);
272 var bv = cellText(b, index);
273 var cmp;
274 if (numeric) {
275 cmp = (parseFloat(av.replace(/[^0-9.\-]/g, '')) || 0) - (parseFloat(bv.replace(/[^0-9.\-]/g, '')) || 0);
276 } else {
277 cmp = av.localeCompare(bv, undefined, { sensitivity: 'base', numeric: true });
278 }
279 return dir === 'asc' ? cmp : -cmp;
280 });
281
282 rows.forEach(function (row) { tbody.appendChild(row); });
283 };
284
285 th.addEventListener('click', sort);
286 th.addEventListener('keydown', function (e) {
287 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); sort(); }
288 });
289 });
290 });
291})();
292
293/*
294 * Client-side row filter for small lists (plugins, shop offers - anything
295 * that is already fully rendered on the page, not a paginated server query).
296 * Usage: <input data-acp-search-input="tableId"> filters
297 * #tableId tbody tr[data-acp-search] by substring match, and optionally
298 * updates a live count in [data-acp-search-count="tableId"].
299 */
300(function () {
301 Array.prototype.slice.call(document.querySelectorAll('[data-acp-search-input]')).forEach(function (input) {
302 var tableId = input.getAttribute('data-acp-search-input');
303 var table = document.getElementById(tableId);
304 if (!table) return;
305
306 var rows = Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-acp-search]'));
307 var count = document.querySelector('[data-acp-search-count="' + tableId + '"]');
308
309 input.addEventListener('keyup', function () {
310 var needle = input.value.trim().toLowerCase();
311 var shown = 0;
312 rows.forEach(function (row) {
313 var match = needle === '' || (row.getAttribute('data-acp-search') || '').indexOf(needle) !== -1;
314 row.hidden = !match;
315 if (match) shown++;
316 });
317 if (count) count.textContent = needle === '' ? '' : (shown + ' / ' + rows.length);
318 });
319 });
320})();
321
322/*
323 * Dynamic add/remove-row tables: any element with data-acp-table-add="key"
324 * clones the row template registered as data-acp-table-template="key" into
325 * the matching [data-acp-table="key"] container; data-acp-table-remove
326 * removes its own row. Shared by settings.php's 'table' schema fields and
327 * the serverdata single-record editors (items/creatures attribute rows).
328 */
329(function () {
330 document.addEventListener('click', function (e) {
331 var addBtn = e.target.closest('[data-acp-table-add]');
332 if (addBtn) {
333 var key = addBtn.getAttribute('data-acp-table-add');
334 var container = document.querySelector('[data-acp-table="' + key + '"]');
335 var template = document.querySelector('template[data-acp-table-template="' + key + '"]');
336 if (!container || !template) return;
337 var body = container.querySelector('[data-acp-table-body]') || container;
338 // <tr> fragments only parse correctly inside a <tbody> wrapper; anything
339 // else (the permissions table's plain <div> account blocks) parses fine
340 // in a generic <div>.
341 var wrapper = document.createElement(body.tagName === 'TBODY' ? 'tbody' : 'div');
342 var nextIndex = body.children.length;
343 var html = template.innerHTML.split('__ROWIDX__').join(String(nextIndex));
344 wrapper.innerHTML = html;
345 body.appendChild(wrapper.firstElementChild);
346 return;
347 }
348
349 var removeBtn = e.target.closest('[data-acp-table-remove]');
350 if (removeBtn) {
351 var row = removeBtn.closest('tr, .acp-perm-account');
352 if (row) row.remove();
353 }
354 });
355})();
356
357/* Ctrl+K / Cmd+K jumps focus to the top-bar search, from anywhere in the panel. */
358(function () {
359 document.addEventListener('keydown', function (e) {
360 if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'k') return;
361 var input = document.getElementById('acpTopSearch');
362 if (!input) return;
363 e.preventDefault();
364 input.focus();
365 input.select();
366 });
367})();
368
369/* Arriving from search: highlight the field the anchor points at. */
370(function () {
371 var id = (window.location.hash || '').replace('#', '');
372 if (!id) return;
373
374 var el = document.getElementById(id);
375 if (!el) return;
376
377 var field = el.closest ? el.closest('.acp-field') : null;
378 var target = field || el;
379
380 target.classList.add('acp-field--found');
381 setTimeout(function () { target.classList.remove('acp-field--found'); }, 2600);
382
383 setTimeout(function () {
384 target.scrollIntoView({ behavior: 'smooth', block: 'center' });
385 if (el.focus && el.type !== 'checkbox') { try { el.focus({ preventScroll: true }); } catch (e) {} }
386 }, 60);
387})();
388