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/search.php +91-0 View file
@@ -0,0 +1,91 @@
1+<?php
2+/**
3+ * Title: Search
4+ * Icon: fa-search
5+ * Group: Overview
6+ * Order: 5
7+ * Hidden: true
8+ * Description: Everything in the panel, by name or by what a setting does.
9+ */
10+
11+if (!defined('ACP_ROOT')) {
12+ http_response_code(403);
13+ die('Direct access denied.');
14+}
15+
16+$query = trim((string)($_GET['q'] ?? ''));
17+$results = acp_search($query);
18+
19+$pages = array_values(array_filter($results, static fn(array $r): bool => $r['kind'] === 'page'));
20+$settings = array_values(array_filter($results, static fn(array $r): bool => $r['kind'] === 'setting'));
21+
22+$highlight = static function (string $text, string $q): string {
23+ $text = h($text);
24+ foreach (preg_split('/\s+/', trim($q)) ?: [] as $term) {
25+ if ($term === '') continue;
26+ $text = preg_replace('/(' . preg_quote(h($term), '/') . ')/i', '<mark>$1</mark>', $text);
27+ }
28+ return $text;
29+};
30+?>
31+
32+<form class="acp-search-page" method="get">
33+ <input type="hidden" name="p" value="search">
34+ <div class="acp-field">
35+ <input class="acp-input acp-input--lg" type="search" name="q" value="<?= h($query) ?>"
36+ placeholder="<?= h(t('acp.search.placeholder')) ?>" autofocus>
37+ </div>
38+</form>
39+
40+<?php if ($query === ''): ?>
41+ <div class="acp-flash acp-flash--info">
42+ <i class="fa fa-info-circle"></i>
43+ <span>
44+ <?= str_replace(
45+ ['{download}', '{paypal}', '{auction}'],
46+ ['<code>download</code>', '<code>paypal</code>', '<code>auction</code>'],
47+ h(t('acp.search.hint'))
48+ ) ?>
49+ </span>
50+ </div>
51+<?php elseif (!$results): ?>
52+ <div class="acp-flash acp-flash--error">
53+ <i class="fa fa-exclamation-triangle"></i>
54+ <span><?= t('acp.search.nothing') ?> <strong><?= h($query) ?></strong>.</span>
55+ </div>
56+<?php else: ?>
57+
58+ <p class="acp-hint">
59+ <?= t('acp.search.results_line', [
60+ 'count' => count($results),
61+ 'noun' => (count($results) === 1) ? t('acp.search.result_one') : t('acp.search.result_many'),
62+ ]) ?> <strong><?= h($query) ?></strong>
63+ </p>
64+
65+ <?php foreach (array(t('acp.search.section_pages') => $pages, t('acp.search.section_settings') => $settings) as $heading => $rows): ?>
66+ <?php if (!$rows) continue; ?>
67+ <section class="acp-card">
68+ <header class="acp-card-head"><h2><?= h($heading) ?></h2></header>
69+ <div class="acp-card-body is-flush">
70+ <ul class="acp-search-results">
71+ <?php foreach ($rows as $row): ?>
72+ <li>
73+ <a href="<?= h($row['url']) ?>">
74+ <i class="fa <?= h($row['icon']) ?>"></i>
75+ <span class="acp-search-main">
76+ <span class="acp-search-title"><?= $highlight($row['title'], $query) ?></span>
77+ <span class="acp-search-ctx"><?= $row['context'] ?></span>
78+ <?php if (!empty($row['detail'])): ?>
79+ <span class="acp-search-detail"><?= $highlight($row['detail'], $query) ?></span>
80+ <?php endif; ?>
81+ </span>
82+ <i class="fa fa-angle-right acp-search-go"></i>
83+ </a>
84+ </li>
85+ <?php endforeach; ?>
86+ </ul>
87+ </div>
88+ </section>
89+ <?php endforeach; ?>
90+
91+<?php endif; ?>
A admin/modules/serverinfo.php +247-0 View file
@@ -0,0 +1,247 @@
1+<?php
2+/**
3+ * Title: Server Information
4+ * Icon: fa-server
5+ * Group: Server Info
6+ * Order: 10
7+ * Description: Upload config.lua, stages.xml, items.xml, spells.xml and your monster folder.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+$sources = serverdata_sources();
16+
17+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
18+
19+ $do = (string)($_POST['do'] ?? '');
20+ $key = (string)($_POST['src'] ?? '');
21+
22+ if (!isset($sources[$key])) {
23+ acp_flash_error(t('acp.srv.unknown_source'));
24+ acp_redirect('serverinfo');
25+ }
26+
27+ $label = $sources[$key]['label'];
28+
29+ if ($do === 'clear') {
30+ serverdata_clear($key);
31+ acp_log('serverdata.clear', $key);
32+ acp_flash_success(t('acp.srv.removed', ['label' => $label, 'page' => $sources[$key]['page']]));
33+ acp_redirect('serverinfo');
34+ }
35+
36+ if ($do === 'rebuild') {
37+ $error = null;
38+ if (serverdata_rebuild($key, $error)) {
39+ $count = serverdata_count($key, serverdata_load($key));
40+ acp_log('serverdata.rebuild', $key, ['records' => $count]);
41+ acp_flash_success(t('acp.srv.rebuilt', ['label' => $label, 'count' => number_format($count)]));
42+ if ($error !== null) {
43+ acp_flash_info($error);
44+ }
45+ } else {
46+ acp_flash_error($label . ': ' . (string)$error);
47+ }
48+ acp_redirect('serverinfo');
49+ }
50+
51+ if ($do === 'upload') {
52+ $file = $_FILES['payload'] ?? null;
53+
54+ if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
55+ acp_flash_error(t('acp.srv.select_file'));
56+ acp_redirect('serverinfo');
57+ }
58+
59+ if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
60+ $reasons = [
61+ UPLOAD_ERR_INI_SIZE => t('acp.srv.err_ini_size'),
62+ UPLOAD_ERR_FORM_SIZE => t('acp.srv.err_form_size'),
63+ UPLOAD_ERR_PARTIAL => t('acp.srv.err_partial'),
64+ UPLOAD_ERR_NO_TMP_DIR => t('acp.srv.err_no_tmp_dir'),
65+ UPLOAD_ERR_CANT_WRITE => t('acp.srv.err_cant_write'),
66+ UPLOAD_ERR_EXTENSION => t('acp.srv.err_extension'),
67+ ];
68+ acp_flash_error($reasons[(int)$file['error']] ?? t('acp.srv.upload_failed'));
69+ acp_redirect('serverinfo');
70+ }
71+
72+ if (!is_uploaded_file((string)$file['tmp_name'])) {
73+ acp_flash_error(t('acp.srv.upload_unverified'));
74+ acp_redirect('serverinfo');
75+ }
76+
77+ $error = null;
78+ if (serverdata_publish_upload($key, (string)$file['tmp_name'], (string)$file['name'], $error)) {
79+ $count = serverdata_count($key, serverdata_load($key));
80+ acp_log('serverdata.upload', $key, [
81+ 'file' => basename((string)$file['name']),
82+ 'records' => $count,
83+ ]);
84+ acp_flash_success(t('acp.srv.uploaded', [
85+ 'label' => $label,
86+ 'count' => number_format($count),
87+ 'page' => $sources[$key]['page'],
88+ ]));
89+ if ($error !== null) {
90+ acp_flash_info($error);
91+ }
92+ } else {
93+ acp_flash_error($label . ': ' . (string)$error);
94+ }
95+
96+ acp_redirect('serverinfo');
97+ }
98+}
99+
100+$status = serverdata_status();
101+$itemsPage = !empty($config['items']);
102+$maxUpload = min(
103+ (int)serverdata_ini_bytes((string)ini_get('upload_max_filesize')),
104+ (int)serverdata_ini_bytes((string)ini_get('post_max_size'))
105+);
106+?>
107+
108+<div class="acp-toolbar">
109+ <div>
110+ <?php
111+ $ready = 0;
112+ foreach ($status as $row) {
113+ if ($row['cached']) {
114+ $ready++;
115+ }
116+ }
117+ ?>
118+ <span class="acp-pill acp-pill--<?= $ready === count($status) ? 'green' : 'grey' ?>">
119+ <?= t('acp.srv.sources_published', ['ready' => (int)$ready, 'total' => count($status)]) ?>
120+ </span>
121+ </div>
122+ <div class="acp-actions is-tight">
123+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_site('serverinfo.php')) ?>" target="_blank">
124+ <i class="fa fa-external-link"></i> <?= t('acp.srv.public_page') ?>
125+ </a>
126+ </div>
127+</div>
128+
129+<?php if (!$itemsPage): ?>
130+ <div class="acp-flash acp-flash--info">
131+ <i class="fa fa-info-circle"></i>
132+ <span>
133+ <?= t('acp.srv.items_off', [
134+ 'items' => '<code>items</code>',
135+ 'settings' => '<a href="' . h(acp_url('settings')) . '">' . t('acp.srv.settings_link') . '</a>',
136+ ]) ?>
137+ </span>
138+ </div>
139+<?php endif; ?>
140+
141+<div class="acp-grid acp-grid--2">
142+ <?php
143+ $editorModules = array('config' => 'config_editor', 'items' => 'items_editor', 'creatures' => 'creatures_editor');
144+ ?>
145+ <?php foreach ($status as $key => $row): ?>
146+ <section class="acp-card">
147+ <header class="acp-card-head">
148+ <h2><?= h($row['label']) ?></h2>
149+ <p><?= h($row['help']) ?></p>
150+ <?php if ($row['cached'] && isset($editorModules[$key])): ?>
151+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url($editorModules[$key])) ?>">
152+ <i class="fa fa-pencil"></i> <?= t_default('acp.srv.edit_entries', 'Edit entries') ?>
153+ </a>
154+ <?php endif; ?>
155+ </header>
156+ <div class="acp-card-body">
157+
158+ <p>
159+ <?php if ($row['cached']): ?>
160+ <span class="acp-pill acp-pill--green"><?= t('acp.srv.published', ['count' => number_format((int)$row['count'])]) ?></span>
161+ <?php else: ?>
162+ <span class="acp-pill acp-pill--grey"><?= t('acp.srv.nothing_published') ?></span>
163+ <?php endif; ?>
164+ <?php if ($row['upload_size']): ?>
165+ <span class="acp-pill acp-pill--blue"><?= h(serverdata_human_size((int)$row['upload_size'])) ?></span>
166+ <?php endif; ?>
167+ <?php if ($key === 'creatures' && (int)$row['files'] > 1): ?>
168+ <span class="acp-pill acp-pill--blue"><?= t('acp.srv.xml_files', ['count' => number_format((int)$row['files'])]) ?></span>
169+ <?php endif; ?>
170+ </p>
171+
172+ <table class="acp-table">
173+ <tbody>
174+ <tr>
175+ <td><?= t('acp.srv.col_source') ?></td>
176+ <td><code><?= h($row['path_label']) ?></code></td>
177+ </tr>
178+ <?php if ($row['keeps_file']): ?>
179+ <tr>
180+ <td><?= t('acp.srv.col_uploaded') ?></td>
181+ <td><?= $row['upload_date'] ? h(date('Y-m-d H:i', (int)$row['upload_date'])) : '&mdash;' ?></td>
182+ </tr>
183+ <?php endif; ?>
184+ <tr>
185+ <td><?= t('acp.srv.col_published') ?></td>
186+ <td><?= $row['cache_date'] ? h(date('Y-m-d H:i', (int)$row['cache_date'])) : '&mdash;' ?></td>
187+ </tr>
188+ <tr>
189+ <td><?= t('acp.srv.col_public_page') ?></td>
190+ <td><a href="<?= h(acp_site($row['page'])) ?>" target="_blank"><?= h($row['page']) ?></a></td>
191+ </tr>
192+ </tbody>
193+ </table>
194+
195+ <form method="post" enctype="multipart/form-data">
196+ <?= acp_csrf_field() ?>
197+ <input type="hidden" name="do" value="upload">
198+ <input type="hidden" name="src" value="<?= h($key) ?>">
199+
200+ <div class="acp-field">
201+ <label class="acp-label" for="src_<?= h($key) ?>"><?= t('acp.srv.upload_label', ['accept' => h($row['accept'])]) ?></label>
202+ <input class="acp-input" type="file" id="src_<?= h($key) ?>" name="payload" accept="<?= h($row['accept']) ?>" required>
203+ </div>
204+
205+ <div class="acp-actions">
206+ <button class="acp-btn acp-btn--green" type="submit">
207+ <i class="fa fa-upload"></i> <?= t('acp.srv.upload_publish') ?>
208+ </button>
209+ <?php if ($row['uploaded'] && $row['keeps_file']): ?>
210+ <button class="acp-btn acp-btn--ghost" type="submit" form="rebuild_<?= h($key) ?>">
211+ <i class="fa fa-refresh"></i> <?= t('acp.srv.reread') ?>
212+ </button>
213+ <?php endif; ?>
214+ <?php if ($row['uploaded'] || $row['cached']): ?>
215+ <button class="acp-btn acp-btn--red" type="submit" form="clear_<?= h($key) ?>">
216+ <i class="fa fa-trash"></i> <?= t('acp.srv.remove') ?>
217+ </button>
218+ <?php endif; ?>
219+ </div>
220+ </form>
221+
222+ <?php if ($row['uploaded'] && $row['keeps_file']): ?>
223+ <form method="post" id="rebuild_<?= h($key) ?>">
224+ <?= acp_csrf_field() ?>
225+ <input type="hidden" name="do" value="rebuild">
226+ <input type="hidden" name="src" value="<?= h($key) ?>">
227+ </form>
228+ <?php endif; ?>
229+ <?php if ($row['uploaded'] || $row['cached']): ?>
230+ <form method="post" id="clear_<?= h($key) ?>" onsubmit="return confirm('<?= h(t('acp.srv.confirm_remove', ['label' => $row['label'], 'page' => $row['page']])) ?>');">
231+ <?= acp_csrf_field() ?>
232+ <input type="hidden" name="do" value="clear">
233+ <input type="hidden" name="src" value="<?= h($key) ?>">
234+ </form>
235+ <?php endif; ?>
236+
237+ </div>
238+ </section>
239+ <?php endforeach; ?>
240+</div>
241+
242+<p class="acp-hint">
243+ <?= t('acp.srv.storage_hint', [
244+ 'path' => '<code>' . h(ZNOTE_SERVERDATA_DIR) . '</code>',
245+ 'size' => h(serverdata_human_size($maxUpload)),
246+ ]) ?>
247+</p>
A admin/modules/shop.php +498-0 View file
@@ -0,0 +1,498 @@
1+<?php
2+/**
3+ * Title: Shop Manager
4+ * Icon: fa-shopping-cart
5+ * Group: Economy
6+ * Order: 10
7+ * Description: Add, preview, hide and remove database shop offers.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+$items = getItemList();
16+
17+db()->execute("
18+ CREATE TABLE IF NOT EXISTS `znote_shop_offers` (
19+ `id` int NOT NULL AUTO_INCREMENT,
20+ `type` int NOT NULL,
21+ `itemid` int DEFAULT NULL,
22+ `count` int NOT NULL DEFAULT '1',
23+ `description` varchar(255) NOT NULL,
24+ `points` int NOT NULL DEFAULT '10',
25+ `active` tinyint NOT NULL DEFAULT '1',
26+ `sort_order` int NOT NULL DEFAULT '0',
27+ `created_by` int DEFAULT NULL,
28+ `created_at` int DEFAULT NULL,
29+ `updated_at` int DEFAULT NULL,
30+ PRIMARY KEY (`id`),
31+ KEY `active_sort` (`active`, `sort_order`, `id`)
32+ ) ENGINE=InnoDB;
33+");
34+
35+function acp_shop_offer_columns(): array {
36+ $columns = [];
37+ $rows = db()->fetchAll("SHOW COLUMNS FROM `znote_shop_offers`;");
38+
39+ if (is_array($rows)) {
40+ foreach ($rows as $row) {
41+ if (!empty($row['Field'])) {
42+ $columns[(string)$row['Field']] = true;
43+ }
44+ }
45+ }
46+
47+ return $columns;
48+}
49+
50+function acp_shop_ensure_offer_schema(): void {
51+ $columns = acp_shop_offer_columns();
52+
53+ if (empty($columns['active'])) {
54+ db()->execute("ALTER TABLE `znote_shop_offers` ADD `active` tinyint NOT NULL DEFAULT '1' AFTER `points`;");
55+ $columns['active'] = true;
56+ }
57+ if (empty($columns['sort_order'])) {
58+ db()->execute("ALTER TABLE `znote_shop_offers` ADD `sort_order` int NOT NULL DEFAULT '0' AFTER `active`;");
59+ $columns['sort_order'] = true;
60+ }
61+ if (empty($columns['updated_at'])) {
62+ db()->execute("ALTER TABLE `znote_shop_offers` ADD `updated_at` int DEFAULT NULL AFTER `created_at`;");
63+ $columns['updated_at'] = true;
64+ }
65+
66+ $indexes = db()->fetchAll("SHOW INDEX FROM `znote_shop_offers` WHERE `Key_name` = 'active_sort';");
67+ if (empty($indexes) && !empty($columns['active']) && !empty($columns['sort_order'])) {
68+ db()->execute("ALTER TABLE `znote_shop_offers` ADD KEY `active_sort` (`active`, `sort_order`, `id`);");
69+ }
70+}
71+
72+acp_shop_ensure_offer_schema();
73+
74+function acp_shop_pack_outfit_pair(string $raw): int|false {
75+ if (!preg_match('/^\s*(\d+)\s*,\s*(\d+)\s*$/', $raw, $m)) {
76+ return false;
77+ }
78+
79+ return ((int)$m[1] * 10000) + (int)$m[2];
80+}
81+
82+function acp_shop_unpack_outfit_pair(int $packed): string {
83+ $male = (int)floor($packed / 10000);
84+ $female = (int)($packed % 10000);
85+
86+ return $male . ',' . $female;
87+}
88+
89+function acp_shop_offer_type_label(int $type): string {
90+ $types = [
91+ 1 => t('acp.shp.type_item'),
92+ 2 => t('acp.shp.type_premium'),
93+ 3 => t('acp.shp.type_gender'),
94+ 4 => t('acp.shp.type_name'),
95+ 5 => t('acp.shp.type_outfit'),
96+ 6 => t('acp.shp.type_mount'),
97+ 7 => t('acp.shp.type_custom'),
98+ 8 => t('acp.shp.type_custom'),
99+ ];
100+
101+ return $types[$type] ?? t('acp.shp.type_custom');
102+}
103+
104+function acp_shop_public_asset_url(string $url): string {
105+ if ($url === '' || preg_match('~^(https?:)?//~i', $url) || $url[0] === '/') {
106+ return $url;
107+ }
108+
109+ return acp_site($url);
110+}
111+
112+function acp_shop_item_image_url(int $itemId): string {
113+ global $config;
114+
115+ if (function_exists('znote_item_image_url')) {
116+ return acp_shop_public_asset_url(znote_item_image_url($itemId));
117+ }
118+
119+ $server = trim((string)($config['shop']['imageServer'] ?? ''), '/');
120+ $typeExt = trim((string)($config['shop']['imageType'] ?? 'gif'), '.');
121+
122+ if ($server === '' || $itemId <= 0) {
123+ return '';
124+ }
125+
126+ if (preg_match('~^https?://~i', $server)) {
127+ return $server . '/' . $itemId . '.' . $typeExt;
128+ }
129+
130+ if (str_contains($server, '.')) {
131+ return 'http://' . $server . '/' . $itemId . '.' . $typeExt;
132+ }
133+
134+ return acp_shop_public_asset_url($server . '/' . $itemId . '.' . $typeExt);
135+}
136+
137+function acp_shop_outfit_server_url(): string {
138+ global $config;
139+
140+ $server = trim((string)($config['show_outfits']['imageServer'] ?? ''));
141+ if ($server === '') {
142+ return '';
143+ }
144+
145+ if (preg_match('~^(https?:)?//~i', $server) || substr($server, 0, 1) === '/') {
146+ return $server;
147+ }
148+
149+ return acp_site($server);
150+}
151+
152+function acp_shop_outfit_image_url(int $outfitId, int $addons, int $mountId = 0): string {
153+ $server = acp_shop_outfit_server_url();
154+ if ($server === '' || $outfitId <= 0) {
155+ return '';
156+ }
157+
158+ $url = $server
159+ . '?id=' . $outfitId
160+ . '&addons=' . $addons
161+ . '&head=78&body=68&legs=58&feet=76&direction=2';
162+
163+ if ($mountId > 0) {
164+ $url .= '&mount=' . $mountId;
165+ }
166+
167+ return $url;
168+}
169+
170+function acp_shop_offer_preview(array $offer, array $items): string {
171+ global $config;
172+
173+ $type = intv($offer['type'] ?? 0);
174+ $itemId = intv($offer['itemid'] ?? 0);
175+ $count = intv($offer['count'] ?? 0);
176+
177+ if ($type === 5 && !empty($config['show_outfits']['imageServer'])) {
178+ $male = (int)floor($itemId / 10000);
179+ $female = (int)($itemId % 10000);
180+
181+ return '<img class="acp-shop-preview-img" src="' . h(acp_shop_outfit_image_url($male, $count)) . '" alt="">'
182+ . '<img class="acp-shop-preview-img" src="' . h(acp_shop_outfit_image_url($female, $count)) . '" alt="">';
183+ }
184+
185+ if ($type === 6 && !empty($config['show_outfits']['imageServer'])) {
186+ return '<img class="acp-shop-preview-img" src="' . h(acp_shop_outfit_image_url(128, 3, $itemId)) . '" alt="">';
187+ }
188+
189+ if (!empty($config['shop']['showImage']) && $itemId > 0) {
190+ return '<img class="acp-shop-preview-item" src="' . h(acp_shop_item_image_url($itemId)) . '" alt="">';
191+ }
192+
193+ return '<span class="is-muted">' . h($items[$itemId] ?? '#' . $itemId) . '</span>';
194+}
195+
196+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
197+ $shopAction = (string)($_POST['shop_offer_action'] ?? '');
198+ $now = time();
199+
200+ if ($shopAction === 'add') {
201+ $type = intv($_POST['type'] ?? 0);
202+ $itemRaw = trim((string)($_POST['itemid'] ?? ''));
203+ $count = max(0, intv($_POST['count'] ?? 0));
204+ $points = max(1, intv($_POST['points'] ?? 0));
205+ $sortOrder = intv($_POST['sort_order'] ?? 0);
206+ $description = substr(trim((string)($_POST['description'] ?? '')), 0, 255);
207+ $itemId = false;
208+
209+ if ($type === 5) {
210+ $itemId = acp_shop_pack_outfit_pair($itemRaw);
211+ if ($itemId === false) {
212+ acp_flash_error(t('acp.shp.outfit_field_error'));
213+ }
214+ } elseif (preg_match('/^\d+$/', $itemRaw)) {
215+ $itemId = (int)$itemRaw;
216+ }
217+
218+ if ($type <= 0 || $description === '' || $points <= 0 || $itemId === false) {
219+ acp_flash_error(t('acp.shp.missing_fields'));
220+ } else {
221+ $created = db()->execute("
222+ INSERT INTO `znote_shop_offers`
223+ (`type`, `itemid`, `count`, `description`, `points`, `active`, `sort_order`, `created_by`, `created_at`, `updated_at`)
224+ VALUES
225+ (?, ?, ?, ?, ?, 1, ?, ?, ?, ?);
226+ ", [$type, $itemId, $count, $description, $points, $sortOrder, (int)$user_data['id'], $now, $now]);
227+ if ($created) {
228+ acp_log('shop.offer_add', $description, ['type' => $type, 'points' => $points]);
229+ acp_flash_success(t('acp.shp.offer_added'));
230+ } else {
231+ acp_flash_error(t('acp.shp.offer_add_failed'));
232+ }
233+ acp_redirect('shop');
234+ }
235+ }
236+
237+ if ($shopAction === 'toggle') {
238+ $id = intv($_POST['id'] ?? 0);
239+ $updated = db()->execute("
240+ UPDATE `znote_shop_offers`
241+ SET `active` = CASE WHEN `active` = 1 THEN 0 ELSE 1 END,
242+ `updated_at` = ?
243+ WHERE `id` = ?
244+ LIMIT 1;
245+ ", [$now, $id]);
246+ if ($updated) { acp_log('shop.offer_toggle', '#' . $id); }
247+ $updated ? acp_flash_success(t('acp.shp.status_updated')) : acp_flash_error(t('acp.shp.status_failed'));
248+ acp_redirect('shop');
249+ }
250+
251+ if ($shopAction === 'delete') {
252+ $id = intv($_POST['id'] ?? 0);
253+ $deleted = db()->execute("DELETE FROM `znote_shop_offers` WHERE `id` = ? LIMIT 1;", [$id]);
254+ if ($deleted) { acp_log('shop.offer_delete', '#' . $id); }
255+ $deleted ? acp_flash_success(t('acp.shp.removed')) : acp_flash_error(t('acp.shp.remove_failed'));
256+ acp_redirect('shop');
257+ }
258+
259+ if (in_array($shopAction, ['bulk_enable', 'bulk_disable', 'bulk_delete'], true)) {
260+ $ids = array_values(array_filter(array_map('intv', (array)($_POST['ids'] ?? []))));
261+
262+ if (!$ids) {
263+ acp_flash_error(t('acp.shp.bulk_none_selected'));
264+ acp_redirect('shop');
265+ }
266+
267+ $placeholders = implode(',', array_fill(0, count($ids), '?'));
268+
269+ if ($shopAction === 'bulk_delete') {
270+ $done = db()->execute("DELETE FROM `znote_shop_offers` WHERE `id` IN ({$placeholders});", $ids);
271+ } else {
272+ $active = ($shopAction === 'bulk_enable') ? 1 : 0;
273+ $done = db()->execute("
274+ UPDATE `znote_shop_offers` SET `active` = ?, `updated_at` = ? WHERE `id` IN ({$placeholders});
275+ ", array_merge([$active, $now], $ids));
276+ }
277+
278+ if ($done !== false) {
279+ acp_log('shop.offer_bulk_' . $shopAction, implode(',', $ids), ['count' => count($ids)]);
280+ acp_flash_success(t('acp.shp.bulk_done', ['n' => count($ids)]));
281+ } else {
282+ acp_flash_error(t('acp.shp.bulk_failed'));
283+ }
284+
285+ acp_redirect('shop');
286+ }
287+}
288+
289+$offers = db()->fetchAll("SELECT * FROM `znote_shop_offers` ORDER BY `active` DESC, `sort_order` ASC, `id` ASC;");
290+$offers = is_array($offers) ? $offers : [];
291+$activeOffers = 0;
292+$hiddenOffers = 0;
293+foreach ($offers as $offer) {
294+ !empty($offer['active']) ? $activeOffers++ : $hiddenOffers++;
295+}
296+
297+$itemImageTemplate = acp_shop_item_image_url(999999);
298+if ($itemImageTemplate !== '') {
299+ $itemImageTemplate = str_replace('999999', '{id}', $itemImageTemplate);
300+}
301+?>
302+
303+<div class="acp-stats">
304+ <?php
305+ acp_stat(t('acp.shp.stat_db_offers'), count($offers), 'fa-tags', null, 'blue');
306+ acp_stat(t('acp.shp.stat_active'), $activeOffers, 'fa-check-circle', null, 'green');
307+ acp_stat(t('acp.shp.stat_hidden'), $hiddenOffers, 'fa-eye-slash', null, 'amber');
308+ acp_stat(t('acp.shp.stat_orders'), acp_count("SELECT COUNT(*) AS `c` FROM `znote_shop_orders`;"), 'fa-history', acp_url('shop_orders'), 'purple');
309+ ?>
310+</div>
311+
312+<!-- ------------------------------------------------------- Add offer -->
313+<section class="acp-card acp-shop-form-card">
314+ <header class="acp-card-head">
315+ <h2><?= h(t('acp.shp.add_offer_title')) ?></h2>
316+ <p><?= h(t('acp.shp.add_offer_sub')) ?></p>
317+ </header>
318+ <div class="acp-card-body">
319+ <form method="post">
320+ <?= acp_csrf_field() ?>
321+ <input type="hidden" name="shop_offer_action" value="add">
322+
323+ <div class="acp-row acp-shop-form-row">
324+ <div class="acp-field">
325+ <label class="acp-label" for="type"><?= h(t('acp.shp.type_label')) ?></label>
326+ <select class="acp-select" id="type" name="type">
327+ <option value="1"><?= h(t('acp.shp.type_item')) ?></option>
328+ <option value="2"><?= h(t('acp.shp.type_premium')) ?></option>
329+ <option value="3"><?= h(t('acp.shp.type_gender')) ?></option>
330+ <option value="4"><?= h(t('acp.shp.type_name')) ?></option>
331+ <option value="5"><?= h(t('acp.shp.outfit_option')) ?></option>
332+ <option value="6"><?= h(t('acp.shp.type_mount')) ?></option>
333+ <option value="8"><?= h(t('acp.shp.type_custom')) ?></option>
334+ </select>
335+ </div>
336+ <div class="acp-field">
337+ <label class="acp-label" for="itemid"><?= h(t('acp.shp.item_id')) ?></label>
338+ <div class="acp-shop-id-field">
339+ <input class="acp-input" id="itemid" name="itemid" placeholder="2160 or 128,136" required>
340+ <div class="acp-shop-live-preview"
341+ id="shopOfferPreview"
342+ data-item-template="<?= h($itemImageTemplate) ?>"
343+ data-outfit-server="<?= h(acp_shop_outfit_server_url()) ?>">
344+ <span class="is-muted"><?= h(t('acp.shp.preview_label')) ?></span>
345+ </div>
346+ </div>
347+ <p class="acp-hint"><?= h(t('acp.shp.outfit_hint')) ?></p>
348+ </div>
349+ <div class="acp-field">
350+ <label class="acp-label" for="description"><?= h(t('acp.shp.description')) ?></label>
351+ <input class="acp-input" id="description" name="description" maxlength="255" placeholder="5 x Crystal coin" required>
352+ </div>
353+ </div>
354+
355+ <div class="acp-row acp-shop-form-row">
356+ <div class="acp-field">
357+ <label class="acp-label" for="count"><?= h(t('acp.shp.count_label')) ?></label>
358+ <input class="acp-input" id="count" name="count" type="number" min="0" value="1" required>
359+ </div>
360+ <div class="acp-field">
361+ <label class="acp-label" for="points"><?= h(t('acp.shp.points')) ?></label>
362+ <input class="acp-input" id="points" name="points" type="number" min="1" value="10" required>
363+ </div>
364+ <div class="acp-field">
365+ <label class="acp-label" for="sort_order"><?= h(t('acp.shp.sort_order')) ?></label>
366+ <input class="acp-input" id="sort_order" name="sort_order" type="number" value="0">
367+ </div>
368+ </div>
369+
370+ <div class="acp-actions">
371+ <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-plus"></i> <?= h(t('acp.shp.add_offer_btn')) ?></button>
372+ </div>
373+ </form>
374+ </div>
375+</section>
376+
377+<!-- ------------------------------------------------------- Current offers -->
378+<section class="acp-card acp-shop-offers-card">
379+ <header class="acp-card-head">
380+ <h2><?= h(t('acp.shp.db_offers_title')) ?></h2>
381+ <p><?= h(t('acp.shp.db_offers_sub')) ?></p>
382+ </header>
383+ <div class="acp-card-body is-flush">
384+ <?php if ($offers): ?>
385+ <form id="acpShopBulkForm" method="post">
386+ <?= acp_csrf_field() ?>
387+ <input type="hidden" name="shop_offer_action" id="acpShopBulkAction" value="">
388+ </form>
389+
390+ <div class="acp-toolbar" id="acpShopBulkBar" hidden>
391+ <span class="is-muted"><span id="acpShopBulkCount">0</span> <?= h(t_default('acp.shp.bulk_selected', 'selected')) ?></span>
392+ <div class="acp-actions is-tight">
393+ <button type="submit" form="acpShopBulkForm" class="acp-btn acp-btn--sm"
394+ onclick="document.getElementById('acpShopBulkAction').value='bulk_enable';">
395+ <i class="fa fa-eye"></i> <?= h(t_default('acp.shp.bulk_enable', 'Show selected')) ?>
396+ </button>
397+ <button type="submit" form="acpShopBulkForm" class="acp-btn acp-btn--ghost acp-btn--sm"
398+ onclick="document.getElementById('acpShopBulkAction').value='bulk_disable';">
399+ <i class="fa fa-eye-slash"></i> <?= h(t_default('acp.shp.bulk_disable', 'Hide selected')) ?>
400+ </button>
401+ <button type="submit" form="acpShopBulkForm" class="acp-btn acp-btn--red acp-btn--sm"
402+ onclick="document.getElementById('acpShopBulkAction').value='bulk_delete'; return confirm('<?= h(t_default('acp.shp.bulk_confirm_delete', 'Delete every selected offer? This cannot be undone.')) ?>');">
403+ <i class="fa fa-trash"></i> <?= h(t_default('acp.shp.bulk_delete', 'Delete selected')) ?>
404+ </button>
405+ </div>
406+ </div>
407+ <?php if (count($offers) > 8): ?>
408+ <div class="acp-toolbar">
409+ <div style="display:flex;gap:8px;flex:1 1 320px;max-width:460px;">
410+ <input class="acp-input" type="search" data-acp-search-input="acpShopOfferTable"
411+ placeholder="<?= h(t_default('acp.shp.search_placeholder', 'Search offers...')) ?>">
412+ </div>
413+ <span class="is-muted" data-acp-search-count="acpShopOfferTable"></span>
414+ </div>
415+ <?php endif; ?>
416+ <div class="acp-table-wrap">
417+ <table class="acp-table acp-shop-offers-table" data-sortable id="acpShopOfferTable">
418+ <thead>
419+ <tr>
420+ <th><input type="checkbox" id="acpShopBulkAll" aria-label="<?= h(t_default('acp.shp.bulk_select_all', 'Select all')) ?>"></th>
421+ <th>#</th>
422+ <th><?= h(t('acp.shp.col_preview')) ?></th>
423+ <th><?= h(t('acp.shp.col_offer')) ?></th>
424+ <th class="is-num"><?= h(t('acp.shp.col_points')) ?></th>
425+ <th><?= h(t('acp.shp.col_status')) ?></th>
426+ <th class="is-num"><?= h(t('acp.shp.col_actions')) ?></th>
427+ </tr>
428+ </thead>
429+ <tbody>
430+ <?php foreach ($offers as $offer): ?>
431+ <tr data-acp-search="<?= h(strtolower((string)($offer['description'] ?? ''))) ?>">
432+ <td><input type="checkbox" class="acp-shop-bulk-check" form="acpShopBulkForm" name="ids[]" value="<?= intv($offer['id'] ?? 0) ?>"></td>
433+ <td class="is-muted"><?= intv($offer['id'] ?? 0) ?></td>
434+ <td><?= acp_shop_offer_preview($offer, $items) ?></td>
435+ <td>
436+ <strong><?= h($offer['description'] ?? '') ?></strong><br>
437+ <span class="is-muted">
438+ <?= h(acp_shop_offer_type_label(intv($offer['type'] ?? 0))) ?> -
439+ <?= intv($offer['type'] ?? 0) === 5 ? h(acp_shop_unpack_outfit_pair(intv($offer['itemid'] ?? 0))) : intv($offer['itemid'] ?? 0) ?>,
440+ <?= h(t('acp.shp.count_label2')) ?> <?= intv($offer['count'] ?? 0) ?>
441+ </span>
442+ </td>
443+ <td class="is-num"><strong><?= intv($offer['points'] ?? 0) ?></strong></td>
444+ <td>
445+ <span class="acp-pill <?= !empty($offer['active']) ? 'acp-pill--green' : 'acp-pill--grey' ?>">
446+ <?= !empty($offer['active']) ? h(t('acp.shp.active_pill')) : h(t('acp.shp.hidden_pill')) ?>
447+ </span>
448+ </td>
449+ <td class="is-nowrap is-num acp-shop-offer-actions">
450+ <form class="acp-inline-form" method="post">
451+ <?= acp_csrf_field() ?>
452+ <input type="hidden" name="shop_offer_action" value="toggle">
453+ <input type="hidden" name="id" value="<?= intv($offer['id'] ?? 0) ?>">
454+ <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit">
455+ <?= !empty($offer['active']) ? h(t('acp.shp.hide')) : h(t('acp.shp.show')) ?>
456+ </button>
457+ </form>
458+ <form class="acp-inline-form" method="post" data-confirm="<?= h(t('acp.shp.confirm_remove')) ?>">
459+ <?= acp_csrf_field() ?>
460+ <input type="hidden" name="shop_offer_action" value="delete">
461+ <input type="hidden" name="id" value="<?= intv($offer['id'] ?? 0) ?>">
462+ <button class="acp-btn acp-btn--red acp-btn--sm" type="submit" title="<?= h(t('acp.shp.remove_offer')) ?>" aria-label="<?= h(t('acp.shp.remove_offer')) ?>"><i class="fa fa-times"></i></button>
463+ </form>
464+ </td>
465+ </tr>
466+ <?php endforeach; ?>
467+ </tbody>
468+ </table>
469+ </div>
470+ <?php else: ?>
471+ <?php acp_empty(t('acp.shp.empty'), 'fa-tags'); ?>
472+ <?php endif; ?>
473+ </div>
474+</section>
475+
476+<script>
477+(function () {
478+ var all = document.getElementById('acpShopBulkAll');
479+ var boxes = Array.prototype.slice.call(document.querySelectorAll('.acp-shop-bulk-check'));
480+ var bar = document.getElementById('acpShopBulkBar');
481+ var count = document.getElementById('acpShopBulkCount');
482+ if (!all || !bar) return;
483+
484+ function refresh() {
485+ var checked = boxes.filter(function (b) { return b.checked; });
486+ bar.hidden = checked.length === 0;
487+ if (count) count.textContent = checked.length;
488+ all.checked = checked.length > 0 && checked.length === boxes.length;
489+ all.indeterminate = checked.length > 0 && checked.length < boxes.length;
490+ }
491+
492+ all.addEventListener('change', function () {
493+ boxes.forEach(function (b) { if (!b.closest('tr').hidden) b.checked = all.checked; });
494+ refresh();
495+ });
496+ boxes.forEach(function (b) { b.addEventListener('change', refresh); });
497+})();
498+</script>
A admin/modules/shop_orders.php +208-0 View file
@@ -0,0 +1,208 @@
1+<?php
2+/**
3+ * Title: Shop Pending / History
4+ * Icon: fa-list-alt
5+ * Group: Economy
6+ * Order: 15
7+ * Description: Pending shop deliveries and completed purchase history.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+$items = getItemList();
16+
17+$historyPage = max(1, intv($_GET['page'] ?? 1));
18+$historyPerPage = 50;
19+$pendingTotal = acp_count("SELECT COUNT(*) AS `c` FROM `znote_shop_orders`;");
20+$historyTotal = acp_count("SELECT COUNT(*) AS `c` FROM `znote_shop_logs`;");
21+$historyPages = max(1, (int)ceil($historyTotal / $historyPerPage));
22+$historyPage = min($historyPage, $historyPages);
23+$historyOffset = ($historyPage - 1) * $historyPerPage;
24+
25+$pendingPerPage = 50;
26+$pendingPages = max(1, (int)ceil($pendingTotal / $pendingPerPage));
27+$pendingPage = max(1, min($pendingPages, intv($_GET['ppage'] ?? 1)));
28+$pendingOffset = ($pendingPage - 1) * $pendingPerPage;
29+
30+$pending = db()->fetchAll("SELECT * FROM `znote_shop_orders` ORDER BY `id` ASC LIMIT {$pendingOffset}, {$pendingPerPage};");
31+$history = db()->fetchAll("SELECT * FROM `znote_shop_logs` ORDER BY `id` DESC LIMIT {$historyOffset}, {$historyPerPage};");
32+
33+$pending = is_array($pending) ? $pending : [];
34+$history = is_array($history) ? $history : [];
35+
36+$orderTypes = [
37+ 1 => t('acp.sord.type_item'),
38+ 2 => t('acp.sord.type_premium'),
39+ 3 => t('acp.sord.type_gender'),
40+ 4 => t('acp.sord.type_name'),
41+ 5 => t('acp.sord.type_outfit'),
42+ 6 => t('acp.sord.type_mount'),
43+ 7 => t('acp.sord.type_custom'),
44+ 8 => t('acp.sord.type_custom'),
45+];
46+
47+$accountIds = [];
48+foreach ([$pending, $history] as $set) {
49+ foreach ($set as $order) {
50+ $id = intv($order['account_id'] ?? 0);
51+ if ($id > 0) {
52+ $accountIds[$id] = true;
53+ }
54+ }
55+}
56+
57+$accountNames = [];
58+if ($accountIds) {
59+ $nameColumn = '`' . znote_server_adapter()->accountIdentityColumn() . '`';
60+ $ids = array_map('intval', array_keys($accountIds));
61+ $placeholders = implode(',', array_fill(0, count($ids), '?'));
62+
63+ $rows = db()->fetchAll("
64+ SELECT `id`, {$nameColumn} AS `account_name`
65+ FROM `accounts`
66+ WHERE `id` IN ({$placeholders});
67+ ", $ids);
68+
69+ if (is_array($rows)) {
70+ foreach ($rows as $row) {
71+ $accountNames[(int)$row['id']] = (string)$row['account_name'];
72+ }
73+ }
74+}
75+
76+function acp_shop_orders_account(int $id, array $names): string {
77+ return isset($names[$id])
78+ ? h($names[$id]) . ' <span class="is-muted">#' . $id . '</span>'
79+ : '<span class="is-muted">' . t('acp.sord.deleted_account', ['id' => $id]) . '</span>';
80+}
81+
82+$historyPoints = acp_count("SELECT COALESCE(SUM(`points`), 0) AS `c` FROM `znote_shop_logs`;");
83+?>
84+
85+<div class="acp-stats">
86+ <?php
87+ acp_stat(t('acp.sord.stat_pending'), $pendingTotal, 'fa-hourglass-half', null, 'amber');
88+ acp_stat(t('acp.sord.stat_completed'), $historyTotal, 'fa-check-circle', null, 'green');
89+ acp_stat(t('acp.sord.stat_points'), $historyPoints, 'fa-diamond', null, 'purple');
90+ acp_stat(t('acp.sord.stat_manage'), t('acp.sord.open'), 'fa-tags', acp_url('shop'), 'blue');
91+ ?>
92+</div>
93+
94+<section class="acp-card">
95+ <header class="acp-card-head">
96+ <h2><?= t('acp.sord.pending_title') ?></h2>
97+ <p><?= t('acp.sord.pending_sub') ?></p>
98+ </header>
99+ <div class="acp-card-body is-flush">
100+ <?php if ($pending): ?>
101+ <div class="acp-table-wrap">
102+ <table class="acp-table" data-sortable>
103+ <thead>
104+ <tr>
105+ <th>#</th>
106+ <th><?= t('acp.sord.col_account') ?></th>
107+ <th><?= t('acp.sord.col_type') ?></th>
108+ <th><?= t('acp.sord.col_item') ?></th>
109+ <th class="is-num"><?= t('acp.sord.col_count') ?></th>
110+ <th><?= t('acp.sord.col_ordered') ?></th>
111+ </tr>
112+ </thead>
113+ <tbody>
114+ <?php foreach ($pending as $order):
115+ $itemId = intv($order['itemid'] ?? 0);
116+ ?>
117+ <tr>
118+ <td class="is-muted"><?= intv($order['id'] ?? 0) ?></td>
119+ <td class="is-nowrap"><?= acp_shop_orders_account(intv($order['account_id'] ?? 0), $accountNames) ?></td>
120+ <td><span class="acp-pill acp-pill--blue"><?= h($orderTypes[intv($order['type'] ?? 0)] ?? t('acp.sord.type_unknown')) ?></span></td>
121+ <td>
122+ <?php if ($itemId > 0): ?>
123+ <?= h($items[$itemId] ?? t('acp.sord.item_unknown')) ?>
124+ <span class="is-muted">(<?= $itemId ?>)</span>
125+ <?php else: ?>
126+ <span class="is-muted">&mdash;</span>
127+ <?php endif; ?>
128+ </td>
129+ <td class="is-num"><?= intv($order['count'] ?? 0) ?></td>
130+ <td class="is-nowrap is-muted"><?= h(getClock(intv($order['time'] ?? 0), true)) ?></td>
131+ </tr>
132+ <?php endforeach; ?>
133+ </tbody>
134+ </table>
135+ </div>
136+ <?php else: ?>
137+ <?php acp_empty(t('acp.sord.pending_empty'), 'fa-check-circle'); ?>
138+ <?php endif; ?>
139+ <?php if ($pendingPages > 1): ?>
140+ <div class="acp-toolbar">
141+ <span class="is-muted"><?= $pendingPage ?> / <?= $pendingPages ?></span>
142+ <div class="acp-actions is-tight">
143+ <?php if ($pendingPage > 1): ?><a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('shop_orders', ['ppage' => $pendingPage - 1, 'page' => $historyPage])) ?>"><?= t('common.previous') ?></a><?php endif; ?>
144+ <?php if ($pendingPage < $pendingPages): ?><a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('shop_orders', ['ppage' => $pendingPage + 1, 'page' => $historyPage])) ?>"><?= t('common.next') ?></a><?php endif; ?>
145+ </div>
146+ </div>
147+ <?php endif; ?>
148+ </div>
149+</section>
150+
151+<section class="acp-card">
152+ <header class="acp-card-head">
153+ <h2><?= t('acp.sord.history_title') ?></h2>
154+ <p><?= t('acp.sord.history_sub') ?></p>
155+ </header>
156+ <div class="acp-card-body is-flush">
157+ <?php if ($history): ?>
158+ <div class="acp-table-wrap">
159+ <table class="acp-table" data-sortable>
160+ <thead>
161+ <tr>
162+ <th>#</th>
163+ <th><?= t('acp.sord.col_account') ?></th>
164+ <th><?= t('acp.sord.col_type') ?></th>
165+ <th><?= t('acp.sord.col_item') ?></th>
166+ <th class="is-num"><?= t('acp.sord.col_count') ?></th>
167+ <th class="is-num"><?= t('acp.sord.col_points') ?></th>
168+ <th><?= t('acp.sord.col_date') ?></th>
169+ </tr>
170+ </thead>
171+ <tbody>
172+ <?php foreach ($history as $order):
173+ $itemId = intv($order['itemid'] ?? 0);
174+ ?>
175+ <tr>
176+ <td class="is-muted"><?= intv($order['id'] ?? 0) ?></td>
177+ <td class="is-nowrap"><?= acp_shop_orders_account(intv($order['account_id'] ?? 0), $accountNames) ?></td>
178+ <td><span class="acp-pill acp-pill--grey"><?= h($orderTypes[intv($order['type'] ?? 0)] ?? t('acp.sord.type_unknown')) ?></span></td>
179+ <td>
180+ <?php if ($itemId > 0): ?>
181+ <?= h($items[$itemId] ?? t('acp.sord.item_unknown')) ?>
182+ <span class="is-muted">(<?= $itemId ?>)</span>
183+ <?php else: ?>
184+ <span class="is-muted">&mdash;</span>
185+ <?php endif; ?>
186+ </td>
187+ <td class="is-num"><?= intv($order['count'] ?? 0) ?></td>
188+ <td class="is-num"><strong><?= intv($order['points'] ?? 0) ?></strong></td>
189+ <td class="is-nowrap is-muted"><?= h(getClock(intv($order['time'] ?? 0), true, false)) ?></td>
190+ </tr>
191+ <?php endforeach; ?>
192+ </tbody>
193+ </table>
194+ </div>
195+ <?php else: ?>
196+ <?php acp_empty(t('acp.sord.history_empty'), 'fa-shopping-cart'); ?>
197+ <?php endif; ?>
198+ <?php if ($historyPages > 1): ?>
199+ <div class="acp-toolbar">
200+ <span class="is-muted"><?= $historyPage ?> / <?= $historyPages ?></span>
201+ <div class="acp-actions is-tight">
202+ <?php if ($historyPage > 1): ?><a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('shop_orders', ['page' => $historyPage - 1, 'ppage' => $pendingPage])) ?>"><?= t('common.previous') ?></a><?php endif; ?>
203+ <?php if ($historyPage < $historyPages): ?><a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('shop_orders', ['page' => $historyPage + 1, 'ppage' => $pendingPage])) ?>"><?= t('common.next') ?></a><?php endif; ?>
204+ </div>
205+ </div>
206+ <?php endif; ?>
207+ </div>
208+</section>
A admin/modules/skills.php +319-0 View file
@@ -0,0 +1,319 @@
1+<?php
2+/**
3+ * Title: Character Skills
4+ * Icon: fa-bolt
5+ * Group: Players
6+ * Order: 30
7+ * Description: Read and rewrite a character's level, vocation and skills.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+function acp_skill_value(?array $skills, int $index): int {
16+ return (is_array($skills) && isset($skills[$index]['value'])) ? (int)$skills[$index]['value'] : 0;
17+}
18+
19+$isTfs10 = (znote_server_adapter()->normalizedEngine() === 'TFS_10');
20+
21+// ---------------------------------------------------------------------------
22+// Save
23+// ---------------------------------------------------------------------------
24+if (isset($_POST['pid']) && intv($_POST['pid']) > 0) {
25+
26+ $pid = intv($_POST['pid']);
27+ $backToName = trim((string)($_POST['name'] ?? ''));
28+
29+ $isOnline = $isTfs10 ? user_is_online_10($pid) : user_is_online($pid);
30+
31+ if ($isOnline) {
32+ acp_flash_error(t('acp.skl.must_offline'));
33+ } else {
34+ $level = intv($_POST['level'] ?? 1);
35+ $vocation = intv($_POST['vocation'] ?? 0);
36+
37+ // Base stats, derived exactly like character creation does.
38+ $playercnf = $config['player'];
39+ $statgain = $config['vocations_gain'][$vocation] ?? ['hp' => 0, 'mp' => 0, 'cap' => 0];
40+
41+ $levelsFromBase = $level - intv($playercnf['base']['level']);
42+
43+ $newHp = intv($playercnf['base']['health'] + ($statgain['hp'] * $levelsFromBase));
44+ $newMp = intv($playercnf['base']['mana'] + ($statgain['mp'] * $levelsFromBase));
45+ $newCap = intv($playercnf['base']['cap'] + ($statgain['cap'] * $levelsFromBase));
46+
47+ $fist = intv($_POST['fist'] ?? 0);
48+ $club = intv($_POST['club'] ?? 0);
49+ $sword = intv($_POST['sword'] ?? 0);
50+ $axe = intv($_POST['axe'] ?? 0);
51+ $dist = intv($_POST['dist'] ?? 0);
52+ $shield = intv($_POST['shield'] ?? 0);
53+ $fish = intv($_POST['fish'] ?? 0);
54+ $magic = intv($_POST['magic'] ?? 0);
55+
56+ $db = db();
57+ if (!$db->beginTransaction()) {
58+ acp_flash_error('Could not start the database transaction.');
59+ acp_redirect('skills', $backToName !== '' ? ['name' => $backToName] : []);
60+ }
61+
62+ $ok = true;
63+ if (!$isTfs10) {
64+ // TFS 0.x keeps skills in their own table.
65+ foreach ([0 => $fist, 1 => $club, 2 => $sword, 3 => $axe, 4 => $dist, 5 => $shield, 6 => $fish] as $skillId => $skillValue) {
66+ $ok = $ok && $db->execute(
67+ "UPDATE `player_skills` SET `value` = ? WHERE `player_id` = ? AND `skillid` = ? LIMIT 1;",
68+ [$skillValue, $pid, $skillId]
69+ );
70+ }
71+
72+ $ok = $ok && $db->execute("
73+ UPDATE `players`
74+ SET `maglevel` = ?,
75+ `vocation` = ?,
76+ `level` = ?,
77+ `experience` = ?,
78+ `health` = ?,
79+ `healthmax` = ?,
80+ `mana` = ?,
81+ `manamax` = ?,
82+ `cap` = ?
83+ WHERE `id` = ?
84+ LIMIT 1;
85+ ", [$magic, $vocation, $level, level_to_experience($level), $newHp, $newHp, $newMp, $newMp, $newCap, $pid]);
86+ } else {
87+ $ok = $db->execute("
88+ UPDATE `players`
89+ SET `vocation` = ?,
90+ `level` = ?,
91+ `experience` = ?,
92+ `health` = ?,
93+ `healthmax` = ?,
94+ `mana` = ?,
95+ `manamax` = ?,
96+ `cap` = ?,
97+ `skill_fist` = ?,
98+ `skill_club` = ?,
99+ `skill_sword` = ?,
100+ `skill_axe` = ?,
101+ `skill_dist` = ?,
102+ `skill_shielding` = ?,
103+ `skill_fishing` = ?,
104+ `maglevel` = ?
105+ WHERE `id` = ?
106+ LIMIT 1;
107+ ", [$vocation, $level, level_to_experience($level), $newHp, $newHp, $newMp, $newMp, $newCap, $fist, $club, $sword, $axe, $dist, $shield, $fish, $magic, $pid]);
108+ }
109+
110+ if ($ok) {
111+ $db->commit();
112+ acp_log('player.update_skills', $backToName, [
113+ 'level' => $level, 'vocation' => $vocation, 'magic' => $magic,
114+ 'fist' => $fist, 'club' => $club, 'sword' => $sword, 'axe' => $axe,
115+ 'dist' => $dist, 'shield' => $shield, 'fish' => $fish,
116+ ]);
117+ acp_flash_success(t('acp.skl.updated'));
118+ } else {
119+ $db->rollback();
120+ acp_flash_error('Could not update character skills.');
121+ }
122+ }
123+
124+ acp_redirect('skills', $backToName !== '' ? ['name' => $backToName] : []);
125+}
126+
127+// ---------------------------------------------------------------------------
128+// Load
129+// ---------------------------------------------------------------------------
130+$name = isset($_GET['name']) ? trim((string)$_GET['name']) : '';
131+$skills = null;
132+$pid = 0;
133+
134+if ($name !== '') {
135+ if (!user_character_exist($name)) {
136+ acp_flash_error(t('acp.skl.not_found', ['name' => '<strong>' . h($name) . '</strong>']));
137+ acp_redirect('skills');
138+ }
139+
140+ $pid = (int)user_character_id($name);
141+
142+ if (!$isTfs10) {
143+ $rows = db()->fetchAll("
144+ SELECT `value` FROM `player_skills`
145+ WHERE `player_id` = ?
146+ ORDER BY `skillid` ASC
147+ LIMIT 7;
148+ ", [$pid]);
149+
150+ // The character can exist without skill rows yet.
151+ $skills = is_array($rows) ? $rows : [];
152+
153+ $player = db()->fetchOne("
154+ SELECT `maglevel`, `level`, `vocation`
155+ FROM `players`
156+ WHERE `id` = ?
157+ LIMIT 1;
158+ ", [$pid]);
159+ if (!is_array($player)) {
160+ $player = ['maglevel' => 0, 'level' => 0, 'vocation' => 0];
161+ }
162+
163+ // Pad missing skill rows so indexes 0-6 always exist.
164+ while (count($skills) < 7) {
165+ $skills[] = ['value' => 0];
166+ }
167+
168+ $skills[] = ['value' => $player['maglevel']];
169+ $skills[] = ['value' => $player['level']];
170+ $skills[] = ['value' => $player['vocation']];
171+ } else {
172+ $p = db()->fetchOne("
173+ SELECT `skill_fist`, `skill_club`, `skill_sword`, `skill_axe`,
174+ `skill_dist`, `skill_shielding`, `skill_fishing`,
175+ `maglevel`, `level`, `vocation`
176+ FROM `players`
177+ WHERE `id` = ?
178+ LIMIT 1;
179+ ", [$pid]);
180+ if (!is_array($p)) {
181+ $p = [];
182+ }
183+
184+ $skills = [
185+ ['value' => $p['skill_fist'] ?? 0],
186+ ['value' => $p['skill_club'] ?? 0],
187+ ['value' => $p['skill_sword'] ?? 0],
188+ ['value' => $p['skill_axe'] ?? 0],
189+ ['value' => $p['skill_dist'] ?? 0],
190+ ['value' => $p['skill_shielding'] ?? 0],
191+ ['value' => $p['skill_fishing'] ?? 0],
192+ ['value' => $p['maglevel'] ?? 0],
193+ ['value' => $p['level'] ?? 0],
194+ ['value' => $p['vocation'] ?? 0],
195+ ];
196+ }
197+}
198+
199+$loaded = ($name !== '');
200+?>
201+
202+<?php if (!$loaded): ?>
203+
204+ <section class="acp-card" style="max-width:520px;">
205+ <header class="acp-card-head">
206+ <h2><?= t('acp.skl.lookup_title') ?></h2>
207+ <p><?= t('acp.skl.lookup_sub') ?></p>
208+ </header>
209+ <div class="acp-card-body">
210+ <form method="get">
211+ <input type="hidden" name="p" value="skills">
212+ <div class="acp-field">
213+ <label class="acp-label" for="name"><?= t('acp.skl.char_name') ?></label>
214+ <input class="acp-input" id="name" name="name" placeholder="<?= h(t('acp.skl.char_name')) ?>" autofocus required>
215+ </div>
216+ <div class="acp-actions">
217+ <button class="acp-btn" type="submit"><i class="fa fa-search"></i> <?= t('acp.skl.fetch') ?></button>
218+ </div>
219+ </form>
220+ </div>
221+ </section>
222+
223+<?php else: ?>
224+
225+ <div class="acp-toolbar">
226+ <div>
227+ <strong><?= h($name) ?></strong>
228+ <span class="acp-pill acp-pill--grey">#<?= (int)$pid ?></span>
229+ <a href="<?= h(acp_site('characterprofile.php?name=' . urlencode($name))) ?>" target="_blank" rel="noopener"><?= t('acp.skl.view_profile') ?></a>
230+ </div>
231+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('skills')) ?>">
232+ <i class="fa fa-refresh"></i> <?= t('acp.skl.search_another') ?>
233+ </a>
234+ </div>
235+
236+ <form method="post" data-confirm="<?= h(t('acp.skl.confirm_overwrite')) ?>">
237+ <?= acp_csrf_field() ?>
238+ <input type="hidden" name="pid" value="<?= (int)$pid ?>">
239+ <input type="hidden" name="name" value="<?= h($name) ?>">
240+
241+ <div class="acp-grid acp-grid--2">
242+ <section class="acp-card">
243+ <header class="acp-card-head"><h2><?= t('acp.skl.character_title') ?></h2></header>
244+ <div class="acp-card-body">
245+ <div class="acp-field">
246+ <label class="acp-label" for="vocation"><?= t('acp.skl.vocation') ?></label>
247+ <select class="acp-select" id="vocation" name="vocation">
248+ <?php foreach (($config['vocations'] ?? []) as $vid => $v): ?>
249+ <option value="<?= (int)$vid ?>" <?= (int)$vid === acp_skill_value($skills, 9) ? 'selected' : '' ?>>
250+ <?= h($v['name']) ?>
251+ </option>
252+ <?php endforeach; ?>
253+ </select>
254+ </div>
255+ <div class="acp-row">
256+ <div class="acp-field">
257+ <label class="acp-label" for="level"><?= t('acp.skl.level') ?></label>
258+ <input class="acp-input" id="level" name="level" type="number" min="1" value="<?= acp_skill_value($skills, 8) ?>">
259+ </div>
260+ <div class="acp-field">
261+ <label class="acp-label" for="magic"><?= t('acp.skl.magic_level') ?></label>
262+ <input class="acp-input" id="magic" name="magic" type="number" min="0" value="<?= acp_skill_value($skills, 7) ?>">
263+ </div>
264+ </div>
265+ <p class="acp-hint">
266+ <?= t('acp.skl.recalc_hint', [
267+ 'base' => '<code>$config[\'player\'][\'base\']</code>',
268+ 'gain' => '<code>$config[\'vocations_gain\']</code>',
269+ ]) ?>
270+ </p>
271+ </div>
272+ </section>
273+
274+ <section class="acp-card">
275+ <header class="acp-card-head"><h2><?= t('acp.skl.skills_title') ?></h2></header>
276+ <div class="acp-card-body">
277+ <div class="acp-row">
278+ <div class="acp-field">
279+ <label class="acp-label" for="fist"><?= t('acp.skl.fist') ?></label>
280+ <input class="acp-input" id="fist" name="fist" type="number" min="0" value="<?= acp_skill_value($skills, 0) ?>">
281+ </div>
282+ <div class="acp-field">
283+ <label class="acp-label" for="club"><?= t('acp.skl.club') ?></label>
284+ <input class="acp-input" id="club" name="club" type="number" min="0" value="<?= acp_skill_value($skills, 1) ?>">
285+ </div>
286+ <div class="acp-field">
287+ <label class="acp-label" for="sword"><?= t('acp.skl.sword') ?></label>
288+ <input class="acp-input" id="sword" name="sword" type="number" min="0" value="<?= acp_skill_value($skills, 2) ?>">
289+ </div>
290+ </div>
291+ <div class="acp-row">
292+ <div class="acp-field">
293+ <label class="acp-label" for="axe"><?= t('acp.skl.axe') ?></label>
294+ <input class="acp-input" id="axe" name="axe" type="number" min="0" value="<?= acp_skill_value($skills, 3) ?>">
295+ </div>
296+ <div class="acp-field">
297+ <label class="acp-label" for="dist"><?= t('acp.skl.dist') ?></label>
298+ <input class="acp-input" id="dist" name="dist" type="number" min="0" value="<?= acp_skill_value($skills, 4) ?>">
299+ </div>
300+ <div class="acp-field">
301+ <label class="acp-label" for="shield"><?= t('acp.skl.shield') ?></label>
302+ <input class="acp-input" id="shield" name="shield" type="number" min="0" value="<?= acp_skill_value($skills, 5) ?>">
303+ </div>
304+ </div>
305+ <div class="acp-field" style="max-width:200px;">
306+ <label class="acp-label" for="fish"><?= t('acp.skl.fish') ?></label>
307+ <input class="acp-input" id="fish" name="fish" type="number" min="0" value="<?= acp_skill_value($skills, 6) ?>">
308+ </div>
309+ </div>
310+ </section>
311+ </div>
312+
313+ <div class="acp-actions">
314+ <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-save"></i> <?= t('acp.skl.save') ?></button>
315+ <span class="acp-hint"><?= t('acp.skl.offline_hint') ?></span>
316+ </div>
317+ </form>
318+
319+<?php endif; ?>
A admin/modules/update.json +7-0 View file
@@ -0,0 +1,7 @@
1+{
2+ "title": "Update",
3+ "icon": "fa-cloud-download",
4+ "group": "Operations",
5+ "order": 70,
6+ "description": "Check, validate, install and roll back ZnoteX core releases."
7+}
A admin/modules/update.php +364-0 View file
@@ -0,0 +1,364 @@
1+<?php
2+
3+if (!defined('ACP_ROOT')) {
4+ http_response_code(403);
5+ die('Direct access denied.');
6+}
7+
8+$latest = null;
9+$preflight = null;
10+$action = (string)($_POST['action'] ?? '');
11+
12+if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($action, array('check_start', 'check_step', 'install_start', 'install_step'), true)) {
13+ header('Content-Type: application/json');
14+
15+ if (!acp_verify_csrf()) {
16+ http_response_code(400);
17+ echo json_encode(array('ok' => false, 'error' => 'Your session expired. Reload the page and try again.'));
18+ exit;
19+ }
20+
21+ if ($action === 'check_start') {
22+ $latest = znote_update_latest(true);
23+ $result = znote_update_check_start($latest);
24+ } elseif ($action === 'check_step') {
25+ $result = znote_update_check_step();
26+ } elseif ($action === 'install_start') {
27+ $latest = znote_update_latest(true);
28+ $result = znote_update_install_start($latest);
29+ } else {
30+ $result = znote_update_install_step();
31+ }
32+
33+ if (in_array($action, array('check_start', 'check_step'), true) && ($result['phase'] ?? '') === 'done') {
34+ acp_log('update.check', (string)($result['manifest']['version'] ?? ''), array('passed' => $result['ok']));
35+ }
36+ if (($result['phase'] ?? '') === 'done' && $action === 'install_step') {
37+ acp_log('update.install', (string)$result['version'], array('backup' => $result['backup']));
38+ } elseif (empty($result['ok']) && $action === 'install_step') {
39+ acp_log('update.install_failed', '', array('error' => $result['error'] ?? ''));
40+ }
41+
42+ echo json_encode($result);
43+ exit;
44+}
45+
46+if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'rollback') {
47+ $result = znote_update_rollback_latest();
48+ if ($result['ok']) {
49+ acp_log('update.rollback', (string)$result['version']);
50+ acp_flash_success('ZnoteX core files were restored to version ' . h($result['version']) . '. Additive database migrations were kept.');
51+ } else {
52+ acp_log('update.rollback_failed', '', array('error' => $result['error']));
53+ acp_flash_error(h($result['error']));
54+ }
55+ acp_redirect('update');
56+}
57+
58+if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($action, array('check', 'install'), true)) {
59+ @set_time_limit(0);
60+ @ini_set('max_execution_time', '0');
61+
62+ $latest = znote_update_latest(true);
63+ $preflight = znote_update_preflight($latest);
64+ acp_log('update.check', (string)($latest['manifest']['version'] ?? ''), array('passed' => $preflight['ok']));
65+
66+ if ($action === 'install') {
67+ if (!$preflight['ok']) {
68+ acp_flash_error('The update was not started because at least one check failed.');
69+ } else {
70+ $result = znote_update_install($latest);
71+ if ($result['ok']) {
72+ acp_log('update.install', (string)$result['version'], array('backup' => $result['backup']));
73+ acp_flash_success('ZnoteX was updated successfully to version ' . h($result['version']) . '.');
74+ acp_redirect('update');
75+ } else {
76+ acp_log('update.install_failed', (string)($latest['manifest']['version'] ?? ''), array('error' => $result['error']));
77+ acp_flash_error(h($result['error']));
78+ }
79+ }
80+ }
81+}
82+
83+if ($latest === null) {
84+ $latest = znote_update_latest(false);
85+}
86+
87+$manifest = !empty($latest['ok']) ? $latest['manifest'] : array();
88+$backups = znote_update_backups();
89+$textValue = static function ($value): string {
90+ if (is_string($value)) {
91+ return $value;
92+ }
93+ if (!is_array($value)) {
94+ return '';
95+ }
96+ $language = strtolower((string)($GLOBALS['config']['language'] ?? 'en'));
97+ return (string)($value[$language] ?? $value[substr($language, 0, 2)] ?? $value['en'] ?? reset($value));
98+};
99+?>
100+
101+<div class="acp-grid">
102+ <?php
103+ acp_stat('Installed version', znote_update_current_version(), 'fa-code-fork', null, 'blue');
104+ acp_stat('Latest stable', !empty($latest['ok']) ? (string)$manifest['version'] : 'Unavailable', 'fa-cloud-download', null, !empty($latest['available']) ? 'amber' : 'green');
105+ acp_stat('Update status', !empty($latest['available']) ? 'Available' : (!empty($latest['ok']) ? 'Up to date' : 'Check failed'), !empty($latest['available']) ? 'fa-arrow-up' : 'fa-check', null, !empty($latest['ok']) ? 'green' : 'red');
106+ ?>
107+</div>
108+
109+<?php acp_card_open('ZnoteX core update', 'Stable releases are downloaded from the official GitHub repository and accepted only after signature and integrity verification.'); ?>
110+ <?php if (empty($latest['ok'])): ?>
111+ <div style="padding:14px;border:1px solid var(--acp-red);border-radius:6px;color:var(--acp-red);">
112+ <i class="fa fa-times-circle"></i> <?= h($latest['error'] ?? 'The release could not be checked.') ?>
113+ </div>
114+ <?php elseif (!empty($manifest)): ?>
115+ <h3 style="margin-top:0;"><?= h($textValue($manifest['title'] ?? ('ZnoteX ' . $manifest['version']))) ?></h3>
116+ <p><?= nl2br(h($textValue($manifest['description'] ?? ($manifest['summary'] ?? '')))) ?></p>
117+ <?php $highlights = $manifest['highlights'] ?? array(); ?>
118+ <?php if (is_array($highlights) && $highlights !== array()): ?>
119+ <ul>
120+ <?php foreach ($highlights as $highlight): ?><li><?= h($textValue($highlight)) ?></li><?php endforeach; ?>
121+ </ul>
122+ <?php endif; ?>
123+ <?php if (empty($latest['available'])): ?>
124+ <p style="color:var(--acp-green);"><i class="fa fa-check-circle"></i> This installation already uses the latest stable release.</p>
125+ <?php endif; ?>
126+ <?php endif; ?>
127+
128+ <div id="acpCheckIdle" style="margin-top:18px;">
129+ <button class="acp-btn" type="button" id="acpCheckBtn" data-csrf="<?= h(acp_csrf()) ?>"><i class="fa fa-refresh"></i> Run full pre-installation check</button>
130+ <noscript>
131+ <form method="post" style="margin-top:12px;">
132+ <?= acp_csrf_field() ?>
133+ <input type="hidden" name="action" value="check">
134+ <button class="acp-btn" type="submit"><i class="fa fa-refresh"></i> Run check without a progress bar (JavaScript is off)</button>
135+ </form>
136+ </noscript>
137+ </div>
138+ <div id="acpCheckProgress" hidden style="margin-top:18px;">
139+ <div class="acp-progress-bar"><div class="acp-progress-fill" id="acpCheckFill"></div></div>
140+ <p><span id="acpCheckPct">0%</span> - <span id="acpCheckPhase">Starting...</span></p>
141+ <div class="acp-progress-log" id="acpCheckLog"></div>
142+ </div>
143+<?php acp_card_close(); ?>
144+
145+<div id="acpPreflightCard" <?= $preflight === null ? 'hidden' : '' ?>>
146+ <?php acp_card_open('Pre-installation check', $preflight !== null && $preflight['ok'] ? 'Every check passed. The update can be installed.' : 'Installation is blocked until every failed check is resolved.'); ?>
147+ <div id="acpPreflightCardBody">
148+ <?php if ($preflight !== null): ?>
149+ <table class="acp-table">
150+ <thead><tr><th style="width:52px;">Status</th><th>Check</th><th>Result</th></tr></thead>
151+ <tbody>
152+ <?php foreach ($preflight['checks'] as $check): ?>
153+ <tr>
154+ <td style="font-size:20px;color:<?= $check['ok'] ? 'var(--acp-green)' : 'var(--acp-red)' ?>;"><i class="fa <?= $check['ok'] ? 'fa-check-circle' : 'fa-times-circle' ?>"></i></td>
155+ <td><strong><?= h($check['label']) ?></strong></td>
156+ <td><?= h($check['detail']) ?></td>
157+ </tr>
158+ <?php endforeach; ?>
159+ </tbody>
160+ </table>
161+ <div id="acpInstallIdle" style="margin-top:18px;">
162+ <button class="acp-btn <?= $preflight['ok'] ? 'acp-btn--green' : 'acp-btn--ghost' ?>" type="button" id="acpInstallBtn" data-csrf="<?= h(acp_csrf()) ?>" <?= $preflight['ok'] ? '' : 'disabled' ?>><i class="fa fa-cloud-download"></i> Install version <?= h((string)($manifest['version'] ?? '')) ?></button>
163+ <noscript>
164+ <form method="post" style="margin-top:12px;">
165+ <?= acp_csrf_field() ?>
166+ <input type="hidden" name="action" value="install">
167+ <button class="acp-btn acp-btn--green" type="submit" <?= $preflight['ok'] ? '' : 'disabled' ?>><i class="fa fa-cloud-download"></i> Install without a progress bar (JavaScript is off)</button>
168+ </form>
169+ </noscript>
170+ </div>
171+ <div id="acpInstallProgress" hidden style="margin-top:18px;">
172+ <div class="acp-progress-bar"><div class="acp-progress-fill" id="acpInstallFill"></div></div>
173+ <p><span id="acpInstallPct">0%</span> - <span id="acpInstallPhase">Starting...</span></p>
174+ <div class="acp-progress-log" id="acpInstallLog"></div>
175+ </div>
176+ <?php endif; ?>
177+ </div>
178+ <?php acp_card_close(); ?>
179+</div>
180+
181+<script>
182+(function () {
183+ var checkBtn = document.getElementById('acpCheckBtn');
184+ if (!checkBtn) return;
185+
186+ var idle = document.getElementById('acpCheckIdle');
187+ var box = document.getElementById('acpCheckProgress');
188+ var fill = document.getElementById('acpCheckFill');
189+ var pct = document.getElementById('acpCheckPct');
190+ var phaseEl = document.getElementById('acpCheckPhase');
191+ var log = document.getElementById('acpCheckLog');
192+ var csrf = checkBtn.getAttribute('data-csrf');
193+ var preflightCard = document.getElementById('acpPreflightCard');
194+ var preflightBody = document.getElementById('acpPreflightCardBody');
195+
196+ var checkPhaseLabels = { extract: 'Extracting package', localmods: 'Checking for local modifications', done: 'Done' };
197+ var installPhaseLabels = { backup: 'Backing up current files', migrate: 'Applying database migrations', copy: 'Installing new files', done: 'Done' };
198+
199+ function addLog(target, text) {
200+ var line = document.createElement('div');
201+ line.textContent = text;
202+ target.appendChild(line);
203+ target.scrollTop = target.scrollHeight;
204+ }
205+
206+ function post(action) {
207+ var body = new URLSearchParams();
208+ body.set('action', action);
209+ body.set('csrf_token', csrf);
210+ return fetch(window.location.href, { method: 'POST', body: body }).then(function (r) { return r.json(); });
211+ }
212+
213+ function escapeHtml(s) {
214+ var d = document.createElement('div');
215+ d.textContent = String(s == null ? '' : s);
216+ return d.innerHTML;
217+ }
218+
219+ function renderPreflight(result) {
220+ var rows = result.checks.map(function (check) {
221+ var color = check.ok ? 'var(--acp-green)' : 'var(--acp-red)';
222+ var icon = check.ok ? 'fa-check-circle' : 'fa-times-circle';
223+ return '<tr><td style="font-size:20px;color:' + color + ';"><i class="fa ' + icon + '"></i></td>'
224+ + '<td><strong>' + escapeHtml(check.label) + '</strong></td>'
225+ + '<td>' + escapeHtml(check.detail) + '</td></tr>';
226+ }).join('');
227+
228+ var version = (result.manifest && result.manifest.version) ? result.manifest.version : '';
229+
230+ preflightBody.innerHTML =
231+ '<table class="acp-table"><thead><tr><th style="width:52px;">Status</th><th>Check</th><th>Result</th></tr></thead><tbody>' + rows + '</tbody></table>'
232+ + '<div id="acpInstallIdle" style="margin-top:18px;">'
233+ + '<button class="acp-btn ' + (result.ok ? 'acp-btn--green' : 'acp-btn--ghost') + '" type="button" id="acpInstallBtn" data-csrf="' + escapeHtml(csrf) + '"' + (result.ok ? '' : ' disabled') + '><i class="fa fa-cloud-download"></i> Install version ' + escapeHtml(version) + '</button>'
234+ + '</div>'
235+ + '<div id="acpInstallProgress" hidden style="margin-top:18px;">'
236+ + '<div class="acp-progress-bar"><div class="acp-progress-fill" id="acpInstallFill"></div></div>'
237+ + '<p><span id="acpInstallPct">0%</span> - <span id="acpInstallPhase">Starting...</span></p>'
238+ + '<div class="acp-progress-log" id="acpInstallLog"></div>'
239+ + '</div>';
240+
241+ preflightCard.hidden = false;
242+ bindInstall();
243+ }
244+
245+ function bindInstall() {
246+ var btn = document.getElementById('acpInstallBtn');
247+ if (!btn) return;
248+
249+ var iidle = document.getElementById('acpInstallIdle');
250+ var ibox = document.getElementById('acpInstallProgress');
251+ var ifill = document.getElementById('acpInstallFill');
252+ var ipct = document.getElementById('acpInstallPct');
253+ var iphase = document.getElementById('acpInstallPhase');
254+ var ilog = document.getElementById('acpInstallLog');
255+
256+ function setProgress(cursor, total, phase) {
257+ var percent = total > 0 ? Math.round((cursor / total) * 100) : (phase === 'migrate' ? 50 : 0);
258+ ifill.style.width = percent + '%';
259+ ipct.textContent = percent + '%';
260+ iphase.textContent = installPhaseLabels[phase] || phase;
261+ }
262+
263+ function step() {
264+ post('install_step').then(function (result) {
265+ if (!result.ok) {
266+ addLog(ilog, 'Failed: ' + result.error);
267+ iphase.textContent = 'Failed';
268+ return;
269+ }
270+ if (result.message) addLog(ilog, result.message);
271+ setProgress(result.cursor || 0, result.total || 0, result.phase);
272+ if (result.phase === 'done') {
273+ addLog(ilog, 'Reloading...');
274+ setTimeout(function () { window.location.reload(); }, 800);
275+ return;
276+ }
277+ step();
278+ }).catch(function (e) {
279+ addLog(ilog, 'Network error: ' + e.message);
280+ });
281+ }
282+
283+ btn.addEventListener('click', function () {
284+ iidle.hidden = true;
285+ ibox.hidden = false;
286+ addLog(ilog, 'Starting install...');
287+ post('install_start').then(function (result) {
288+ if (!result.ok) {
289+ addLog(ilog, 'Failed: ' + result.error);
290+ iphase.textContent = 'Failed';
291+ return;
292+ }
293+ setProgress(0, result.total || 0, 'backup');
294+ step();
295+ }).catch(function (e) {
296+ addLog(ilog, 'Network error: ' + e.message);
297+ });
298+ });
299+ }
300+
301+ function setCheckProgress(cursor, total, phase) {
302+ var percent = total > 0 ? Math.round((cursor / total) * 100) : 0;
303+ fill.style.width = percent + '%';
304+ pct.textContent = percent + '%';
305+ phaseEl.textContent = checkPhaseLabels[phase] || phase;
306+ }
307+
308+ function step() {
309+ post('check_step').then(function (result) {
310+ if (!result.ok && result.phase !== 'done') {
311+ addLog(log, 'Failed: ' + result.error);
312+ phaseEl.textContent = 'Failed';
313+ return;
314+ }
315+ if (result.message) addLog(log, result.message);
316+ if (result.phase === 'done') {
317+ setCheckProgress(1, 1, 'done');
318+ renderPreflight(result);
319+ return;
320+ }
321+ setCheckProgress(result.cursor || 0, result.total || 0, result.phase);
322+ step();
323+ }).catch(function (e) {
324+ addLog(log, 'Network error: ' + e.message);
325+ });
326+ }
327+
328+ checkBtn.addEventListener('click', function () {
329+ idle.hidden = true;
330+ box.hidden = false;
331+ log.innerHTML = '';
332+ addLog(log, 'Starting check...');
333+ post('check_start').then(function (result) {
334+ if (result.phase === 'done') {
335+ renderPreflight(result);
336+ idle.hidden = false;
337+ box.hidden = true;
338+ return;
339+ }
340+ setCheckProgress(0, result.total || 0, result.phase);
341+ step();
342+ }).catch(function (e) {
343+ addLog(log, 'Network error: ' + e.message);
344+ });
345+ });
346+
347+ if (document.getElementById('acpInstallBtn')) {
348+ bindInstall();
349+ }
350+})();
351+</script>
352+
353+<?php acp_card_open('Recovery', 'Each installation creates a complete backup of every managed file before changing the site.'); ?>
354+ <?php if ($backups === array()): ?>
355+ <p>No update backup is available yet.</p>
356+ <?php else: ?>
357+ <p>Latest backup: <strong><?= h((string)$backups[0]['journal']['version']) ?></strong> from <?= h((string)$backups[0]['journal']['created_at']) ?>.</p>
358+ <form method="post" onsubmit="return confirm('Restore the latest core file backup?');">
359+ <?= acp_csrf_field() ?>
360+ <input type="hidden" name="action" value="rollback">
361+ <button class="acp-btn acp-btn--amber" type="submit"><i class="fa fa-undo"></i> Restore latest file backup</button>
362+ </form>
363+ <?php endif; ?>
364+<?php acp_card_close(); ?>
A admin/modules/visitors.php +221-0 View file
@@ -0,0 +1,221 @@
1+<?php
2+/**
3+ * Title: Visitors
4+ * Icon: fa-line-chart
5+ * Group: Overview
6+ * Order: 20
7+ * Description: Traffic ZnoteX has been recording all along.
8+ */
9+
10+if (!defined('ACP_ROOT')) {
11+ http_response_code(403);
12+ die('Direct access denied.');
13+}
14+
15+/**
16+ * ZnoteX has written to znote_visitors and znote_visitors_details on every page
17+ * load since forever, and until now nothing read them back - a SQL write per
18+ * visitor, for nobody. This page is that data, finally shown.
19+ *
20+ * Turn the collection off entirely in Settings > Privacy if you would rather
21+ * not keep it.
22+ */
23+
24+// The type column, as written by znote_visitor_insert_detailed_data().
25+function acp_visit_types(): array {
26+ return array(
27+ 0 => t('acp.vis.type_pageview'),
28+ 1 => t('acp.vis.type_register'),
29+ 2 => t('acp.vis.type_char_created'),
30+ 3 => t('acp.vis.type_highscores'),
31+ 4 => t('acp.vis.type_char_search'),
32+ 5 => t('acp.vis.type_other'),
33+ );
34+}
35+
36+$days = intv($_GET['days'] ?? 7);
37+if (!in_array($days, array(1, 7, 30, 90), true)) {
38+ $days = 7;
39+}
40+$since = time() - ($days * 86400);
41+
42+$collecting = !empty($config['log_ip']);
43+
44+$totalRows = acp_count("SELECT COUNT(*) AS `c` FROM `znote_visitors_details`;");
45+$uniqueAll = acp_count("SELECT COUNT(DISTINCT `ip`) AS `c` FROM `znote_visitors_details`;");
46+$rowsPeriod = acp_count("SELECT COUNT(*) AS `c` FROM `znote_visitors_details` WHERE `time` >= ?;", [$since]);
47+$uniquePeriod= acp_count("SELECT COUNT(DISTINCT `ip`) AS `c` FROM `znote_visitors_details` WHERE `time` >= ?;", [$since]);
48+
49+// Visits per day
50+$perDay = db()->fetchAll("
51+ SELECT FROM_UNIXTIME(`time`, '%Y-%m-%d') AS `day`,
52+ COUNT(*) AS `hits`,
53+ COUNT(DISTINCT `ip`) AS `uniques`
54+ FROM `znote_visitors_details`
55+ WHERE `time` >= ?
56+ GROUP BY `day`
57+ ORDER BY `day` DESC;
58+", [$since]);
59+$perDay = is_array($perDay) ? $perDay : array();
60+
61+// Breakdown by what people did
62+$byType = db()->fetchAll("
63+ SELECT `type`, COUNT(*) AS `hits`
64+ FROM `znote_visitors_details`
65+ WHERE `time` >= ?
66+ GROUP BY `type`
67+ ORDER BY `hits` DESC;
68+", [$since]);
69+$byType = is_array($byType) ? $byType : array();
70+
71+// Busiest addresses
72+$topIps = db()->fetchAll("
73+ SELECT `ip`, COUNT(*) AS `hits`, MAX(`time`) AS `last`, MAX(`account_id`) AS `account_id`
74+ FROM `znote_visitors_details`
75+ WHERE `time` >= ?
76+ GROUP BY `ip`
77+ ORDER BY `hits` DESC
78+ LIMIT 15;
79+", [$since]);
80+$topIps = is_array($topIps) ? $topIps : array();
81+
82+$peak = 0;
83+foreach ($perDay as $row) {
84+ $peak = max($peak, (int)$row['hits']);
85+}
86+?>
87+
88+<?php if (!$collecting): ?>
89+ <div class="acp-flash acp-flash--info">
90+ <i class="fa fa-info-circle"></i>
91+ <span>
92+ <?= t('acp.vis.off_notice', [
93+ 'off' => '<strong>' . t('acp.vis.off') . '</strong>',
94+ 'link' => '<a href="' . h(acp_url('settings')) . '">' . t('acp.vis.settings_privacy') . '</a>',
95+ ]) ?>
96+ </span>
97+ </div>
98+<?php endif; ?>
99+
100+<div class="acp-toolbar">
101+ <div class="acp-actions is-tight">
102+ <?php foreach (array(1 => t('acp.vis.today'), 7 => t('acp.vis.7days'), 30 => t('acp.vis.30days'), 90 => t('acp.vis.90days')) as $d => $label): ?>
103+ <a class="acp-btn <?= $d === $days ? '' : 'acp-btn--ghost' ?> acp-btn--sm"
104+ href="<?= h(acp_url('visitors', array('days' => $d))) ?>"><?= h($label) ?></a>
105+ <?php endforeach; ?>
106+ </div>
107+ <span class="is-muted"><?= t('acp.vis.events_recorded', ['n' => number_format($totalRows)]) ?></span>
108+</div>
109+
110+<div class="acp-stats">
111+ <?php
112+ acp_stat(t('acp.vis.stat_visits'), $rowsPeriod, 'fa-eye', null, 'blue');
113+ acp_stat(t('acp.vis.stat_unique_period'), $uniquePeriod, 'fa-users', null, 'teal');
114+ acp_stat(t('acp.vis.stat_unique_all'), $uniqueAll, 'fa-globe', null, 'purple');
115+ acp_stat(t('acp.vis.stat_events_all'), $totalRows, 'fa-database', null, 'green');
116+ ?>
117+</div>
118+
119+<div class="acp-grid acp-grid--2">
120+
121+ <section class="acp-card">
122+ <header class="acp-card-head">
123+ <h2><?= t('acp.vis.per_day') ?></h2>
124+ <p><?= t('acp.vis.last_n_days', ['n' => (int)$days]) ?></p>
125+ </header>
126+ <div class="acp-card-body is-flush">
127+ <?php if ($perDay): ?>
128+ <div class="acp-table-wrap">
129+ <table class="acp-table">
130+ <thead>
131+ <tr><th><?= t('acp.vis.col_day') ?></th><th class="is-num"><?= t('acp.vis.col_visits') ?></th><th class="is-num"><?= t('acp.vis.col_unique') ?></th><th>&nbsp;</th></tr>
132+ </thead>
133+ <tbody>
134+ <?php foreach ($perDay as $row):
135+ $hits = (int)$row['hits'];
136+ $pct = $peak > 0 ? round(($hits / $peak) * 100) : 0;
137+ ?>
138+ <tr>
139+ <td class="is-nowrap"><?= h((string)$row['day']) ?></td>
140+ <td class="is-num"><strong><?= number_format($hits) ?></strong></td>
141+ <td class="is-num is-muted"><?= number_format((int)$row['uniques']) ?></td>
142+ <td style="width:45%;">
143+ <div style="height:8px;border-radius:4px;background:var(--acp-panel-2);overflow:hidden;">
144+ <div style="height:8px;width:<?= $pct ?>%;background:var(--acp-blue);"></div>
145+ </div>
146+ </td>
147+ </tr>
148+ <?php endforeach; ?>
149+ </tbody>
150+ </table>
151+ </div>
152+ <?php else: ?>
153+ <?php acp_empty(t('acp.vis.empty'), 'fa-line-chart'); ?>
154+ <?php endif; ?>
155+ </div>
156+ </section>
157+
158+ <section class="acp-card">
159+ <header class="acp-card-head">
160+ <h2><?= t('acp.vis.what_they_did') ?></h2>
161+ <p><?= t('acp.vis.last_n_days', ['n' => (int)$days]) ?></p>
162+ </header>
163+ <div class="acp-card-body is-flush">
164+ <?php if ($byType): ?>
165+ <div class="acp-table-wrap">
166+ <table class="acp-table">
167+ <thead><tr><th><?= t('acp.vis.col_action') ?></th><th class="is-num"><?= t('acp.vis.col_count') ?></th></tr></thead>
168+ <tbody>
169+ <?php foreach ($byType as $row): ?>
170+ <tr>
171+ <td><?= h(acp_visit_types()[(int)$row['type']] ?? t('acp.vis.type_n', ['n' => (int)$row['type']])) ?></td>
172+ <td class="is-num"><?= number_format((int)$row['hits']) ?></td>
173+ </tr>
174+ <?php endforeach; ?>
175+ </tbody>
176+ </table>
177+ </div>
178+ <?php else: ?>
179+ <?php acp_empty(t('acp.vis.empty'), 'fa-list'); ?>
180+ <?php endif; ?>
181+ </div>
182+ </section>
183+</div>
184+
185+<section class="acp-card">
186+ <header class="acp-card-head">
187+ <h2><?= t('acp.vis.busiest') ?></h2>
188+ <p><?= t('acp.vis.busiest_sub') ?></p>
189+ </header>
190+ <div class="acp-card-body is-flush">
191+ <?php if ($topIps): ?>
192+ <div class="acp-table-wrap">
193+ <table class="acp-table">
194+ <thead>
195+ <tr><th><?= t('acp.vis.col_ip') ?></th><th class="is-num"><?= t('acp.vis.col_visits') ?></th><th><?= t('acp.vis.col_last_seen') ?></th><th><?= t('acp.vis.col_account') ?></th></tr>
196+ </thead>
197+ <tbody>
198+ <?php foreach ($topIps as $row):
199+ $accountId = (int)$row['account_id'];
200+ ?>
201+ <tr>
202+ <td class="is-nowrap"><code><?= h(long2ip((int)$row['ip'])) ?></code></td>
203+ <td class="is-num"><strong><?= number_format((int)$row['hits']) ?></strong></td>
204+ <td class="is-nowrap is-muted"><?= h(getClock((int)$row['last'], true)) ?></td>
205+ <td>
206+ <?php if ($accountId > 0): ?>
207+ <a href="<?= h(acp_url('accounts', array('id' => $accountId))) ?>">#<?= $accountId ?></a>
208+ <?php else: ?>
209+ <span class="is-muted">&mdash;</span>
210+ <?php endif; ?>
211+ </td>
212+ </tr>
213+ <?php endforeach; ?>
214+ </tbody>
215+ </table>
216+ </div>
217+ <?php else: ?>
218+ <?php acp_empty(t('acp.vis.empty'), 'fa-globe'); ?>
219+ <?php endif; ?>
220+ </div>
221+</section>
A admin/modules/_partials/layouts_browse.php +195-0 View file
@@ -0,0 +1,195 @@
1+<?php
2+/**
3+ * Browse tab: themes offered by the remote catalogue.
4+ * Included by admin/modules/layouts.php when ?tab=browse.
5+ *
6+ * @var array $themes installed themes, keyed by folder
7+ * @var string $active the active theme's key
8+ */
9+
10+$repoConfig = theme_repository_config();
11+$catalogue = theme_repository_list(isset($_GET['refresh']));
12+$remote = $catalogue['themes'];
13+$repoError = (string)($catalogue['error'] ?? '');
14+?>
15+
16+<div class="acp-toolbar">
17+ <div>
18+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('layouts')) ?>">
19+ <i class="fa fa-arrow-left"></i> <?= t('acp.laybr.back') ?>
20+ </a>
21+ </div>
22+ <div class="acp-actions is-tight">
23+ <span class="is-muted"><?= t('acp.laybr.available', ['n' => count($remote)]) ?></span>
24+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('layouts', ['tab' => 'browse', 'refresh' => 1])) ?>">
25+ <i class="fa fa-refresh"></i> <?= t('acp.laybr.refresh') ?>
26+ </a>
27+ </div>
28+</div>
29+
30+<?php if (!$repoConfig['enabled']): ?>
31+
32+ <section class="acp-card">
33+ <div class="acp-card-body">
34+ <?php acp_empty(t('acp.laybr.repo_off'), 'fa-plug'); ?>
35+ <p class="is-muted" style="text-align:center;">
36+ <?= t('acp.laybr.repo_off_hint', ['code' => '<code>$config[\'layout_repository\'][\'enabled\'] = true;</code>']) ?>
37+ </p>
38+ </div>
39+ </section>
40+
41+<?php elseif ($repoError !== ''): ?>
42+
43+ <div class="acp-actions is-tight" style="margin-bottom:12px;">
44+ <form class="acp-inline-form" method="post">
45+ <?= acp_csrf_field() ?>
46+ <input type="hidden" name="clear_layout_repository_cache" value="1">
47+ <button class="acp-btn acp-btn--red acp-btn--sm" type="submit">
48+ <i class="fa fa-trash"></i> <?= t('acp.laybr.delete_cache') ?>
49+ </button>
50+ </form>
51+ </div>
52+
53+ <div class="acp-flash acp-flash--error">
54+ <i class="fa fa-exclamation-triangle"></i>
55+ <span>
56+ <strong><?= t('acp.laybr.catalogue_error') ?></strong><br>
57+ <?= h($repoError) ?><br>
58+ <span class="is-muted"><?= t('acp.laybr.catalogue_url') ?> <code><?= h($repoConfig['index']) ?></code></span>
59+ </span>
60+ </div>
61+
62+<?php elseif (!$remote): ?>
63+
64+ <section class="acp-card">
65+ <div class="acp-card-body">
66+ <?php acp_empty(t('acp.laybr.catalogue_empty'), 'fa-inbox'); ?>
67+ </div>
68+ </section>
69+
70+<?php else: ?>
71+
72+ <div class="acp-flash acp-flash--info">
73+ <i class="fa fa-info-circle"></i>
74+ <span>
75+ <?= t('acp.laybr.warning', [
76+ 'hosts' => '<code>' . h(implode('</code>, <code>', $repoConfig['allowed_hosts'])) . '</code>',
77+ 'configphp' => '<code>config.php</code>',
78+ ]) ?>
79+ </span>
80+ </div>
81+
82+ <div class="acp-media">
83+ <?php foreach ($remote as $key => $item):
84+ $installed = isset($themes[$key]);
85+ $isActive = ($key === $active);
86+ $localVer = $installed ? (string)$themes[$key]['version'] : '';
87+ $isUpdate = ($installed && $item['version'] !== '' && $localVer !== '' && version_compare($item['version'], $localVer, '>'));
88+ ?>
89+ <article class="acp-media-item<?= $installed && !$isUpdate ? ' is-dimmed' : '' ?>">
90+
91+ <?php if ($item['screenshot'] !== ''): ?>
92+ <img src="<?= h($item['screenshot']) ?>" alt="<?= h($item['name']) ?>" loading="lazy"
93+ referrerpolicy="no-referrer">
94+ <?php else: ?>
95+ <div style="display:grid;place-items:center;height:170px;background:var(--acp-panel-2);color:var(--acp-fg-muted);">
96+ <span><i class="fa fa-picture-o"></i> &nbsp;<?= t('acp.laybr.no_screenshot') ?></span>
97+ </div>
98+ <?php endif; ?>
99+
100+ <div class="acp-media-body">
101+ <h3>
102+ <?= h($item['name']) ?>
103+ <?php if ($isActive): ?>
104+ <span class="acp-pill acp-pill--green"><?= t('acp.laybr.active') ?></span>
105+ <?php elseif ($isUpdate): ?>
106+ <span class="acp-pill acp-pill--amber"><?= t('acp.laybr.update_available') ?></span>
107+ <?php elseif ($installed): ?>
108+ <span class="acp-pill acp-pill--grey"><?= t('acp.laybr.installed_pill') ?></span>
109+ <?php endif; ?>
110+ </h3>
111+
112+ <p><?= h($item['description']) ?></p>
113+
114+ <?php if ($isUpdate && (string)($item['changelog'] ?? '') !== ''): ?>
115+ <div style="margin:10px 0;padding:10px 12px;border:1px solid var(--acp-border);border-left:3px solid var(--acp-amber);border-radius:8px;background:var(--acp-panel-2);font-size:12px;">
116+ <strong style="display:block;margin-bottom:5px;">
117+ <i class="fa fa-list-ul"></i>
118+ What&apos;s new in v<?= h($item['version']) ?>
119+ </strong>
120+ <div class="is-muted" style="white-space:pre-line;line-height:1.45;"><?= h($item['changelog']) ?></div>
121+ </div>
122+ <?php endif; ?>
123+
124+ <p class="is-muted" style="font-size:12px;">
125+ <code>layouts/<?= h($key) ?>/</code>
126+ <?php if ($item['author'] !== ''): ?>&middot; <?= t('acp.laybr.by_author', ['author' => h($item['author'])]) ?><?php endif; ?>
127+ <?php if ($item['version'] !== ''): ?>
128+ &middot; v<?= h($item['version']) ?>
129+ <?php if ($isUpdate): ?>
130+ <span class="acp-pill acp-pill--amber"><?= t('acp.laybr.you_have', ['version' => h($localVer)]) ?></span>
131+ <?php endif; ?>
132+ <?php endif; ?>
133+ </p>
134+
135+ <?php if (!$item['installable']): ?>
136+ <p style="font-size:12px;color:var(--acp-red);">
137+ <?= t('acp.laybr.not_installable') ?>
138+ </p>
139+ <?php endif; ?>
140+ </div>
141+
142+ <div class="acp-media-foot">
143+ <?php if (!$item['installable']): ?>
144+
145+ <?php if ($item['url'] !== ''): ?>
146+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h($item['url']) ?>" target="_blank" rel="noopener">
147+ <i class="fa fa-external-link"></i> <?= t('acp.laybr.open_page') ?>
148+ </a>
149+ <?php endif; ?>
150+
151+ <?php elseif ($installed && !$isUpdate): ?>
152+
153+ <span class="acp-btn acp-btn--ghost acp-btn--sm is-disabled">
154+ <i class="fa fa-check"></i> <?= t('acp.laybr.installed_badge') ?>
155+ </span>
156+ <form class="acp-inline-form" method="post"
157+ data-confirm="<?= h(t('acp.laybr.confirm_reinstall', ['name' => $item['name'], 'key' => $key])) ?>">
158+ <?= acp_csrf_field() ?>
159+ <input type="hidden" name="install" value="<?= h($key) ?>">
160+ <input type="hidden" name="overwrite" value="1">
161+ <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit">
162+ <i class="fa fa-refresh"></i> <?= t('acp.laybr.reinstall') ?>
163+ </button>
164+ </form>
165+
166+ <?php else: ?>
167+
168+ <form class="acp-inline-form" method="post"
169+ data-confirm="<?= $installed
170+ ? h(t('acp.laybr.confirm_update', ['name' => $item['name'], 'version' => $item['version'], 'key' => $key]))
171+ : h(t('acp.laybr.confirm_install', ['name' => $item['name']])) ?>">
172+ <?= acp_csrf_field() ?>
173+ <input type="hidden" name="install" value="<?= h($key) ?>">
174+ <?php if ($installed): ?>
175+ <input type="hidden" name="overwrite" value="1">
176+ <?php endif; ?>
177+ <button class="acp-btn acp-btn--sm" type="submit">
178+ <i class="fa fa-<?= $installed ? 'arrow-up' : 'download' ?>"></i>
179+ <?= $installed ? t('acp.laybr.update_btn') : t('acp.laybr.install_btn') ?>
180+ </button>
181+ </form>
182+
183+ <?php endif; ?>
184+
185+ <?php if ($item['url'] !== '' && $item['installable']): ?>
186+ <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h($item['url']) ?>" target="_blank" rel="noopener">
187+ <i class="fa fa-external-link"></i> <?= t('acp.laybr.details') ?>
188+ </a>
189+ <?php endif; ?>
190+ </div>
191+ </article>
192+ <?php endforeach; ?>
193+ </div>
194+
195+<?php endif; ?>
A admin/modules/_partials/layouts_options.php +102-0 View file
@@ -0,0 +1,102 @@
1+<?php
2+
3+if (!defined('ACP_ROOT')) {
4+ http_response_code(403);
5+ die('Direct access denied.');
6+}
7+
8+$theme = $themes[$optionTheme];
9+$isActive = ($optionTheme === $active);
10+$backUrl = acp_url('layouts');
11+?>
12+
13+<div class="acp-toolbar">
14+ <div>
15+ <a class="acp-btn acp-btn--ghost" href="<?= h($backUrl) ?>">
16+ <i class="fa fa-angle-left"></i> <?= t('acp.layopt.back') ?>
17+ </a>
18+ </div>
19+ <div class="acp-actions is-tight">
20+ <?php if (!$isActive): ?>
21+ <form class="acp-inline-form" method="post">
22+ <?= acp_csrf_field() ?>
23+ <input type="hidden" name="activate" value="<?= h($optionTheme) ?>">
24+ <button class="acp-btn" type="submit"><i class="fa fa-check"></i> <?= t('acp.layopt.activate') ?></button>
25+ </form>
26+ <?php else: ?>
27+ <a class="acp-btn acp-btn--ghost" href="<?= h(acp_site()) ?>" target="_blank" rel="noopener">
28+ <i class="fa fa-external-link"></i> <?= t('acp.layopt.view_site') ?>
29+ </a>
30+ <?php endif; ?>
31+ </div>
32+</div>
33+
34+<section class="acp-card">
35+ <header class="acp-card-head">
36+ <h2>
37+ <?= h($theme['name']) ?> &mdash; <?= t('acp.layopt.options_suffix') ?>
38+ <?php if ($isActive): ?><span class="acp-pill acp-pill--green"><?= t('acp.layopt.active') ?></span><?php endif; ?>
39+ </h2>
40+ <p>
41+ <code><?= h('layouts/' . $optionTheme . '/') ?></code>
42+ <?php if ($theme['author'] !== ''): ?>&middot; <?= t('acp.layopt.by_author', ['author' => h($theme['author'])]) ?><?php endif; ?>
43+ <?php if ($theme['version'] !== ''): ?>&middot; v<?= h($theme['version']) ?><?php endif; ?>
44+ </p>
45+ </header>
46+
47+ <div class="acp-card-body">
48+ <p class="acp-hint">
49+ <?= t('acp.layopt.saved_hint') ?>
50+ </p>
51+
52+ <form method="post" enctype="multipart/form-data">
53+ <?= acp_csrf_field() ?>
54+ <input type="hidden" name="theme_options" value="<?= h($optionTheme) ?>">
55+
56+ <?php foreach ($optionList as $optKey => $opt):
57+ $stored = function_exists('setting') ? setting(theme_option_key($optionTheme, $optKey), null) : null;
58+ $value = ($stored !== null) ? $stored : theme_option($optKey, '', $optionTheme);
59+ ?>
60+ <div class="acp-field">
61+ <label class="acp-label" for="opt_<?= h($optKey) ?>"><?= h($opt['label']) ?></label>
62+
63+ <?php if ($opt['type'] === 'textarea'): ?>
64+ <textarea class="acp-textarea" id="opt_<?= h($optKey) ?>" name="opt[<?= h($optKey) ?>]" rows="3"><?= h($value) ?></textarea>
65+ <?php elseif ($opt['type'] === 'checkbox'): ?>
66+ <label style="display:flex;align-items:center;gap:8px;font-weight:400;">
67+ <input type="checkbox" id="opt_<?= h($optKey) ?>" name="opt[<?= h($optKey) ?>]" value="1" <?= $value !== '' ? 'checked' : '' ?>>
68+ <span class="is-muted"><?= t('acp.layopt.enabled') ?></span>
69+ </label>
70+ <?php elseif ($opt['type'] === 'image'): ?>
71+ <?php $shown = ($value !== '') ? $value : $opt['default']; ?>
72+ <?php if ($shown !== ''): ?>
73+ <p style="margin:0 0 8px;">
74+ <img src="<?= h(acp_site($shown)) ?>" alt=""
75+ style="max-width:260px;max-height:110px;border:1px solid var(--acp-line);background:#0b0d10;">
76+ </p>
77+ <?php endif; ?>
78+ <input class="acp-input" id="opt_<?= h($optKey) ?>" name="opt[<?= h($optKey) ?>]"
79+ type="text" value="<?= h($value) ?>"
80+ placeholder="<?= h($opt['default'] !== '' ? $opt['default'] : t('acp.layopt.image_placeholder')) ?>">
81+ <p class="acp-hint" style="margin:6px 0 4px;"><?= t('acp.layopt.upload_hint') ?></p>
82+ <input class="acp-input" type="file" name="optfile[<?= h($optKey) ?>]" accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon">
83+ <?php else: ?>
84+ <input class="acp-input" id="opt_<?= h($optKey) ?>" name="opt[<?= h($optKey) ?>]"
85+ type="<?= in_array($opt['type'], array('url', 'datetime-local'), true) ? h($opt['type']) : 'text' ?>"
86+ value="<?= h($value) ?>"
87+ placeholder="<?= h($opt['default']) ?>">
88+ <?php endif; ?>
89+
90+ <?php if ($opt['help'] !== ''): ?>
91+ <p class="acp-hint"><?= h($opt['help']) ?></p>
92+ <?php endif; ?>
93+ </div>
94+ <?php endforeach; ?>
95+
96+ <div class="acp-actions">
97+ <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-check"></i> <?= t('acp.layopt.save_btn') ?></button>
98+ <a class="acp-btn acp-btn--ghost" href="<?= h($backUrl) ?>"><?= t('acp.layopt.back') ?></a>
99+ </div>
100+ </form>
101+ </div>
102+</section>
A admin/modules/_partials/payments_schema.php +88-0 View file
@@ -0,0 +1,88 @@
1+<?php
2+
3+return array(
4+
5+ t_default('acp.paysec.PayPal', 'PayPal') => array(
6+ 'icon' => 'fa-paypal',
7+ 'help' => t_default('acp.paysec.PayPal.help', 'Payments land through ipn.php. The address below must be the one that receives them.'),
8+ 'fields' => array(
9+ 'paypal.enabled' => array('label' => t_default('acp.pay.paypal.enabled.label', 'PayPal enabled'), 'type' => 'bool'),
10+ 'paypal.email' => array('label' => t_default('acp.pay.paypal.email.label', 'PayPal account e-mail'), 'type' => 'text'),
11+ 'paypal.currency' => array('label' => t_default('acp.pay.paypal.currency.label', 'Currency'), 'type' => 'text', 'help' => t_default('acp.pay.paypal.currency.help', 'Three-letter code, e.g. EUR, USD, BRL.')),
12+ 'paypal.points_per_currency' => array('label' => t_default('acp.pay.paypal.points_per_currency.label', 'Points per unit of currency'), 'type' => 'int', 'help' => t_default('acp.pay.paypal.points_per_currency.help', 'Only used to work out the bonus percentages shown to players.')),
13+ 'paypal.showBonus' => array('label' => t_default('acp.pay.paypal.showBonus.label', 'Show bonus percentages'), 'type' => 'bool'),
14+ ),
15+ ),
16+
17+ t_default('acp.paysec.Stripe', 'Stripe') => array(
18+ 'icon' => 'fa-credit-card',
19+ 'help' => t_default('acp.paysec.Stripe.help', 'Hosted Checkout. Points are credited only by signed webhook after Stripe confirms the session is paid.'),
20+ 'fields' => array(
21+ 'stripe.enabled' => array('label' => t_default('acp.pay.stripe.enabled.label', 'Stripe enabled'), 'type' => 'bool'),
22+ 'stripe.test_mode' => array('label' => t_default('acp.pay.stripe.test_mode.label', 'Test mode'), 'type' => 'bool'),
23+ 'stripe.publishable_key' => array('label' => t_default('acp.pay.stripe.publishable_key.label', 'Publishable key'), 'type' => 'text'),
24+ 'stripe.secret_key' => array('label' => t_default('acp.pay.stripe.secret_key.label', 'Secret key'), 'type' => 'secret'),
25+ 'stripe.webhook_secret' => array('label' => t_default('acp.pay.stripe.webhook_secret.label', 'Webhook secret'), 'type' => 'secret', 'help' => t_default('acp.pay.stripe.webhook_secret.help', 'Required. Stripe signs each webhook with this endpoint secret.')),
26+ 'stripe.currency' => array('label' => t_default('acp.pay.stripe.currency.label', 'Currency'), 'type' => 'text', 'help' => t_default('acp.pay.stripe.currency.help', 'Three-letter code, e.g. EUR, USD, BRL.')),
27+ 'stripe.points_per_currency' => array('label' => t_default('acp.pay.stripe.points_per_currency.label', 'Points per unit of currency'), 'type' => 'int'),
28+ 'stripe.amount_multiplier' => array('label' => t_default('acp.pay.stripe.amount_multiplier.label', 'Amount multiplier'), 'type' => 'int', 'help' => t_default('acp.pay.stripe.amount_multiplier.help', '100 for EUR/USD/BRL, 1 for zero-decimal currencies.')),
29+ 'stripe.webhook_url' => array('label' => t_default('acp.pay.stripe.webhook_url.label', 'Webhook URL'), 'type' => 'text'),
30+ 'stripe.showBonus' => array('label' => t_default('acp.pay.stripe.showBonus.label', 'Show bonus percentages'), 'type' => 'bool'),
31+ ),
32+ ),
33+
34+ t_default('acp.paysec.Mercado Pago', 'Mercado Pago') => array(
35+ 'icon' => 'fa-credit-card',
36+ 'help' => t_default('acp.paysec.Mercado Pago.help', 'Checkout Pro. Points are credited only by signed webhook after Mercado Pago confirms the payment is approved.'),
37+ 'fields' => array(
38+ 'mercadopago.enabled' => array('label' => t_default('acp.pay.mercadopago.enabled.label', 'Mercado Pago enabled'), 'type' => 'bool'),
39+ 'mercadopago.test_mode' => array('label' => t_default('acp.pay.mercadopago.test_mode.label', 'Test mode'), 'type' => 'bool'),
40+ 'mercadopago.public_key' => array('label' => t_default('acp.pay.mercadopago.public_key.label', 'Public key'), 'type' => 'text'),
41+ 'mercadopago.access_token' => array('label' => t_default('acp.pay.mercadopago.access_token.label', 'Access token'), 'type' => 'secret'),
42+ 'mercadopago.webhook_secret' => array('label' => t_default('acp.pay.mercadopago.webhook_secret.label', 'Webhook secret'), 'type' => 'secret', 'help' => t_default('acp.pay.mercadopago.webhook_secret.help', 'Required. Mercado Pago signs notifications with this secret.')),
43+ 'mercadopago.currency' => array('label' => t_default('acp.pay.mercadopago.currency.label', 'Currency'), 'type' => 'text', 'help' => t_default('acp.pay.mercadopago.currency.help', 'Currency supported by your Mercado Pago account, e.g. BRL, MXN, ARS, CLP, COP, PEN, UYU.')),
44+ 'mercadopago.points_per_currency' => array('label' => t_default('acp.pay.mercadopago.points_per_currency.label', 'Points per unit of currency'), 'type' => 'int'),
45+ 'mercadopago.webhook_url' => array('label' => t_default('acp.pay.mercadopago.webhook_url.label', 'Webhook URL'), 'type' => 'text'),
46+ 'mercadopago.showBonus' => array('label' => t_default('acp.pay.mercadopago.showBonus.label', 'Show bonus percentages'), 'type' => 'bool'),
47+ ),
48+ ),
49+
50+ t_default('acp.paysec.PagSeguro', 'PagSeguro') => array(
51+ 'icon' => 'fa-credit-card',
52+ 'help' => t_default('acp.paysec.PagSeguro.help', 'Brazilian gateway. Sandbox mode switches every URL to the test environment.'),
53+ 'fields' => array(
54+ 'pagseguro.enabled' => array('label' => t_default('acp.pay.pagseguro.enabled.label', 'PagSeguro enabled'), 'type' => 'bool'),
55+ 'pagseguro.sandbox' => array('label' => t_default('acp.pay.pagseguro.sandbox.label', 'Sandbox mode'), 'type' => 'bool'),
56+ 'pagseguro.email' => array('label' => t_default('acp.pay.pagseguro.email.label', 'Account e-mail'), 'type' => 'text'),
57+ 'pagseguro.token' => array('label' => t_default('acp.pay.pagseguro.token.label', 'API token'), 'type' => 'secret'),
58+ 'pagseguro.currency' => array('label' => t_default('acp.pay.pagseguro.currency.label', 'Currency'), 'type' => 'text'),
59+ 'pagseguro.product_name' => array('label' => t_default('acp.pay.pagseguro.product_name.label', 'Product name'), 'type' => 'text'),
60+ 'pagseguro.price' => array('label' => t_default('acp.pay.pagseguro.price.label', 'Price'), 'type' => 'int'),
61+ ),
62+ ),
63+
64+ t_default('acp.paysec.PayGol SMS', 'PayGol SMS') => array(
65+ 'icon' => 'fa-mobile',
66+ 'help' => t_default('acp.paysec.PayGol SMS.help', 'PayGol keeps a large share of each payment and pays out to PayPal once the balance passes their threshold.'),
67+ 'fields' => array(
68+ 'paygol.enabled' => array('label' => t_default('acp.pay.paygol.enabled.label', 'PayGol enabled'), 'type' => 'bool'),
69+ 'paygol.serviceID' => array('label' => t_default('acp.pay.paygol.serviceID.label', 'Service ID'), 'type' => 'int'),
70+ 'paygol.secretKey' => array('label' => t_default('acp.pay.paygol.secretKey.label', 'Secret key'), 'type' => 'secret', 'help' => t_default('acp.pay.paygol.secretKey.help', 'Never share this. It signs the payment callbacks.')),
71+ 'paygol.currency' => array('label' => t_default('acp.pay.paygol.currency.label', 'Currency'), 'type' => 'text'),
72+ 'paygol.price' => array('label' => t_default('acp.pay.paygol.price.label', 'Price'), 'type' => 'int'),
73+ 'paygol.points' => array('label' => t_default('acp.pay.paygol.points.label', 'Points given'), 'type' => 'int'),
74+ 'paygol.name' => array('label' => t_default('acp.pay.paygol.name.label', 'Package name'), 'type' => 'text'),
75+ ),
76+ ),
77+
78+ t_default('acp.paysec.OTServers.eu voting', 'OTServers.eu voting') => array(
79+ 'icon' => 'fa-star',
80+ 'help' => t_default('acp.paysec.OTServers.eu voting.help', 'Rewards players with points for voting. Get the token from OTServers.eu under "Encourage players to vote".'),
81+ 'fields' => array(
82+ 'otservers_eu_voting.enabled' => array('label' => t_default('acp.pay.otservers_eu_voting.enabled.label', 'Voting rewards enabled'), 'type' => 'bool'),
83+ 'otservers_eu_voting.secretToken' => array('label' => t_default('acp.pay.otservers_eu_voting.secretToken.label', 'Secret token'), 'type' => 'secret'),
84+ 'otservers_eu_voting.points' => array('label' => t_default('acp.pay.otservers_eu_voting.points.label', 'Points per vote'), 'type' => 'int'),
85+ 'otservers_eu_voting.simpleVoteUrl' => array('label' => t_default('acp.pay.otservers_eu_voting.simpleVoteUrl.label', 'Vote URL for guests'), 'type' => 'text'),
86+ ),
87+ ),
88+ );
Top