1<?php
2/**
3 * ZnoteX Admin Control Panel - shared runtime.
4 */
5
6if (!defined('ACP_ROOT')) {
7 http_response_code(403);
8 die('Direct access denied.');
9}
10
11if (!function_exists('h')) {
12 function h($s): string {
13 return htmlspecialchars((string)($s ?? ''), ENT_QUOTES, 'UTF-8');
14 }
15}
16if (!function_exists('intv')) {
17 function intv($v): int {
18 return is_numeric($v) ? (int)$v : 0;
19 }
20}
21if (!function_exists('esc')) {
22 function esc($s): string {
23 return mysql_znote_escape_string((string)($s ?? ''));
24 }
25}
26
27// Scheduled maintenance (engine/function/scheduler.php) normally runs off
28// public-page traffic (page.footer) - the admin panel never fires that hook,
29// so a site with only admin visitors still needs this call to run its due
30// tasks.
31if (function_exists('scheduler_tick')) {
32 scheduler_tick();
33}
34
35function acp_site(string $path = ''): string {
36 return '../' . ltrim($path, '/');
37}
38
39function acp_url(string $module = 'dashboard', array $params = []): string {
40 return 'index.php?' . http_build_query(array_merge(['p' => $module], $params));
41}
42
43/**
44 * Every scoped account gets Dashboard and Search for free, whatever else it
45 * was granted - otherwise an account limited to, say, just "news" would land
46 * on an access-denied wall the moment it logs in, since dashboard is the
47 * default page.
48 */
49function acp_module_free_for_any_role(): array {
50 return array('dashboard', 'search');
51}
52
53function acp_can_module(string $module, ?array $account = null): bool {
54 $account = $account ?? ($GLOBALS['user_data'] ?? null);
55 $roles = admin_roles($account);
56 if (in_array('owner', $roles, true)) return true;
57 if ($roles === array()) return false;
58 if (in_array($module, acp_module_free_for_any_role(), true)) return true;
59 return in_array(strtolower($module), $roles, true);
60}
61
62/** Literal Owner only - never satisfied by a granted module, even 'settings'. */
63function acp_is_owner(?array $account = null): bool {
64 $account = $account ?? ($GLOBALS['user_data'] ?? null);
65 return in_array('owner', admin_roles($account), true);
66}
67
68function acp_editor_assets(): void {
69 static $loaded = false;
70 if ($loaded) return;
71 $loaded = true;
72 $v = '3.2.1';
73 $base = '../assets/sceditor/';
74 ?>
75 <link rel="stylesheet" href="<?= $base ?>themes/defaultdark.min.css?v=<?= $v ?>">
76 <link rel="stylesheet" href="<?= $base ?>znote-editor-acp.css?v=<?= $v ?>">
77 <script src="<?= $base ?>sceditor.min.js?v=<?= $v ?>"></script>
78 <script src="<?= $base ?>formats/bbcode.min.js?v=<?= $v ?>"></script>
79 <script src="<?= $base ?>icons/material.min.js?v=<?= $v ?>"></script>
80 <script src="<?= $base ?>plugins/undo.min.js?v=<?= $v ?>"></script>
81 <script src="<?= $base ?>plugins/autoyoutube.min.js?v=<?= $v ?>"></script>
82 <script src="<?= $base ?>znote-editor.js?v=<?= $v ?>"></script>
83 <?php
84}
85
86/**
87 * A BBCode editor bound to $name. $maxlength reflects a column limit and is
88 * shown as a live counter, because BBCode tags count toward it.
89 */
90function acp_editor(string $name, string $value = '', array $options = []): void {
91 $height = (int)($options['height'] ?? 260);
92 $maxlength = (int)($options['maxlength'] ?? 0);
93 $toolbar = (string)($options['toolbar'] ?? '');
94 $note = (string)($options['note'] ?? '');
95 ?>
96 <div class="znote-editor-wrap">
97 <textarea class="znote-editor" id="<?= h($name) ?>" name="<?= h($name) ?>"
98 data-asset-base="../assets/sceditor/"
99 data-content-css="znote-content-acp.css"
100 data-height="<?= $height ?>"
101 <?php if ($maxlength > 0): ?>data-maxlength="<?= $maxlength ?>"<?php endif; ?>
102 <?php if ($toolbar !== ''): ?>data-toolbar="<?= h($toolbar) ?>"<?php endif; ?>><?= h($value) ?></textarea>
103 </div>
104 <?php if ($note !== ''): ?>
105 <p class="znote-editor-note"><?= h($note) ?></p>
106 <?php endif; ?>
107 <?php
108 acp_editor_assets();
109}
110
111function acp_color_field(string $name, $value = '', string $placeholder = '', string $style = ''): void {
112 $styleAttr = $style !== '' ? ' style="' . h($style) . '"' : '';
113 ?>
114 <input class="acp-input" type="text" name="<?= h($name) ?>" value="<?= h($value) ?>" placeholder="<?= h($placeholder) ?>"<?= $styleAttr ?>>
115 <?php
116}
117function acp_redirect(string $module = 'dashboard', array $params = []): void {
118 header('Location: ' . acp_url($module, $params));
119 exit;
120}
121
122const ACP_GROUP_ORDER = [
123 'Overview' => 10,
124 'Content' => 20,
125 'Players' => 30,
126 'Server Info' => 40,
127 'Economy' => 50,
128 'Support' => 60,
129 'Operations' => 65,
130 'Settings' => 70,
131];
132
133function acp_parse_module_header(string $file): array {
134 $head = (string)file_get_contents($file, false, null, 0, 1500);
135 $meta = [];
136 $manifestFile = substr($file, 0, -4) . '.json';
137
138 if (is_file($manifestFile)) {
139 $manifest = json_decode((string)file_get_contents($manifestFile), true);
140 if (is_array($manifest)) {
141 foreach ($manifest as $key => $value) {
142 if (is_string($key) && (is_string($value) || is_numeric($value) || is_bool($value))) {
143 $meta[strtolower($key)] = $value;
144 }
145 }
146 }
147 }
148
149 if (preg_match('~/\*\*(.*?)\*/~s', $head, $block)) {
150 foreach (preg_split('~\R~', $block[1]) as $line) {
151 if (preg_match('~^\s*\*\s*([A-Za-z]+)\s*:\s*(.+?)\s*$~', $line, $kv)) {
152 $meta[strtolower($kv[1])] = $kv[2];
153 }
154 }
155 }
156
157 return $meta;
158}
159
160/** Every admin/modules/*.php file, parsed into a nav entry each. */
161function acp_modules_core(): array {
162 $modules = [];
163
164 foreach (glob(ACP_ROOT . '/modules/*.php') ?: [] as $file) {
165 $key = basename($file, '.php');
166 if ($key === '' || $key[0] === '_') {
167 continue;
168 }
169
170 $meta = acp_parse_module_header($file);
171 $group = $meta['group'] ?? 'Other';
172
173 $modules[$key] = [
174 'key' => $key,
175 'file' => $file,
176 'title' => t_default('acp.mod.' . $key . '.title', $meta['title'] ?? ucwords(str_replace('_', ' ', $key))),
177 'icon' => $meta['icon'] ?? 'fa-square-o',
178 'group' => t_default('acp.group.' . $group, $group),
179 'order' => isset($meta['order']) ? (int)$meta['order'] : 100,
180 'description' => t_default('acp.mod.' . $key . '.desc', $meta['description'] ?? ''),
181 'url' => $meta['url'] ?? null,
182 'target' => $meta['target'] ?? null,
183 'hidden' => !empty($meta['hidden']) && strtolower((string)$meta['hidden']) !== 'false',
184 ];
185 }
186
187 return $modules;
188}
189
190/**
191 * Modules contributed by enabled plugins, listed beside the built-in ones.
192 * Their key is prefixed with the plugin name, so a plugin can never shadow a
193 * core module by choosing the same filename.
194 */
195function acp_modules_from_plugins(): array {
196 $modules = [];
197
198 if (function_exists('znote_plugin_admin_modules')) {
199 foreach (znote_plugin_admin_modules() as $key => $file) {
200 $meta = acp_parse_module_header($file);
201
202 $modules[$key] = array(
203 'key' => $key,
204 'file' => $file,
205 'title' => $meta['title'] ?? ucwords(str_replace('_', ' ', $key)),
206 'icon' => $meta['icon'] ?? 'fa-plug',
207 'group' => $meta['group'] ?? 'Settings',
208 'order' => isset($meta['order']) ? (int)$meta['order'] : 100,
209 'description' => $meta['description'] ?? '',
210 'url' => $meta['url'] ?? null,
211 'target' => $meta['target'] ?? null,
212 );
213 }
214 }
215
216 return $modules;
217}
218
219/**
220 * A plugin's generic settings.json config used to only be reachable from its
221 * row on the Plugins list page, off on its own away from the same plugin's
222 * custom admin pages (Slots, Storage Watch, ...) that already show up under
223 * "Installed Plugins". Listing it right there too - same group, same place -
224 * means everything about one plugin lives in one spot instead of two.
225 * $existingKeys is what acp_modules_core()/acp_modules_from_plugins() already
226 * produced, so a plugin that names its own page "settings" is never clobbered.
227 */
228function acp_modules_plugin_settings(array $existingKeys): array {
229 $modules = [];
230
231 if (function_exists('znote_plugins') && function_exists('znote_plugin_settings_has')) {
232 foreach (znote_plugins() as $name => $plugin) {
233 if (!$plugin['enabled'] || !$plugin['installed'] || !$plugin['compatible'] || !znote_plugin_settings_has($name)) {
234 continue;
235 }
236
237 $key = $name . '__settings';
238 if (isset($existingKeys[$key])) {
239 continue;
240 }
241
242 $modules[$key] = array(
243 'key' => $key,
244 'file' => ACP_ROOT . '/modules/plugin_settings.php',
245 'title' => (string)($plugin['name'] ?? ucwords(str_replace('_', ' ', $name))) . ' Settings',
246 'icon' => 'fa-sliders',
247 'group' => 'Installed Plugins',
248 'order' => 5,
249 'description' => t_default('acp.mod.plugin_settings.desc', "This plugin's own configuration fields."),
250 'url' => acp_url('plugin_settings', array('plugin' => $name)),
251 'target' => null,
252 );
253 }
254 }
255
256 return $modules;
257}
258
259function acp_modules(): array {
260 static $modules = null;
261 if ($modules !== null) {
262 return $modules;
263 }
264
265 $modules = acp_modules_core() + acp_modules_from_plugins();
266 $modules += acp_modules_plugin_settings($modules);
267
268 uasort($modules, static function (array $a, array $b): int {
269 $ga = ACP_GROUP_ORDER[$a['group']] ?? 900;
270 $gb = ACP_GROUP_ORDER[$b['group']] ?? 900;
271
272 return [$ga, $a['order'], strtolower($a['title'])]
273 <=> [$gb, $b['order'], strtolower($b['title'])];
274 });
275
276 return $modules;
277}
278
279function acp_nav_groups(): array {
280 $groups = [];
281 foreach (acp_modules() as $key => $module) {
282 if (!empty($module['hidden']) || !acp_can_module($key)) {
283 continue;
284 }
285 $groups[$module['group']][$key] = $module;
286 }
287 return $groups;
288}
289
290/**
291 * Splits one group's modules into standalone links and per-plugin clusters,
292 * so a sidebar group with several plugins each contributing 2-3 pages shows
293 * one row per plugin (expanding to its pages) instead of every page flattened
294 * into the group - the more plugins are installed, the more this matters.
295 *
296 * A plugin-contributed key is always "<plugin>__<page>" (see
297 * znote_plugin_admin_modules() and the settings.json injection above); core
298 * modules never contain "__", so they always end up in 'plain'.
299 */
300function acp_nav_cluster(array $groupModules): array {
301 $plain = [];
302 $plugins = [];
303
304 foreach ($groupModules as $key => $module) {
305 $sep = strpos($key, '__');
306 if ($sep === false) {
307 $plain[$key] = $module;
308 continue;
309 }
310
311 $pluginKey = substr($key, 0, $sep);
312 if (!isset($plugins[$pluginKey])) {
313 $manifest = function_exists('znote_plugins') ? (znote_plugins()[$pluginKey] ?? null) : null;
314 $plugins[$pluginKey] = [
315 'label' => (string)($manifest['name'] ?? ucwords(str_replace('_', ' ', $pluginKey))),
316 'items' => [],
317 ];
318 }
319
320 $plugins[$pluginKey]['items'][$key] = $module;
321 }
322
323 // A plugin with only one page gains nothing from being nested - it is
324 // the same click count either way, just with an extra level in between -
325 // so it goes back to a plain, top-level link instead.
326 foreach ($plugins as $pluginKey => $cluster) {
327 if (count($cluster['items']) === 1) {
328 $plain += $cluster['items'];
329 unset($plugins[$pluginKey]);
330 }
331 }
332
333 // "Build Simulator Slots" under the "Build Simulator" cluster reads
334 // better as just "Slots" - only strips the prefix when it is actually
335 // there, so a plugin with differently-worded titles is untouched.
336 foreach ($plugins as $pluginKey => &$cluster) {
337 $prefix = $cluster['label'] . ' ';
338 foreach ($cluster['items'] as &$module) {
339 if (stripos($module['title'], $prefix) === 0) {
340 $module['title'] = substr($module['title'], strlen($prefix));
341 }
342 }
343 unset($module);
344 }
345 unset($cluster);
346
347 return ['plain' => $plain, 'plugins' => $plugins];
348}
349
350/**
351 * Everything the top-bar search can find: the modules themselves and every
352 * field they expose. The point is that "download" reaches the client URL
353 * fields under Settings, not just a module whose title happens to match.
354 *
355 * The schemas are read from _partials/, which only return arrays - including
356 * the module itself would run its POST handling and print its page.
357 */
358function acp_search_index_modules(): array {
359 $entries = [];
360 foreach (acp_modules() as $key => $module) {
361 if (!empty($module['hidden']) || !acp_can_module($key)) {
362 continue;
363 }
364 $entries[] = [
365 'kind' => 'page',
366 'title' => $module['title'],
367 'context' => $module['group'],
368 'detail' => $module['description'],
369 'url' => $module['url'] ?: acp_url($key),
370 'icon' => $module['icon'],
371 'haystack' => strtolower($key . ' ' . $module['title'] . ' ' . $module['group'] . ' ' . $module['description']),
372 ];
373 }
374 return $entries;
375}
376
377function acp_search_index_settings(): array {
378 $entries = [];
379 $settings = ACP_ROOT . '/modules/_partials/settings_schema.php';
380 if (acp_can_module('settings') && is_file($settings)) {
381 foreach ((array)require $settings as $section => $fields) {
382 foreach ((array)$fields as $key => $field) {
383 $entries[] = [
384 'kind' => 'setting',
385 'title' => $field['label'] ?? $key,
386 'context' => 'Settings › ' . $section,
387 'detail' => $field['help'] ?? '',
388 'url' => acp_url('settings') . '#set_' . $key,
389 'icon' => 'fa-cogs',
390 'haystack' => strtolower($key . ' ' . ($field['label'] ?? '') . ' ' . ($field['help'] ?? '') . ' ' . $section),
391 ];
392 }
393 }
394 }
395 return $entries;
396}
397
398function acp_search_index_payments(): array {
399 $entries = [];
400 $payments = ACP_ROOT . '/modules/_partials/payments_schema.php';
401 if (acp_can_module('payments') && is_file($payments)) {
402 foreach ((array)require $payments as $groupName => $group) {
403 foreach ((array)($group['fields'] ?? []) as $key => $field) {
404 $entries[] = [
405 'kind' => 'setting',
406 'title' => $field['label'] ?? $key,
407 'context' => 'Payments › ' . $groupName,
408 'detail' => $field['help'] ?? '',
409 'url' => acp_url('payments') . '#pay_' . $key,
410 'icon' => 'fa-credit-card',
411 'haystack' => strtolower($key . ' ' . ($field['label'] ?? '') . ' ' . ($field['help'] ?? '') . ' ' . $groupName),
412 ];
413 }
414 }
415 }
416 return $entries;
417}
418
419function acp_search_index_layouts(): array {
420 $entries = [];
421 if (acp_can_module('layouts') && function_exists('theme_list') && function_exists('theme_options')) {
422 foreach (theme_list() as $themeKey => $theme) {
423 foreach (theme_options($themeKey) as $optKey => $opt) {
424 $entries[] = [
425 'kind' => 'setting',
426 'title' => $opt['label'] ?? $optKey,
427 'context' => 'Layout › ' . ($theme['name'] ?? $themeKey) . ' options',
428 'detail' => $opt['help'] ?? '',
429 'url' => acp_url('layouts', ['options' => $themeKey]),
430 'icon' => 'fa-paint-brush',
431 'haystack' => strtolower($optKey . ' ' . ($opt['label'] ?? '') . ' ' . ($opt['help'] ?? '') . ' ' . $themeKey),
432 ];
433 }
434 }
435 }
436 return $entries;
437}
438
439function acp_search_index(): array {
440 static $index = null;
441 if ($index !== null) {
442 return $index;
443 }
444
445 $index = array_merge(
446 acp_search_index_modules(),
447 acp_search_index_settings(),
448 acp_search_index_payments(),
449 acp_search_index_layouts()
450 );
451
452 return $index;
453}
454
455/** Rank matches: whole-word and title hits first. */
456function acp_search(string $query, int $limit = 40): array {
457 $query = trim(strtolower($query));
458 if ($query === '') {
459 return [];
460 }
461
462 $terms = preg_split('/\s+/', $query) ?: [$query];
463 $hits = [];
464
465 foreach (acp_search_index() as $row) {
466 $score = 0;
467 foreach ($terms as $term) {
468 if ($term === '' || strpos($row['haystack'], $term) === false) {
469 $score = 0;
470 break;
471 }
472 $score += 1;
473 if (strpos(strtolower($row['title']), $term) !== false) {
474 $score += 3;
475 }
476 if (preg_match('/\b' . preg_quote($term, '/') . '\b/', $row['haystack'])) {
477 $score += 2;
478 }
479 }
480 if ($score > 0) {
481 $row['score'] = $score + ($row['kind'] === 'page' ? 1 : 0);
482 $hits[] = $row;
483 }
484 }
485
486 usort($hits, static function (array $a, array $b): int {
487 return $b['score'] <=> $a['score'] ?: strcmp($a['title'], $b['title']);
488 });
489
490 return array_slice($hits, 0, $limit);
491}
492
493function acp_badge(string $key): ?int {
494 $fn = 'acp_badge_' . $key;
495 if (!function_exists($fn)) {
496 return null;
497 }
498 $count = (int)$fn();
499 return $count > 0 ? $count : null;
500}
501
502function acp_count(string $sql, array $params = []): int {
503 $row = db()->fetchOne($sql, $params);
504 return is_array($row) && $row ? (int)reset($row) : 0;
505}
506
507function acp_badge_reports(): int {
508 return acp_count("SELECT COUNT(*) AS `c` FROM `znote_player_reports` WHERE `status` IN (0, 1, 2);");
509}
510
511function acp_badge_helpdesk(): int {
512 return acp_count("SELECT COUNT(*) AS `c` FROM `znote_tickets` WHERE `status` <> 'CLOSED';");
513}
514
515function acp_badge_gallery(): int {
516 return acp_count("SELECT COUNT(*) AS `c` FROM `znote_images` WHERE `status` = 1;");
517}
518
519function acp_badge_feedback(): int {
520 $cache = new Cache('engine/cache/asideFeedbackCount');
521 if (!$cache->hasExpired()) {
522 return (int)$cache->load();
523 }
524
525 $new = 0;
526 $threads = db()->fetchAll("SELECT `id`, `player_id` FROM `znote_forum_threads` WHERE `forum_id` = 4 AND `closed` = 0;");
527
528 if (is_array($threads)) {
529 $staffIds = [];
530 $staffs = db()->fetchAll("SELECT `id` FROM `players` WHERE `group_id` > 1;");
531 if (is_array($staffs)) {
532 foreach ($staffs as $staff) {
533 $staffIds[(int)$staff['id']] = true;
534 }
535 }
536
537 foreach ($threads as $thread) {
538 $answered = false;
539 $posts = db()->fetchAll("SELECT `id`, `player_id` FROM `znote_forum_posts` WHERE `thread_id` = ?;", [(int)$thread['id']]);
540
541 if (is_array($posts)) {
542 foreach ($posts as $post) {
543 if (isset($staffIds[(int)$post['player_id']])) {
544 $answered = true;
545 break;
546 }
547 }
548 }
549
550 if (!$answered) {
551 $new++;
552 }
553 }
554 }
555
556 $cache->setContent($new);
557 $cache->save();
558
559 return $new;
560}
561
562function acp_badge_shop_orders(): int {
563 return acp_count("SELECT COUNT(*) AS `c` FROM `znote_shop_orders`;");
564}
565
566function acp_flash(string $type, string $message): void {
567 $_SESSION['acp_flash'][] = ['type' => $type, 'message' => $message];
568}
569
570function acp_flash_success(string $m): void { acp_flash('success', $m); }
571function acp_flash_error(string $m): void { acp_flash('error', $m); }
572function acp_flash_info(string $m): void { acp_flash('info', $m); }
573
574function acp_take_flashes(): array {
575 $flashes = $_SESSION['acp_flash'] ?? [];
576 unset($_SESSION['acp_flash']);
577 return is_array($flashes) ? $flashes : [];
578}
579
580function acp_csrf(): string {
581 if (empty($_SESSION['acp_csrf'])) {
582 $_SESSION['acp_csrf'] = bin2hex(random_bytes(16));
583 }
584 return $_SESSION['acp_csrf'];
585}
586
587function acp_csrf_field(): string {
588 return '<input type="hidden" name="csrf_token" value="' . h(acp_csrf()) . '">';
589}
590
591function acp_verify_csrf(): bool {
592 return !empty($_POST['csrf_token'])
593 && is_string($_POST['csrf_token'])
594 && hash_equals(acp_csrf(), $_POST['csrf_token']);
595}
596
597/**
598 * Dynamic add/remove-row key-value table fields, shared by any module that
599 * needs an editable list of rows (originally built for settings.php's
600 * 'table' schema field type, also used by the serverdata single-record
601 * editors). Lives here rather than in one module file since only the active
602 * module's own file is included per request (see admin/index.php).
603 */
604
605/** Convert a decoded JSON/array value into editable table rows. */
606function acp_table_rows_from_json(array $field, array $decoded): array {
607 $shape = $field['json_shape'] ?? 'map';
608
609 if ($shape === 'list') {
610 $rows = array();
611 foreach ($decoded as $value) {
612 if (!is_array($value)) continue;
613 $row = array();
614 foreach ($field['columns'] as $colKey => $colDef) {
615 $v = $value[$colKey] ?? null;
616 $row[$colKey] = acp_table_cell_to_string($colDef, $v);
617 }
618 $rows[] = $row;
619 }
620 return $rows;
621 }
622
623 $rowKey = $field['row_key'] ?? 'id';
624 $valueColumn = $field['value_column'] ?? null;
625 $rows = array();
626
627 foreach ($decoded as $key => $value) {
628 $row = array($rowKey => (string)$key);
629
630 if ($valueColumn !== null) {
631 $row[$valueColumn] = is_scalar($value) ? (string)$value : '';
632 } else {
633 foreach ($field['columns'] as $colKey => $colDef) {
634 if ($colKey === $rowKey) continue;
635 $v = is_array($value) ? ($value[$colKey] ?? null) : null;
636 $row[$colKey] = acp_table_cell_to_string($colDef, $v);
637 }
638 }
639
640 $rows[] = $row;
641 }
642
643 return $rows;
644}
645
646/** Convert one stored cell value into the string used to populate a form input. */
647function acp_table_cell_to_string(array $colDef, $v): string {
648 switch ($colDef['type'] ?? 'text') {
649 case 'nullable_int':
650 return ($v === false || $v === null) ? '' : (string)(int)$v;
651 case 'bool':
652 return !empty($v) ? '1' : '';
653 default:
654 return ($v === null) ? '' : (string)$v;
655 }
656}
657
658/** Render the form control for one table cell (name attribute pre-built by the caller). */
659function acp_table_cell_input(array $colDef, string $name, string $value): string {
660 $type = $colDef['type'] ?? 'text';
661
662 if ($type === 'bool') {
663 return '<label style="display:flex;align-items:center;justify-content:center;">'
664 . '<input type="checkbox" name="' . h($name) . '" value="1" ' . ($value !== '' ? 'checked' : '') . '>'
665 . '</label>';
666 }
667
668 if ($type === 'select') {
669 $html = '<select class="acp-input" name="' . h($name) . '">';
670 foreach (($colDef['options'] ?? array()) as $optValue => $optLabel) {
671 $html .= '<option value="' . h((string)$optValue) . '" ' . ((string)$optValue === $value ? 'selected' : '') . '>' . h((string)$optLabel) . '</option>';
672 }
673 $html .= '</select>';
674 return $html;
675 }
676
677 if ($type === 'textarea') {
678 return '<textarea class="acp-textarea" name="' . h($name) . '" rows="2">' . h($value) . '</textarea>';
679 }
680
681 $inputType = in_array($type, array('int', 'nullable_int'), true) ? 'number' : 'text';
682 return '<input class="acp-input" type="' . h($inputType) . '" name="' . h($name) . '" value="' . h($value) . '">';
683}
684
685/** Convert one posted cell value back into its typed PHP value for storage. */
686function acp_table_cell_from_post(array $colDef, $v) {
687 switch ($colDef['type'] ?? 'text') {
688 case 'nullable_int':
689 $v = trim((string)$v);
690 return ($v === '') ? false : (int)$v;
691 case 'int':
692 return (int)trim((string)$v);
693 case 'bool':
694 return !empty($v);
695 default:
696 return trim((string)$v);
697 }
698}
699
700/**
701 * acp_table_json_from_rows() (the reverse conversion) is NOT here - the
702 * map-shape branch's key validation differs per caller (settings.php requires
703 * numeric row keys for its vocation/town id fields; the serverdata editors
704 * need arbitrary text keys for item attributes), so each defines its own.
705 */
706
707function acp_card_open(string $title = '', string $subtitle = '', string $extraClass = ''): void {
708 echo '<section class="acp-card ' . h($extraClass) . '">';
709 if ($title !== '') {
710 echo '<header class="acp-card-head"><h2>' . h($title) . '</h2>';
711 if ($subtitle !== '') {
712 echo '<p>' . h($subtitle) . '</p>';
713 }
714 echo '</header>';
715 }
716 echo '<div class="acp-card-body">';
717}
718
719function acp_card_close(): void {
720 echo '</div></section>';
721}
722
723function acp_empty(string $message, string $icon = 'fa-inbox'): void {
724 echo '<div class="acp-empty"><i class="fa ' . h($icon) . '"></i><p>' . h($message) . '</p></div>';
725}
726
727function acp_stat(string $label, $value, string $icon = 'fa-circle-o', ?string $href = null, string $tone = 'blue'): void {
728 $display = is_int($value) ? number_format($value) : (string)$value;
729
730 $inner = '<span class="acp-stat-icon"><i class="fa ' . h($icon) . '"></i></span>'
731 . '<span class="acp-stat-text">'
732 . '<span class="acp-stat-label">' . h($label) . '</span>'
733 . '<span class="acp-stat-value">' . h($display) . '</span>'
734 . '</span>';
735
736 $class = 'acp-stat acp-stat--' . h($tone);
737
738 echo $href !== null
739 ? '<a class="' . $class . '" href="' . h($href) . '">' . $inner . '</a>'
740 : '<div class="' . $class . '">' . $inner . '</div>';
741}
742