1<?php
2/**
3 * Title: Settings
4 * Icon: fa-cogs
5 * Group: Settings
6 * Order: 30
7 * Description: Change the settings that used to mean editing config.php by FTP.
8 */
9
10if (!defined('ACP_ROOT')) {
11 http_response_code(403);
12 die('Direct access denied.');
13}
14
15/**
16 * What this page can change.
17 *
18 * Each entry names a config key, and the value is stored in znote_config so it
19 * survives an update to config.php. engine/init.php applies them over the file
20 * values, so config.php stays the fallback and nothing here is ever written
21 * back into a PHP file.
22 *
23 * type: text | textarea | bool | int | select
24 */
25function acp_settings_schema(): array {
26 return require __DIR__ . '/_partials/settings_schema.php';
27}
28
29/**
30 * Every grantable admin module, grouped the same way the sidebar groups them,
31 * so the permissions table reads like a shrunken copy of the sidebar instead
32 * of an abstract role name. 'settings' IS included - an owner can hand out
33 * general Settings access - but the Permissions section within it stays
34 * owner-only no matter what (see acp_is_owner() checks below), so granting
35 * 'settings' can never let that account edit its own access.
36 */
37function acp_permission_module_groups(): array {
38 $groups = array();
39 foreach (acp_modules() as $key => $module) {
40 if (!empty($module['hidden'])) continue;
41 $groups[$module['group']][$key] = $module['title'];
42 }
43 return $groups;
44}
45
46/**
47 * page_admin_access (a flat list of owner account names) and page_admin_roles
48 * (account name => list of granted module keys) are two separate config
49 * arrays, but the panel edits them as one table: an "Owner" checkbox plus one
50 * checkbox per module. Owner wins if an account somehow ended up in both.
51 */
52function acp_permissions_current(): array {
53 global $config;
54
55 $accessStored = setting('config:page_admin_access', null);
56 $access = $accessStored !== null
57 ? (json_decode($accessStored, true) ?: array())
58 : (array)znote_config_path($config, 'page_admin_access', array());
59
60 $rolesStored = setting('config:page_admin_roles', null);
61 $roles = $rolesStored !== null
62 ? (json_decode($rolesStored, true) ?: array())
63 : (array)znote_config_path($config, 'page_admin_roles', array());
64
65 $rows = array();
66 foreach ($access as $name) {
67 $name = trim((string)$name);
68 if ($name === '') continue;
69 $rows[$name] = array('account' => $name, 'owner' => true, 'modules' => array());
70 }
71 foreach ($roles as $name => $assigned) {
72 $name = trim((string)$name);
73 if ($name === '' || isset($rows[$name])) continue;
74 $rows[$name] = array('account' => $name, 'owner' => false, 'modules' => array_values((array)$assigned));
75 }
76
77 return array_values($rows);
78}
79
80/** One account's editable block: name + Owner toggle + a module checklist grouped like the sidebar. */
81function acp_render_permission_account(string $key, $rowIndex, array $row, array $moduleGroups): string {
82 $prefix = 'set[' . $key . '][rows][' . $rowIndex . ']';
83
84 $html = '<div class="acp-perm-account">';
85 $html .= '<div class="acp-perm-account-head">';
86 $html .= '<input class="acp-input" type="text" name="' . h($prefix) . '[account]" value="' . h($row['account']) . '" placeholder="' . h(t_default('acp.perm.account_placeholder', 'Account name')) . '">';
87 $html .= '<label class="acp-perm-owner-toggle"><input type="checkbox" class="acp-perm-owner-check" name="' . h($prefix) . '[owner]" value="1" ' . ($row['owner'] ? 'checked' : '') . '> ' . h(t_default('acp.perm.col_owner', 'Owner')) . '</label>';
88 $html .= '<button type="button" class="acp-btn acp-btn--sm acp-btn--red" data-acp-table-remove>' . h(t_default('acp.perm.remove_account', 'Remove')) . '</button>';
89 $html .= '</div>';
90 $html .= '<div class="acp-perm-modules" ' . ($row['owner'] ? 'hidden' : '') . '>';
91 foreach ($moduleGroups as $groupLabel => $modules) {
92 $html .= '<div class="acp-perm-group"><h4>' . h($groupLabel) . '</h4>';
93 foreach ($modules as $modKey => $modTitle) {
94 $checked = in_array($modKey, $row['modules'], true) ? 'checked' : '';
95 $html .= '<label class="acp-perm-mod"><input type="checkbox" name="' . h($prefix) . '[modules][]" value="' . h($modKey) . '" ' . $checked . '> ' . h($modTitle) . '</label>';
96 }
97 $html .= '</div>';
98 }
99 $html .= '</div>';
100 $html .= '</div>';
101
102 return $html;
103}
104
105/** Turn a section label into a stable, URL/hash-friendly tab id. */
106function acp_settings_tab_slug(string $label): string {
107 $slug = strtolower($label);
108 $slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
109 $slug = trim((string)$slug, '-');
110 return $slug !== '' ? $slug : 'section';
111}
112
113/** Cast a submitted value for storage. */
114function acp_setting_cast(array $field, $raw): string {
115 switch ($field['type']) {
116 case 'bool': return empty($raw) ? '0' : '1';
117 case 'int':
118 $value = intv($raw);
119 if (isset($field['min'])) $value = max((int)$field['min'], $value);
120 if (isset($field['max'])) $value = min((int)$field['max'], $value);
121 return (string)$value;
122
123 case 'select':
124 $options = $field['options'] ?? array();
125 $value = trim((string)$raw);
126 return isset($options[$value]) ? $value : (string)array_key_first($options);
127
128 case 'json':
129 $decoded = json_decode(is_string($raw) ? $raw : '', true);
130 return is_array($decoded)
131 ? (string)json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
132 : '';
133
134 case 'checklist':
135 $options = $field['options'] ?? array();
136 $picked = is_array($raw) ? $raw : array();
137 $kept = array();
138
139 foreach (array_keys($options) as $option) {
140 if (in_array((string)$option, array_map('strval', $picked), true)) {
141 $kept[] = (string)$option;
142 }
143 }
144
145 if (!$kept && $options) {
146 $kept[] = (string)array_key_first($options);
147 }
148
149 if (!empty($field['int_values'])) {
150 $kept = array_values(array_map('intval', $kept));
151 }
152
153 return (string)json_encode($kept);
154
155 default: return trim((string)$raw);
156 }
157}
158
159/**
160 * acp_table_rows_from_json() / acp_table_cell_to_string() / acp_table_cell_input() /
161 * acp_table_cell_from_post() are defined in admin/bootstrap.php (shared with the
162 * serverdata single-record editors). acp_table_json_from_rows() stays here because
163 * this map-shape branch is specific to this module's numeric-id schema fields
164 * (towns/vocations/skills) - it rejects non-digit keys, which a generic reuse
165 * elsewhere should not inherit.
166 */
167
168/** Convert posted table rows back into the JSON structure used for storage. */
169function acp_table_json_from_rows(array $field, array $rowsRaw): array {
170 $shape = $field['json_shape'] ?? 'map';
171
172 if ($shape === 'list') {
173 $json = array();
174 foreach ($rowsRaw as $row) {
175 if (!is_array($row)) continue;
176 $obj = array();
177 foreach ($field['columns'] as $colKey => $colDef) {
178 $obj[$colKey] = acp_table_cell_from_post($colDef, $row[$colKey] ?? null);
179 }
180 // Skip fully blank rows (an "add row" the admin never filled in).
181 $hasContent = false;
182 foreach ($obj as $v) {
183 if ($v !== '' && $v !== false) { $hasContent = true; break; }
184 }
185 if (!$hasContent) continue;
186 $json[] = $obj;
187 }
188 return $json;
189 }
190
191 $rowKey = $field['row_key'] ?? 'id';
192 $valueColumn = $field['value_column'] ?? null;
193 $json = array();
194
195 foreach ($rowsRaw as $row) {
196 if (!is_array($row)) continue;
197 $keyRaw = trim((string)($row[$rowKey] ?? ''));
198 if ($keyRaw === '' || !ctype_digit($keyRaw)) continue;
199 $key = (string)(int)$keyRaw;
200
201 if ($valueColumn !== null) {
202 $json[$key] = trim((string)($row[$valueColumn] ?? ''));
203 continue;
204 }
205
206 $obj = array();
207 foreach ($field['columns'] as $colKey => $colDef) {
208 if ($colKey === $rowKey) continue;
209 $obj[$colKey] = acp_table_cell_from_post($colDef, $row[$colKey] ?? null);
210 }
211 $json[$key] = $obj;
212 }
213
214 return $json;
215}
216
217if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['clear_cache'])) {
218 $removed = znote_cache_flush();
219 acp_log('cache.flush', '', ['entries' => $removed]);
220 acp_flash_success(t_default('acp.settings.cache_cleared', 'Cache cleared: {n} entries removed.', ['n' => $removed]));
221 acp_redirect('settings');
222}
223
224// ---------------------------------------------------------------------------
225// Save
226// ---------------------------------------------------------------------------
227if ($_SERVER['REQUEST_METHOD'] === 'POST') {
228
229 $schema = acp_settings_schema();
230 $saved = 0;
231 $failed = 0;
232 $savedKeys = array();
233
234 foreach ($schema as $fields) {
235 foreach ($fields as $key => $field) {
236 $raw = $_POST['set'][$key] ?? '';
237
238 if (($field['type'] ?? '') === 'permissions') {
239 // Whoever is submitting this form, only a literal Owner may change
240 // who has access. Without this check, an account merely granted
241 // the 'settings' module could open this same tab and grant itself
242 // Owner - the module-level check alone cannot catch that, since
243 // this whole page is one form and 'settings' access already lets
244 // the request reach this branch.
245 if (!acp_is_owner()) {
246 $failed++;
247 acp_flash_error(t_default('acp.perm.owner_only', 'Only an Owner can change permissions.'));
248 continue;
249 }
250
251 $rowsRaw = (is_array($raw) && isset($raw['rows']) && is_array($raw['rows'])) ? $raw['rows'] : array();
252 $validModules = array_keys(acp_modules());
253 $access = array();
254 $roles = array();
255
256 foreach ($rowsRaw as $row) {
257 if (!is_array($row)) continue;
258 $account = trim((string)($row['account'] ?? ''));
259 if ($account === '') continue;
260
261 if (!empty($row['owner'])) {
262 $access[] = $account;
263 continue;
264 }
265
266 $picked = array_values(array_intersect($validModules, array_map('strval', (array)($row['modules'] ?? array()))));
267 if ($picked) {
268 $roles[$account] = $picked;
269 }
270 }
271
272 $access = array_values(array_unique($access));
273
274 // Never save a state with zero owners - that would lock every
275 // admin, including the one submitting this form, out of the
276 // panel with no way back in short of editing the database by hand.
277 if (!$access) {
278 $failed++;
279 acp_flash_error(t_default('acp.perm.no_owner', 'At least one account must stay an Owner. Permissions were not saved.'));
280 continue;
281 }
282
283 $accessOk = setting_set('config:page_admin_access', (string)json_encode($access));
284 $rolesOk = setting_set('config:page_admin_roles', (string)json_encode($roles, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
285
286 if ($accessOk && $rolesOk) {
287 $saved++;
288 $savedKeys[] = 'page_admin_access';
289 $savedKeys[] = 'page_admin_roles';
290 } else {
291 $failed++;
292 }
293 continue;
294 }
295
296 if (($field['type'] ?? '') === 'table') {
297 $rowsRaw = (is_array($raw) && isset($raw['rows']) && is_array($raw['rows'])) ? $raw['rows'] : array();
298 $jsonArr = acp_table_json_from_rows($field, $rowsRaw);
299 if (setting_set('config:' . $key, (string)json_encode($jsonArr, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))) {
300 $saved++;
301 $savedKeys[] = $key;
302 } else $failed++;
303 continue;
304 }
305
306 if (($field['type'] ?? '') === 'json') {
307 $decoded = json_decode(is_string($raw) ? $raw : '', true);
308 if (!is_array($decoded)) { $failed++; continue; }
309 if (setting_set('config:' . $key, (string)json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))) {
310 $saved++;
311 $savedKeys[] = $key;
312 } else $failed++;
313 continue;
314 }
315
316 $value = acp_setting_cast($field, $raw);
317 if (setting_set('config:' . $key, $value)) {
318 $saved++;
319 $savedKeys[] = $key;
320 } else $failed++;
321 }
322 }
323
324 if ($saved > 0) {
325 acp_log('settings.save', '', ['fields' => $savedKeys, 'failed' => $failed]);
326 }
327 if ($failed > 0) {
328 acp_flash_error(t('acp.settings.save_failed', ['n' => $failed, 'table' => '<code>znote_config</code>']));
329 } else {
330 acp_flash_success(t('acp.settings.save_success', ['n' => $saved]));
331 }
332
333 acp_redirect('settings');
334}
335
336$schema = acp_settings_schema();
337$hasTable = znote_table_exists('znote_config');
338$cacheStats = znote_cache_stats();
339$cacheSize = $cacheStats['bytes'] >= 1048576
340 ? number_format($cacheStats['bytes'] / 1048576, 1) . ' MB'
341 : number_format($cacheStats['bytes'] / 1024, 1) . ' KB';
342?>
343
344<?php if (!$hasTable): ?>
345 <div class="acp-flash acp-flash--error">
346 <i class="fa fa-exclamation-triangle"></i>
347 <span>
348 <?= t('acp.settings.table_missing', [
349 'table' => '<code>znote_config</code>',
350 'file' => '<code>SQL/migrations/2.0.0_znote_config.sql</code>',
351 ]) ?>
352 </span>
353 </div>
354<?php endif; ?>
355
356<div class="acp-flash acp-flash--info">
357 <i class="fa fa-info-circle"></i>
358 <span>
359 <?= t('acp.settings.stored_note', [
360 'configphp' => '<code>config.php</code>',
361 'configphp2' => '<code>config.php</code>',
362 ]) ?>
363 </span>
364</div>
365
366<section class="acp-card" style="margin-bottom:20px;">
367 <header class="acp-card-head">
368 <h2><?= h(t_default('acp.settings.cache_status', 'Cache status')) ?></h2>
369 <form method="post" style="margin-left:auto;">
370 <?= acp_csrf_field() ?>
371 <input type="hidden" name="clear_cache" value="1">
372 <button class="acp-btn acp-btn--red acp-btn--sm" type="submit">
373 <i class="fa fa-trash"></i> <?= h(t_default('acp.settings.cache_clear', 'Clear cache')) ?>
374 </button>
375 </form>
376 </header>
377 <div class="acp-card-body">
378 <dl class="acp-dl">
379 <dt><?= h(t_default('acp.settings.cache_driver', 'Active driver')) ?></dt>
380 <dd><span class="acp-pill acp-pill--blue"><?= h($cacheStats['driver']) ?></span></dd>
381 <dt><?= h(t_default('acp.settings.cache_namespace', 'Namespace')) ?></dt>
382 <dd><code><?= h($cacheStats['prefix']) ?></code></dd>
383 <dt><?= h(t_default('acp.settings.cache_files', 'File entries')) ?></dt>
384 <dd><?= number_format($cacheStats['files']) ?> · <?= h($cacheSize) ?></dd>
385 <dt>APCu</dt>
386 <dd>
387 <span class="acp-pill acp-pill--<?= $cacheStats['apcu_available'] ? 'green' : 'grey' ?>">
388 <?= h($cacheStats['apcu_available']
389 ? t_default('acp.settings.cache_available', 'Available')
390 : t_default('acp.settings.cache_unavailable', 'Unavailable')) ?>
391 </span>
392 <?php if ($cacheStats['requested_memory'] && !$cacheStats['apcu_available']): ?>
393 <span class="acp-hint"><?= h(t_default('acp.settings.cache_fallback', 'File fallback is active.')) ?></span>
394 <?php endif; ?>
395 </dd>
396 </dl>
397 </div>
398</section>
399
400<form method="post">
401 <?= acp_csrf_field() ?>
402
403 <div class="acp-settings-tabs" role="tablist">
404 <?php $acpTabIndex = 0; foreach ($schema as $section => $fields): $acpTabSlug = acp_settings_tab_slug((string)$section); ?>
405 <button type="button" class="acp-settings-tab <?= $acpTabIndex === 0 ? 'is-active' : '' ?>"
406 role="tab" data-acp-settings-tab="<?= h($acpTabSlug) ?>"><?= h($section) ?></button>
407 <?php $acpTabIndex++; endforeach; ?>
408 </div>
409
410 <div class="acp-settings-panels">
411 <?php $acpTabIndex = 0; foreach ($schema as $section => $fields): $acpTabSlug = acp_settings_tab_slug((string)$section); ?>
412 <div class="acp-settings-panel" data-acp-settings-panel="<?= h($acpTabSlug) ?>" <?= $acpTabIndex === 0 ? '' : 'hidden' ?>>
413 <section class="acp-card">
414 <header class="acp-card-head">
415 <h2><?= h($section) ?></h2>
416 <button class="acp-btn acp-btn--green acp-btn--sm" type="submit" style="margin-left:auto;">
417 <i class="fa fa-check"></i> <?= t('acp.settings.save_btn') ?>
418 </button>
419 </header>
420 <div class="acp-card-body">
421 <?php foreach ($fields as $key => $field):
422 $stored = setting('config:' . $key, null);
423 $fromFile = znote_config_path($config, $key, $field['default'] ?? '');
424 if (is_bool($fromFile)) {
425 $fromFile = $fromFile ? '1' : '0';
426 } elseif (is_array($fromFile)) {
427 if ($field['type'] === 'checklist') {
428 $fromFile = (string)json_encode(array_values($fromFile));
429 } elseif ($field['type'] === 'json' || $field['type'] === 'table') {
430 $fromFile = (string)json_encode($fromFile, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
431 } else {
432 $fromFile = '';
433 }
434 }
435 $current = ($stored !== null) ? $stored : (string)$fromFile;
436 if (($field['type'] === 'json' || $field['type'] === 'table') && $stored !== null) {
437 $decoded = json_decode($stored, true);
438 if (is_array($decoded)) {
439 $current = (string)json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
440 }
441 }
442 $fromDb = ($stored !== null);
443 ?>
444 <div class="acp-field">
445 <label class="acp-label" for="set_<?= h($key) ?>">
446 <?= h($field['label']) ?>
447 <?php if (!$fromDb): ?>
448 <span class="acp-pill acp-pill--grey" title="<?= h(t('acp.settings.following_title')) ?>"><?= t('acp.settings.file_pill') ?></span>
449 <?php endif; ?>
450 </label>
451
452 <?php if ($field['type'] === 'permissions'): ?>
453 <?php if (!acp_is_owner()): ?>
454 <p class="acp-hint"><?= t_default('acp.perm.owner_only_view', 'Only an Owner can view or change this.') ?></p>
455 <?php else:
456 $permRows = acp_permissions_current();
457 $permGroups = acp_permission_module_groups();
458 ?>
459 <div class="acp-perm-list" data-acp-table="<?= h($key) ?>">
460 <div data-acp-table-body>
461 <?php foreach ($permRows as $i => $row): ?>
462 <?= acp_render_permission_account($key, (int)$i, $row, $permGroups) ?>
463 <?php endforeach; ?>
464 </div>
465 </div>
466 <button type="button" class="acp-btn acp-btn--sm" data-acp-table-add="<?= h($key) ?>">
467 <i class="fa fa-plus"></i> <?= t_default('acp.perm.add_account', 'Add account') ?>
468 </button>
469 <template data-acp-table-template="<?= h($key) ?>">
470 <?= acp_render_permission_account($key, '__ROWIDX__', array('account' => '', 'owner' => false, 'modules' => array()), $permGroups) ?>
471 </template>
472 <?php endif; ?>
473 <?php elseif ($field['type'] === 'bool'): ?>
474 <label style="display:flex;align-items:center;gap:8px;font-weight:400;">
475 <input type="checkbox" id="set_<?= h($key) ?>" name="set[<?= h($key) ?>]" value="1"
476 <?= !empty($current) && $current !== '0' ? 'checked' : '' ?>>
477 <span class="is-muted"><?= t('acp.settings.enabled') ?></span>
478 </label>
479 <?php elseif ($field['type'] === 'textarea'): ?>
480 <textarea class="acp-textarea" id="set_<?= h($key) ?>" name="set[<?= h($key) ?>]" rows="3"><?= h($current) ?></textarea>
481 <?php elseif ($field['type'] === 'json'): ?>
482 <textarea class="acp-textarea" id="set_<?= h($key) ?>" name="set[<?= h($key) ?>]" rows="12" spellcheck="false"
483 style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre;min-height:200px;"><?= h($current) ?></textarea>
484 <?php elseif ($field['type'] === 'table'):
485 $tableDecoded = json_decode($current, true);
486 if (!is_array($tableDecoded)) $tableDecoded = array();
487 $tableRows = acp_table_rows_from_json($field, $tableDecoded);
488 ?>
489 <table class="acp-table acp-table--editable" data-acp-table="<?= h($key) ?>">
490 <thead>
491 <tr>
492 <?php foreach ($field['columns'] as $colDef): ?>
493 <th><?= h($colDef['label']) ?></th>
494 <?php endforeach; ?>
495 <th></th>
496 </tr>
497 </thead>
498 <tbody data-acp-table-body>
499 <?php foreach ($tableRows as $i => $row): ?>
500 <tr>
501 <?php foreach ($field['columns'] as $colKey => $colDef): ?>
502 <td><?= acp_table_cell_input($colDef, 'set[' . $key . '][rows][' . (int)$i . '][' . $colKey . ']', (string)($row[$colKey] ?? '')) ?></td>
503 <?php endforeach; ?>
504 <td><button type="button" class="acp-btn acp-btn--sm acp-btn--red" data-acp-table-remove>×</button></td>
505 </tr>
506 <?php endforeach; ?>
507 </tbody>
508 </table>
509 <button type="button" class="acp-btn acp-btn--sm" data-acp-table-add="<?= h($key) ?>">
510 <i class="fa fa-plus"></i> <?= t_default('acp.settings.table_add_row', 'Add row') ?>
511 </button>
512 <template data-acp-table-template="<?= h($key) ?>">
513 <tr>
514 <?php foreach ($field['columns'] as $colKey => $colDef): ?>
515 <td><?= acp_table_cell_input($colDef, 'set[' . $key . '][rows][__ROWIDX__][' . $colKey . ']', '') ?></td>
516 <?php endforeach; ?>
517 <td><button type="button" class="acp-btn acp-btn--sm acp-btn--red" data-acp-table-remove>×</button></td>
518 </tr>
519 </template>
520 <?php elseif ($field['type'] === 'checklist'):
521 $picked = json_decode($current, true);
522 if (!is_array($picked)) {
523 $picked = array_filter(array_map('trim', explode(',', $current)), 'strlen');
524 }
525 $picked = array_map('strval', $picked);
526 ?>
527 <div class="acp-checklist">
528 <?php foreach (($field['options'] ?? array()) as $value => $caption): ?>
529 <label style="display:flex;align-items:center;gap:8px;font-weight:400;padding:3px 0;">
530 <input type="checkbox" name="set[<?= h($key) ?>][]" value="<?= h((string)$value) ?>"
531 <?= in_array((string)$value, $picked, true) ? 'checked' : '' ?>>
532 <span><?= h($caption) ?></span>
533 </label>
534 <?php endforeach; ?>
535 </div>
536 <?php elseif ($field['type'] === 'select'): ?>
537 <select class="acp-input" id="set_<?= h($key) ?>" name="set[<?= h($key) ?>]">
538 <?php foreach (($field['options'] ?? array()) as $value => $caption): ?>
539 <option value="<?= h((string)$value) ?>" <?= ((string)$value === $current) ? 'selected' : '' ?>>
540 <?= h($caption) ?>
541 </option>
542 <?php endforeach; ?>
543 </select>
544 <?php else: ?>
545 <input class="acp-input" id="set_<?= h($key) ?>" name="set[<?= h($key) ?>]"
546 type="<?= $field['type'] === 'int' ? 'number' : 'text' ?>"
547 <?php if ($field['type'] === 'int' && isset($field['min'])): ?>min="<?= (int)$field['min'] ?>"<?php endif; ?>
548 <?php if ($field['type'] === 'int' && isset($field['max'])): ?>max="<?= (int)$field['max'] ?>"<?php endif; ?>
549 value="<?= h($current) ?>">
550 <?php endif; ?>
551
552 <?php if (!empty($field['help'])): ?>
553 <p class="acp-hint"><?= h($field['help']) ?></p>
554 <?php endif; ?>
555 </div>
556 <?php endforeach; ?>
557 </div>
558 </section>
559 </div>
560 <?php $acpTabIndex++; endforeach; ?>
561 </div>
562
563 <div class="acp-actions">
564 <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-check"></i> <?= t('acp.settings.save_btn') ?></button>
565 <span class="acp-hint"><?= t('acp.settings.legend_pill', ['pill' => '<span class="acp-pill acp-pill--grey">' . t('acp.settings.file_pill') . '</span>']) ?></span>
566 </div>
567</form>
568
569<script>
570document.addEventListener('click', function (e) {
571 var tabBtn = e.target.closest('[data-acp-settings-tab]');
572 if (tabBtn) {
573 var tabId = tabBtn.getAttribute('data-acp-settings-tab');
574 document.querySelectorAll('.acp-settings-tab').forEach(function (t) {
575 t.classList.toggle('is-active', t === tabBtn);
576 });
577 document.querySelectorAll('.acp-settings-panel').forEach(function (panel) {
578 panel.hidden = panel.getAttribute('data-acp-settings-panel') !== tabId;
579 });
580 try { sessionStorage.setItem('acp_settings_tab', tabId); } catch (err) {}
581 return;
582 }
583 // Add/remove-row table handling (data-acp-table-add/-remove) lives in acp.js, shared with other modules.
584});
585
586document.addEventListener('change', function (e) {
587 if (!e.target.classList.contains('acp-perm-owner-check')) return;
588 var account = e.target.closest('.acp-perm-account');
589 var modules = account ? account.querySelector('.acp-perm-modules') : null;
590 if (modules) modules.hidden = e.target.checked;
591});
592
593document.addEventListener('DOMContentLoaded', function () {
594 var wanted = (location.hash || '').replace(/^#/, '');
595 var btn = null;
596
597 // Search results link to the field itself (for example #set_serverName).
598 // Resolve that field's panel so the correct tab is visible before the
599 // generic search-arrival script highlights and scrolls to it.
600 if (wanted) {
601 var target = document.getElementById(wanted);
602 var panel = target && target.closest ? target.closest('[data-acp-settings-panel]') : null;
603 if (panel) {
604 btn = document.querySelector('[data-acp-settings-tab="' + panel.getAttribute('data-acp-settings-panel') + '"]');
605 } else {
606 btn = document.querySelector('[data-acp-settings-tab="' + wanted + '"]');
607 }
608 }
609 if (!wanted) {
610 try { wanted = sessionStorage.getItem('acp_settings_tab') || ''; } catch (err) {}
611 if (wanted) btn = document.querySelector('[data-acp-settings-tab="' + wanted + '"]');
612 }
613 if (btn) btn.click();
614});
615</script>
616