menus.php

main 275 lines · 8 KB Raw
Alex Alex Commit Initial commit 01/10/2026 09:20
1<?php
2/**
3 * Navigation menus.
4 *
5 * Menu links used to be hardcoded in each theme, so adding one meant editing
6 * PHP, and every theme carried its own copy. They live in `znote_menu` now and
7 * are edited from Admin Panel > Menus.
8 *
9 * A theme declares the slots it renders in theme.json:
10 *
11 * "menus": {
12 * "main": "Top navigation",
13 * "sidebar": "Left column",
14 * "footer": "Footer links"
15 * }
16 *
17 * and renders one with:
18 *
19 * <?php foreach (theme_menu_items('main') as $item): ?>
20 * <a href="<?= h($item['url']) ?>"><?= h($item['label']) ?></a>
21 * <?php endforeach; ?>
22 *
23 * The theme keeps full control of the markup - this only supplies the data,
24 * already filtered for the current visitor and nested by parent.
25 */
26
27/**
28 * Entries for one location, filtered by visibility and nested.
29 *
30 * Each item: id, label, url, icon, target, children[].
31 * A parent whose children are all hidden still shows; a child of a hidden
32 * parent does not, because it is unreachable.
33 */
34function theme_menu_items(string $location): array {
35 static $cache = array();
36
37 $location = preg_replace('/[^a-z0-9_-]/', '', strtolower($location));
38 if ($location === '') {
39 return array();
40 }
41 if (isset($cache[$location])) {
42 return $cache[$location];
43 }
44
45 $rows = db()->fetchAll("
46 SELECT `id`, `parent_id`, `label`, `url`, `icon`, `target`, `visibility`
47 FROM `znote_menu`
48 WHERE `location` = ?
49 AND `active` = 1
50 ORDER BY `sort_order` ASC, `id` ASC;
51 ", [$location]);
52
53 if (!is_array($rows)) {
54 // No table yet (migration not run) or nothing defined: the theme falls
55 // back to whatever it hardcodes.
56 return $cache[$location] = array();
57 }
58
59 $loggedIn = (function_exists('user_logged_in') && user_logged_in() === true);
60 $isAdmin = $loggedIn && isset($GLOBALS['user_data']) && has_admin_panel_access($GLOBALS['user_data']);
61
62 $visible = array();
63 foreach ($rows as $row) {
64 switch ($row['visibility']) {
65 case 'guest': $show = !$loggedIn; break;
66 case 'user': $show = $loggedIn; break;
67 case 'admin': $show = $isAdmin; break;
68 default: $show = true;
69 }
70 if ($show && !menu_url_available((string)$row['url'])) {
71 $show = false;
72 }
73 if ($show) {
74 $visible[(int)$row['id']] = array(
75 'id' => (int)$row['id'],
76 'parent' => (int)$row['parent_id'],
77 'label' => (string)$row['label'],
78 'url' => (string)$row['url'],
79 'icon' => (string)$row['icon'],
80 'target' => (string)$row['target'],
81 'children' => array(),
82 );
83 }
84 }
85
86 // Nest. A child whose parent was filtered out disappears with it.
87 $tree = array();
88 foreach ($visible as $id => $item) {
89 if ($item['parent'] > 0 && isset($visible[$item['parent']])) {
90 continue;
91 }
92 if ($item['parent'] > 0) {
93 continue; // parent hidden: so is this
94 }
95 $tree[$id] = $item;
96 }
97 foreach ($visible as $id => $item) {
98 if ($item['parent'] > 0 && isset($tree[$item['parent']])) {
99 $tree[$item['parent']]['children'][] = $item;
100 }
101 }
102
103 return $cache[$location] = array_values($tree);
104}
105
106function menu_url_available(string $url): bool {
107 global $config;
108
109 $url = trim($url);
110 if ($url === '' || $url === '#') {
111 return true;
112 }
113
114 $path = parse_url($url, PHP_URL_PATH);
115 $page = strtolower(basename($path !== null && $path !== false ? $path : $url));
116
117 if ($page === 'page.php') {
118 parse_str((string)(parse_url($url, PHP_URL_QUERY) ?: ''), $mq);
119
120 if (function_exists('setting')) {
121 $mp = isset($mq['plugin']) ? preg_replace('/[^a-z0-9_-]/i', '', (string)$mq['plugin']) : '';
122 if ($mp !== '') {
123 return setting('plugin:' . $mp . ':enabled', '0') === '1'
124 && (string)setting('plugin:' . $mp . ':version', '') !== '';
125 }
126 }
127
128 if (function_exists('theme_file')) {
129 $mp = isset($mq['p']) ? preg_replace('/[^a-z0-9_-]/i', '', (string)$mq['p']) : '';
130 if ($mp !== '') {
131 return theme_file('pages/' . $mp . '.php') !== null;
132 }
133 }
134 }
135
136 switch ($page) {
137 case 'shop.php':
138 return !empty($config['shop']['enabled']);
139 case 'buypoints.php':
140 return !empty($config['buypoints_enabled']);
141 case 'guildwar.php':
142 case 'guildwars.php':
143 return !empty($config['guildwar_enabled']);
144 case 'forum.php':
145 return !empty($config['forum']['enabled']);
146 case 'powergamers.php':
147 return !empty($config['powergamers']['enabled']);
148 case 'toponline.php':
149 return !empty($config['toponline']['enabled']);
150 case 'achievements.php':
151 return !empty($config['Ach']);
152 case 'items.php':
153 return !empty($config['items']);
154 case 'credits.php':
155 return $config['credits_enabled'] ?? true;
156 case 'queststatus.php':
157 return !empty($config['queststatus_enabled']);
158 default:
159 return true;
160 }
161}
162
163function theme_menu_label(string $label): string {
164 $raw = trim($label);
165 if ($raw === '') {
166 return '';
167 }
168
169 if (function_exists('t') && preg_match('/^[a-z0-9_.-]+$/i', $raw)) {
170 $translated = t($raw);
171 if ($translated !== $raw) {
172 return $translated;
173 }
174 }
175
176 $keyByLabel = array(
177 'account' => 'nav.account_section',
178 'account management' => 'nav.account_management',
179 'admin panel' => 'widget.admin.panel',
180 'bans' => 'bans.title',
181 'buy points' => 'shop.buy_points',
182 'changelog' => 'changelog.title',
183 'community' => 'nav.community',
184 'contact' => 'nav.contact',
185 'create account' => 'nav.register',
186 'create character' => 'account.create_character',
187 'credits' => 'nav.credits',
188 'creatures' => 'creatures.title',
189 'donate' => 'shop.buy_points',
190 'download' => 'downloads.download',
191 'download client' => 'nav.download_client',
192 'download game' => 'nav.downloads',
193 'downloads' => 'nav.downloads',
194 'forum' => 'nav.forum',
195 'guilds' => 'nav.guilds',
196 'helpdesk' => 'helpdesk.title',
197 'highscores' => 'nav.highscores',
198 'home' => 'nav.home',
199 'houses' => 'nav.houses',
200 'information' => 'front.server_information',
201 'item market' => 'nav.item_market',
202 'kill statistics' => 'nav.kill_statistics',
203 'kills statistics' => 'nav.kill_statistics',
204 'latest deaths' => 'deaths.latest',
205 'latest news' => 'nav.latest_news',
206 'library' => 'nav.library',
207 'log in' => 'nav.login',
208 'login' => 'nav.login',
209 'logout' => 'nav.logout',
210 'lost account' => 'recovery.lost_account_title',
211 'lost account?' => 'nav.lost_account',
212 'my account' => 'nav.account',
213 'news' => 'nav.news',
214 'register' => 'nav.register',
215 'server info' => 'nav.serverinfo',
216 'server information' => 'front.server_information',
217 'settings' => 'settings.title',
218 'shop' => 'nav.shop',
219 'spells' => 'spells.title',
220 'store' => 'nav.shop',
221 'support' => 'nav.support',
222 'vote for us' => 'nav.vote_for_us',
223 'vote for us!' => 'nav.vote_for_us',
224 'wheel of destiny' => 'tco.nav.wheelofdestiny',
225 'wheel of destiny planner' => 'tco.nav.wheelofdestiny',
226 'who is online' => 'nav.online',
227 'wikipedia' => 'nav.wikipedia',
228 'wiki search' => 'nav.wiki_search',
229 );
230
231 $normalized = strtolower(preg_replace('/\s+/', ' ', str_replace(array('_', '-'), ' ', $raw)));
232 if (isset($keyByLabel[$normalized]) && function_exists('t_default')) {
233 return t_default($keyByLabel[$normalized], $label);
234 }
235
236 return $label;
237}
238
239/**
240 * The menu slots the active theme declares, as slug => label.
241 * Falls back to a single "main" slot so the admin page is never empty.
242 */
243function theme_menu_locations(?string $theme = null): array {
244 $manifest = theme_manifest($theme ?? theme_active());
245 $declared = $manifest['menus'] ?? null;
246
247 if (!is_array($declared) || !$declared) {
248 return array('main' => t_default('acp.menu.default_location', 'Main navigation'));
249 }
250
251 $out = array();
252 foreach ($declared as $slug => $label) {
253 // Accept both {"main":"Top"} and ["main","sidebar"].
254 if (is_int($slug)) {
255 $slug = (string)$label;
256 $label = ucfirst(str_replace(array('-', '_'), ' ', $slug));
257 }
258 $slug = preg_replace('/[^a-z0-9_-]/', '', strtolower((string)$slug));
259 if ($slug !== '') {
260 $out[$slug] = (string)$label;
261 }
262 }
263
264 return $out ?: array('main' => 'Main navigation');
265}
266
267/**
268 * True when the menu table exists and holds at least one entry.
269 * A theme can use it to decide between the managed menu and its own fallback.
270 */
271function theme_menu_available(): bool {
272 $row = db()->fetchOne("SELECT `id` FROM `znote_menu` LIMIT 1;");
273 return is_array($row) && $row;
274}
275
Top