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