Initial commit

ZnoteX / Commit #5

Commit Initial commit

Alex Alex committed 01/10/2026 09:20 main Full upload
481 files +128,311 -0
A admin/modules/creatures_editor.php +196-0 View file
@@ -0,0 +1,196 @@
1+<?php
2+/**
3+ * Title: Creatures
4+ * Icon: fa-paw
5+ * Group: Server Info
6+ * Order: 13
7+ * Description: Add or edit a single monster without re-uploading the whole monster set.
8+ * Hidden: true
9+ */
10+
11+
12+if (!defined('ACP_ROOT')) {
13+ http_response_code(403);
14+ die('Direct access denied.');
15+}
16+
17+$creatures = serverdata_load('creatures');
18+$creatures = is_array($creatures) ? $creatures : array();
19+
20+if (!$creatures && !serverdata_override_table_exists()) {
21+ acp_flash_error(t_default('acp.crted.no_data', 'Upload your monster data on Server Info first - there is nothing published to edit yet.'));
22+ acp_redirect('serverinfo');
23+}
24+
25+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
26+ $adminName = (string)($GLOBALS['user_data']['name'] ?? '');
27+ $action = (string)($_POST['crted_action'] ?? '');
28+
29+ if ($action === 'save') {
30+ $originalName = trim((string)($_POST['original_name'] ?? ''));
31+ $name = trim((string)($_POST['name'] ?? ''));
32+
33+ if ($name === '') {
34+ acp_flash_error(t_default('acp.crted.missing_fields', 'A name is required.'));
35+ acp_redirect('creatures_editor');
36+ }
37+
38+ $record = array(
39+ 'health' => max(0, intv($_POST['health'] ?? 0)),
40+ 'experience' => max(0, intv($_POST['experience'] ?? 0)),
41+ 'speed' => max(0, intv($_POST['speed'] ?? 0)),
42+ 'race' => trim((string)($_POST['race'] ?? '')),
43+ 'looktype' => max(0, intv($_POST['looktype'] ?? 0)),
44+ );
45+
46+ $ok = serverdata_override_set('creatures', $name, $record, $adminName);
47+
48+ // Renaming: the old key must stop winning over the base cache too.
49+ if ($ok && $originalName !== '' && $originalName !== $name) {
50+ serverdata_override_delete('creatures', $originalName, $adminName);
51+ }
52+
53+ if ($ok) {
54+ acp_log('serverdata.creature_save', $name);
55+ acp_flash_success(t_default('acp.crted.saved', '{name} saved.', ['name' => $name]));
56+ } else {
57+ acp_flash_error(t_default('acp.crted.save_failed', 'Could not save - run the pending database migration first (Admin Panel > Migrations).'));
58+ }
59+ acp_redirect('creatures_editor');
60+ }
61+
62+ if ($action === 'delete') {
63+ $name = trim((string)($_POST['name'] ?? ''));
64+ if (serverdata_override_delete('creatures', $name, $adminName)) {
65+ acp_log('serverdata.creature_delete', $name);
66+ acp_flash_success(t_default('acp.crted.removed', '{name} removed.', ['name' => $name]));
67+ } else {
68+ acp_flash_error(t_default('acp.crted.remove_failed', 'Could not remove that creature.'));
69+ }
70+ acp_redirect('creatures_editor');
71+ }
72+}
73+
74+$editName = trim((string)($_GET['edit'] ?? ''));
75+$editing = null;
76+if ($editName !== '') {
77+ foreach ($creatures as $c) {
78+ if (($c['name'] ?? '') === $editName) {
79+ $editing = $c;
80+ break;
81+ }
82+ }
83+}
84+
85+$list = $creatures;
86+usort($list, static function (array $a, array $b): int {
87+ return strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? ''));
88+});
89+?>
90+
91+<section class="acp-card">
92+ <header class="acp-card-head">
93+ <h2><?= $editing ? h(t_default('acp.crted.edit_title', 'Edit {name}', ['name' => $editName])) : h(t_default('acp.crted.add_title', 'Add a creature')) ?></h2>
94+ <p><?= h(t_default('acp.crted.sub', 'Saved separately from the uploaded monster files - a later re-upload will not overwrite this.')) ?></p>
95+ </header>
96+ <div class="acp-card-body">
97+ <form method="post">
98+ <?= acp_csrf_field() ?>
99+ <input type="hidden" name="crted_action" value="save">
100+ <input type="hidden" name="original_name" value="<?= h($editName) ?>">
101+
102+ <div class="acp-row">
103+ <div class="acp-field">
104+ <label class="acp-label" for="crted_name"><?= t_default('acp.crted.field_name', 'Name') ?></label>
105+ <input class="acp-input" type="text" id="crted_name" name="name" value="<?= h((string)($editing['name'] ?? '')) ?>" required>
106+ </div>
107+ <div class="acp-field">
108+ <label class="acp-label" for="crted_race"><?= t_default('acp.crted.field_race', 'Race') ?></label>
109+ <input class="acp-input" type="text" id="crted_race" name="race" value="<?= h((string)($editing['race'] ?? '')) ?>" placeholder="blood, venom, undead...">
110+ </div>
111+ </div>
112+
113+ <div class="acp-row">
114+ <div class="acp-field">
115+ <label class="acp-label" for="crted_health"><?= t_default('acp.crted.field_health', 'Health') ?></label>
116+ <input class="acp-input" type="number" min="0" id="crted_health" name="health" value="<?= (int)($editing['health'] ?? 0) ?>">
117+ </div>
118+ <div class="acp-field">
119+ <label class="acp-label" for="crted_experience"><?= t_default('acp.crted.field_experience', 'Experience') ?></label>
120+ <input class="acp-input" type="number" min="0" id="crted_experience" name="experience" value="<?= (int)($editing['experience'] ?? 0) ?>">
121+ </div>
122+ <div class="acp-field">
123+ <label class="acp-label" for="crted_speed"><?= t_default('acp.crted.field_speed', 'Speed') ?></label>
124+ <input class="acp-input" type="number" min="0" id="crted_speed" name="speed" value="<?= (int)($editing['speed'] ?? 0) ?>">
125+ </div>
126+ <div class="acp-field">
127+ <label class="acp-label" for="crted_looktype"><?= t_default('acp.crted.field_looktype', 'Looktype') ?></label>
128+ <input class="acp-input" type="number" min="0" id="crted_looktype" name="looktype" value="<?= (int)($editing['looktype'] ?? 0) ?>">
129+ </div>
130+ </div>
131+
132+ <div class="acp-actions">
133+ <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-check"></i> <?= $editing ? t_default('acp.crted.save_btn', 'Save') : t_default('acp.crted.add_btn', 'Add creature') ?></button>
134+ <?php if ($editing): ?>
135+ <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('creatures_editor')) ?>"><?= t_default('acp.crted.cancel', 'Cancel edit') ?></a>
136+ <?php endif; ?>
137+ </div>
138+ </form>
139+ </div>
140+</section>
141+
142+<section class="acp-card">
143+ <header class="acp-card-head">
144+ <h2><?= t_default('acp.crted.list_title', 'Published creatures') ?></h2>
145+ </header>
146+ <div class="acp-card-body is-flush">
147+ <?php if ($list): ?>
148+ <?php if (count($list) > 8): ?>
149+ <div class="acp-toolbar">
150+ <div style="display:flex;gap:8px;flex:1 1 320px;max-width:460px;">
151+ <input class="acp-input" type="search" data-acp-search-input="crtedTable"
152+ placeholder="<?= h(t_default('acp.crted.search_placeholder', 'Search creatures...')) ?>">
153+ </div>
154+ <span class="is-muted" data-acp-search-count="crtedTable"></span>
155+ </div>
156+ <?php endif; ?>
157+ <div class="acp-table-wrap">
158+ <table class="acp-table" data-sortable id="crtedTable">
159+ <thead>
160+ <tr>
161+ <th><?= t_default('acp.crted.col_name', 'Name') ?></th>
162+ <th class="is-num"><?= t_default('acp.crted.col_health', 'Health') ?></th>
163+ <th class="is-num"><?= t_default('acp.crted.col_experience', 'Experience') ?></th>
164+ <th><?= t_default('acp.crted.col_race', 'Race') ?></th>
165+ <th class="is-num"><?= t_default('acp.crted.col_actions', 'Actions') ?></th>
166+ </tr>
167+ </thead>
168+ <tbody>
169+ <?php foreach ($list as $c): ?>
170+ <?php $cName = (string)($c['name'] ?? ''); ?>
171+ <tr data-acp-search="<?= h(strtolower($cName)) ?>">
172+ <td><?= h($cName) ?></td>
173+ <td class="is-num"><?= (int)($c['health'] ?? 0) ?></td>
174+ <td class="is-num"><?= (int)($c['experience'] ?? 0) ?></td>
175+ <td class="is-muted"><?= h((string)($c['race'] ?? '')) ?></td>
176+ <td class="is-nowrap is-num">
177+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('creatures_editor', array('edit' => $cName))) ?>"><i class="fa fa-pencil"></i></a>
178+ <form class="acp-inline-form" method="post" data-confirm="<?= h(t_default('acp.crted.confirm_remove', 'Remove this creature?')) ?>">
179+ <?= acp_csrf_field() ?>
180+ <input type="hidden" name="crted_action" value="delete">
181+ <input type="hidden" name="name" value="<?= h($cName) ?>">
182+ <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"><i class="fa fa-times"></i></button>
183+ </form>
184+ </td>
185+ </tr>
186+ <?php endforeach; ?>
187+ </tbody>
188+ </table>
189+ </div>
190+ <?php else: ?>
191+ <?php acp_empty(t_default('acp.crted.empty', 'No creatures published yet.'), 'fa-paw'); ?>
192+ <?php endif; ?>
193+ </div>
194+</section>
195+
196+<p class="acp-hint"><a href="<?= h(acp_url('serverinfo')) ?>"><i class="fa fa-arrow-left"></i> <?= t_default('acp.crted.back', 'Back to Server Info') ?></a></p>
A admin/modules/dashboard.php +440-0 View file
@@ -0,0 +1,440 @@
1+<?php
2+/**
3+ * Title: Dashboard
4+ * Icon: fa-tachometer
5+ * Group: Overview
6+ * Order: 10
7+ * Description: Server and community at a glance.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+// OTHIRE has no accounts.name column - it identifies accounts by number.
16+$accNameCol = znote_server_adapter()->accountDisplayColumn();
17+
18+// ---------------------------------------------------------------------------
19+// Counters. acp_count() returns 0 for a table this engine does not have,
20+// so an unusual schema degrades to a zero instead of a fatal error.
21+// ---------------------------------------------------------------------------
22+$statAccounts = acp_count("SELECT COUNT(*) AS `c` FROM `accounts`;");
23+$statPlayers = acp_count("SELECT COUNT(*) AS `c` FROM `players`;");
24+$statGuilds = acp_count("SELECT COUNT(*) AS `c` FROM `guilds`;");
25+$statHouses = acp_count("SELECT COUNT(*) AS `c` FROM `houses`;");
26+
27+$statOnline = znote_server_adapter()->onlineCount();
28+
29+$statPoints = acp_count("SELECT COALESCE(SUM(`points`), 0) AS `c` FROM `znote_accounts`;");
30+$statOrders = acp_count("SELECT COUNT(*) AS `c` FROM `znote_shop_orders`;");
31+
32+// Moderation queues - reuse the same counters the sidebar badges use.
33+$queueReports = acp_badge_reports();
34+$queueTickets = acp_badge_helpdesk();
35+$queueImages = acp_badge_gallery();
36+$queueFeedback = acp_badge_feedback();
37+
38+// ---------------------------------------------------------------------------
39+// Recent activity
40+// ---------------------------------------------------------------------------
41+$latestAccounts = db()->fetchAll("
42+ SELECT `a`.`id` AS `account_id`, {$accNameCol} AS `account_name`, `za`.`created`, `za`.`points`
43+ FROM `znote_accounts` `za`
44+ INNER JOIN `accounts` `a` ON `a`.`id` = `za`.`account_id`
45+ ORDER BY `za`.`created` DESC
46+ LIMIT 10;
47+");
48+
49+$latestPlayers = db()->fetchAll("
50+ SELECT `name`, `level`, `vocation`
51+ FROM `players`
52+ ORDER BY `id` DESC
53+ LIMIT 10;
54+");
55+
56+$topPoints = db()->fetchAll("
57+ SELECT `a`.`id` AS `account_id`, {$accNameCol} AS `account_name`, `za`.`points`
58+ FROM `znote_accounts` `za`
59+ INNER JOIN `accounts` `a` ON `a`.`id` = `za`.`account_id`
60+ WHERE `za`.`points` > 0
61+ ORDER BY `za`.`points` DESC
62+ LIMIT 10;
63+");
64+
65+$recentShopPurchases = znote_table_exists('znote_shop_logs')
66+ ? db()->fetchAll("
67+ SELECT `l`.`account_id`, {$accNameCol} AS `account_name`, `l`.`itemid`, `l`.`type`, `l`.`count`, `l`.`points`, `l`.`time`
68+ FROM `znote_shop_logs` `l`
69+ LEFT JOIN `accounts` `a` ON `a`.`id` = `l`.`account_id`
70+ ORDER BY `l`.`id` DESC
71+ LIMIT 8;
72+ ")
73+ : false;
74+$recentShopPurchases = is_array($recentShopPurchases) ? $recentShopPurchases : array();
75+
76+$shopItemNames = function_exists('getItemList') ? getItemList() : array();
77+$shopOrderTypes = array(
78+ 1 => t('acp.sord.type_item'),
79+ 2 => t('acp.sord.type_premium'),
80+ 3 => t('acp.sord.type_gender'),
81+ 4 => t('acp.sord.type_name'),
82+ 5 => t('acp.sord.type_outfit'),
83+ 6 => t('acp.sord.type_mount'),
84+ 7 => t('acp.sord.type_custom'),
85+ 8 => t('acp.sord.type_custom'),
86+);
87+
88+$recentPointsPurchases = znote_table_exists('znote_payment_transactions')
89+ ? db()->fetchAll("
90+ SELECT `t`.`account_id`, {$accNameCol} AS `account_name`, `t`.`provider`, `t`.`price`, `t`.`currency`, `t`.`points`, `t`.`credited`, `t`.`created_at`
91+ FROM `znote_payment_transactions` `t`
92+ LEFT JOIN `accounts` `a` ON `a`.`id` = `t`.`account_id`
93+ ORDER BY `t`.`id` DESC
94+ LIMIT 8;
95+ ")
96+ : false;
97+$recentPointsPurchases = is_array($recentPointsPurchases) ? $recentPointsPurchases : array();
98+
99+// znote row: keep the stored version in step with the running one, the way
100+// the old admin.php did on every visit.
101+$znote = user_znote_data('version', 'installed', 'cached');
102+if (is_array($znote) && ($znote['version'] ?? null) !== $version) {
103+ $oldVersion = (string)($znote['version'] ?? '');
104+ db()->execute("UPDATE `znote` SET `version` = ?;", [$version]);
105+ acp_log('system.version_sync', $version, ['from' => $oldVersion]);
106+ $znote['version'] = $version;
107+}
108+
109+$obSteps = array(
110+ array(
111+ 'done' => !empty($config['twoFactorAuthenticator']),
112+ 'label' => t_default('acp.dash.ob_2fa', 'Enable two-factor authentication'),
113+ 'url' => acp_url('settings') . '#set_twoFactorAuthenticator',
114+ ),
115+ array(
116+ 'done' => function_exists('znote_backups_list') && count(znote_backups_list()) > 0,
117+ 'label' => t_default('acp.dash.ob_backup', 'Create your first backup'),
118+ 'url' => acp_url('backups'),
119+ ),
120+ array(
121+ 'done' => function_exists('scheduler_enabled') && scheduler_enabled('backups'),
122+ 'label' => t_default('acp.dash.ob_scheduler', 'Turn on automatic backups'),
123+ 'url' => acp_url('scheduler'),
124+ ),
125+ array(
126+ 'done' => function_exists('znote_migrations_pending') && !znote_migrations_pending(),
127+ 'label' => t_default('acp.dash.ob_migrations', 'Apply pending database migrations'),
128+ 'url' => acp_url('migrations'),
129+ ),
130+);
131+$obRemaining = count(array_filter($obSteps, static fn(array $s): bool => !$s['done']));
132+?>
133+
134+<div class="acp-stats">
135+ <?php
136+ acp_stat(t('acp.dash.stat_accounts'), $statAccounts, 'fa-user-plus', null, 'blue');
137+ acp_stat(t('acp.dash.stat_characters'), $statPlayers, 'fa-users', null, 'red');
138+ acp_stat(t('acp.dash.stat_online'), $statOnline, 'fa-signal', null, 'teal');
139+ acp_stat(t('acp.dash.stat_guilds'), $statGuilds, 'fa-shield', null, 'green');
140+ acp_stat(t('acp.dash.stat_houses'), $statHouses, 'fa-home', null, 'amber');
141+ acp_stat(t('acp.dash.stat_points'), $statPoints, 'fa-diamond', acp_url('shop'), 'purple');
142+ ?>
143+</div>
144+
145+<?php if ($obRemaining > 0): ?>
146+ <section class="acp-card">
147+ <header class="acp-card-head">
148+ <h2><?= t_default('acp.dash.ob_title', 'Getting started') ?></h2>
149+ <p><?= h(t_default('acp.dash.ob_sub', '{n} step(s) left - this card goes away once they are all done.', ['n' => $obRemaining])) ?></p>
150+ </header>
151+ <div class="acp-card-body is-flush">
152+ <div class="acp-table-wrap">
153+ <table class="acp-table">
154+ <tbody>
155+ <?php foreach ($obSteps as $step): ?>
156+ <tr>
157+ <td>
158+ <i class="fa <?= $step['done'] ? 'fa-check-circle' : 'fa-circle-o' ?> is-muted"></i>
159+ &nbsp;<?= h($step['label']) ?>
160+ </td>
161+ <td class="is-num">
162+ <span class="acp-pill <?= $step['done'] ? 'acp-pill--green' : 'acp-pill--amber' ?>">
163+ <?= $step['done'] ? t('acp.dash.enabled') : t_default('acp.dash.ob_todo', 'To do') ?>
164+ </span>
165+ </td>
166+ <td class="is-nowrap is-num">
167+ <?php if (!$step['done']): ?>
168+ <a href="<?= h($step['url']) ?>"><?= t('acp.dash.review') ?></a>
169+ <?php endif; ?>
170+ </td>
171+ </tr>
172+ <?php endforeach; ?>
173+ </tbody>
174+ </table>
175+ </div>
176+ </div>
177+ </section>
178+<?php endif; ?>
179+
180+<div class="acp-grid acp-grid--2">
181+
182+ <!-- ------------------------------------------------ Moderation queue -->
183+ <section class="acp-card">
184+ <header class="acp-card-head">
185+ <h2><?= t('acp.dash.attention_title') ?></h2>
186+ <p><?= t('acp.dash.attention_sub') ?></p>
187+ </header>
188+ <div class="acp-card-body is-flush">
189+ <div class="acp-table-wrap">
190+ <table class="acp-table">
191+ <tbody>
192+ <tr>
193+ <td><i class="fa fa-bug is-muted"></i> &nbsp;<?= t('acp.dash.bug_reports') ?></td>
194+ <td class="is-num">
195+ <span class="acp-pill <?= $queueReports > 0 ? 'acp-pill--red' : 'acp-pill--green' ?>"><?= (int)$queueReports ?></span>
196+ </td>
197+ <td class="is-nowrap is-num"><a href="<?= h(acp_url('reports')) ?>"><?= t('acp.dash.review') ?></a></td>
198+ </tr>
199+ <tr>
200+ <td><i class="fa fa-life-ring is-muted"></i> &nbsp;<?= t('acp.dash.helpdesk_open') ?></td>
201+ <td class="is-num">
202+ <span class="acp-pill <?= $queueTickets > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$queueTickets ?></span>
203+ </td>
204+ <td class="is-nowrap is-num"><a href="<?= h(acp_url('helpdesk')) ?>"><?= t('acp.dash.review') ?></a></td>
205+ </tr>
206+ <tr>
207+ <td><i class="fa fa-picture-o is-muted"></i> &nbsp;<?= t('acp.dash.images_pending') ?></td>
208+ <td class="is-num">
209+ <span class="acp-pill <?= $queueImages > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$queueImages ?></span>
210+ </td>
211+ <td class="is-nowrap is-num"><a href="<?= h(acp_url('gallery')) ?>"><?= t('acp.dash.review') ?></a></td>
212+ </tr>
213+ <tr>
214+ <td><i class="fa fa-comments-o is-muted"></i> &nbsp;<?= t('acp.dash.feedback_open') ?></td>
215+ <td class="is-num">
216+ <span class="acp-pill <?= $queueFeedback > 0 ? 'acp-pill--amber' : 'acp-pill--green' ?>"><?= (int)$queueFeedback ?></span>
217+ </td>
218+ <td class="is-nowrap is-num"><a href="<?= h(acp_site('forum.php?cat=4')) ?>"><?= t('acp.dash.open') ?></a></td>
219+ </tr>
220+ <tr>
221+ <td><i class="fa fa-shopping-cart is-muted"></i> &nbsp;<?= t('acp.dash.orders_pending') ?></td>
222+ <td class="is-num">
223+ <span class="acp-pill <?= $statOrders > 0 ? 'acp-pill--blue' : 'acp-pill--green' ?>"><?= (int)$statOrders ?></span>
224+ </td>
225+ <td class="is-nowrap is-num"><a href="<?= h(acp_url('shop_orders')) ?>"><?= t('acp.dash.open') ?></a></td>
226+ </tr>
227+ </tbody>
228+ </table>
229+ </div>
230+ </div>
231+ </section>
232+
233+ <!-- ------------------------------------------------------ Environment -->
234+ <section class="acp-card">
235+ <header class="acp-card-head">
236+ <h2><?= t('acp.dash.env_title') ?></h2>
237+ <p><?= t('acp.dash.env_sub') ?></p>
238+ </header>
239+ <div class="acp-card-body">
240+ <dl class="acp-dl">
241+ <dt>ZnoteX</dt>
242+ <dd><?= h($version) ?><?= is_array($znote) && isset($znote['version']) ? '' : ' <span class="acp-pill acp-pill--red">'. t('acp.dash.znote_table_missing') .'</span>' ?></dd>
243+
244+ <dt>PHP</dt>
245+ <dd><?= h(PHP_VERSION) ?></dd>
246+
247+ <dt><?= t('acp.dash.server_engine') ?></dt>
248+ <dd><span class="acp-pill acp-pill--blue"><?= h(serverEngineReal()) ?></span></dd>
249+
250+ <dt><?= t('acp.dash.database') ?></dt>
251+ <dd><?= h($config['sqlDatabase'] ?? '') ?> @ <?= h($config['sqlHost'] ?? '') ?></dd>
252+
253+ <dt><?= t('acp.dash.site_url') ?></dt>
254+ <dd><a href="<?= h($config['site_url'] ?? '#') ?>" target="_blank" rel="noopener"><?= h($config['site_url'] ?? '') ?></a></dd>
255+
256+ <dt><?= t('acp.dash.installed') ?></dt>
257+ <dd><?= is_array($znote) && !empty($znote['installed']) ? h(getClock((int)$znote['installed'], true)) : '&mdash;' ?></dd>
258+
259+ <dt><?= t('acp.dash.last_cache') ?></dt>
260+ <dd><?= is_array($znote) && !empty($znote['cached']) ? h(getClock((int)$znote['cached'], true)) : '&mdash;' ?></dd>
261+
262+ <dt><?= t('acp.dash.two_factor') ?></dt>
263+ <dd><?= !empty($config['twoFactorAuthenticator'])
264+ ? '<span class="acp-pill acp-pill--green">'. t('acp.dash.enabled') .'</span>'
265+ : '<span class="acp-pill acp-pill--grey">'. t('acp.dash.disabled') .'</span>' ?></dd>
266+ </dl>
267+ </div>
268+ </section>
269+</div>
270+
271+<div class="acp-grid acp-grid--3">
272+
273+ <!-- ------------------------------------------------- Latest accounts -->
274+ <section class="acp-card">
275+ <header class="acp-card-head"><h2><?= t('acp.dash.newest_accounts') ?></h2></header>
276+ <div class="acp-card-body is-flush">
277+ <?php if (is_array($latestAccounts) && $latestAccounts): ?>
278+ <div class="acp-table-wrap">
279+ <table class="acp-table">
280+ <thead>
281+ <tr><th><?= t('acp.dash.col_account') ?></th><th><?= t('acp.dash.col_created') ?></th><th class="is-num"><?= t('acp.dash.col_points') ?></th></tr>
282+ </thead>
283+ <tbody>
284+ <?php foreach ($latestAccounts as $row): ?>
285+ <tr>
286+ <td><?= h($row['account_name']) ?></td>
287+ <td class="is-nowrap is-muted"><?= h(getClock((int)$row['created'], true)) ?></td>
288+ <td class="is-num"><?= (int)$row['points'] ?></td>
289+ </tr>
290+ <?php endforeach; ?>
291+ </tbody>
292+ </table>
293+ </div>
294+ <?php else: ?>
295+ <?php acp_empty(t('acp.dash.no_accounts'), 'fa-user-o'); ?>
296+ <?php endif; ?>
297+ </div>
298+ </section>
299+
300+ <!-- -------------------------------------------------- Latest players -->
301+ <section class="acp-card">
302+ <header class="acp-card-head"><h2><?= t('acp.dash.newest_characters') ?></h2></header>
303+ <div class="acp-card-body is-flush">
304+ <?php if (is_array($latestPlayers) && $latestPlayers): ?>
305+ <div class="acp-table-wrap">
306+ <table class="acp-table">
307+ <thead>
308+ <tr><th><?= t('acp.dash.col_name') ?></th><th><?= t('acp.dash.col_vocation') ?></th><th class="is-num"><?= t('acp.dash.col_level') ?></th></tr>
309+ </thead>
310+ <tbody>
311+ <?php foreach ($latestPlayers as $row): ?>
312+ <tr>
313+ <td>
314+ <a href="<?= h(acp_site('characterprofile.php?name=' . urlencode((string)$row['name']))) ?>" target="_blank" rel="noopener">
315+ <?= h($row['name']) ?>
316+ </a>
317+ </td>
318+ <td class="is-muted"><?= h(vocation_id_to_name((int)$row['vocation'])) ?></td>
319+ <td class="is-num"><?= (int)$row['level'] ?></td>
320+ </tr>
321+ <?php endforeach; ?>
322+ </tbody>
323+ </table>
324+ </div>
325+ <?php else: ?>
326+ <?php acp_empty(t('acp.dash.no_characters'), 'fa-user-o'); ?>
327+ <?php endif; ?>
328+ </div>
329+ </section>
330+
331+ <!-- ---------------------------------------------------- Top balances -->
332+ <section class="acp-card">
333+ <header class="acp-card-head"><h2><?= t('acp.dash.top_balances') ?></h2></header>
334+ <div class="acp-card-body is-flush">
335+ <?php if (is_array($topPoints) && $topPoints): ?>
336+ <div class="acp-table-wrap">
337+ <table class="acp-table">
338+ <thead>
339+ <tr><th>#</th><th><?= t('acp.dash.col_account') ?></th><th class="is-num"><?= t('acp.dash.col_points') ?></th></tr>
340+ </thead>
341+ <tbody>
342+ <?php $rank = 0; foreach ($topPoints as $row): $rank++; ?>
343+ <tr>
344+ <td class="is-muted"><?= $rank ?></td>
345+ <td><?= h($row['account_name']) ?></td>
346+ <td class="is-num"><strong><?= number_format((int)$row['points']) ?></strong></td>
347+ </tr>
348+ <?php endforeach; ?>
349+ </tbody>
350+ </table>
351+ </div>
352+ <?php else: ?>
353+ <?php acp_empty(t('acp.dash.no_points'), 'fa-diamond'); ?>
354+ <?php endif; ?>
355+ </div>
356+ </section>
357+</div>
358+
359+<div class="acp-grid acp-grid--2">
360+
361+ <!-- ------------------------------------------- Recent shop purchases -->
362+ <section class="acp-card">
363+ <header class="acp-card-head"><h2><?= t_default('acp.dash.recent_shop', 'Recent Shop Purchases') ?></h2></header>
364+ <div class="acp-card-body is-flush">
365+ <?php if ($recentShopPurchases): ?>
366+ <div class="acp-table-wrap">
367+ <table class="acp-table">
368+ <thead>
369+ <tr>
370+ <th><?= t('acp.dash.col_account') ?></th>
371+ <th><?= t('acp.sord.col_item') ?></th>
372+ <th class="is-num"><?= t('acp.sord.col_points') ?></th>
373+ <th><?= t('acp.sord.col_date') ?></th>
374+ </tr>
375+ </thead>
376+ <tbody>
377+ <?php foreach ($recentShopPurchases as $row):
378+ $itemId = intv($row['itemid'] ?? 0);
379+ ?>
380+ <tr>
381+ <td><?= $row['account_name'] !== null ? h($row['account_name']) : '<span class="is-muted">'. t('acp.sord.deleted_account', ['id' => (int)($row['account_id'] ?? 0)]) .'</span>' ?></td>
382+ <td>
383+ <?php if ($itemId > 0): ?>
384+ <?= h($shopItemNames[$itemId] ?? t('acp.sord.item_unknown')) ?>
385+ <?php else: ?>
386+ <span class="acp-pill acp-pill--grey"><?= h($shopOrderTypes[(int)($row['type'] ?? 0)] ?? t('acp.sord.type_unknown')) ?></span>
387+ <?php endif; ?>
388+ </td>
389+ <td class="is-num"><?= (int)($row['points'] ?? 0) ?></td>
390+ <td class="is-nowrap is-muted"><?= h(getClock((int)($row['time'] ?? 0), true)) ?></td>
391+ </tr>
392+ <?php endforeach; ?>
393+ </tbody>
394+ </table>
395+ </div>
396+ <?php else: ?>
397+ <?php acp_empty(t('acp.sord.history_empty'), 'fa-shopping-cart'); ?>
398+ <?php endif; ?>
399+ </div>
400+ </section>
401+
402+ <!-- ----------------------------------------- Recent points purchases -->
403+ <section class="acp-card">
404+ <header class="acp-card-head"><h2><?= t_default('acp.dash.recent_points', 'Recent Points Purchases') ?></h2></header>
405+ <div class="acp-card-body is-flush">
406+ <?php if ($recentPointsPurchases): ?>
407+ <div class="acp-table-wrap">
408+ <table class="acp-table">
409+ <thead>
410+ <tr>
411+ <th><?= t('acp.dash.col_account') ?></th>
412+ <th><?= t_default('acp.dash.col_provider', 'Provider') ?></th>
413+ <th class="is-num"><?= t_default('acp.dash.col_amount', 'Amount') ?></th>
414+ <th class="is-num"><?= t('acp.dash.col_points') ?></th>
415+ <th><?= t('acp.sord.col_date') ?></th>
416+ </tr>
417+ </thead>
418+ <tbody>
419+ <?php foreach ($recentPointsPurchases as $row): ?>
420+ <tr>
421+ <td><?= $row['account_name'] !== null ? h($row['account_name']) : '<span class="is-muted">'. t('acp.sord.deleted_account', ['id' => (int)($row['account_id'] ?? 0)]) .'</span>' ?></td>
422+ <td><?= h(ucfirst((string)($row['provider'] ?? ''))) ?></td>
423+ <td class="is-num"><?= h(number_format((float)($row['price'] ?? 0), 2, '.', '')) ?> <?= h((string)($row['currency'] ?? '')) ?></td>
424+ <td class="is-num">
425+ <strong><?= (int)($row['points'] ?? 0) ?></strong>
426+ <?= !empty($row['credited']) ? ' <span class="acp-pill acp-pill--green">'. t_default('acp.dash.credited', 'Credited') .'</span>' : ' <span class="acp-pill acp-pill--amber">'. t_default('acp.dash.pending', 'Pending') .'</span>' ?>
427+ </td>
428+ <td class="is-nowrap is-muted"><?= !empty($row['created_at']) ? h(getClock((int)$row['created_at'], true)) : '&mdash;' ?></td>
429+ </tr>
430+ <?php endforeach; ?>
431+ </tbody>
432+ </table>
433+ </div>
434+ <?php else: ?>
435+ <?php acp_empty(t_default('acp.dash.no_points_purchases', 'No point purchases yet.'), 'fa-credit-card'); ?>
436+ <?php endif; ?>
437+ </div>
438+ </section>
439+
440+</div>
A admin/modules/error_log.php +167-0 View file
@@ -0,0 +1,167 @@
1+<?php
2+/**
3+ * Title: Error Log
4+ * Icon: fa-exclamation-triangle
5+ * Group: Overview
6+ * Order: 40
7+ * Description: Tail the PHP error log - website, plugin and theme errors all land here.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+function acp_error_log_ini_path(): string {
16+ $v = @ini_get('error_log');
17+ return is_string($v) ? trim($v) : '';
18+}
19+
20+function acp_error_log_path(): string {
21+ $configured = trim((string) setting('error_log:path', ''));
22+ return $configured !== '' ? $configured : acp_error_log_ini_path();
23+}
24+
25+/** Last $lines lines of a (possibly large) text file, without loading it all into memory. */
26+function acp_error_log_tail(string $file, int $lines): array {
27+ $handle = @fopen($file, 'rb');
28+ if ($handle === false) {
29+ return array();
30+ }
31+
32+ $chunkSize = 8192;
33+ $buffer = '';
34+ $foundLines = 0;
35+ fseek($handle, 0, SEEK_END);
36+ $pos = ftell($handle);
37+
38+ while ($pos > 0 && $foundLines <= $lines) {
39+ $read = min($chunkSize, $pos);
40+ $pos -= $read;
41+ fseek($handle, $pos);
42+ $buffer = fread($handle, $read) . $buffer;
43+ $foundLines = substr_count($buffer, "\n");
44+ }
45+ fclose($handle);
46+
47+ $rows = explode("\n", rtrim($buffer, "\n"));
48+ $rows = array_slice($rows, -$lines);
49+ return array_reverse($rows);
50+}
51+
52+function acp_error_log_severity(string $line): string {
53+ if (preg_match('/\b(Fatal error|SQL ERROR|Uncaught|Parse error|ZnoteX DB)\b/i', $line)) {
54+ return 'red';
55+ }
56+ if (preg_match('/\b(Warning|Deprecated)\b/i', $line)) {
57+ return 'amber';
58+ }
59+ return '';
60+}
61+
62+$module = 'error_log';
63+
64+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
65+ $do = (string) ($_POST['do'] ?? '');
66+ if ($do === 'save_path') {
67+ $newPath = trim((string) ($_POST['path'] ?? ''));
68+ if (setting_set('error_log:path', $newPath)) {
69+ acp_log('error_log.path_save', $newPath);
70+ acp_flash_success(t_default('acp.error_log.saved', 'Log path saved.'));
71+ } else {
72+ acp_flash_error(t_default('acp.error_log.save_failed', 'The log path could not be saved.'));
73+ }
74+ } elseif ($do === 'reset_path') {
75+ if (setting_set('error_log:path', '')) {
76+ acp_log('error_log.path_reset');
77+ acp_flash_success(t_default('acp.error_log.reset', 'Reverted to the auto-detected path.'));
78+ } else {
79+ acp_flash_error(t_default('acp.error_log.reset_failed', 'The log path could not be reset.'));
80+ }
81+ }
82+ acp_redirect($module);
83+}
84+
85+$path = acp_error_log_path();
86+$hasFile = $path !== '' && is_file($path) && is_readable($path);
87+$linesWant = in_array((string) ($_GET['lines'] ?? ''), array('100', '300', '1000'), true) ? (int) $_GET['lines'] : 300;
88+$rows = $hasFile ? acp_error_log_tail($path, $linesWant) : array();
89+$configured = trim((string) setting('error_log:path', ''));
90+?>
91+
92+<?php if (!$hasFile): ?>
93+ <div class="acp-flash acp-flash--error">
94+ <i class="fa fa-exclamation-triangle"></i>
95+ <span>
96+ <?php if ($path === ''): ?>
97+ <?= t_default('acp.error_log.not_configured', "PHP is not configured with an <code>error_log</code> file for this request - it is very likely logging to the web server's own error log instead (e.g. Apache's log under XAMPP, WAMP, Uniform Server, or a Linux host), which lives outside this site's folder and cannot be found automatically.") ?>
98+ <?php else: ?>
99+ <?= t_default('acp.error_log.path_missing', 'The configured path <code>{path}</code> does not exist or is not readable from PHP.', ['path' => h($path)]) ?>
100+ <?php endif; ?>
101+ <?= t_default('acp.error_log.help', "Paste the correct path below - check your stack's control panel (XAMPP: <code>apache/logs/error.log</code>, WAMP: <code>logs/apache_error.log</code>, Uniform Server: its <code>logs</code> folder) or your <code>php.ini</code>'s <code>error_log</code> directive.") ?>
102+ </span>
103+ </div>
104+<?php endif; ?>
105+
106+<section class="acp-card">
107+ <header class="acp-card-head">
108+ <h2><?= t_default('acp.error_log.title', 'Log file') ?></h2>
109+ <p><?= t_default('acp.error_log.ini_get_label', "PHP's <code>ini_get('error_log')</code> for this request:") ?> <code><?= h(acp_error_log_ini_path() !== '' ? acp_error_log_ini_path() : t_default('acp.error_log.empty_sapi_default', '(empty - using the SAPI default)')) ?></code></p>
110+ </header>
111+ <div class="acp-card-body">
112+ <form method="post" class="acp-row">
113+ <?= acp_csrf_field() ?>
114+ <input type="hidden" name="do" value="save_path">
115+ <div class="acp-field" style="flex:2;min-width:280px;">
116+ <label class="acp-label" for="path"><?= t_default('acp.error_log.path_label', 'Path to the error log') ?></label>
117+ <input class="acp-input" id="path" name="path" value="<?= h($configured) ?>" placeholder="<?= h(acp_error_log_ini_path()) ?>">
118+ </div>
119+ <div class="acp-actions">
120+ <button class="acp-btn" type="submit"><i class="fa fa-check"></i> <?= t_default('acp.error_log.save', 'Save') ?></button>
121+ <?php if ($configured !== ''): ?>
122+ <form method="post" style="display:inline">
123+ <?= acp_csrf_field() ?>
124+ <input type="hidden" name="do" value="reset_path">
125+ <button class="acp-btn acp-btn--ghost" type="submit"><?= t_default('acp.error_log.use_auto_detected', 'Use auto-detected path') ?></button>
126+ </form>
127+ <?php endif; ?>
128+ </div>
129+ </form>
130+ </div>
131+</section>
132+
133+<?php if ($hasFile): ?>
134+ <section class="acp-card">
135+ <header class="acp-card-head">
136+ <h2><?= t_default('acp.error_log.latest_entries', 'Latest entries') ?></h2>
137+ <p>
138+ <code><?= h($path) ?></code>
139+ &middot; <?= h(number_format(@filesize($path) ?: 0)) ?> <?= t_default('acp.error_log.bytes', 'bytes') ?>
140+ &middot; <?= t_default('acp.error_log.last_modified', 'last modified') ?> <?= h(date('Y-m-d H:i:s', @filemtime($path) ?: time())) ?>
141+ </p>
142+ </header>
143+ <div class="acp-card-body">
144+ <div class="acp-row" style="margin-bottom:12px">
145+ <?php foreach (array('100', '300', '1000') as $n): ?>
146+ <a class="acp-btn <?= $linesWant === (int) $n ? '' : 'acp-btn--ghost' ?> acp-btn--sm" href="<?= h(acp_url($module, array('lines' => $n))) ?>"><?= t_default('acp.error_log.last_n', 'Last {n}', ['n' => $n]) ?></a>
147+ <?php endforeach; ?>
148+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url($module, array('lines' => (string) $linesWant))) ?>"><i class="fa fa-refresh"></i> <?= t_default('acp.error_log.refresh', 'Refresh') ?></a>
149+ </div>
150+ <?php if (!$rows): ?>
151+ <?php acp_empty(t_default('acp.error_log.empty', 'The log file is empty.'), 'fa-file-text-o'); ?>
152+ <?php else: ?>
153+ <div class="acp-table-wrap" style="max-height:70vh;overflow:auto">
154+ <table class="acp-table">
155+ <tbody>
156+ <?php foreach ($rows as $line): $sev = acp_error_log_severity($line); ?>
157+ <tr>
158+ <td style="font-family:monospace;font-size:12.5px;white-space:pre-wrap;overflow-wrap:anywhere<?= $sev === 'red' ? ';color:#c0392b' : ($sev === 'amber' ? ';color:#b8860b' : '') ?>"><?= h($line) ?></td>
159+ </tr>
160+ <?php endforeach; ?>
161+ </tbody>
162+ </table>
163+ </div>
164+ <?php endif; ?>
165+ </div>
166+ </section>
167+<?php endif; ?>
A admin/modules/feedback.php +23-0 View file
@@ -0,0 +1,23 @@
1+<?php
2+/**
3+ * Title: Feedback Board
4+ * Icon: fa-comments-o
5+ * Group: Support
6+ * Order: 30
7+ * Description: Forum threads from players awaiting a staff reply.
8+ * Url: ../forum.php?cat=4
9+ * Target: _self
10+ *
11+ * The Url header turns this into an outbound link: index.php redirects there
12+ * rather than including this file. The badge still comes from
13+ * acp_badge_feedback() like any other module.
14+ */
15+
16+if (!defined('ACP_ROOT')) {
17+ http_response_code(403);
18+ die('Direct access denied.');
19+}
20+
21+// Only reached if the Url header above is removed.
22+header('Location: ../forum.php?cat=4');
23+exit;
A admin/modules/gallery.php +190-0 View file
@@ -0,0 +1,190 @@
1+<?php
2+/**
3+ * Title: Gallery
4+ * Icon: fa-picture-o
5+ * Group: Content
6+ * Order: 30
7+ * Description: Moderate player screenshot submissions.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+function acp_gallery_rebuild_cache(): void {
16+ $cache = new Cache('engine/cache/gallery');
17+ $images = fetchImages(2);
18+
19+ $data = [];
20+ if (is_array($images)) {
21+ foreach ($images as $image) {
22+ $data[] = [
23+ 'title' => $image['title'] ?? '',
24+ 'desc' => $image['desc'] ?? '',
25+ 'date' => $image['date'] ?? '',
26+ 'image' => $image['image'] ?? '',
27+ ];
28+ }
29+ }
30+
31+ $cache->setContent($data);
32+ $cache->save();
33+}
34+
35+// ---------------------------------------------------------------------------
36+// Actions
37+// ---------------------------------------------------------------------------
38+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
39+
40+ $do = (string)($_POST['do'] ?? '');
41+ $id = intv($_POST['id'] ?? 0);
42+
43+ if ($id > 0) {
44+ // Soft delete: hidden from the public gallery but kept on record.
45+ if ($do === 'delete') {
46+ updateImage($id, 3);
47+ acp_gallery_rebuild_cache();
48+ acp_log('gallery.hide', '#' . $id);
49+ acp_flash_success(t('acp.gal.hidden', ['id' => $id]));
50+ acp_redirect('gallery');
51+ }
52+
53+ // Accept, or recover a soft-deleted image.
54+ if ($do === 'accept') {
55+ updateImage($id, 2);
56+ acp_gallery_rebuild_cache();
57+ acp_log('gallery.accept', '#' . $id);
58+ acp_flash_success(t('acp.gal.public', ['id' => $id]));
59+ acp_redirect('gallery');
60+ }
61+
62+ // Hard delete: drop it at imgur too, then remove the row.
63+ if ($do === 'remove') {
64+ $delhash = (string)($_POST['delhash'] ?? '');
65+ $imgurClientID = (string)($config['gallery']['Client ID'] ?? '');
66+
67+ if ($delhash !== '' && $imgurClientID !== '' && function_exists('curl_init')) {
68+ $ch = curl_init();
69+ curl_setopt($ch, CURLOPT_URL, 'https://api.imgur.com/3/image/' . rawurlencode($delhash));
70+ curl_setopt($ch, CURLOPT_HEADER, false);
71+ curl_setopt($ch, CURLOPT_POST, true);
72+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
73+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
74+ curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Client-ID {$imgurClientID}"]);
75+
76+ $result = curl_exec($ch);
77+ if ($result === false) {
78+ error_log('cURL error (imgur delete): ' . curl_error($ch));
79+ }
80+ curl_close($ch);
81+ }
82+
83+ db()->execute("DELETE FROM `znote_images` WHERE `id` = ? LIMIT 1;", [$id]);
84+ acp_gallery_rebuild_cache();
85+ acp_log('gallery.remove', '#' . $id);
86+ acp_flash_success(t('acp.gal.removed', ['id' => $id]));
87+ acp_redirect('gallery');
88+ }
89+ }
90+
91+ acp_flash_error(t('acp.gal.unknown_action'));
92+ acp_redirect('gallery');
93+}
94+
95+/**
96+ * One moderation section: heading, count, and a card per image.
97+ *
98+ * @param array<int, array<string, mixed>>|false $images
99+ * @param array<int, array{do: string, label: string, icon: string, class: string, confirm?: string}> $actions
100+ */
101+function acp_gallery_section($images, string $title, string $subtitle, array $actions, string $emptyText): void {
102+ $images = is_array($images) ? $images : [];
103+ ?>
104+ <section class="acp-card">
105+ <header class="acp-card-head">
106+ <h2><?= h($title) ?></h2>
107+ <p><?= h($subtitle) ?></p>
108+ <span class="acp-pill acp-pill--grey"><?= count($images) ?></span>
109+ </header>
110+ <div class="acp-card-body">
111+ <?php if (!$images): ?>
112+ <?php acp_empty($emptyText, 'fa-picture-o'); ?>
113+ <?php else: ?>
114+ <div class="acp-media">
115+ <?php foreach ($images as $image):
116+ $id = (int)($image['id'] ?? 0);
117+ $imageUrl = (string)($image['image'] ?? '');
118+ $delhash = (string)($image['delhash'] ?? '');
119+ $desc = str_replace(['\\r', '\\n'], ['', '<br>'], h((string)($image['desc'] ?? '')));
120+ ?>
121+ <article class="acp-media-item">
122+ <a href="<?= h($imageUrl) ?>" target="_blank" rel="noopener">
123+ <img src="<?= h($imageUrl) ?>" alt="<?= h((string)($image['title'] ?? '')) ?>" loading="lazy">
124+ </a>
125+ <div class="acp-media-body">
126+ <h3><?= h((string)($image['title'] ?? '')) ?></h3>
127+ <p><?= $desc ?></p>
128+ </div>
129+ <div class="acp-media-foot">
130+ <?php foreach ($actions as $action): ?>
131+ <form class="acp-inline-form" method="post"
132+ <?= isset($action['confirm']) ? 'data-confirm="' . h($action['confirm']) . '"' : '' ?>>
133+ <?= acp_csrf_field() ?>
134+ <input type="hidden" name="do" value="<?= h($action['do']) ?>">
135+ <input type="hidden" name="id" value="<?= $id ?>">
136+ <?php if ($action['do'] === 'remove'): ?>
137+ <input type="hidden" name="delhash" value="<?= h($delhash) ?>">
138+ <?php endif; ?>
139+ <button class="acp-btn <?= h($action['class']) ?> acp-btn--sm" type="submit">
140+ <i class="fa <?= h($action['icon']) ?>"></i> <?= h($action['label']) ?>
141+ </button>
142+ </form>
143+ <?php endforeach; ?>
144+ </div>
145+ </article>
146+ <?php endforeach; ?>
147+ </div>
148+ <?php endif; ?>
149+ </div>
150+ </section>
151+ <?php
152+}
153+
154+acp_gallery_section(
155+ fetchImages(1),
156+ t('acp.gal.pending_title'),
157+ t('acp.gal.pending_sub'),
158+ [
159+ ['do' => 'accept', 'label' => t('acp.gal.approve'), 'icon' => 'fa-check', 'class' => 'acp-btn--green'],
160+ ['do' => 'delete', 'label' => t('acp.gal.reject'), 'icon' => 'fa-times', 'class' => 'acp-btn--red'],
161+ ],
162+ t('acp.gal.pending_empty')
163+);
164+
165+acp_gallery_section(
166+ fetchImages(2),
167+ t('acp.gal.public_title'),
168+ t('acp.gal.public_sub'),
169+ [
170+ ['do' => 'delete', 'label' => t('acp.gal.hide'), 'icon' => 'fa-eye-slash', 'class' => 'acp-btn--amber'],
171+ ],
172+ t('acp.gal.public_empty')
173+);
174+
175+acp_gallery_section(
176+ fetchImages(3),
177+ t('acp.gal.hidden_title'),
178+ t('acp.gal.hidden_sub'),
179+ [
180+ ['do' => 'accept', 'label' => t('acp.gal.recover'), 'icon' => 'fa-undo', 'class' => 'acp-btn--green'],
181+ [
182+ 'do' => 'remove',
183+ 'label' => t('acp.gal.delete_forever'),
184+ 'icon' => 'fa-trash',
185+ 'class' => 'acp-btn--red',
186+ 'confirm' => t('acp.gal.delete_confirm'),
187+ ],
188+ ],
189+ t('acp.gal.hidden_empty')
190+);
A admin/modules/health.php +160-0 View file
@@ -0,0 +1,160 @@
1+<?php
2+/**
3+ * Title: System Health
4+ * Icon: fa-heartbeat
5+ * Group: Operations
6+ * Order: 10
7+ * Description: PHP, database, cache, disk and migrations at a glance.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+function acp_health_bytes(int $bytes): string {
16+ if ($bytes >= 1073741824) return number_format($bytes / 1073741824, 2) . ' GB';
17+ if ($bytes >= 1048576) return number_format($bytes / 1048576, 1) . ' MB';
18+ if ($bytes >= 1024) return number_format($bytes / 1024, 1) . ' KB';
19+ return $bytes . ' B';
20+}
21+
22+// --- PHP ---------------------------------------------------------------
23+$phpChecks = array(
24+ array('PHP version', PHP_VERSION_ID >= 80100, PHP_VERSION),
25+);
26+foreach (array('mysqli' => true, 'curl' => false, 'openssl' => false, 'gd' => false, 'zip' => false, 'mbstring' => false) as $ext => $required) {
27+ $phpChecks[] = array(
28+ 'Extension: ' . $ext,
29+ extension_loaded($ext),
30+ extension_loaded($ext) ? 'Loaded' : ($required ? 'Missing (required)' : 'Missing (optional)'),
31+ );
32+}
33+
34+// --- Database ------------------------------------------------------------
35+$dbSize = db()->fetchOne("
36+ SELECT
37+ ROUND(SUM(data_length + index_length)) AS `bytes`,
38+ COUNT(*) AS `tables`
39+ FROM `information_schema`.`tables`
40+ WHERE `table_schema` = DATABASE();
41+");
42+$dbBytes = (int)($dbSize['bytes'] ?? 0);
43+$dbTables = (int)($dbSize['tables'] ?? 0);
44+
45+// --- Cache -----------------------------------------------------------
46+$cacheStats = function_exists('znote_cache_stats') ? znote_cache_stats() : array('files' => 0, 'bytes' => 0, 'apcu_available' => false);
47+
48+// --- Migrations --------------------------------------------------------
49+$migrationStatus = function_exists('znote_migrations_status') ? znote_migrations_status() : array();
50+$pendingMigrations = 0;
51+foreach ($migrationStatus as $m) {
52+ if (($m['state'] ?? '') === 'pending') $pendingMigrations++;
53+}
54+
55+// --- Disk --------------------------------------------------------------
56+$diskFree = @disk_free_space(dirname(__DIR__, 2));
57+$diskTotal = @disk_total_space(dirname(__DIR__, 2));
58+
59+// --- Error log -----------------------------------------------------------
60+$errorLogPath = trim((string)(setting('error_log:path', '') ?: (string)@ini_get('error_log')));
61+$errorLogSize = ($errorLogPath !== '' && is_file($errorLogPath)) ? (int)filesize($errorLogPath) : null;
62+$errorLogModified = ($errorLogPath !== '' && is_file($errorLogPath)) ? (int)filemtime($errorLogPath) : null;
63+
64+// --- Backups -------------------------------------------------------------
65+$backups = function_exists('znote_backups_list') ? znote_backups_list() : array();
66+$lastBackup = $backups[0] ?? null;
67+?>
68+
69+<div class="acp-stats">
70+ <?php
71+ acp_stat(t_default('acp.health.stat_db_size', 'Database size'), acp_health_bytes($dbBytes), 'fa-database', null, 'blue');
72+ acp_stat(t_default('acp.health.stat_cache', 'Cache entries'), number_format((int)($cacheStats['files'] ?? 0)), 'fa-bolt', acp_url('settings'), 'purple');
73+ acp_stat(t_default('acp.health.stat_migrations', 'Pending migrations'), $pendingMigrations, 'fa-database', acp_url('migrations'), $pendingMigrations > 0 ? 'amber' : 'green');
74+ acp_stat(t_default('acp.health.stat_backup', 'Last backup'), $lastBackup ? h(getClock((int)$lastBackup['time'], true)) : t_default('acp.health.no_backup', 'None yet'), 'fa-archive', acp_url('backups'), $lastBackup ? 'green' : 'amber');
75+ ?>
76+</div>
77+
78+<div class="acp-grid acp-grid--2">
79+
80+ <section class="acp-card">
81+ <header class="acp-card-head">
82+ <h2><?= t_default('acp.health.php_title', 'PHP & extensions') ?></h2>
83+ </header>
84+ <div class="acp-card-body is-flush">
85+ <div class="acp-table-wrap">
86+ <table class="acp-table">
87+ <tbody>
88+ <?php foreach ($phpChecks as $check): ?>
89+ <tr>
90+ <td><?= h($check[0]) ?></td>
91+ <td class="is-num">
92+ <span class="acp-pill acp-pill--<?= $check[1] ? 'green' : 'amber' ?>"><?= h($check[2]) ?></span>
93+ </td>
94+ </tr>
95+ <?php endforeach; ?>
96+ </tbody>
97+ </table>
98+ </div>
99+ </div>
100+ </section>
101+
102+ <section class="acp-card">
103+ <header class="acp-card-head">
104+ <h2><?= t_default('acp.health.storage_title', 'Storage') ?></h2>
105+ </header>
106+ <div class="acp-card-body is-flush">
107+ <div class="acp-table-wrap">
108+ <table class="acp-table">
109+ <tbody>
110+ <tr>
111+ <td><?= t_default('acp.health.db_tables', 'Database tables') ?></td>
112+ <td class="is-num"><?= number_format($dbTables) ?></td>
113+ </tr>
114+ <tr>
115+ <td><?= t_default('acp.health.cache_size', 'Cache on disk') ?></td>
116+ <td class="is-num"><?= h(acp_health_bytes((int)($cacheStats['bytes'] ?? 0))) ?></td>
117+ </tr>
118+ <tr>
119+ <td><?= t_default('acp.health.apcu', 'APCu memory cache') ?></td>
120+ <td class="is-num">
121+ <span class="acp-pill acp-pill--<?= !empty($cacheStats['apcu_available']) ? 'green' : 'grey' ?>">
122+ <?= !empty($cacheStats['apcu_available']) ? t_default('acp.health.available', 'Available') : t_default('acp.health.unavailable', 'Unavailable') ?>
123+ </span>
124+ </td>
125+ </tr>
126+ <?php if ($diskFree !== false && $diskTotal !== false && $diskTotal > 0): ?>
127+ <tr>
128+ <td><?= t_default('acp.health.disk_free', 'Disk free') ?></td>
129+ <td class="is-num"><?= h(acp_health_bytes((int)$diskFree)) ?> / <?= h(acp_health_bytes((int)$diskTotal)) ?></td>
130+ </tr>
131+ <?php endif; ?>
132+ </tbody>
133+ </table>
134+ </div>
135+ </div>
136+ </section>
137+
138+</div>
139+
140+<section class="acp-card">
141+ <header class="acp-card-head">
142+ <h2><?= t_default('acp.health.errorlog_title', 'Error log') ?></h2>
143+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('error_log')) ?>" style="margin-left:auto;">
144+ <?= t_default('acp.health.errorlog_open', 'Open') ?>
145+ </a>
146+ </header>
147+ <div class="acp-card-body">
148+ <?php if ($errorLogSize !== null): ?>
149+ <p>
150+ <?= t_default('acp.health.errorlog_summary', '{path} - {size}, last written {when}.', [
151+ 'path' => '<code>' . h($errorLogPath) . '</code>',
152+ 'size' => h(acp_health_bytes($errorLogSize)),
153+ 'when' => h(getClock((int)$errorLogModified, true)),
154+ ]) ?>
155+ </p>
156+ <?php else: ?>
157+ <p class="is-muted"><?= t_default('acp.health.errorlog_none', 'No log file found at the configured or auto-detected path.') ?></p>
158+ <?php endif; ?>
159+ </div>
160+</section>
A admin/modules/helpdesk.php +241-0 View file
@@ -0,0 +1,241 @@
1+<?php
2+/**
3+ * Title: Helpdesk
4+ * Icon: fa-life-ring
5+ * Group: Support
6+ * Order: 20
7+ * Description: Answer, close and delete player support tickets.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+$view = intv($_GET['view'] ?? 0);
16+
17+// ---------------------------------------------------------------------------
18+// Ticket actions
19+// ---------------------------------------------------------------------------
20+if ($_SERVER['REQUEST_METHOD'] === 'POST' && $view > 0) {
21+
22+ // ----------------------------------------------------------- Post reply
23+ if (!empty($_POST['reply_text'])) {
24+ $replyText = sanitize((string)$_POST['reply_text']);
25+ $username = sanitize((string)($_POST['username'] ?? 'ADMIN'));
26+
27+ db()->execute("
28+ INSERT INTO `znote_tickets_replies` (`tid`, `username`, `message`, `created`)
29+ VALUES (?, ?, ?, ?);
30+ ", [$view, $username, $replyText, time()]);
31+
32+ db()->execute("
33+ UPDATE `znote_tickets`
34+ SET `status` = 'Staff-Reply'
35+ WHERE `id` = ?
36+ LIMIT 1;
37+ ", [$view]);
38+
39+ acp_log('helpdesk.reply', '#' . $view);
40+ acp_flash_success(t('acp.hd.reply_posted'));
41+ acp_redirect('helpdesk', ['view' => $view]);
42+ }
43+
44+ $ticketId = intv($_POST['admin_ticket_id'] ?? 0);
45+
46+ // ---------------------------------------------------------- Close ticket
47+ if (!empty($_POST['admin_ticket_close']) && $ticketId > 0) {
48+ db()->execute("UPDATE `znote_tickets` SET `status` = 'CLOSED' WHERE `id` = ? LIMIT 1;", [$ticketId]);
49+ acp_log('helpdesk.close', '#' . $ticketId);
50+ acp_flash_success(t('acp.hd.closed'));
51+ acp_redirect('helpdesk', ['view' => $view]);
52+ }
53+
54+ // ----------------------------------------------------------- Open ticket
55+ if (!empty($_POST['admin_ticket_open']) && $ticketId > 0) {
56+ db()->execute("UPDATE `znote_tickets` SET `status` = 'Open' WHERE `id` = ? LIMIT 1;", [$ticketId]);
57+ acp_log('helpdesk.reopen', '#' . $ticketId);
58+ acp_flash_success(t('acp.hd.reopened'));
59+ acp_redirect('helpdesk', ['view' => $view]);
60+ }
61+
62+ // --------------------------------------------------------- Delete ticket
63+ if (!empty($_POST['admin_ticket_delete']) && $ticketId > 0) {
64+ db()->execute("DELETE FROM `znote_tickets` WHERE `id` = ? LIMIT 1;", [$ticketId]);
65+ db()->execute("DELETE FROM `znote_tickets_replies` WHERE `tid` = ?;", [$ticketId]);
66+ acp_log('helpdesk.delete', '#' . $ticketId);
67+ acp_flash_success(t('acp.hd.deleted', ['id' => $ticketId]));
68+ acp_redirect('helpdesk');
69+ }
70+}
71+
72+// Pill colour per ticket status.
73+function acp_ticket_tone(string $status): string {
74+ switch (strtoupper($status)) {
75+ case 'CLOSED': return 'grey';
76+ case 'STAFF-REPLY': return 'blue';
77+ case 'OPEN': return 'green';
78+ default: return 'amber';
79+ }
80+}
81+?>
82+
83+<?php if ($view > 0):
84+
85+ $ticket = db()->fetchOne("SELECT * FROM `znote_tickets` WHERE `id` = ? LIMIT 1;", [$view]);
86+
87+ if (!is_array($ticket)):
88+ ?>
89+ <section class="acp-card">
90+ <div class="acp-card-body">
91+ <?php acp_empty(t('acp.hd.not_found'), 'fa-question-circle-o'); ?>
92+ <div class="acp-actions" style="justify-content:center;">
93+ <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('helpdesk')) ?>"><?= t('acp.hd.back_all') ?></a>
94+ </div>
95+ </div>
96+ </section>
97+ <?php
98+ else:
99+ $ticketId = (int)($ticket['id'] ?? $view);
100+ $status = (string)($ticket['status'] ?? '');
101+ $isClosed = (strtoupper($status) === 'CLOSED');
102+
103+ $replies = db()->fetchAll("
104+ SELECT * FROM `znote_tickets_replies`
105+ WHERE `tid` = ?
106+ ORDER BY `created` ASC;
107+ ", [$view]);
108+ ?>
109+
110+ <div class="acp-toolbar">
111+ <div>
112+ <strong><?= t('acp.hd.ticket_hash', ['id' => $ticketId]) ?></strong>
113+ &mdash; <?= h((string)($ticket['subject'] ?? '')) ?>
114+ <span class="acp-pill acp-pill--<?= h(acp_ticket_tone($status)) ?>"><?= h($status) ?></span>
115+ </div>
116+ <div class="acp-actions is-tight">
117+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('helpdesk')) ?>">
118+ <i class="fa fa-arrow-left"></i> <?= t('acp.hd.all_tickets') ?>
119+ </a>
120+ <form class="acp-inline-form" method="post">
121+ <?= acp_csrf_field() ?>
122+ <input type="hidden" name="admin_ticket_id" value="<?= $ticketId ?>">
123+ <?php if (!$isClosed): ?>
124+ <button class="acp-btn acp-btn--amber acp-btn--sm" type="submit" name="admin_ticket_close" value="1">
125+ <i class="fa fa-lock"></i> <?= t('acp.hd.close') ?>
126+ </button>
127+ <?php else: ?>
128+ <button class="acp-btn acp-btn--green acp-btn--sm" type="submit" name="admin_ticket_open" value="1">
129+ <i class="fa fa-unlock"></i> <?= t('acp.hd.reopen') ?>
130+ </button>
131+ <?php endif; ?>
132+ </form>
133+ <form class="acp-inline-form" method="post" data-confirm="<?= h(t('acp.hd.confirm_delete')) ?>">
134+ <?= acp_csrf_field() ?>
135+ <input type="hidden" name="admin_ticket_id" value="<?= $ticketId ?>">
136+ <button class="acp-btn acp-btn--red acp-btn--sm" type="submit" name="admin_ticket_delete" value="1">
137+ <i class="fa fa-trash"></i> <?= t('acp.hd.delete') ?>
138+ </button>
139+ </form>
140+ </div>
141+ </div>
142+
143+ <article class="acp-post">
144+ <header class="acp-post-head">
145+ <span><?= t('acp.hd.opened_by') ?> <strong><?= h((string)($ticket['username'] ?? '')) ?></strong></span>
146+ <span><?= h(getClock((int)($ticket['creation'] ?? 0), true)) ?></span>
147+ </header>
148+ <div class="acp-post-body"><?= nl2br(h((string)($ticket['message'] ?? ''))) ?></div>
149+ </article>
150+
151+ <?php if (is_array($replies) && $replies): ?>
152+ <?php foreach ($replies as $reply):
153+ $replyUser = (string)($reply['username'] ?? '');
154+ $isStaff = (strtoupper($replyUser) === 'ADMIN');
155+ ?>
156+ <article class="acp-post<?= $isStaff ? ' acp-post--staff' : '' ?>">
157+ <header class="acp-post-head">
158+ <span><?= $isStaff ? '<i class="fa fa-shield"></i> ' : '' ?><?= t('acp.hd.reply_by') ?> <strong><?= h($replyUser) ?></strong></span>
159+ <span><?= h(getClock((int)($reply['created'] ?? 0), true)) ?></span>
160+ </header>
161+ <div class="acp-post-body"><?= nl2br(h((string)($reply['message'] ?? ''))) ?></div>
162+ </article>
163+ <?php endforeach; ?>
164+ <?php endif; ?>
165+
166+ <?php if (!$isClosed): ?>
167+ <section class="acp-card">
168+ <header class="acp-card-head"><h2><?= t('acp.hd.reply_heading') ?></h2></header>
169+ <div class="acp-card-body">
170+ <form method="post">
171+ <?= acp_csrf_field() ?>
172+ <input type="hidden" name="username" value="ADMIN">
173+ <div class="acp-field">
174+ <textarea class="acp-textarea" name="reply_text" rows="7" placeholder="<?= h(t('acp.hd.reply_placeholder')) ?>" required></textarea>
175+ </div>
176+ <div class="acp-actions">
177+ <button class="acp-btn" type="submit"><i class="fa fa-reply"></i> <?= t('acp.hd.post_reply') ?></button>
178+ </div>
179+ </form>
180+ </div>
181+ </section>
182+ <?php else: ?>
183+ <section class="acp-card">
184+ <div class="acp-card-body">
185+ <?php acp_empty(t('acp.hd.closed_hint'), 'fa-lock'); ?>
186+ </div>
187+ </section>
188+ <?php endif; ?>
189+
190+ <?php endif; ?>
191+
192+<?php else:
193+
194+ $tickets = db()->fetchAll("
195+ SELECT `id`, `subject`, `username`, `creation`, `status`
196+ FROM `znote_tickets`
197+ ORDER BY `creation` DESC;
198+ ");
199+ ?>
200+
201+ <section class="acp-card">
202+ <header class="acp-card-head">
203+ <h2><?= t('acp.hd.all_tickets') ?></h2>
204+ <p><?= t('acp.hd.newest_first') ?></p>
205+ </header>
206+ <div class="acp-card-body is-flush">
207+ <?php if (is_array($tickets) && $tickets): ?>
208+ <div class="acp-table-wrap">
209+ <table class="acp-table">
210+ <thead>
211+ <tr>
212+ <th>#</th>
213+ <th><?= t('acp.hd.col_subject') ?></th>
214+ <th><?= t('acp.hd.col_opened_by') ?></th>
215+ <th><?= t('acp.hd.col_created') ?></th>
216+ <th><?= t('acp.hd.col_status') ?></th>
217+ </tr>
218+ </thead>
219+ <tbody>
220+ <?php foreach ($tickets as $ticket):
221+ $id = (int)($ticket['id'] ?? 0);
222+ $status = (string)($ticket['status'] ?? '');
223+ ?>
224+ <tr>
225+ <td class="is-muted"><?= $id ?></td>
226+ <td><a href="<?= h(acp_url('helpdesk', ['view' => $id])) ?>"><?= h((string)($ticket['subject'] ?? '')) ?></a></td>
227+ <td><?= h((string)($ticket['username'] ?? '')) ?></td>
228+ <td class="is-nowrap is-muted"><?= h(getClock((int)($ticket['creation'] ?? 0), true)) ?></td>
229+ <td><span class="acp-pill acp-pill--<?= h(acp_ticket_tone($status)) ?>"><?= h($status) ?></span></td>
230+ </tr>
231+ <?php endforeach; ?>
232+ </tbody>
233+ </table>
234+ </div>
235+ <?php else: ?>
236+ <?php acp_empty(t('acp.hd.empty'), 'fa-life-ring'); ?>
237+ <?php endif; ?>
238+ </div>
239+ </section>
240+
241+<?php endif; ?>
A admin/modules/items_editor.php +220-0 View file
@@ -0,0 +1,220 @@
1+<?php
2+/**
3+ * Title: Items
4+ * Icon: fa-shield
5+ * Group: Server Info
6+ * Order: 12
7+ * Description: Add or edit a single item without re-uploading the whole items.xml.
8+ * Hidden: true
9+ */
10+
11+
12+if (!defined('ACP_ROOT')) {
13+ http_response_code(403);
14+ die('Direct access denied.');
15+}
16+
17+const ITMED_TYPE = 'item';
18+
19+/** Stored attribute map -> editable {key, value} rows. */
20+function itmed_attr_rows(array $attributes): array {
21+ $rows = array();
22+ foreach ($attributes as $key => $value) {
23+ $rows[] = array('key' => (string)$key, 'value' => is_scalar($value) ? (string)$value : '');
24+ }
25+ return $rows;
26+}
27+
28+/** Posted {key, value} rows -> attribute map, blank/duplicate keys dropped. */
29+function itmed_attr_map_from_post(array $rowsRaw): array {
30+ $map = array();
31+ foreach ($rowsRaw as $row) {
32+ if (!is_array($row)) continue;
33+ $key = trim((string)($row['key'] ?? ''));
34+ if ($key === '') continue;
35+ $map[$key] = trim((string)($row['value'] ?? ''));
36+ }
37+ return $map;
38+}
39+
40+$items = serverdata_load('items');
41+$items = is_array($items) ? $items : array();
42+
43+if (!$items && !serverdata_override_table_exists()) {
44+ acp_flash_error(t_default('acp.itmed.no_data', 'Upload an items.xml on Server Info first - there is nothing published to edit yet.'));
45+ acp_redirect('serverinfo');
46+}
47+
48+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
49+ $adminName = (string)($GLOBALS['user_data']['name'] ?? '');
50+ $action = (string)($_POST['itmed_action'] ?? '');
51+
52+ if ($action === 'save') {
53+ $id = intv($_POST['id'] ?? 0);
54+ $name = trim((string)($_POST['name'] ?? ''));
55+
56+ if ($id <= 0 || $name === '') {
57+ acp_flash_error(t_default('acp.itmed.missing_fields', 'ID and name are required.'));
58+ acp_redirect('items_editor');
59+ }
60+
61+ $record = array(
62+ 'id' => (string)$id,
63+ 'name' => $name,
64+ 'attributes' => itmed_attr_map_from_post((array)($_POST['attr'] ?? array())),
65+ );
66+
67+ if (serverdata_override_set('items', ITMED_TYPE . ':' . $id, $record, $adminName)) {
68+ acp_log('serverdata.item_save', $name, ['type' => ITMED_TYPE, 'id' => $id]);
69+ acp_flash_success(t_default('acp.itmed.saved', 'Item #{id} saved.', ['id' => $id]));
70+ } else {
71+ acp_flash_error(t_default('acp.itmed.save_failed', 'Could not save - run the pending database migration first (Admin Panel > Migrations).'));
72+ }
73+ acp_redirect('items_editor');
74+ }
75+
76+ if ($action === 'delete') {
77+ $id = intv($_POST['id'] ?? 0);
78+ if (serverdata_override_delete('items', ITMED_TYPE . ':' . $id, $adminName)) {
79+ acp_log('serverdata.item_delete', '#' . $id, ['type' => ITMED_TYPE, 'id' => $id]);
80+ acp_flash_success(t_default('acp.itmed.removed', 'Item #{id} removed.', ['id' => $id]));
81+ } else {
82+ acp_flash_error(t_default('acp.itmed.remove_failed', 'Could not remove that item.'));
83+ }
84+ acp_redirect('items_editor');
85+ }
86+}
87+
88+$editId = intv($_GET['edit'] ?? 0);
89+$editing = null;
90+if ($editId > 0 && isset($items[ITMED_TYPE][(string)$editId])) {
91+ $editing = $items[ITMED_TYPE][(string)$editId];
92+}
93+
94+$attrRows = itmed_attr_rows($editing['attributes'] ?? array());
95+
96+$attrColumns = array(
97+ 'key' => array('label' => t_default('acp.itmed.col_attr_key', 'Attribute'), 'type' => 'text'),
98+ 'value' => array('label' => t_default('acp.itmed.col_attr_value', 'Value'), 'type' => 'text'),
99+);
100+
101+$list = $items[ITMED_TYPE] ?? array();
102+ksort($list, SORT_NUMERIC);
103+?>
104+
105+<section class="acp-card">
106+ <header class="acp-card-head">
107+ <h2><?= $editing ? h(t_default('acp.itmed.edit_title', 'Edit item #{id}', ['id' => $editId])) : h(t_default('acp.itmed.add_title', 'Add an item')) ?></h2>
108+ <p><?= h(t_default('acp.itmed.sub', 'Saved separately from items.xml - a later re-upload will not overwrite this.')) ?></p>
109+ </header>
110+ <div class="acp-card-body">
111+ <form method="post">
112+ <?= acp_csrf_field() ?>
113+ <input type="hidden" name="itmed_action" value="save">
114+
115+ <div class="acp-row">
116+ <div class="acp-field">
117+ <label class="acp-label" for="itmed_id"><?= t_default('acp.itmed.field_id', 'Item ID') ?></label>
118+ <input class="acp-input" type="number" min="1" id="itmed_id" name="id" value="<?= $editing ? (int)$editId : '' ?>" <?= $editing ? 'readonly' : '' ?> required>
119+ </div>
120+ <div class="acp-field">
121+ <label class="acp-label" for="itmed_name"><?= t_default('acp.itmed.field_name', 'Name') ?></label>
122+ <input class="acp-input" type="text" id="itmed_name" name="name" value="<?= h((string)($editing['name'] ?? '')) ?>" required>
123+ </div>
124+ </div>
125+
126+ <div class="acp-field">
127+ <label class="acp-label"><?= t_default('acp.itmed.field_attrs', 'Attributes') ?></label>
128+ <table class="acp-table acp-table--editable" data-acp-table="itmedAttrs">
129+ <thead>
130+ <tr>
131+ <?php foreach ($attrColumns as $colDef): ?>
132+ <th><?= h($colDef['label']) ?></th>
133+ <?php endforeach; ?>
134+ <th></th>
135+ </tr>
136+ </thead>
137+ <tbody data-acp-table-body>
138+ <?php foreach ($attrRows as $i => $row): ?>
139+ <tr>
140+ <?php foreach ($attrColumns as $colKey => $colDef): ?>
141+ <td><?= acp_table_cell_input($colDef, 'attr[' . (int)$i . '][' . $colKey . ']', (string)($row[$colKey] ?? '')) ?></td>
142+ <?php endforeach; ?>
143+ <td><button type="button" class="acp-btn acp-btn--sm acp-btn--red" data-acp-table-remove>&times;</button></td>
144+ </tr>
145+ <?php endforeach; ?>
146+ </tbody>
147+ </table>
148+ <button type="button" class="acp-btn acp-btn--sm" data-acp-table-add="itmedAttrs">
149+ <i class="fa fa-plus"></i> <?= t_default('acp.itmed.add_attr', 'Add attribute') ?>
150+ </button>
151+ <template data-acp-table-template="itmedAttrs">
152+ <tr>
153+ <?php foreach ($attrColumns as $colKey => $colDef): ?>
154+ <td><?= acp_table_cell_input($colDef, 'attr[__ROWIDX__][' . $colKey . ']', '') ?></td>
155+ <?php endforeach; ?>
156+ <td><button type="button" class="acp-btn acp-btn--sm acp-btn--red" data-acp-table-remove>&times;</button></td>
157+ </tr>
158+ </template>
159+ </div>
160+
161+ <div class="acp-actions">
162+ <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-check"></i> <?= $editing ? t_default('acp.itmed.save_btn', 'Save') : t_default('acp.itmed.add_btn', 'Add item') ?></button>
163+ <?php if ($editing): ?>
164+ <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('items_editor')) ?>"><?= t_default('acp.itmed.cancel', 'Cancel edit') ?></a>
165+ <?php endif; ?>
166+ </div>
167+ </form>
168+ </div>
169+</section>
170+
171+<section class="acp-card">
172+ <header class="acp-card-head">
173+ <h2><?= t_default('acp.itmed.list_title', 'Published items') ?></h2>
174+ </header>
175+ <div class="acp-card-body is-flush">
176+ <?php if ($list): ?>
177+ <?php if (count($list) > 8): ?>
178+ <div class="acp-toolbar">
179+ <div style="display:flex;gap:8px;flex:1 1 320px;max-width:460px;">
180+ <input class="acp-input" type="search" data-acp-search-input="itmedTable"
181+ placeholder="<?= h(t_default('acp.itmed.search_placeholder', 'Search items...')) ?>">
182+ </div>
183+ <span class="is-muted" data-acp-search-count="itmedTable"></span>
184+ </div>
185+ <?php endif; ?>
186+ <div class="acp-table-wrap">
187+ <table class="acp-table" data-sortable id="itmedTable">
188+ <thead>
189+ <tr>
190+ <th>ID</th>
191+ <th><?= t_default('acp.itmed.col_name', 'Name') ?></th>
192+ <th class="is-num"><?= t_default('acp.itmed.col_actions', 'Actions') ?></th>
193+ </tr>
194+ </thead>
195+ <tbody>
196+ <?php foreach ($list as $id => $rec): ?>
197+ <tr data-acp-search="<?= h(strtolower($id . ' ' . (string)($rec['name'] ?? ''))) ?>">
198+ <td class="is-muted"><?= h((string)$id) ?></td>
199+ <td><?= h((string)($rec['name'] ?? '')) ?></td>
200+ <td class="is-nowrap is-num">
201+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('items_editor', array('edit' => $id))) ?>"><i class="fa fa-pencil"></i></a>
202+ <form class="acp-inline-form" method="post" data-confirm="<?= h(t_default('acp.itmed.confirm_remove', 'Remove this item?')) ?>">
203+ <?= acp_csrf_field() ?>
204+ <input type="hidden" name="itmed_action" value="delete">
205+ <input type="hidden" name="id" value="<?= h((string)$id) ?>">
206+ <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"><i class="fa fa-times"></i></button>
207+ </form>
208+ </td>
209+ </tr>
210+ <?php endforeach; ?>
211+ </tbody>
212+ </table>
213+ </div>
214+ <?php else: ?>
215+ <?php acp_empty(t_default('acp.itmed.empty', 'No items published yet.'), 'fa-shield'); ?>
216+ <?php endif; ?>
217+ </div>
218+</section>
219+
220+<p class="acp-hint"><a href="<?= h(acp_url('serverinfo')) ?>"><i class="fa fa-arrow-left"></i> <?= t_default('acp.itmed.back', 'Back to Server Info') ?></a></p>
A admin/modules/landing.php +173-0 View file
@@ -0,0 +1,173 @@
1+<?php
2+/**
3+ * Title: Landing Page
4+ * Icon: fa-flag
5+ * Group: Content
6+ * Order: 8
7+ * Description: Show a standalone page from landing/ instead of the front page.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
16+
17+ $enabled = empty($_POST['enabled']) ? '' : '1';
18+ $file = trim((string)($_POST['file'] ?? ''));
19+
20+ if ($file !== '' && !landing_is_valid_file($file)) {
21+ acp_flash_error(t('acp.land.bad_file'));
22+ acp_redirect('landing');
23+ }
24+
25+ if ($enabled === '1') {
26+ if (!is_dir(landing_root())) {
27+ acp_flash_error(t('acp.land.no_folder', ['folder' => '<code>landing/</code>']));
28+ acp_redirect('landing');
29+ }
30+ if ($file === '' || !is_file(landing_root() . '/' . $file)) {
31+ acp_flash_error(t('acp.land.pick_page', ['folder' => '<code>landing/</code>']));
32+ acp_redirect('landing');
33+ }
34+ }
35+
36+ setting_set('landing.enabled', $enabled);
37+ setting_set('landing.file', $file);
38+
39+ acp_log('landing.toggle', $file, ['enabled' => $enabled === '1']);
40+
41+ acp_flash_success($enabled === '1'
42+ ? t('acp.land.live', ['path' => '<code>landing/' . h($file) . '</code>'])
43+ : t('acp.land.off'));
44+
45+ acp_redirect('landing');
46+}
47+
48+$files = landing_available_files();
49+$hasFolder = is_dir(landing_root());
50+$enabled = landing_enabled();
51+$current = landing_file();
52+$live = landing_ready();
53+?>
54+
55+<div class="acp-toolbar">
56+ <div>
57+ <?php if ($live): ?>
58+ <span class="acp-pill acp-pill--green"><?= t('acp.land.live_pill') ?></span>
59+ <?php elseif ($enabled): ?>
60+ <span class="acp-pill acp-pill--red"><?= t('acp.land.missing_pill') ?></span>
61+ <?php else: ?>
62+ <span class="acp-pill acp-pill--grey"><?= t('acp.land.off_pill') ?></span>
63+ <?php endif; ?>
64+ </div>
65+ <div class="acp-actions is-tight">
66+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_site()) ?>" target="_blank" rel="noopener">
67+ <i class="fa fa-external-link"></i> <?= t('acp.land.open_site') ?>
68+ </a>
69+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_site('index.php?site=1')) ?>" target="_blank" rel="noopener">
70+ <i class="fa fa-sign-in"></i> <?= t('acp.land.skip') ?>
71+ </a>
72+ </div>
73+</div>
74+
75+<div class="acp-grid acp-grid--2">
76+
77+ <section class="acp-card">
78+ <header class="acp-card-head">
79+ <h2><?= t('acp.land.title') ?></h2>
80+ <p><?= t('acp.land.sub') ?></p>
81+ </header>
82+ <div class="acp-card-body">
83+
84+ <?php if (!$hasFolder): ?>
85+ <div class="acp-flash acp-flash--error">
86+ <i class="fa fa-exclamation-triangle"></i>
87+ <span><?= t('acp.land.no_folder_flash', [
88+ 'folder' => '<code>landing/</code>',
89+ 'index' => '<code>index.html</code>',
90+ 'indexphp' => '<code>index.php</code>',
91+ ]) ?></span>
92+ </div>
93+ <?php elseif (!$files): ?>
94+ <div class="acp-flash acp-flash--error">
95+ <i class="fa fa-exclamation-triangle"></i>
96+ <span><?= t('acp.land.no_pages_flash', [
97+ 'folder' => '<code>landing/</code>',
98+ 'html' => '<code>.html</code>',
99+ 'php' => '<code>.php</code>',
100+ ]) ?></span>
101+ </div>
102+ <?php endif; ?>
103+
104+ <form method="post">
105+ <?= acp_csrf_field() ?>
106+
107+ <div class="acp-field">
108+ <label class="acp-label" for="file"><?= t('acp.land.page_to_show') ?></label>
109+ <select class="acp-select" id="file" name="file" <?= $files ? '' : 'disabled' ?>>
110+ <?php if (!$files): ?>
111+ <option value=""><?= t('acp.land.nothing_yet') ?></option>
112+ <?php endif; ?>
113+ <?php foreach ($files as $name): ?>
114+ <option value="<?= h($name) ?>" <?= $name === $current ? 'selected' : '' ?>>
115+ <?= h('landing/' . $name) ?>
116+ </option>
117+ <?php endforeach; ?>
118+ </select>
119+ </div>
120+
121+ <div class="acp-field">
122+ <label style="display:flex;align-items:center;gap:8px;font-weight:400;">
123+ <input type="checkbox" name="enabled" value="1" <?= $enabled ? 'checked' : '' ?>>
124+ <span><?= t('acp.land.show_at_root') ?></span>
125+ </label>
126+ </div>
127+
128+ <div class="acp-actions">
129+ <button class="acp-btn acp-btn--green" type="submit">
130+ <i class="fa fa-check"></i> <?= t('acp.land.save') ?>
131+ </button>
132+ </div>
133+ </form>
134+ </div>
135+ </section>
136+
137+ <section class="acp-card">
138+ <header class="acp-card-head">
139+ <h2><?= t('acp.land.behaves_title') ?></h2>
140+ <p><?= t('acp.land.behaves_sub') ?></p>
141+ </header>
142+ <div class="acp-card-body">
143+ <table class="acp-table">
144+ <tbody>
145+ <tr>
146+ <td><?= t('acp.land.col_folder') ?></td>
147+ <td><code><?= h(ZNOTE_LANDING_DIR) ?>/</code> <?= $hasFolder ? '' : '<em>' . t('acp.land.missing_suffix') . '</em>' ?></td>
148+ </tr>
149+ <tr>
150+ <td><?= t('acp.land.col_pages') ?></td>
151+ <td><?= $files ? h(implode(', ', $files)) : '&mdash;' ?></td>
152+ </tr>
153+ <tr>
154+ <td><?= t('acp.land.col_way_in') ?></td>
155+ <td><code>index.php?site=1</code></td>
156+ </tr>
157+ </tbody>
158+ </table>
159+
160+ <p class="acp-hint">
161+ <?= t('acp.land.hint1') ?>
162+ </p>
163+ <p class="acp-hint">
164+ <?= t('acp.land.hint2', [
165+ 'base' => '<code>&lt;base&gt;</code>',
166+ 'folder' => '<code>' . h(ZNOTE_LANDING_DIR) . '/</code>',
167+ 'link' => '<code>&lt;a href="/index.php?site=1"&gt;Enter&lt;/a&gt;</code>',
168+ ]) ?>
169+ </p>
170+ </div>
171+ </section>
172+
173+</div>
A admin/modules/layouts.php +518-0 View file
@@ -0,0 +1,518 @@
1+<?php
2+/**
3+ * Title: Layout
4+ * Icon: fa-paint-brush
5+ * Group: Settings
6+ * Order: 10
7+ * Description: Pick the theme the public site is dressed in.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['clear_layout_repository_cache'])) {
16+ if (theme_repository_clear_cache()) {
17+ acp_log('layouts.cache_clear');
18+ acp_flash_success(t('acp.laybr.cache_deleted'));
19+ } else {
20+ acp_flash_error(t('acp.laybr.cache_delete_failed', [
21+ 'path' => '<code>' . h(theme_repository_cache_path()) . '</code>',
22+ ]));
23+ }
24+
25+ acp_redirect('layouts', array('tab' => 'browse', 'refresh' => 1));
26+}
27+
28+// ---------------------------------------------------------------------------
29+// Activate a theme
30+// ---------------------------------------------------------------------------
31+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['install'])) {
32+
33+ $key = theme_sanitize((string)$_POST['install']);
34+ $overwrite = !empty($_POST['overwrite']);
35+ $result = theme_repository_install($key, $overwrite);
36+
37+ if ($result === '') {
38+ acp_log('layouts.install', $key);
39+ acp_flash_success(t('acp.lay.installed', [
40+ 'theme' => '<strong>' . h($key) . '</strong>',
41+ 'path' => '<code>layouts/' . h($key) . '/</code>',
42+ ]));
43+ } elseif ($result === 'already-installed') {
44+ acp_flash_error(t('acp.lay.already_installed', ['theme' => '<strong>' . h($key) . '</strong>']));
45+ } else {
46+ acp_flash_error(t('acp.lay.install_failed', ['error' => h($result)]));
47+ }
48+
49+ acp_redirect('layouts', array('tab' => 'browse'));
50+}
51+
52+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['uninstall'])) {
53+
54+ $key = theme_sanitize((string)$_POST['uninstall']);
55+ $result = theme_uninstall($key);
56+
57+ if ($result === '') {
58+ acp_log('layouts.uninstall', $key);
59+ acp_flash_success(t('acp.lay.removed', [
60+ 'theme' => '<strong>' . h($key) . '</strong>',
61+ 'path' => '<code>layouts/</code>',
62+ ]));
63+ } else {
64+ acp_flash_error(t('acp.lay.remove_failed', ['error' => h($result)]));
65+ }
66+
67+ acp_redirect('layouts');
68+}
69+
70+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['theme_options'])) {
71+
72+ $target = theme_sanitize((string)$_POST['theme_options']);
73+ $themes = theme_list();
74+ $options = isset($themes[$target]) ? theme_options($target) : array();
75+
76+ if ($options === array()) {
77+ acp_flash_error(t('acp.lay.no_options'));
78+ acp_redirect('layouts');
79+ }
80+
81+ $saved = 0;
82+ $failed = 0;
83+ $savedKeys = array();
84+ $uploads = 0;
85+ $errors = array();
86+
87+ foreach ($options as $key => $option) {
88+
89+ if ($option['type'] === 'checkbox') {
90+ $value = empty($_POST['opt'][$key]) ? '' : '1';
91+ } else {
92+ $value = trim((string)($_POST['opt'][$key] ?? ''));
93+ }
94+
95+ // An uploaded image wins over whatever the path field says: the admin
96+ // picked a file, so that is the intent.
97+ if ($option['type'] === 'image') {
98+ $file = $_FILES['optfile']['tmp_name'][$key] ?? '';
99+ $code = $_FILES['optfile']['error'][$key] ?? UPLOAD_ERR_NO_FILE;
100+
101+ if ($code === UPLOAD_ERR_OK && is_uploaded_file((string)$file)) {
102+ $error = null;
103+ $stored = theme_image_store($target, $key, (string)$file, $error);
104+
105+ if ($stored !== '') {
106+ $value = $stored;
107+ $uploads++;
108+ } else {
109+ $errors[] = $option['label'] . ': ' . (string)$error;
110+ continue;
111+ }
112+ } elseif ($code === UPLOAD_ERR_INI_SIZE || $code === UPLOAD_ERR_FORM_SIZE) {
113+ $errors[] = $option['label'] . ': ' . t('acp.lay.file_too_large');
114+ continue;
115+ }
116+ }
117+
118+ if (setting_set(theme_option_key($target, $key), $value)) {
119+ $saved++;
120+ $savedKeys[] = $key;
121+ } else $failed++;
122+ }
123+
124+ foreach ($errors as $message) {
125+ acp_flash_error($message);
126+ }
127+
128+ if ($uploads > 0) {
129+ acp_flash_info(t('acp.lay.images_uploaded', [
130+ 'n' => $uploads,
131+ 'path' => '<code>engine/img/theme/' . h($target) . '/</code>',
132+ ]));
133+ }
134+
135+ if ($saved > 0) {
136+ acp_log('layouts.options_save', $target, ['fields' => $savedKeys, 'uploads' => $uploads, 'failed' => $failed]);
137+ }
138+ if ($failed > 0) {
139+ acp_flash_error(t('acp.lay.save_failed', ['n' => $failed, 'table' => '<code>znote_config</code>']));
140+ } else {
141+ acp_flash_success(t('acp.lay.options_saved', ['theme' => '<strong>' . h($themes[$target]['name']) . '</strong>']));
142+ }
143+
144+ acp_redirect('layouts', array('options' => $target));
145+}
146+
147+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
148+ $requested = theme_sanitize((string)($_POST['layout'] ?? ''));
149+ $themes = theme_list();
150+
151+ if ($requested === '' || !isset($themes[$requested])) {
152+ acp_flash_error(t('acp.lay.unknown_theme'));
153+ } elseif (!$themes[$requested]['compatible']) {
154+ acp_flash_error(h(implode(' ', $themes[$requested]['compatibility_errors'])));
155+ } elseif (theme_file_exists_in($requested, 'shells/default.php') === false) {
156+ acp_flash_error(t('acp.lay.no_shell', [
157+ 'theme' => '<strong>' . h($themes[$requested]['name']) . '</strong>',
158+ 'file' => '<code>shells/default.php</code>',
159+ ]));
160+ } elseif (setting_set('layout', $requested)) {
161+ acp_log('layouts.activate', $requested);
162+ acp_flash_success(t('acp.lay.switched', ['theme' => '<strong>' . h($themes[$requested]['name']) . '</strong>']));
163+ } else {
164+ acp_flash_error(t('acp.lay.save_setting_failed', ['table' => '<code>znote_config</code>']));
165+ }
166+
167+ acp_redirect('layouts');
168+}
169+
170+/** A theme is only usable if it can produce a page frame. */
171+function theme_file_exists_in(string $theme, string $relative): bool {
172+ return is_file(theme_root() . '/' . $theme . '/' . ltrim($relative, '/'));
173+}
174+
175+/**
176+ * How many shells / views / pages a theme ships, for the card summary.
177+ *
178+ * readdir rather than glob: measured on Windows with 100 themes installed,
179+ * glob takes 159ms and readdir 37ms for the same 309 directory reads. glob
180+ * builds and sorts a match array we immediately throw away.
181+ */
182+function acp_theme_counts(string $theme): array {
183+ static $cache = [];
184+ if (isset($cache[$theme])) {
185+ return $cache[$theme];
186+ }
187+
188+ $counts = ['shells' => 0, 'views' => 0, 'pages' => 0];
189+ $base = theme_root() . '/' . $theme;
190+
191+ foreach (array_keys($counts) as $sub) {
192+ $handle = @opendir($base . '/' . $sub);
193+ if ($handle === false) {
194+ continue;
195+ }
196+ while (($entry = readdir($handle)) !== false) {
197+ if (substr($entry, -4) === '.php') {
198+ $counts[$sub]++;
199+ }
200+ }
201+ closedir($handle);
202+ }
203+
204+ return $cache[$theme] = $counts;
205+}
206+
207+$themes = theme_list();
208+$active = theme_active();
209+
210+// The Browse tab is a separate view over the same module.
211+if (($_GET['tab'] ?? '') === 'browse') {
212+ // "Refresh catalogue" also wipes the local caches - a stale cache is what
213+ // usually keeps a just-installed or updated theme (and its options/locale)
214+ // from showing correctly.
215+ if (isset($_GET['refresh']) && function_exists('znote_cache_flush')) {
216+ znote_cache_flush();
217+ acp_log('layouts.catalogue_refresh');
218+ }
219+ include ACP_ROOT . '/modules/_partials/layouts_browse.php';
220+ return;
221+}
222+
223+// Options get a page of their own. Resolved against the full theme list, before
224+// the search and paging below narrow it - a theme on page 3 has options too.
225+$optionTheme = theme_sanitize((string)($_GET['options'] ?? ''));
226+if ($optionTheme !== '' && isset($themes[$optionTheme])) {
227+ $optionList = theme_options($optionTheme);
228+
229+ if ($optionList) {
230+ include ACP_ROOT . '/modules/_partials/layouts_options.php';
231+ return;
232+ }
233+
234+ acp_flash_error('That theme has no options to set.');
235+ acp_redirect('layouts');
236+}
237+
238+// ---------------------------------------------------------------------------
239+// Search and paging.
240+//
241+// Both server side on purpose: only the themes actually on screen get their
242+// shells/views/pages counted, so the page costs the same with 5 themes
243+// installed or 500. A client-side filter would have had to render every card.
244+// ---------------------------------------------------------------------------
245+const ACP_THEMES_PER_PAGE = 12;
246+
247+$search = trim((string)($_GET['q'] ?? ''));
248+
249+// The _-prefixed folders are references to copy, not themes to run. They get a
250+// line in the "making a theme" card at the bottom instead of a card each up
251+// here, where they only push real themes onto a second page. One that somehow
252+// ended up active still shows, so it can be switched away from.
253+$examples = array_filter($themes, static function (array $t) use ($active): bool {
254+ return !empty($t['is_example']) && $t['key'] !== $active;
255+});
256+$themes = array_diff_key($themes, $examples);
257+
258+$total = count($themes);
259+
260+if ($search !== '') {
261+ $needle = strtolower($search);
262+ $themes = array_filter($themes, static function (array $t) use ($needle): bool {
263+ return str_contains(strtolower($t['name'] . ' ' . $t['key'] . ' ' . $t['author'] . ' ' . $t['description']), $needle);
264+ });
265+}
266+
267+$matched = count($themes);
268+$pageCount = max(1, (int)ceil($matched / ACP_THEMES_PER_PAGE));
269+$page = max(1, min($pageCount, intv($_GET['tp'] ?? 1)));
270+$themes = array_slice($themes, ($page - 1) * ACP_THEMES_PER_PAGE, ACP_THEMES_PER_PAGE, true);
271+
272+/** Keep the current search when building a paging link. */
273+function acp_theme_page_url(int $page, string $search): string {
274+ $params = ['tp' => $page];
275+ if ($search !== '') {
276+ $params['q'] = $search;
277+ }
278+ return acp_url('layouts', $params);
279+}
280+$hasTable = znote_table_exists('znote_config');
281+?>
282+
283+<?php if (!$hasTable): ?>
284+ <div class="acp-flash acp-flash--error">
285+ <i class="fa fa-exclamation-triangle"></i>
286+ <span>
287+ <?= t('acp.lay.table_missing_banner', [
288+ 'table' => '<code>znote_config</code>',
289+ 'default' => '<strong>default</strong>',
290+ 'file' => '<code>SQL/migrations/2.0.0_znote_config.sql</code>',
291+ ]) ?>
292+ </span>
293+ </div>
294+<?php endif; ?>
295+
296+<div class="acp-toolbar">
297+ <div></div>
298+ <div class="acp-actions is-tight">
299+ <?php if (theme_repository_config()['enabled']): ?>
300+ <a class="acp-btn" href="<?= h(acp_url('layouts', array('tab' => 'browse'))) ?>">
301+ <i class="fa fa-cloud-download"></i> <?= t('acp.lay.browse_themes') ?>
302+ </a>
303+ <?php endif; ?>
304+ </div>
305+</div>
306+
307+<div class="acp-stats">
308+ <?php
309+ acp_stat(t('acp.lay.stat_installed'), count($themes), 'fa-paint-brush', null, 'purple');
310+ acp_stat(t('acp.lay.stat_active'), $themes[$active]['name'] ?? $active, 'fa-check-circle', null, 'green');
311+ ?>
312+</div>
313+
314+<?php if ($total > ACP_THEMES_PER_PAGE || $search !== ''): ?>
315+ <div class="acp-toolbar">
316+ <form method="get" style="display:flex;gap:8px;flex:1 1 320px;max-width:460px;">
317+ <input type="hidden" name="p" value="layouts">
318+ <input class="acp-input" type="search" name="q" value="<?= h($search) ?>"
319+ placeholder="<?= h(t('acp.lay.search_placeholder')) ?>">
320+ <button class="acp-btn" type="submit"><i class="fa fa-search"></i></button>
321+ <?php if ($search !== ''): ?>
322+ <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('layouts')) ?>"><?= t('acp.lay.clear') ?></a>
323+ <?php endif; ?>
324+ </form>
325+ <span class="is-muted">
326+ <?php if ($search !== ''): ?>
327+ <?= t('acp.lay.matched_of_total', ['matched' => (int)$matched, 'total' => (int)$total]) ?>
328+ <?php else: ?>
329+ <?= t('acp.lay.total_themes', ['total' => (int)$total]) ?>
330+ <?php endif; ?>
331+ <?php if ($pageCount > 1): ?>
332+ &middot; <?= t('acp.lay.page_of', ['page' => (int)$page, 'pageCount' => (int)$pageCount]) ?>
333+ <?php endif; ?>
334+ </span>
335+ </div>
336+<?php endif; ?>
337+
338+<div class="acp-media">
339+ <?php foreach ($themes as $key => $theme):
340+ $isActive = ($key === $active);
341+ $isUsable = theme_file_exists_in($key, 'shells/default.php') && $theme['compatible'];
342+ $counts = acp_theme_counts($key);
343+ $views = $counts['views'];
344+ $pages = $counts['pages'];
345+ $shells = $counts['shells'];
346+ ?>
347+ <article class="acp-media-item"<?= $isActive ? ' style="border-color:var(--acp-green);"' : '' ?>>
348+ <?php if (!empty($theme['screenshot'])): ?>
349+ <img src="<?= h(acp_site($theme['screenshot'])) ?>" alt="<?= h($theme['name']) ?>" loading="lazy">
350+ <?php else: ?>
351+ <div style="display:grid;place-items:center;height:170px;background:var(--acp-panel-2);color:var(--acp-fg-muted);">
352+ <span><i class="fa fa-picture-o"></i> &nbsp;no screenshot.png</span>
353+ </div>
354+ <?php endif; ?>
355+
356+ <div class="acp-media-body">
357+ <h3>
358+ <?= h($theme['name']) ?>
359+ <?php if ($isActive): ?>
360+ <span class="acp-pill acp-pill--green"><?= t('acp.lay.active_pill') ?></span>
361+ <?php endif; ?>
362+ <?php if (!empty($theme['is_example'])): ?>
363+ <span class="acp-pill acp-pill--grey"><?= t('acp.lay.template_pill') ?></span>
364+ <?php endif; ?>
365+ <?php if (!$theme['compatible']): ?>
366+ <span class="acp-pill acp-pill--red"><?= h(t_default('acp.lay.incompatible', 'Incompatible')) ?></span>
367+ <?php endif; ?>
368+ </h3>
369+
370+ <p>
371+ <?= h($theme['description']) ?>
372+ </p>
373+
374+ <p class="is-muted" style="font-size:12px;">
375+ <code>layouts/<?= h($key) ?>/</code>
376+ <?php if ($theme['author'] !== ''): ?>
377+ &middot; <?= t('acp.lay.by_author', ['author' => h($theme['author'])]) ?>
378+ <?php endif; ?>
379+ <?php if ($theme['version'] !== ''): ?>
380+ &middot; v<?= h($theme['version']) ?>
381+ <?php endif; ?>
382+ </p>
383+
384+ <p style="font-size:12px;">
385+ <span class="acp-pill acp-pill--blue"><?= t('acp.lay.shell_count', ['n' => $shells]) ?></span>
386+ <span class="acp-pill acp-pill--grey"><?= t('acp.lay.view_count', ['n' => $views]) ?></span>
387+ <span class="acp-pill acp-pill--grey"><?= t('acp.lay.page_count', ['n' => $pages]) ?></span>
388+ </p>
389+
390+ <?php if (!$isUsable): ?>
391+ <p class="is-muted" style="font-size:12px;color:var(--acp-red);">
392+ <?= $theme['compatible']
393+ ? t('acp.lay.not_usable', ['file' => '<code>shells/default.php</code>'])
394+ : h(implode(' ', $theme['compatibility_errors'])) ?>
395+ </p>
396+ <?php elseif ($views === 0): ?>
397+ <p class="is-muted" style="font-size:12px;">
398+ <?= t('acp.lay.no_views') ?>
399+ </p>
400+ <?php endif; ?>
401+ </div>
402+
403+ <div class="acp-media-foot">
404+ <?php if ($isActive): ?>
405+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_site('index.php')) ?>" target="_blank" rel="noopener">
406+ <i class="fa fa-external-link"></i> <?= t('acp.lay.view_site') ?>
407+ </a>
408+ <?php else: ?>
409+ <form class="acp-inline-form" method="post"
410+ data-confirm="<?= h(t('acp.lay.confirm_switch')) ?>">
411+ <?= acp_csrf_field() ?>
412+ <input type="hidden" name="layout" value="<?= h($key) ?>">
413+ <button class="acp-btn acp-btn--sm" type="submit" <?= $isUsable ? '' : 'disabled' ?>>
414+ <i class="fa fa-check"></i> <?= t('acp.lay.activate') ?>
415+ </button>
416+ </form>
417+ <?php endif; ?>
418+
419+ <?php if (theme_options($key)): ?>
420+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('layouts', array('options' => $key))) ?>">
421+ <i class="fa fa-sliders"></i> <?= t('acp.lay.options') ?>
422+ </a>
423+ <?php endif; ?>
424+
425+ <?php if (!$isActive && $key !== 'default' && empty($theme['is_example'])): ?>
426+ <form class="acp-inline-form" method="post"
427+ data-confirm="<?= h(t('acp.lay.confirm_delete', ['path' => 'layouts/' . $key . '/'])) ?>">
428+ <?= acp_csrf_field() ?>
429+ <input type="hidden" name="uninstall" value="<?= h($key) ?>">
430+ <button class="acp-btn acp-btn--red acp-btn--sm" type="submit">
431+ <i class="fa fa-trash"></i> <?= t('acp.lay.remove') ?>
432+ </button>
433+ </form>
434+ <?php endif; ?>
435+ </div>
436+ </article>
437+ <?php endforeach; ?>
438+</div>
439+
440+<?php if ($matched === 0): ?>
441+ <section class="acp-card">
442+ <div class="acp-card-body">
443+ <?php acp_empty(t('acp.lay.no_match', ['search' => $search]), 'fa-search'); ?>
444+ <div class="acp-actions" style="justify-content:center;">
445+ <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('layouts')) ?>"><?= t('acp.lay.show_all') ?></a>
446+ </div>
447+ </div>
448+ </section>
449+<?php endif; ?>
450+
451+<?php if ($pageCount > 1): ?>
452+ <nav class="acp-actions" style="justify-content:center;margin:18px 0 24px;" aria-label="<?= h(t('acp.lay.pages_label')) ?>">
453+ <a class="acp-btn acp-btn--ghost acp-btn--sm<?= $page <= 1 ? ' is-disabled' : '' ?>"
454+ href="<?= h(acp_theme_page_url(max(1, $page - 1), $search)) ?>"
455+ <?= $page <= 1 ? 'aria-disabled="true" tabindex="-1"' : '' ?>>
456+ <i class="fa fa-angle-left"></i> <?= t('acp.lay.previous') ?>
457+ </a>
458+
459+ <?php for ($i = 1; $i <= $pageCount; $i++): ?>
460+ <?php if ($i === $page): ?>
461+ <span class="acp-btn acp-btn--sm"><?= $i ?></span>
462+ <?php else: ?>
463+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_theme_page_url($i, $search)) ?>"><?= $i ?></a>
464+ <?php endif; ?>
465+ <?php endfor; ?>
466+
467+ <a class="acp-btn acp-btn--ghost acp-btn--sm<?= $page >= $pageCount ? ' is-disabled' : '' ?>"
468+ href="<?= h(acp_theme_page_url(min($pageCount, $page + 1), $search)) ?>"
469+ <?= $page >= $pageCount ? 'aria-disabled="true" tabindex="-1"' : '' ?>>
470+ <?= t('acp.lay.next') ?> <i class="fa fa-angle-right"></i>
471+ </a>
472+ </nav>
473+<?php endif; ?>
474+
475+<section class="acp-card">
476+ <header class="acp-card-head">
477+ <h2>Making a theme</h2>
478+ <p>The short version</p>
479+ </header>
480+ <div class="acp-card-body">
481+ <p>
482+ Copy <code>layouts/_example/</code>, rename the folder, edit
483+ <code>theme.json</code>. Your theme is listed here immediately.
484+ </p>
485+ <p>
486+ The only file a theme truly needs is <code>shells/default.php</code> &mdash; the frame
487+ your pages render inside. Anything else you leave out is taken from the default theme,
488+ so a stylesheet and a shell are enough to redress the entire site.
489+ </p>
490+ <p>
491+ The picture on these cards is a file named <code>screenshot.png</code> at the root of
492+ the theme folder &mdash; nothing to declare, drop it in and reload this page.
493+ Any size works; roughly 3:2 shows best.
494+ </p>
495+ <?php if ($examples): ?>
496+ <p>
497+ Two folders in <code>layouts/</code> are starting points rather than themes, so they
498+ are kept out of the grid above:
499+ </p>
500+ <ul>
501+ <?php foreach ($examples as $exampleKey => $example): ?>
502+ <li>
503+ <code><?= h('layouts/' . $exampleKey . '/') ?></code>
504+ &mdash; <?= h($example['description'] !== '' ? $example['description'] : $example['name']) ?>
505+ </li>
506+ <?php endforeach; ?>
507+ </ul>
508+ <p>
509+ Copy one, rename the folder, and it appears above on the next page load. Never edit
510+ them in place: they are the reference every other theme is copied from.
511+ </p>
512+ <?php endif; ?>
513+ <p>
514+ Full instructions, including the list of CSS classes the pages emit, are in
515+ <code>layouts/README.md</code>.
516+ </p>
517+ </div>
518+</section>
A admin/modules/login_attempts.php +133-0 View file
@@ -0,0 +1,133 @@
1+<?php
2+/**
3+ * Title: Login Attempts
4+ * Icon: fa-lock
5+ * Group: Operations
6+ * Order: 50
7+ * Description: Recent login attempts and which IPs are currently locked out.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+if (!znote_table_exists('znote_login_attempts')) {
16+ acp_flash_error(t_default('acp.lattempt.no_table', 'Run the pending database migration first (Admin Panel > Migrations).'));
17+ acp_redirect('migrations');
18+}
19+
20+if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['do'] ?? '') === 'clear_ip') {
21+ $ip = substr(trim((string)($_POST['ip'] ?? '')), 0, 45);
22+ if ($ip !== '') {
23+ db()->execute("DELETE FROM `znote_login_attempts` WHERE `ip` = ? AND `success` = 0;", [$ip]);
24+ acp_log('login_guard.clear_ip', $ip);
25+ acp_flash_success(t_default('acp.lattempt.cleared', 'Cleared - that IP can log in again immediately.'));
26+ }
27+ acp_redirect('login_attempts');
28+}
29+
30+$cfg = znote_login_guard_config();
31+
32+$lockedRows = db()->fetchAll("
33+ SELECT `ip`, COUNT(*) AS `failures`, MAX(`created_at`) AS `last_failure`
34+ FROM `znote_login_attempts`
35+ WHERE `success` = 0 AND `created_at` >= ?
36+ GROUP BY `ip`
37+ HAVING `failures` >= ?
38+ ORDER BY `last_failure` DESC;
39+", [time() - $cfg['window_seconds'], $cfg['threshold']]);
40+$lockedRows = is_array($lockedRows) ? $lockedRows : array();
41+
42+$recent = db()->fetchAll("
43+ SELECT `ip`, `username`, `success`, `created_at`
44+ FROM `znote_login_attempts`
45+ ORDER BY `id` DESC
46+ LIMIT 100;
47+");
48+$recent = is_array($recent) ? $recent : array();
49+?>
50+
51+<div class="acp-stats">
52+ <?php
53+ acp_stat(t_default('acp.lattempt.stat_locked', 'IPs locked out now'), count($lockedRows), 'fa-lock', null, count($lockedRows) > 0 ? 'red' : 'green');
54+ acp_stat(t_default('acp.lattempt.stat_guard', 'Protection'), $cfg['enabled'] ? t_default('acp.lattempt.on', 'On') : t_default('acp.lattempt.off', 'Off'), 'fa-shield', acp_url('settings'), $cfg['enabled'] ? 'green' : 'grey');
55+ ?>
56+</div>
57+
58+<?php if ($lockedRows): ?>
59+ <section class="acp-card">
60+ <header class="acp-card-head">
61+ <h2><?= t_default('acp.lattempt.locked_title', 'Currently locked out') ?></h2>
62+ </header>
63+ <div class="acp-card-body is-flush">
64+ <div class="acp-table-wrap">
65+ <table class="acp-table">
66+ <thead>
67+ <tr>
68+ <th><?= t_default('acp.lattempt.col_ip', 'IP') ?></th>
69+ <th class="is-num"><?= t_default('acp.lattempt.col_failures', 'Failures') ?></th>
70+ <th><?= t_default('acp.lattempt.col_last', 'Last attempt') ?></th>
71+ <th class="is-num"></th>
72+ </tr>
73+ </thead>
74+ <tbody>
75+ <?php foreach ($lockedRows as $row): ?>
76+ <tr>
77+ <td><code><?= h((string)$row['ip']) ?></code></td>
78+ <td class="is-num"><?= (int)$row['failures'] ?></td>
79+ <td class="is-nowrap is-muted"><?= h(getClock((int)$row['last_failure'], true)) ?></td>
80+ <td class="is-num">
81+ <form class="acp-inline-form" method="post" data-confirm="<?= h(t_default('acp.lattempt.confirm_clear', 'Let this IP try again immediately?')) ?>">
82+ <?= acp_csrf_field() ?>
83+ <input type="hidden" name="do" value="clear_ip">
84+ <input type="hidden" name="ip" value="<?= h((string)$row['ip']) ?>">
85+ <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit"><?= t_default('acp.lattempt.clear_btn', 'Unlock') ?></button>
86+ </form>
87+ </td>
88+ </tr>
89+ <?php endforeach; ?>
90+ </tbody>
91+ </table>
92+ </div>
93+ </div>
94+ </section>
95+<?php endif; ?>
96+
97+<section class="acp-card">
98+ <header class="acp-card-head">
99+ <h2><?= t_default('acp.lattempt.recent_title', 'Last 100 attempts') ?></h2>
100+ </header>
101+ <div class="acp-card-body is-flush">
102+ <?php if ($recent): ?>
103+ <div class="acp-table-wrap">
104+ <table class="acp-table" data-sortable>
105+ <thead>
106+ <tr>
107+ <th><?= t_default('acp.lattempt.col_when', 'When') ?></th>
108+ <th><?= t_default('acp.lattempt.col_ip', 'IP') ?></th>
109+ <th><?= t_default('acp.lattempt.col_username', 'Username tried') ?></th>
110+ <th><?= t_default('acp.lattempt.col_result', 'Result') ?></th>
111+ </tr>
112+ </thead>
113+ <tbody>
114+ <?php foreach ($recent as $row): ?>
115+ <tr>
116+ <td class="is-nowrap is-muted"><?= h(getClock((int)$row['created_at'], true)) ?></td>
117+ <td><code><?= h((string)$row['ip']) ?></code></td>
118+ <td><?= h((string)$row['username']) ?></td>
119+ <td>
120+ <span class="acp-pill acp-pill--<?= $row['success'] ? 'green' : 'red' ?>">
121+ <?= $row['success'] ? t_default('acp.lattempt.success', 'Success') : t_default('acp.lattempt.failed', 'Failed') ?>
122+ </span>
123+ </td>
124+ </tr>
125+ <?php endforeach; ?>
126+ </tbody>
127+ </table>
128+ </div>
129+ <?php else: ?>
130+ <?php acp_empty(t_default('acp.lattempt.empty', 'No login attempts recorded yet.'), 'fa-lock'); ?>
131+ <?php endif; ?>
132+ </div>
133+</section>
A admin/modules/mass_actions.php +239-0 View file
@@ -0,0 +1,239 @@
1+<?php
2+/**
3+ * Title: Mass Actions
4+ * Icon: fa-tasks
5+ * Group: Players
6+ * Order: 21
7+ * Description: Apply points, premium days or a teleport to every account or character at once.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+$accountTotal = acp_count("SELECT COUNT(*) AS `c` FROM `accounts`;");
16+
17+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
18+
19+ // -------------------------------------------------------- Mass points
20+ if (isset($_POST['points_delta'])) {
21+ $delta = intv($_POST['points_delta'] ?? 0);
22+
23+ if ($delta === 0) {
24+ acp_flash_error(t('acp.mpts.zero_value'));
25+ acp_redirect('mass_actions');
26+ }
27+
28+ db()->execute("
29+ INSERT INTO `znote_accounts` (`account_id`, `ip`, `created`, `flag`)
30+ SELECT `a`.`id`, 0, ?, ''
31+ FROM `accounts` `a`
32+ LEFT JOIN `znote_accounts` `z` ON `z`.`account_id` = `a`.`id`
33+ WHERE `z`.`account_id` IS NULL;
34+ ", [time()]);
35+
36+ $updated = db()->execute("UPDATE `znote_accounts` SET `points` = GREATEST(0, `points` + ?);", [$delta]);
37+
38+ if ($updated !== false) {
39+ acp_log('accounts.mass_points', '', ['delta' => $delta, 'accounts' => $accountTotal]);
40+ acp_flash_success(t('acp.mpts.done', ['delta' => ($delta >= 0 ? '+' : '') . $delta, 'n' => $accountTotal]));
41+ } else {
42+ acp_flash_error(t('acp.mpts.failed'));
43+ }
44+
45+ acp_redirect('mass_actions');
46+ }
47+
48+ // --------------------------------------------------- Mass premium days
49+ if (isset($_POST['premium_days'])) {
50+ $days = intv($_POST['premium_days'] ?? 0);
51+
52+ if ($days <= 0) {
53+ acp_flash_error(t('acp.mprem.invalid_value'));
54+ acp_redirect('mass_actions');
55+ }
56+
57+ $updated = user_accounts_add_premdays_all($days);
58+
59+ if ($updated !== false) {
60+ acp_log('accounts.mass_premium_days', '', ['days' => $days, 'accounts' => $accountTotal]);
61+ acp_flash_success(t('acp.mprem.done', ['days' => $days, 'n' => $accountTotal]));
62+ } else {
63+ acp_flash_error(t('acp.mprem.not_supported'));
64+ }
65+
66+ acp_redirect('mass_actions');
67+ }
68+
69+ // ------------------------------------------------------------ Teleport
70+ if (isset($_POST['from'], $_POST['to'])) {
71+ $from = (string)$_POST['from'];
72+ $to = (string)$_POST['to'];
73+
74+ $target = '';
75+ $fail = false;
76+
77+ if ($from === 'only') {
78+ $target = trim((string)($_POST['player_name'] ?? ''));
79+ if ($target === '' || !user_character_exist($target)) {
80+ acp_flash_error(t('acp.plr.err_invalid_teleport_char'));
81+ $fail = true;
82+ }
83+ }
84+
85+ if (!$fail) {
86+ $set = null;
87+
88+ if ($to === 'home') {
89+ $set = '`posx`=0, `posy`=0, `posz`=0';
90+ } elseif ($to === 'town') {
91+ $set = '`posx`=0, `posy`=0, `posz`=0, `town_id`=' . intv($_POST['town'] ?? 0);
92+ } elseif ($to === 'xyz') {
93+ $set = '`posx`=' . intv($_POST['x'] ?? 0)
94+ . ', `posy`=' . intv($_POST['y'] ?? 0)
95+ . ', `posz`=' . intv($_POST['z'] ?? 0);
96+ }
97+
98+ // Anything else would have produced "UPDATE players SET " and a
99+ // SQL error, so refuse instead of guessing.
100+ if ($set === null) {
101+ acp_flash_error(t('acp.plr.err_unknown_destination'));
102+ } else {
103+ if ($from === 'only') {
104+ db()->execute("UPDATE `players` SET {$set} WHERE `name` = ?;", [$target]);
105+ } else {
106+ db()->execute("UPDATE `players` SET {$set};");
107+ }
108+ acp_log('player.teleport', $from === 'only' ? $target : 'ALL', ['destination' => $to]);
109+ acp_flash_success($from === 'only'
110+ ? t('acp.plr.tp_one_done')
111+ : t('acp.plr.tp_all_done'));
112+ }
113+ }
114+
115+ acp_redirect('mass_actions');
116+ }
117+}
118+?>
119+
120+<div class="acp-stats">
121+ <?php acp_stat(t('acp.mpts.stat_accounts'), $accountTotal, 'fa-users', acp_url('accounts'), 'blue'); ?>
122+</div>
123+
124+<div class="acp-grid acp-grid--2">
125+
126+ <!-- --------------------------------------------------------- Mass points -->
127+ <section class="acp-card">
128+ <header class="acp-card-head">
129+ <h2><?= t('acp.mpts.title') ?></h2>
130+ <p><?= t('acp.mpts.sub') ?></p>
131+ </header>
132+ <div class="acp-card-body">
133+ <div class="acp-flash acp-flash--error" style="margin-bottom:16px;">
134+ <i class="fa fa-exclamation-triangle"></i>
135+ <span><?= t('acp.mpts.warning', ['n' => $accountTotal]) ?></span>
136+ </div>
137+
138+ <form method="post" data-confirm="<?= h(t('acp.mpts.confirm', ['n' => $accountTotal])) ?>">
139+ <?= acp_csrf_field() ?>
140+ <div class="acp-field">
141+ <label class="acp-label" for="points_delta"><?= t('acp.mpts.delta_label') ?></label>
142+ <input class="acp-input" id="points_delta" name="points_delta" type="number" step="1" placeholder="<?= h(t('acp.mpts.delta_placeholder')) ?>" required>
143+ <p class="acp-hint"><?= t('acp.mpts.delta_help') ?></p>
144+ </div>
145+ <div class="acp-actions">
146+ <button class="acp-btn acp-btn--amber" type="submit"><i class="fa fa-bolt"></i> <?= t('acp.mpts.submit_btn') ?></button>
147+ </div>
148+ </form>
149+ </div>
150+ </section>
151+
152+ <!-- --------------------------------------------------- Mass premium days -->
153+ <section class="acp-card">
154+ <header class="acp-card-head">
155+ <h2><?= t('acp.mprem.title') ?></h2>
156+ <p><?= t('acp.mprem.sub') ?></p>
157+ </header>
158+ <div class="acp-card-body">
159+ <div class="acp-flash acp-flash--error" style="margin-bottom:16px;">
160+ <i class="fa fa-exclamation-triangle"></i>
161+ <span><?= t('acp.mprem.warning', ['n' => $accountTotal]) ?></span>
162+ </div>
163+
164+ <form method="post" data-confirm="<?= h(t('acp.mprem.confirm', ['n' => $accountTotal])) ?>">
165+ <?= acp_csrf_field() ?>
166+ <div class="acp-field">
167+ <label class="acp-label" for="premium_days"><?= t('acp.mprem.days_label') ?></label>
168+ <input class="acp-input" id="premium_days" name="premium_days" type="number" min="1" step="1" placeholder="<?= h(t('acp.mprem.days_placeholder')) ?>" required>
169+ <p class="acp-hint"><?= t('acp.mprem.days_help') ?></p>
170+ </div>
171+ <div class="acp-actions">
172+ <button class="acp-btn acp-btn--amber" type="submit"><i class="fa fa-star"></i> <?= t('acp.mprem.submit_btn') ?></button>
173+ </div>
174+ </form>
175+ </div>
176+ </section>
177+
178+</div>
179+
180+<!-- ------------------------------------------------------------ Teleport -->
181+<section class="acp-card">
182+ <header class="acp-card-head">
183+ <h2><?= h(t('acp.plr.teleport_title')) ?></h2>
184+ <p><?= h(t('acp.plr.teleport_sub')) ?></p>
185+ </header>
186+ <div class="acp-card-body">
187+ <form method="post" data-confirm="<?= h(t('acp.plr.teleport_confirm')) ?>">
188+ <?= acp_csrf_field() ?>
189+ <div class="acp-row">
190+ <div class="acp-field">
191+ <label class="acp-label" for="tp_from"><?= h(t('acp.plr.who_label')) ?></label>
192+ <select class="acp-select" id="tp_from" name="from">
193+ <option value="only"><?= h(t('acp.plr.tp_one_option')) ?></option>
194+ <option value="all"><?= h(t('acp.plr.tp_all_option')) ?></option>
195+ </select>
196+ </div>
197+ <div class="acp-field">
198+ <label class="acp-label" for="player_name"><?= h(t('acp.plr.character_label')) ?></label>
199+ <input class="acp-input" id="player_name" name="player_name" placeholder="<?= h(t('acp.plr.tp_char_placeholder')) ?>">
200+ </div>
201+ <div class="acp-field">
202+ <label class="acp-label" for="tp_to"><?= h(t('acp.plr.destination_label')) ?></label>
203+ <select class="acp-select" id="tp_to" name="to">
204+ <option value="home"><?= h(t('acp.plr.dest_home')) ?></option>
205+ <option value="town"><?= h(t('acp.plr.dest_town')) ?></option>
206+ <option value="xyz"><?= h(t('acp.plr.dest_xyz')) ?></option>
207+ </select>
208+ </div>
209+ </div>
210+
211+ <div class="acp-row">
212+ <div class="acp-field">
213+ <label class="acp-label" for="tp_town"><?= h(t('acp.plr.town_label')) ?></label>
214+ <select class="acp-select" id="tp_town" name="town">
215+ <?php foreach (($config['towns'] ?? []) as $tid => $tname): ?>
216+ <option value="<?= (int)$tid ?>"><?= h($tname) ?></option>
217+ <?php endforeach; ?>
218+ </select>
219+ </div>
220+ <div class="acp-field">
221+ <label class="acp-label" for="tp_x">X</label>
222+ <input class="acp-input" id="tp_x" name="x" type="number" value="0">
223+ </div>
224+ <div class="acp-field">
225+ <label class="acp-label" for="tp_y">Y</label>
226+ <input class="acp-input" id="tp_y" name="y" type="number" value="0">
227+ </div>
228+ <div class="acp-field">
229+ <label class="acp-label" for="tp_z">Z</label>
230+ <input class="acp-input" id="tp_z" name="z" type="number" value="7">
231+ </div>
232+ </div>
233+
234+ <div class="acp-actions">
235+ <button class="acp-btn" type="submit"><i class="fa fa-location-arrow"></i> <?= h(t('acp.plr.teleport_btn')) ?></button>
236+ </div>
237+ </form>
238+ </div>
239+</section>
Top