Initial commit

ZnoteX / Commit #5

Commit Initial commit

Alex Alex committed 01/10/2026 09:20 main Full upload
481 files +128,311 -0
A layouts/default/views/twofa_legacy.php +22-0 View file
@@ -0,0 +1,22 @@
1+<h1><?= t('twofa.title') ?></h1>
2+<p><?= t('twofa.security') ?> <b><?php echo ($status) ? t('common.enabled') : t('common.disabled'); ?></b>.</p>
3+
4+<?php if ($status === false): ?>
5+ <p><strong><?= t('twofa.login_to_activate') ?></strong></p>
6+<?php else: ?>
7+ <form method="post" data-confirm="<?= htmlspecialchars(t('twofa.disable_confirm'), ENT_QUOTES, 'UTF-8') ?>">
8+ <button type="submit" name="disable_2fa" value="1"><?= t('twofa.disable_btn') ?> <?= t('twofa.title') ?></button>
9+ </form>
10+<?php endif; ?>
11+
12+<img
13+ src="<?php echo TokenAuth6238::getBarCodeUrl($user_data['name'], $_SERVER["HTTP_HOST"], $query['znote_secret'], preg_replace('/\s+/', '', $config['site_title'])); ?>"
14+ alt="<?= t('twofa.qr_alt', ['title' => t('twofa.title')]) ?>"
15+/>
16+
17+<h2><?= t('twofa.howto') ?></h2>
18+<ol>
19+ <li><?= t('twofa.step_download', ['authy' => '<strong>Authy</strong> (<a target="_BLANK" href="https://play.google.com/store/apps/details?id=com.authy.authy">Android</a>), (<a target="_BLANK" href="https://itunes.apple.com/us/app/authy/id494168017">iPhone</a>)', 'google' => '<strong>' . t('twofa.google') . '</strong> (<a target="_BLANK" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>), (<a target="_BLANK" href="https://itunes.apple.com/us/app/google-authenticator/id388497605">iPhone</a>)']) ?></li>
20+ <li><?= t('twofa.step_scan') ?></li>
21+ <li><?= t('twofa.step_login', ['logout_link' => '<a href="logout.php">' . t('nav.logout') . '</a>', 'title' => t('twofa.title')]) ?></li>
22+</ol>
A layouts/default/views/twofa_legacy_incompatible.php +8-0 View file
@@ -0,0 +1,8 @@
1+<h1><?= t('twofa.incompatible') ?></h1>
2+<p><?= t('twofa.incompatible2') ?> <?= t('twofa.title') ?>.<br>
3+TFS 1.2 or higher is required to run the legacy in-game two-factor authentication, grab it
4+<a href="https://github.com/otland/forgottenserver/releases" target="_BLANK">here</a>.
5+<?php if ($twofa2Enabled): ?>
6+ <br>You can still use the website's own two-factor authentication below - it does not depend on the game engine.
7+<?php endif; ?>
8+</p>
A layouts/default/views/voting.php +8-0 View file
@@ -0,0 +1,8 @@
1+<?php
2+?>
3+<div class="znx-acct">
4+
5+<div class="znx-acct-head"><?= t_default('voting.title', 'Vote for Us') ?></div>
6+<div class="znx-acct-info"><?= $votingMessage ?></div>
7+
8+</div><!-- .znx-acct -->
A layouts/default/widgets/admin.php +46-0 View file
@@ -0,0 +1,46 @@
1+<div class="well myaccount_widget widget">
2+ <div class="header">
3+ <?= t('widget.admin.title') ?>
4+ </div>
5+ <div class="body">
6+ <ul class="linkbuttons">
7+ <li>
8+ <a href="admin/index.php"><i class="fa fa-sliders"></i> <?= t('widget.admin.panel') ?></a>
9+ </li>
10+ <?php
11+ // Everything that used to be a separate admin_*.php link now lives
12+ // inside the panel. Only the counter is worth surfacing out here.
13+ $new = 0;
14+ $cache = new Cache('engine/cache/asideFeedbackCount');
15+ if ($cache->hasExpired()) {
16+ $cat = 4; // Category ID for feedback section
17+ $threads = db()->fetchAll("SELECT `id`, `player_id` FROM `znote_forum_threads` WHERE `forum_id` = ? AND `closed` = 0;", [$cat]);
18+ if ($threads !== false) {
19+ $staffs = db()->fetchAll("SELECT `id` FROM `players` WHERE `group_id` > 1;");
20+
21+ foreach($threads as $thread) {
22+ $response = false;
23+ $posts = db()->fetchAll("SELECT `id`, `player_id` FROM `znote_forum_posts` WHERE `thread_id` = ?;", [$thread['id']]);
24+ if ($posts !== false) {
25+ foreach($posts as $post) {
26+ foreach ($staffs as $staff) {
27+ if ($post['player_id'] == $staff['id']) $response = true;
28+ }
29+ }
30+ }
31+
32+ if (!$response) $new++;
33+ }
34+ }
35+ $cache->setContent($new);
36+ $cache->save();
37+ } else {
38+ $new = $cache->load();
39+ }
40+ ?>
41+ <li>
42+ <a href='forum.php?cat=4'><?= t('widget.admin.feedback', ['count' => $new]) ?></a>
43+ </li>
44+ </ul>
45+ </div>
46+</div>
A layouts/default/widgets/charactersearch.php +66-0 View file
@@ -0,0 +1,66 @@
1+<div class="well search_widget" id="searchContainer">
2+ <div class="header">
3+ <?= t('widget.search.title') ?>
4+ </div>
5+ <div class="body">
6+ <div class="relative">
7+ <div id="name_suggestion"></div>
8+ </div>
9+ <form class="searchForm" action="characterprofile.php" method="get">
10+ <label for="src_name"><?= t('widget.search.label') ?> </label><input autocomplete="off" type="text" name="name" id="src_name" class="search" placeholder="<?= t('widget.search.placeholder') ?>">
11+ </form>
12+ <?php
13+ $cache = new Cache('engine/cache/characterNames');
14+ if ($cache->hasExpired()) {
15+ $names_sql = db()->fetchAll('SELECT `name` FROM `players` ORDER BY `name` ASC;');
16+ $names = array();
17+ if ($names_sql !== false): foreach ($names_sql as $name) {
18+ $names[] = $name['name'];
19+ } endif;
20+ $cache->setContent($names);
21+ $cache->save();
22+ } else {
23+ $names = $cache->load();
24+ }
25+ ?>
26+ <script type="text/javascript">
27+ window.searchNames = <?php echo json_encode($names); ?>;
28+ document.addEventListener('DOMContentLoaded', function () {
29+ var input = document.getElementById('src_name');
30+ var suggestion = document.getElementById('name_suggestion');
31+ if (!input || !suggestion || !Array.isArray(window.searchNames) || window.searchNames.length === 0) {
32+ return;
33+ }
34+
35+ input.addEventListener('keyup', function () {
36+ suggestion.innerHTML = '';
37+ var search = input.value.toLowerCase();
38+ var results = [];
39+
40+ if (search.length > 0) {
41+ for (var i = 0; i < window.searchNames.length && results.length < 10; i += 1) {
42+ if (String(window.searchNames[i]).toLowerCase().indexOf(search) > -1) {
43+ results.push(window.searchNames[i]);
44+ }
45+ }
46+ }
47+
48+ if (results.length > 0) {
49+ results.forEach(function (name) {
50+ var row = document.createElement('div');
51+ var link = document.createElement('a');
52+ row.className = 'sname';
53+ link.href = 'characterprofile.php?name=' + encodeURIComponent(name);
54+ link.textContent = name;
55+ row.appendChild(link);
56+ suggestion.appendChild(row);
57+ });
58+ suggestion.classList.add('show');
59+ } else {
60+ suggestion.classList.remove('show');
61+ }
62+ });
63+ });
64+ </script>
65+ </div>
66+</div>
A layouts/default/widgets/highscore.php +21-0 View file
@@ -0,0 +1,21 @@
1+<div class="well widget">
2+ <div class="header">
3+ <?= t('widget.highscores.title') ?>
4+ </div>
5+ <div class="body">
6+ <form action="highscores.php" method="get">
7+ <select name="type">
8+ <option value="7"><?= t('skill.experience') ?></option>
9+ <option value="5"><?= t('skill.shielding') ?></option>
10+ <option value="3"><?= t('skill.axe') ?></option>
11+ <option value="2"><?= t('skill.sword') ?></option>
12+ <option value="1"><?= t('skill.club') ?></option>
13+ <option value="4"><?= t('skill.distance') ?></option>
14+ <option value="9"><?= t('skill.fist') ?></option>
15+ <option value="6"><?= t('skill.fishing') ?></option>
16+ <option value="8"><?= t('skill.magic') ?></option>
17+ </select>
18+ <input type="submit" value="<?= t('widget.highscores.submit') ?>">
19+ </form>
20+ </div>
21+</div>
A layouts/default/widgets/houses.php +16-0 View file
@@ -0,0 +1,16 @@
1+<div class="well widget">
2+ <div class="header">
3+ <?= t('widget.houses.title') ?>
4+ </div>
5+ <div class="body">
6+ <form action="houses.php" method="get">
7+ <select name="id">
8+ <?php
9+ foreach ($config['towns'] as $id => $name)
10+ echo '<option value="'. $id .'">'. $name .'</option>';
11+ ?>
12+ </select>
13+ <input type="submit" value="<?= t('widget.houses.submit') ?>">
14+ </form>
15+ </div>
16+</div>
A layouts/default/widgets/language.php +8-0 View file
@@ -0,0 +1,8 @@
1+<?php
2+if (function_exists('translate_selector')) {
3+ $markup = translate_selector();
4+ if ($markup !== '') {
5+ echo $markup . translate_selector_assets();
6+ translate_selector_rendered(true);
7+ }
8+}
A layouts/default/widgets/login.php +31-0 View file
@@ -0,0 +1,31 @@
1+<div class="well loginContainer widget" id="loginContainer">
2+ <div class="header">
3+ <?= t('widget.login.title') ?>
4+ </div>
5+ <div class="body">
6+ <form class="loginForm" action="login.php" method="post">
7+ <div class="well">
8+ <label for="login_username"><?= t('widget.login.username') ?></label> <input type="text" name="username" id="login_username">
9+ </div>
10+ <div class="well">
11+ <label for="login_password"><?= t('widget.login.password') ?></label> <input type="password" name="password" id="login_password">
12+ </div>
13+ <?php if ($config['twoFactorAuthenticator']): ?>
14+ <div class="well">
15+ <label for="login_authcode"><?= t('widget.login.token') ?></label> <input type="password" name="authcode">
16+ </div>
17+ <?php endif; ?>
18+ <div class="well">
19+ <input type="submit" value="<?= t('widget.login.submit') ?>" class="submitButton">
20+ </div>
21+ <?php
22+ /* Form file */
23+ Token::create();
24+ ?>
25+ <center>
26+ <h3><a href="register.php"><?= t('widget.login.new_account') ?></a></h3>
27+ <p><?= t('widget.login.lost') ?></p>
28+ </center>
29+ </form>
30+ </div>
31+</div>
A layouts/default/widgets/myaccount.php +24-0 View file
@@ -0,0 +1,24 @@
1+<div class="well myaccount_widget widget" id="loginContainer">
2+ <div class="header">
3+ <?= t('widget.account.welcome', ['name' => $user_data['name'] ?? '']) ?>
4+ </div>
5+ <div class="body">
6+ <ul class="linkbuttons">
7+ <li>
8+ <a href='myaccount.php'><?= t('widget.account.my_account') ?></a>
9+ </li>
10+ <li>
11+ <a href='createcharacter.php'><?= t('widget.account.create_character') ?></a>
12+ </li>
13+ <li>
14+ <a href='changepassword.php'><?= t('widget.account.change_password') ?></a>
15+ </li>
16+ <li>
17+ <a href='settings.php'><?= t('widget.account.settings') ?></a>
18+ </li>
19+ <li>
20+ <a href='logout.php'><?= t('widget.account.logout') ?></a>
21+ </li>
22+ </ul>
23+ </div>
24+</div>
A layouts/default/widgets/powergamers.php +63-0 View file
@@ -0,0 +1,63 @@
1+<div class="well widget">
2+ <div class="header">
3+ <?= t('widget.powergamers.title') ?>
4+ </div>
5+ <div class="body">
6+ <table>
7+ <?php
8+ $cache = new Cache('engine/cache/widget_powergamers');
9+ if ($cache->hasExpired()) {
10+ $players = db()->fetchAll("
11+ SELECT
12+ `h`.`player_id`,
13+ `p`.`name`,
14+ `p`.`level`,
15+ CAST(`p`.`experience` as signed) - CAST(`f`.`experience` as signed) AS `diff_experience`
16+ FROM (
17+ SELECT
18+ `i`.`player_id`,
19+ IFNULL(`o`.`id`, `i`.`id`) AS `from_id`
20+ FROM `player_history_skill` AS `i`
21+ LEFT JOIN (
22+ SELECT
23+ `x`.`player_id`,
24+ MAX(`x`.`id`) AS `id`
25+ FROM `player_history_skill` AS `x`
26+ WHERE
27+ `x`.`lastlogout` < UNIX_TIMESTAMP() - 7 * 24 * 60 * 60
28+ GROUP BY
29+ `x`.`player_id`
30+ ) AS `o`
31+ ON `i`.`player_id` = `o`.`player_id`
32+ WHERE
33+ `i`.`lastlogout` >= UNIX_TIMESTAMP() - 7 * 24 * 60 * 60
34+ GROUP BY
35+ `i`.`player_id`
36+ ) AS `h`
37+ INNER JOIN `player_history_skill` AS `f`
38+ ON `h`.`from_id` = `f`.`id`
39+ INNER JOIN `players` AS `p`
40+ ON `h`.`player_id` = `p`.`id`
41+ WHERE CAST(`p`.`experience` as signed) - CAST(`f`.`experience` as signed) > 0
42+ ORDER BY CAST(`p`.`experience` as signed) - CAST(`f`.`experience` as signed) DESC
43+ LIMIT 5
44+ ");
45+
46+ $cache->setContent($players);
47+ $cache->save();
48+ } else {
49+ $players = $cache->load();
50+ }
51+
52+ if ($players) {
53+ foreach($players as $count => $player) {
54+ $nr = $count+1;
55+ $kexp = $player['diff_experience'] / 1000;
56+ $kexp = number_format($kexp, 0, '', ' ');
57+ echo "<tr><td>{$nr}</td><td><a href='characterprofile.php?name={$player['name']}'>{$player['name']}</a> ({$player['level']}) <span style='float: right;font-size:14px;'>{$kexp} K exp</span></td></tr>";
58+ }
59+ }
60+ ?>
61+ </table>
62+ </div>
63+</div>
A layouts/default/widgets/serverinfo.php +27-0 View file
@@ -0,0 +1,27 @@
1+<?php
2+$cache = new Cache('engine/cache/asideServerInfo');
3+if ($cache->hasExpired()) {
4+ $asideServerInfo = db()->fetchOne("
5+ SELECT
6+ (SELECT COUNT(`id`) FROM `accounts`) as `accounts`,
7+ (SELECT COUNT(`id`) FROM `players`) as `players`,
8+ (SELECT COUNT(`player_id`) FROM `players_online`) as `online`
9+ ");
10+ $cache->setContent($asideServerInfo);
11+ $cache->save();
12+} else {
13+ $asideServerInfo = $cache->load();
14+}
15+?>
16+<div class="well widget">
17+ <div class="header">
18+ <?= t('widget.serverinfo.title') ?>
19+ </div>
20+ <div class="body">
21+ <ul>
22+ <li><a href="onlinelist.php"><?= t('widget.serverinfo.online', ['count' => $asideServerInfo['online'] ?? 0]) ?></a></li>
23+ <li><?= t('widget.serverinfo.accounts', ['count' => $asideServerInfo['accounts'] ?? 0]) ?></li>
24+ <li><?= t('widget.serverinfo.players', ['count' => $asideServerInfo['players'] ?? 0]) ?></li>
25+ </ul>
26+ </div>
27+</div>
A layouts/default/widgets/topplayers.php +31-0 View file
@@ -0,0 +1,31 @@
1+<div class="well widget">
2+ <div class="header">
3+ <?= t('widget.topplayers.title') ?>
4+ </div>
5+ <div class="body">
6+ <table>
7+ <?php
8+ $cache = new Cache('engine/cache/topPlayer');
9+ if ($cache->hasExpired()) {
10+ $players = db()->fetchAll("
11+ SELECT `name`, `level`, `experience`
12+ FROM `players` WHERE `group_id` < ?
13+ ORDER BY `level` DESC, `experience` DESC LIMIT 5;
14+ ", [$config['highscore']['ignoreGroupId']]);
15+
16+ $cache->setContent($players);
17+ $cache->save();
18+ } else {
19+ $players = $cache->load();
20+ }
21+
22+ if ($players) {
23+ foreach($players as $count => $player) {
24+ $nr = $count+1;
25+ echo "<tr><td>{$nr}</td><td><a href='characterprofile.php?name={$player['name']}'>{$player['name']}</a> ({$player['level']}).</td></tr>";
26+ }
27+ }
28+ ?>
29+ </table>
30+ </div>
31+</div>
A layouts/default/widgets/vote.php +11-0 View file
@@ -0,0 +1,11 @@
1+<div class="well vote_widget" id="loginContainer">
2+ <div class="header">
3+ <?= t('widget.vote.title') ?>
4+ </div>
5+ <div class="body">
6+ <form type="submit" action="voting.php" method="GET">
7+ <?= t('widget.vote.text') ?>
8+ <input type="submit" value="<?= t('widget.vote.submit') ?>">
9+ </form>
10+ </div>
11+</div>
A layouts/README.md +307-0 View file
@@ -0,0 +1,307 @@
1+# Making a ZnoteX theme
2+
3+A theme is one folder in `layouts/`. It holds **HTML, CSS, JS and images only** —
4+never database queries, never business logic. The root pages keep doing the work;
5+your theme decides how the result looks.
6+
7+Nothing outside your folder needs to change. You never touch `engine/`,
8+the root `.php` files, or `config.php`.
9+
10+---
11+
12+## Quick start
13+
14+1. Copy `layouts/_example/` and rename it, e.g. `layouts/oldhell/`.
15+ Folder names may only contain `a-z`, `0-9`, `-` and `_`.
16+2. Edit `theme.json`.
17+3. Open the admin panel → **Layout** → **Activate**.
18+
19+That is the whole install. Your theme appears in the panel the moment the folder
20+exists; there is no registry to edit.
21+
22+Declare compatibility independently from the theme's own version:
23+
24+```json
25+{
26+ "name": "My Theme",
27+ "version": "1.0.0",
28+ "requires": {
29+ "znotex": ">=2.0.0 <3.0.0",
30+ "php": ">=8.1",
31+ "api": "^1.0"
32+ }
33+}
34+```
35+
36+An incompatible theme remains installed and visible in the panel, but ZnoteX
37+will not activate it. The theme version does not need to match a plugin version.
38+
39+---
40+
41+## What a theme folder can contain
42+
43+```
44+layouts/yourtheme/
45+ theme.json name, author, version, description, update [recommended]
46+ screenshot.png thumbnail in the admin panel [optional]
47+
48+ shells/
49+ default.php the page frame [REQUIRED]
50+ wide.php any other frame you want [optional]
51+
52+ views/
53+ index.php the middle block of index.php [optional]
54+ highscores.php the middle block of highscores.php [optional]
55+
56+ pages/
57+ wiki.php a page YOUR theme adds to the site [optional]
58+
59+ assets/
60+ css/style.css your stylesheet
61+ js/theme.js your scripts
62+ img/... your images
63+
64+ menu.php only if your shell calls theme_menu() [optional]
65+ aside.php only if your shell calls theme_sidebar() [optional]
66+ widgets/ only if your shell calls widget() [optional]
67+```
68+
69+**`shells/default.php` is the only required file.** Everything else falls back
70+to `layouts/default/`. A theme made of one shell and one stylesheet already
71+redresses the entire site.
72+
73+---
74+
75+## The shell
76+
77+The frame every page renders inside. Plain HTML plus a few one-liners:
78+
79+```php
80+<!DOCTYPE html>
81+<html lang="en">
82+<head>
83+ <meta charset="utf-8">
84+ <title><?= theme_title() ?></title>
85+ <link rel="stylesheet" href="<?= theme_asset('css/style.css') ?>">
86+</head>
87+<body class="<?= theme_body_class() ?>">
88+
89+ <header class="my-header">
90+ <nav>
91+ <a href="index.php">Home</a>
92+ <a href="highscores.php">Highscores</a>
93+ </nav>
94+ </header>
95+
96+ <main>
97+ <?php theme_content(); ?>
98+ </main>
99+
100+ <footer>&copy; <?= theme_title() ?></footer>
101+</body>
102+</html>
103+```
104+
105+`theme_content()` is the only line you cannot remove — it is where the page goes.
106+Everything else is yours to move, delete or rewrite.
107+
108+### Functions available in a shell, view or page
109+
110+| Call | Does |
111+| --- | --- |
112+| `theme_content()` | prints the page body — **required, once, in the shell** |
113+| `theme_title()` | site title from `config.php`, already escaped |
114+| `theme_body_class()` | `"theme-yourtheme page_highscores"` |
115+| `theme_asset('css/style.css')` | URL of a file in your `assets/` |
116+| `theme_menu()` | includes your `menu.php` |
117+| `theme_sidebar()` | includes your `aside.php` |
118+| `widget('login')` | includes one file from your `widgets/` |
119+| `theme_shell('wide')` | render this page in `shells/wide.php` instead |
120+| `$config` | everything from `config.php` |
121+| `user_logged_in()`, `is_admin($user_data)` | session state |
122+
123+For stable access to configuration, settings, cache, database and hooks, use
124+`$api = znote_theme_api()`. The same object exposes `asset()`,
125+`themeOption()`, `apiVersion()` and `znoteVersion()`.
126+
127+Write your menu directly in the shell if you prefer — `theme_menu()` exists only
128+if you want it. Nothing is imposed.
129+
130+---
131+
132+## Child themes — building on another theme
133+
134+Name a parent in `theme.json` and your theme only has to ship what it changes:
135+
136+```json
137+{
138+ "name": "Exodus Dark",
139+ "parent": "default"
140+}
141+```
142+
143+Files are then looked up **child → parent → default**. A stylesheet and two
144+views on top of a full parent is a complete, working theme — and a fix in the
145+parent reaches every child without touching them.
146+
147+`layouts/_childexample/` is exactly that: one stylesheet, nothing else.
148+
149+Parents can themselves have parents, up to 8 levels. A cycle or a missing
150+parent is ignored rather than fatal — the chain just falls through to
151+`default`, so a typo degrades the look instead of taking the site down.
152+
153+### One rule that matters
154+
155+Inside a shell, use **`theme_include()`**, never `theme_path()`:
156+
157+```php
158+<?php theme_include('parts/head.php'); ?> // correct
159+<?php include theme_path() . '/parts/head.php'; ?> // breaks children
160+```
161+
162+`theme_path()` points at the active theme only. A child that does not ship
163+`parts/head.php` would include nothing and render without its frame.
164+`theme_include()` walks the chain. Pass variables as a second argument, since
165+an include from inside a function cannot see the caller's locals:
166+
167+```php
168+<?php theme_include('parts/box.php', ['title' => $title]); ?>
169+```
170+
171+---
172+
173+## Views — restyling an existing page
174+
175+A view is the middle block of one root page. The page's logic has already run,
176+so every variable it prepared is yours to use.
177+
178+`layouts/yourtheme/views/highscores.php`:
179+
180+```php
181+<div class="my-panel">
182+ <h1>Ranking for <?= skillName($type) ?></h1>
183+
184+ <table class="table table-striped">
185+ <tr class="yellow"><td>#</td><td>Name</td><td>Level</td></tr>
186+ <?php foreach ($players as $player): ?>
187+ <tr>
188+ <td><?= (int)$player['rank'] ?></td>
189+ <td><a href="characterprofile.php?name=<?= urlencode($player['name']) ?>">
190+ <?= htmlspecialchars($player['name'], ENT_QUOTES, 'UTF-8') ?>
191+ </a></td>
192+ <td><?= (int)$player['level'] ?></td>
193+ </tr>
194+ <?php endforeach; ?>
195+ </table>
196+</div>
197+```
198+
199+**Only write the views you actually want to change.** Any page without a view of
200+its own uses the default theme's markup, inside your shell, styled by your CSS.
201+
202+To find out which variables a page gives you, open the root file — e.g.
203+`highscores.php` — and read the logic above `view('highscores')`.
204+
205+---
206+
207+## Pages — adding pages of your own
208+
209+Drop a file in `pages/` and it is live. No registration.
210+
211+`layouts/yourtheme/pages/wiki.php` → **`page.php?p=wiki`**
212+
213+```php
214+<h1>Wiki</h1>
215+<p>Anything you want.</p>
216+```
217+
218+It renders inside your shell like every other page, and gets the body class
219+`page_wiki` so you can target it from CSS.
220+
221+Pretty URLs, if you want them, in `.htaccess`:
222+
223+```apache
224+RewriteRule ^([a-z0-9_-]+)\.html$ page.php?p=$1 [L,QSA]
225+```
226+
227+---
228+
229+## Several frames in one theme
230+
231+Some pages need a different structure — a landing page with no sidebar, a
232+full-width page. Add another shell and ask for it from the view or page:
233+
234+```php
235+<?php theme_shell('wide'); ?>
236+<div class="hero">...</div>
237+```
238+
239+`shells/wide.php` is a complete frame, just like `default.php`.
240+
241+---
242+
243+## The CSS contract
244+
245+This is the part people miss.
246+
247+The root pages emit some markup themselves, with class names your theme does not
248+control. **Style these or those pages render unstyled.** The full list:
249+
250+| Class | Where |
251+| --- | --- |
252+| `table`, `table-striped`, `table-hover`, `tbl-hover` | every listing page |
253+| `tr.yellow` | table header rows — Znote does not use `<th>` |
254+| `znoteTable`, `ThreadTable` | forum and helpdesk |
255+| `btn`, `btn-primary`, `btn-success`, `btn-warning`, `btn-danger`, `btn-info` | every form |
256+| `form-control` | inputs |
257+| `special` | highlighted rows |
258+| `txt`, `zheadline`, `bighr` | text helpers |
259+| `outfitColumn` | outfit images in listings |
260+| `span12`, `show`, `wtf`, `nav_link` | odds and ends |
261+
262+`layouts/_example/assets/css/style.css` styles all of them and is annotated —
263+copy that section as your starting point.
264+
265+If your shell calls `theme_sidebar()` or `widget()`, you also need `.well`,
266+`.widget` and `.header`.
267+
268+---
269+
270+## Rules
271+
272+- **No logic in a theme.** No `mysql_insert`, no `UPDATE`. Reading data for a
273+ page of your own is fine; that is what `pages/` is for.
274+- **Escape anything from the database**: `htmlspecialchars($x, ENT_QUOTES, 'UTF-8')`.
275+- **Never edit `layouts/_example/`.** It is the reference every theme is copied
276+ from. Copy it, do not modify it.
277+- **Never edit `layouts/default/`** either, unless you mean to change the fallback
278+ for every theme on the site.
279+- **Reference your files through `theme_asset()`**, not with a hardcoded path.
280+ It keeps working when your folder is renamed, and falls back to the default
281+ theme when a file is missing.
282+
283+Shared vendor files that are not part of any theme live in `assets/`
284+(`assets/fontawesome/`, `assets/js/jquery.js`). The admin panel uses them too,
285+which is why they are not inside a theme.
286+
287+---
288+
289+## Troubleshooting
290+
291+**The site is unstyled.** Your shell is probably not loading your CSS. Check
292+`theme_asset('css/style.css')` and that the file is at
293+`layouts/yourtheme/assets/css/style.css`.
294+
295+**A page is blank.** Look in the PHP error log for `[ZnoteX theme]`. A view that
296+fails to resolve is logged there.
297+
298+**A page renders but has no frame.** `shells/default.php` is missing or has a
299+parse error. The admin panel refuses to activate a theme without it, but it can
300+break after activation.
301+
302+**My change does nothing.** Confirm which theme is active: admin panel →
303+Layout. The active one is marked. The setting lives in the `znote_config`
304+table, not in `config.php`.
305+
306+**The admin panel looks unchanged.** That is intentional. The panel has its own
307+styling in `admin/assets/` and is identical for every theme.
A layouts/_childexample/assets/css/style.css +19-0 View file
@@ -0,0 +1,19 @@
1+/* ---------------------------------------------------------------------------
2+ Child theme example - the ONLY file this theme ships.
3+
4+ theme.json names "default" as the parent. Because the chain is searched
5+ child -> parent -> default, this file wins wherever the parent asks for
6+ css/style.css, and everything else - shells, parts, views, pages, images,
7+ scripts - still comes from the parent.
8+
9+ Point "parent" at any installed theme, adjust the import path below to match
10+ where that theme keeps its stylesheet, and you have a variant of it that
11+ survives the parent being updated underneath you.
12+
13+ Import the parent's sheet first, then override only what you want.
14+ --------------------------------------------------------------------------- */
15+
16+@import url("../../../default/assets/css/style.css");
17+
18+/* Example override: shift the whole palette. Delete this and start yours. */
19+body { filter: hue-rotate(200deg); }
A layouts/_childexample/theme.json +12-0 View file
@@ -0,0 +1,12 @@
1+{
2+ "name": "Child Example",
3+ "author": "Alex - opengamescommunity.com",
4+ "version": "1.0.0",
5+ "requires": {
6+ "znotex": ">=2.0.0 <3.0.0",
7+ "php": ">=8.1",
8+ "api": "^1.0"
9+ },
10+ "parent": "default",
11+ "description": "Shows what a child theme is: it ships one stylesheet and inherits everything else from its parent. Change \"parent\" to any installed theme and copy this folder to base a variant on it."
12+}
A layouts/_example/assets/css/style.css +181-0 View file
@@ -0,0 +1,181 @@
1+/* ===========================================================================
2+ Example theme
3+ ---------------------------------------------------------------------------
4+ Deliberately plain. It is a starting point, not a design.
5+
6+ The bottom half of this file is the CSS CONTRACT: the classes the root
7+ pages emit in their own markup. Style those and every page of the site is
8+ readable, even the ones you never wrote a view for. Skip them and those
9+ pages render unstyled.
10+ =========================================================================== */
11+
12+:root {
13+ --ex-bg: #14171d;
14+ --ex-panel: #1d222b;
15+ --ex-line: #2c333f;
16+ --ex-text: #dfe4ec;
17+ --ex-muted: #93a0b2;
18+ --ex-accent: #d1a233;
19+ --ex-radius: 6px;
20+}
21+
22+* { box-sizing: border-box; }
23+
24+body {
25+ margin: 0;
26+ background: var(--ex-bg);
27+ color: var(--ex-text);
28+ font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
29+}
30+
31+a { color: var(--ex-accent); text-decoration: none; }
32+a:hover { text-decoration: underline; }
33+
34+img { max-width: 100%; height: auto; }
35+
36+/* --------------------------------------------------------------- Chrome --
37+ These belong to this theme's shell (shells/default.php). Rename them
38+ freely - nothing outside your theme refers to them. */
39+
40+.ex-header {
41+ display: flex;
42+ flex-wrap: wrap;
43+ align-items: center;
44+ gap: 10px 26px;
45+ padding: 14px 24px;
46+ background: var(--ex-panel);
47+ border-bottom: 2px solid var(--ex-accent);
48+}
49+
50+.ex-logo {
51+ font-size: 20px;
52+ font-weight: 700;
53+ color: #fff;
54+}
55+.ex-logo:hover { text-decoration: none; }
56+
57+.ex-nav { display: flex; flex-wrap: wrap; gap: 18px; }
58+.ex-nav a { color: var(--ex-muted); font-size: 14px; }
59+.ex-nav a:hover { color: var(--ex-accent); text-decoration: none; }
60+
61+.ex-main {
62+ max-width: 1100px;
63+ margin: 26px auto;
64+ padding: 0 20px;
65+}
66+
67+.ex-footer {
68+ max-width: 1100px;
69+ margin: 40px auto 24px;
70+ padding: 18px 20px 0;
71+ border-top: 1px solid var(--ex-line);
72+ color: var(--ex-muted);
73+ font-size: 13px;
74+}
75+
76+/* ============================================================ CSS CONTRACT
77+ Emitted by the root pages themselves, not by your theme. Every theme has
78+ to style these or those pages look broken. This is the whole list.
79+ ======================================================================== */
80+
81+h1, h2, h3 { line-height: 1.25; margin: 1.4em 0 .6em; }
82+h1 { font-size: 26px; }
83+h2 { font-size: 20px; }
84+
85+/* Tables - highscores, guilds, deaths, online list, forum, ... */
86+table,
87+.table,
88+.znoteTable,
89+.ThreadTable {
90+ width: 100%;
91+ border-collapse: collapse;
92+ margin: 14px 0;
93+ background: var(--ex-panel);
94+ border-radius: var(--ex-radius);
95+ overflow: hidden;
96+}
97+
98+table td, table th {
99+ padding: 9px 12px;
100+ border-bottom: 1px solid var(--ex-line);
101+ text-align: left;
102+}
103+
104+/* Header rows. Znote marks them with .yellow, not with <th>. */
105+tr.yellow td,
106+tr.yellow th,
107+.yellow {
108+ background: #262d38;
109+ color: #fff;
110+ font-weight: 700;
111+}
112+
113+.table-striped tbody tr:nth-child(odd) td { background: rgba(255, 255, 255, .02); }
114+.table-hover tbody tr:hover td,
115+.tbl-hover tr:hover td { background: rgba(209, 162, 51, .08); }
116+
117+tr.special td { font-weight: 600; }
118+
119+/* Buttons and form controls */
120+.btn,
121+button,
122+input[type="submit"],
123+input[type="button"] {
124+ display: inline-block;
125+ padding: 8px 16px;
126+ border: 0;
127+ border-radius: var(--ex-radius);
128+ background: var(--ex-accent);
129+ color: #14171d;
130+ font: inherit;
131+ font-weight: 600;
132+ cursor: pointer;
133+}
134+.btn:hover { filter: brightness(1.1); text-decoration: none; }
135+
136+.btn-primary { background: #3c8dbc; color: #fff; }
137+.btn-success { background: #2ea36c; color: #fff; }
138+.btn-warning { background: #e6a532; color: #14171d; }
139+.btn-danger { background: #e05563; color: #fff; }
140+.btn-info { background: #17a2b8; color: #fff; }
141+
142+input[type="text"],
143+input[type="password"],
144+input[type="number"],
145+input[type="email"],
146+select,
147+textarea,
148+.form-control {
149+ padding: 8px 11px;
150+ border: 1px solid var(--ex-line);
151+ border-radius: var(--ex-radius);
152+ background: #12161c;
153+ color: var(--ex-text);
154+ font: inherit;
155+ max-width: 100%;
156+}
157+
158+textarea { min-height: 120px; }
159+.forumReply { width: 100%; }
160+
161+/* Odds and ends the pages use */
162+.txt { color: var(--ex-muted); }
163+.zheadline { font-size: 22px; font-weight: 700; margin: 1.2em 0 .5em; }
164+.bighr { border: 0; border-top: 1px solid var(--ex-line); margin: 24px 0; }
165+.outfitColumn { width: 64px; text-align: center; }
166+.span12 { width: 100%; }
167+.show { display: block; }
168+.wtf { overflow-x: auto; }
169+.nav_link { display: inline-block; padding: 4px 8px; }
170+
171+/* The sidebar widgets, if your shell calls theme_sidebar() or widget().
172+ This theme's shell does not, so these are here only for reference. */
173+.well { background: var(--ex-panel); border-radius: var(--ex-radius); padding: 14px; margin-bottom: 16px; }
174+.widget { margin-bottom: 16px; }
175+.header { font-weight: 700; margin-bottom: 8px; }
176+
177+@media (max-width: 720px) {
178+ .ex-header { flex-direction: column; align-items: flex-start; }
179+ .ex-main { padding: 0 14px; }
180+ table { font-size: 14px; }
181+}
A layouts/_example/pages/example.php +66-0 View file
@@ -0,0 +1,66 @@
1+<?php
2+/**
3+ * Title: Example page
4+ *
5+ * A page this theme adds to the site. It does not exist in ZnoteX - the theme
6+ * invents it. Nothing to register anywhere: dropping this file in pages/ makes
7+ * it live at page.php?p=example, wrapped in this theme's shell.
8+ *
9+ * Delete it, rename it, add ten more. The filename is the URL.
10+ */
11+?>
12+<h1>An extra page</h1>
13+
14+<p>
15+ This file is <code>layouts/_example/pages/example.php</code> and it is
16+ reachable at <code>page.php?p=example</code>.
17+</p>
18+
19+<p>
20+ Write plain HTML. You have the whole engine available if you want it:
21+ <code>$config</code>, <code>user_logged_in()</code>,
22+ <code>db()->fetchAll($sql, $params)</code>, and every function in
23+ <code>engine/function/</code>.
24+</p>
25+
26+<h2>Example: pull something out of the database</h2>
27+
28+<?php
29+$players = db()->fetchAll("
30+ SELECT `name`, `level`, `vocation`
31+ FROM `players`
32+ ORDER BY `level` DESC
33+ LIMIT 5;
34+");
35+?>
36+
37+<?php if (is_array($players) && $players): ?>
38+ <table class="table table-striped">
39+ <tr class="yellow">
40+ <td>Name</td>
41+ <td>Vocation</td>
42+ <td>Level</td>
43+ </tr>
44+ <?php foreach ($players as $player): ?>
45+ <tr>
46+ <td>
47+ <a href="characterprofile.php?name=<?= urlencode((string)$player['name']) ?>">
48+ <?= htmlspecialchars((string)$player['name'], ENT_QUOTES, 'UTF-8') ?>
49+ </a>
50+ </td>
51+ <td><?= htmlspecialchars(vocation_id_to_name((int)$player['vocation']), ENT_QUOTES, 'UTF-8') ?></td>
52+ <td><?= (int)$player['level'] ?></td>
53+ </tr>
54+ <?php endforeach; ?>
55+ </table>
56+<?php else: ?>
57+ <p>No characters yet.</p>
58+<?php endif; ?>
59+
60+<h2>Want a different frame for this page?</h2>
61+
62+<p>
63+ Add <code>shells/wide.php</code> to your theme and put
64+ <code>&lt;?php theme_shell('wide'); ?&gt;</code> at the top of this file.
65+ The page then renders inside that frame instead of the default one.
66+</p>
A layouts/_example/shells/default.php +66-0 View file
@@ -0,0 +1,66 @@
1+<?php
2+/**
3+ * The shell: the frame every page of this theme is rendered inside.
4+ *
5+ * This is plain HTML. The only line you cannot remove is theme_content(),
6+ * which is where the page itself goes. Everything else is yours - move it,
7+ * delete it, rewrite it.
8+ *
9+ * Available to you here:
10+ *
11+ * theme_title() site title, already escaped
12+ * theme_body_class() "theme-yourtheme page_highscores"
13+ * theme_asset('css/x.css') URL of a file in this theme's assets/ folder
14+ * theme_content() the page body [required]
15+ * theme_menu() includes this theme's menu.php [optional]
16+ * theme_sidebar() includes this theme's aside.php [optional]
17+ * widget('login') one widget from widgets/ [optional]
18+ * $config everything from config.php
19+ * user_logged_in() true/false
20+ *
21+ * This theme writes its menu directly in the markup below rather than calling
22+ * theme_menu(), to show that nothing is imposed on you.
23+ */
24+?>
25+<!DOCTYPE html>
26+<html lang="en" dir="ltr">
27+<head>
28+ <meta charset="utf-8">
29+ <meta name="viewport" content="width=device-width, initial-scale=1">
30+ <title><?= theme_title() ?></title>
31+
32+ <link rel="stylesheet" href="<?= theme_asset('css/style.css') ?>">
33+ <link rel="stylesheet" href="assets/fontawesome/css/font-awesome.min.css">
34+</head>
35+<body class="<?= theme_body_class() ?>">
36+
37+ <header class="ex-header">
38+ <a class="ex-logo" href="index.php"><?= theme_title() ?></a>
39+
40+ <nav class="ex-nav">
41+ <a href="index.php">Home</a>
42+ <a href="highscores.php">Highscores</a>
43+ <a href="onlinelist.php">Online</a>
44+ <a href="guilds.php">Guilds</a>
45+ <a href="shop.php">Shop</a>
46+ <a href="page.php?p=example">Example page</a>
47+ <?php if (user_logged_in()): ?>
48+ <a href="myaccount.php">My account</a>
49+ <?php else: ?>
50+ <a href="login.php">Login</a>
51+ <?php endif; ?>
52+ </nav>
53+ </header>
54+
55+ <main class="ex-main">
56+ <?php theme_content(); ?>
57+ </main>
58+
59+ <footer class="ex-footer">
60+ &copy; <?= theme_title() ?> &middot; Powered by
61+ <a href="credits.php">ZnoteX</a> &middot;
62+ <?= elapsedTime() ?>s
63+ </footer>
64+
65+</body>
66+</html>
A layouts/_example/theme.json +12-0 View file
@@ -0,0 +1,12 @@
1+{
2+ "name": "Example",
3+ "author": "Alex - opengamescommunity.com",
4+ "version": "1.0.0",
5+ "requires": {
6+ "znotex": ">=2.0.0 <3.0.0",
7+ "php": ">=8.1",
8+ "api": "^1.0"
9+ },
10+ "description": "Starter theme. Copy this folder, rename it, and edit. Never edit this one - it is the reference other themes are copied from.",
11+ "url": "https://opengamescommunity.com"
12+}
A layouts/_example/views/index.php +38-0 View file
@@ -0,0 +1,38 @@
1+<?php
2+/**
3+ * View for the front page (index.php).
4+ *
5+ * A view is the middle block of one root page - the part between the header
6+ * and the footer. The page's logic already ran: every variable it prepared is
7+ * available here.
8+ *
9+ * This theme only ships this one view. Every other page of the site falls back
10+ * to layouts/default/views/, wrapped in THIS theme's shell and styled by THIS
11+ * theme's CSS. Add views/highscores.php the day you want to restyle that page,
12+ * and not before.
13+ */
14+?>
15+<h1>Welcome to <?= theme_title() ?></h1>
16+
17+<p>
18+ You are looking at <code>layouts/_example/views/index.php</code>.
19+ The rest of the site is running on the default theme's views, dressed by
20+ this theme.
21+</p>
22+
23+<?php
24+$news = fetchAllNews();
25+if (is_array($news)):
26+ foreach (array_slice($news, 0, (int)($config['news_per_page'] ?? 5)) as $article): ?>
27+ <article class="well">
28+ <h2><?= htmlspecialchars((string)$article['title'], ENT_QUOTES, 'UTF-8') ?></h2>
29+ <p class="txt">
30+ <?= htmlspecialchars(getClock((int)$article['date'], true), ENT_QUOTES, 'UTF-8') ?>
31+ by <?= htmlspecialchars((string)($article['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
32+ </p>
33+ <div><?= $article['text'] ?></div>
34+ </article>
35+ <?php endforeach;
36+else: ?>
37+ <p>No news posted yet. Write one from the admin panel.</p>
38+<?php endif; ?>
A LICENSE +22-0 View file
@@ -0,0 +1,22 @@
1+MIT License
2+
3+Copyright (c) 2019 Stefan André Brannfjell
4+Copyright (c) 2026 Alex / Open Games Community
5+
6+Permission is hereby granted, free of charge, to any person obtaining a copy
7+of this software and associated documentation files (the "Software"), to deal
8+in the Software without restriction, including without limitation the rights
9+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+copies of the Software, and to permit persons to whom the Software is
11+furnished to do so, subject to the following conditions:
12+
13+The above copyright notice and this permission notice shall be included in all
14+copies or substantial portions of the Software.
15+
16+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+SOFTWARE.
Top