1<?php
2/**
3 * Title: Menus
4 * Icon: fa-bars
5 * Group: Content
6 * Order: 40
7 * Description: Add, reorder and hide the links your theme shows.
8 */
9
10if (!defined('ACP_ROOT')) {
11 http_response_code(403);
12 die('Direct access denied.');
13}
14
15function acp_menu_visibility(): array {
16 return array(
17 'all' => t('acp.menu.vis_all'),
18 'guest' => t('acp.menu.vis_guest'),
19 'user' => t('acp.menu.vis_user'),
20 'admin' => t('acp.menu.vis_admin'),
21 );
22}
23
24$locations = theme_menu_locations();
25$location = (string)($_GET['loc'] ?? array_key_first($locations));
26if (!isset($locations[$location])) {
27 $location = (string)array_key_first($locations);
28}
29
30$hasTable = znote_table_exists('znote_menu');
31
32// ---------------------------------------------------------------------------
33// Mutations
34// ---------------------------------------------------------------------------
35if ($_SERVER['REQUEST_METHOD'] === 'POST') {
36
37 $do = (string)($_POST['do'] ?? '');
38 $id = intv($_POST['id'] ?? 0);
39 $loc = preg_replace('/[^a-z0-9_-]/', '', strtolower((string)($_POST['location'] ?? $location)));
40 if (!isset($locations[$loc])) {
41 $loc = $location;
42 }
43
44 if ($do === 'delete' && $id > 0) {
45 $entry = db()->fetchOne("SELECT `parent_id`, `label` FROM `znote_menu` WHERE `id` = ? LIMIT 1;", [$id]);
46 if (is_array($entry) && (int)$entry['parent_id'] === 0) {
47 db()->execute("DELETE FROM `znote_menu` WHERE `id` = ? OR `parent_id` = ?;", [$id, $id]);
48 acp_log('menu.delete_category', (string)$entry['label']);
49 acp_flash_success(t('acp.menu.category_deleted'));
50 acp_redirect('menus', array('loc' => $loc));
51 }
52 db()->execute("DELETE FROM `znote_menu` WHERE `id` = ? LIMIT 1;", [$id]);
53 acp_log('menu.delete', is_array($entry) ? (string)$entry['label'] : ('#' . $id));
54 acp_flash_success(t('acp.menu.deleted'));
55 acp_redirect('menus', array('loc' => $loc));
56 }
57
58 if ($do === 'toggle' && $id > 0) {
59 db()->execute("UPDATE `znote_menu` SET `active` = CASE WHEN `active` = 1 THEN 0 ELSE 1 END WHERE `id` = ? LIMIT 1;", [$id]);
60 acp_redirect('menus', array('loc' => $loc));
61 }
62
63 if ($do === 'move' && $id > 0) {
64 $item = db()->fetchOne("
65 SELECT `id`, `parent_id`, `location`
66 FROM `znote_menu`
67 WHERE `id` = ?
68 LIMIT 1;
69 ", [$id]);
70 if (is_array($item)) {
71 $siblings = db()->fetchAll("
72 SELECT `id`
73 FROM `znote_menu`
74 WHERE `location` = ?
75 AND `parent_id` = ?
76 ORDER BY `sort_order` ASC, `id` ASC;
77 ", [(string)$item['location'], (int)$item['parent_id']]);
78 if (is_array($siblings)) {
79 $ids = array_map('intval', array_column($siblings, 'id'));
80 $pos = array_search($id, $ids, true);
81 $swap = ((string)($_POST['dir'] ?? '') === 'up') ? ((int)$pos - 1) : ((int)$pos + 1);
82
83 if ($pos !== false && isset($ids[$swap])) {
84 $ids[$pos] = $ids[$swap];
85 $ids[$swap] = $id;
86 foreach ($ids as $index => $rowId) {
87 db()->execute("UPDATE `znote_menu` SET `sort_order` = ? WHERE `id` = ? LIMIT 1;", [($index + 1) * 10, $rowId]);
88 }
89 }
90 }
91 }
92 acp_redirect('menus', array('loc' => $loc));
93 }
94
95 if ($do === 'save') {
96 $label = substr(trim((string)($_POST['label'] ?? '')), 0, 64);
97 $url = substr(trim((string)($_POST['url'] ?? '')), 0, 255);
98 $icon = substr(trim((string)($_POST['icon'] ?? '')), 0, 48);
99 $target = ((string)($_POST['target'] ?? '') === '_blank') ? '_blank' : '';
100 $visibility = (string)($_POST['visibility'] ?? 'all');
101 $parent = intv($_POST['parent_id'] ?? 0);
102 $order = intv($_POST['sort_order'] ?? 0);
103 $existing = $id > 0 ? db()->fetchOne("
104 SELECT `id`, `parent_id`, `location`
105 FROM `znote_menu`
106 WHERE `id` = ?
107 LIMIT 1;
108 ", [$id]) : false;
109 $editingCategory = is_array($existing) && (int)$existing['parent_id'] === 0;
110
111 if (!isset(acp_menu_visibility()[$visibility])) {
112 $visibility = 'all';
113 }
114 // A top-level entry is a category: a heading that opens its children,
115 // not a link of its own. Everything nested under one still needs a URL.
116 $isCategory = ($parent === 0);
117
118 if ($label === '') {
119 acp_flash_error(t('acp.menu.label_required'));
120 acp_redirect('menus', array('loc' => $loc));
121 }
122 if (!$isCategory && $url === '') {
123 acp_flash_error(t('acp.menu.url_required'));
124 acp_redirect('menus', array('loc' => $loc));
125 }
126
127 if ($id > 0 && !is_array($existing)) {
128 acp_flash_error(t('acp.menu.entry_gone'));
129 acp_redirect('menus', array('loc' => $loc));
130 }
131
132 if ($editingCategory) {
133 $parent = 0;
134 } elseif ($parent > 0) {
135 $category = $parent > 0 ? db()->fetchOne("
136 SELECT `id`
137 FROM `znote_menu`
138 WHERE `id` = ?
139 AND `location` = ?
140 AND `parent_id` = 0
141 LIMIT 1;
142 ", [$parent, $loc]) : false;
143 if (!is_array($category)) {
144 acp_flash_error(t('acp.menu.choose_category'));
145 acp_redirect('menus', array('loc' => $loc));
146 }
147 }
148
149 $fieldsSql = "`label` = ?, `url` = ?, `icon` = ?, `target` = ?, `visibility` = ?, `parent_id` = ?, `sort_order` = ?";
150 $fieldsParams = [$label, $url, $icon, $target, $visibility, $parent, $order];
151
152 if ($id > 0) {
153 db()->execute("UPDATE `znote_menu` SET {$fieldsSql} WHERE `id` = ? LIMIT 1;", [...$fieldsParams, $id]);
154 acp_log('menu.update', $label, ['url' => $url, 'location' => $loc]);
155 acp_flash_success(t('acp.menu.updated'));
156 } else {
157 db()->execute("INSERT INTO `znote_menu` SET `location` = ?, `active` = 1, {$fieldsSql};", [$loc, ...$fieldsParams]);
158 acp_log('menu.create', $label, ['url' => $url, 'location' => $loc]);
159 acp_flash_success(t('acp.menu.added'));
160 }
161
162 acp_redirect('menus', array('loc' => $loc));
163 }
164
165 acp_flash_error(t('acp.menu.unknown_action'));
166 acp_redirect('menus', array('loc' => $location));
167}
168
169// ---------------------------------------------------------------------------
170// Load
171// ---------------------------------------------------------------------------
172$entries = $hasTable ? db()->fetchAll("
173 SELECT `id`, `parent_id`, `label`, `url`, `icon`, `target`, `visibility`, `sort_order`, `active`
174 FROM `znote_menu`
175 WHERE `location` = ?
176 ORDER BY `sort_order` ASC, `id` ASC;
177", [$location]) : false;
178$entries = is_array($entries) ? $entries : array();
179
180$editing = null;
181if (($_GET['action'] ?? '') === 'edit') {
182 $editId = intv($_GET['id'] ?? 0);
183 foreach ($entries as $entry) {
184 if ((int)$entry['id'] === $editId) {
185 $editing = $entry;
186 break;
187 }
188 }
189}
190$editingCategory = is_array($editing) && (int)$editing['parent_id'] === 0;
191
192// Top-level entries, for the parent dropdown.
193$parents = array();
194foreach ($entries as $entry) {
195 if ((int)$entry['parent_id'] === 0 && (int)$entry['id'] !== (int)($editing['id'] ?? 0)) {
196 $parents[(int)$entry['id']] = (string)$entry['label'];
197 }
198}
199
200$topEntries = array();
201$childrenByParent = array();
202foreach ($entries as $entry) {
203 $parentId = (int)$entry['parent_id'];
204 if ($parentId === 0) {
205 $topEntries[] = $entry;
206 } else {
207 $childrenByParent[$parentId][] = $entry;
208 }
209}
210
211$displayEntries = array();
212foreach ($topEntries as $entry) {
213 $displayEntries[] = $entry;
214 foreach ($childrenByParent[(int)$entry['id']] ?? array() as $child) {
215 $displayEntries[] = $child;
216 }
217}
218foreach ($childrenByParent as $parentId => $children) {
219 if (!isset($parents[(int)$parentId]) && (int)$parentId !== (int)($editing['id'] ?? 0)) {
220 foreach ($children as $child) {
221 $displayEntries[] = $child;
222 }
223 }
224}
225?>
226
227<?php if (!$hasTable): ?>
228 <div class="acp-flash acp-flash--error">
229 <i class="fa fa-exclamation-triangle"></i>
230 <span>
231 <?= t('acp.menu.table_missing', [
232 'table' => '<code>znote_menu</code>',
233 'file' => '<code>SQL/migrations/2.0.0_menus.sql</code>',
234 ]) ?>
235 </span>
236 </div>
237<?php endif; ?>
238
239<div class="acp-toolbar">
240 <div class="acp-actions is-tight">
241 <?php foreach ($locations as $slug => $label): ?>
242 <a class="acp-btn <?= $slug === $location ? '' : 'acp-btn--ghost' ?> acp-btn--sm"
243 href="<?= h(acp_url('menus', array('loc' => $slug))) ?>">
244 <?= h($label) ?>
245 </a>
246 <?php endforeach; ?>
247 </div>
248 <span class="is-muted"><?= t('acp.menu.entries_in', ['n' => count($entries), 'location' => '<code>' . h($location) . '</code>']) ?></span>
249</div>
250
251<div class="acp-grid acp-grid--2">
252
253 <section class="acp-card">
254 <header class="acp-card-head">
255 <h2><?= $editing !== null ? t('acp.menu.edit_entry') : t('acp.menu.add_entry') ?></h2>
256 <p><?= t('acp.menu.to_location', ['location' => h($locations[$location])]) ?></p>
257 </header>
258 <div class="acp-card-body">
259 <form method="post">
260 <?= acp_csrf_field() ?>
261 <input type="hidden" name="do" value="save">
262 <input type="hidden" name="location" value="<?= h($location) ?>">
263 <?php if ($editing !== null): ?>
264 <input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
265 <?php endif; ?>
266
267 <div class="acp-row">
268 <div class="acp-field">
269 <label class="acp-label" for="label"><?= t('acp.menu.label') ?></label>
270 <input class="acp-input" id="label" name="label" required
271 value="<?= h((string)($editing['label'] ?? '')) ?>">
272 </div>
273 <div class="acp-field">
274 <label class="acp-label" for="url"><?= t('acp.menu.url') ?></label>
275 <input class="acp-input" id="url" name="url"
276 placeholder="highscores.php"
277 value="<?= h((string)($editing['url'] ?? '')) ?>">
278 <p class="acp-hint"><?= t('acp.menu.url_hint') ?></p>
279 </div>
280 </div>
281
282 <div class="acp-row">
283 <div class="acp-field">
284 <label class="acp-label" for="icon"><?= t('acp.menu.icon') ?></label>
285 <input class="acp-input" id="icon" name="icon" placeholder="fa-users"
286 value="<?= h((string)($editing['icon'] ?? '')) ?>">
287 <p class="acp-hint"><?= t('acp.menu.icon_hint') ?></p>
288 </div>
289 <div class="acp-field">
290 <label class="acp-label" for="parent_id"><?= t('acp.menu.category') ?></label>
291 <?php if ($editingCategory): ?>
292 <input type="hidden" name="parent_id" value="0">
293 <input class="acp-input" id="parent_id" value="<?= h(t('acp.menu.top_level')) ?>" disabled>
294 <?php else: ?>
295 <select class="acp-select" id="parent_id" name="parent_id" required>
296 <option value="0" <?= (int)($editing['parent_id'] ?? 0) === 0 ? 'selected' : '' ?>><?= t('acp.menu.top_level') ?></option>
297 <?php foreach ($parents as $pid => $plabel): ?>
298 <option value="<?= $pid ?>" <?= (int)($editing['parent_id'] ?? 0) === $pid ? 'selected' : '' ?>>
299 <?= h($plabel) ?>
300 </option>
301 <?php endforeach; ?>
302 </select>
303 <?php endif; ?>
304 </div>
305 </div>
306
307 <div class="acp-row">
308 <div class="acp-field">
309 <label class="acp-label" for="visibility"><?= t('acp.menu.shown_to') ?></label>
310 <select class="acp-select" id="visibility" name="visibility">
311 <?php foreach (acp_menu_visibility() as $value => $vlabel): ?>
312 <option value="<?= h($value) ?>" <?= (string)($editing['visibility'] ?? 'all') === $value ? 'selected' : '' ?>>
313 <?= h($vlabel) ?>
314 </option>
315 <?php endforeach; ?>
316 </select>
317 </div>
318 <div class="acp-field">
319 <label class="acp-label" for="target"><?= t('acp.menu.opens_in') ?></label>
320 <select class="acp-select" id="target" name="target">
321 <option value=""><?= t('acp.menu.same_tab') ?></option>
322 <option value="_blank" <?= (string)($editing['target'] ?? '') === '_blank' ? 'selected' : '' ?>><?= t('acp.menu.new_tab') ?></option>
323 </select>
324 </div>
325 <div class="acp-field">
326 <label class="acp-label" for="sort_order"><?= t('acp.menu.order') ?></label>
327 <input class="acp-input" id="sort_order" name="sort_order" type="number"
328 value="<?= (int)($editing['sort_order'] ?? ((count($entries) + 1) * 10)) ?>">
329 </div>
330 </div>
331
332 <div class="acp-actions">
333 <button class="acp-btn acp-btn--green" type="submit">
334 <i class="fa fa-check"></i> <?= $editing !== null ? t('acp.menu.save_entry') : t('acp.menu.add_entry_btn') ?>
335 </button>
336 <?php if ($editing !== null): ?>
337 <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('menus', array('loc' => $location))) ?>"><?= t('acp.menu.cancel') ?></a>
338 <?php endif; ?>
339 </div>
340 </form>
341 </div>
342 </section>
343
344 <section class="acp-card">
345 <header class="acp-card-head"><h2><?= t('acp.menu.how_title') ?></h2></header>
346 <div class="acp-card-body">
347 <p>
348 <?= t('acp.menu.how_text1', ['json' => '<code>theme.json</code>']) ?>
349 </p>
350 <pre class="acp-dump">"menus": { "main": "Top navigation" }
351
352<?php foreach (theme_menu_items('main') as $item): ?>
353 <a href="<?= $item['url'] ?>"><?= $item['label'] ?></a>
354<?php endforeach; ?></pre>
355 <p class="is-muted">
356 <?= t('acp.menu.how_text2', ['admins_only' => '<em>' . t('acp.menu.admins_only') . '</em>']) ?>
357 </p>
358 <p class="is-muted">
359 <?= t('acp.menu.how_text3') ?>
360 </p>
361 </div>
362 </section>
363</div>
364
365<section class="acp-card">
366 <header class="acp-card-head">
367 <h2><?= h($locations[$location]) ?></h2>
368 <p><?= t('acp.menu.lower_first') ?></p>
369 </header>
370 <div class="acp-card-body is-flush">
371 <?php if ($entries): ?>
372 <div class="acp-table-wrap">
373 <table class="acp-table">
374 <thead>
375 <tr>
376 <th class="is-num"><?= t('acp.menu.col_order') ?></th>
377 <th><?= t('acp.menu.col_label') ?></th>
378 <th><?= t('acp.menu.col_url') ?></th>
379 <th><?= t('acp.menu.col_shown_to') ?></th>
380 <th class="is-num"><?= t('acp.menu.col_actions') ?></th>
381 </tr>
382 </thead>
383 <tbody>
384 <?php foreach ($displayEntries as $entry):
385 $id = (int)$entry['id'];
386 $child = (int)$entry['parent_id'] > 0;
387 $hidden = ((int)$entry['active'] === 0);
388 ?>
389 <tr<?= $hidden ? ' style="opacity:.5;"' : '' ?>>
390 <td class="is-num is-muted"><?= (int)$entry['sort_order'] ?></td>
391 <td>
392 <?= $child ? '<span class="is-muted">— </span>' : '' ?>
393 <?php if ($entry['icon'] !== ''): ?>
394 <i class="fa <?= h((string)$entry['icon']) ?> is-muted"></i>
395 <?php endif; ?>
396 <?= h((string)$entry['label']) ?>
397 <?php if ($hidden): ?>
398 <span class="acp-pill acp-pill--grey"><?= t('acp.menu.hidden_pill') ?></span>
399 <?php endif; ?>
400 </td>
401 <td class="is-muted"><code><?= h((string)$entry['url']) ?></code></td>
402 <td>
403 <span class="acp-pill acp-pill--<?= $entry['visibility'] === 'admin' ? 'red' : ($entry['visibility'] === 'all' ? 'grey' : 'blue') ?>">
404 <?= h(acp_menu_visibility()[$entry['visibility']] ?? $entry['visibility']) ?>
405 </span>
406 </td>
407 <td class="is-num is-nowrap">
408 <?php foreach (array('up' => ['fa-arrow-up', t('acp.menu.move_up')], 'down' => ['fa-arrow-down', t('acp.menu.move_down')]) as $dir => $meta): ?>
409 <form class="acp-inline-form" method="post">
410 <?= acp_csrf_field() ?>
411 <input type="hidden" name="do" value="move">
412 <input type="hidden" name="id" value="<?= $id ?>">
413 <input type="hidden" name="dir" value="<?= $dir ?>">
414 <input type="hidden" name="location" value="<?= h($location) ?>">
415 <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit" title="<?= h($meta[1]) ?>">
416 <i class="fa <?= $meta[0] ?>"></i>
417 </button>
418 </form>
419 <?php endforeach; ?>
420
421 <form class="acp-inline-form" method="post">
422 <?= acp_csrf_field() ?>
423 <input type="hidden" name="do" value="toggle">
424 <input type="hidden" name="id" value="<?= $id ?>">
425 <input type="hidden" name="location" value="<?= h($location) ?>">
426 <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit" title="<?= h($hidden ? t('acp.menu.show') : t('acp.menu.hide')) ?>">
427 <i class="fa <?= $hidden ? 'fa-eye' : 'fa-eye-slash' ?>"></i>
428 </button>
429 </form>
430
431 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('menus', array('loc' => $location, 'action' => 'edit', 'id' => $id))) ?>">
432 <i class="fa fa-pencil"></i>
433 </a>
434
435 <form class="acp-inline-form" method="post" data-confirm="<?= h(t($child ? 'acp.menu.confirm_delete' : 'acp.menu.confirm_delete_category')) ?>">
436 <?= acp_csrf_field() ?>
437 <input type="hidden" name="do" value="delete">
438 <input type="hidden" name="id" value="<?= $id ?>">
439 <input type="hidden" name="location" value="<?= h($location) ?>">
440 <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"><i class="fa fa-trash"></i></button>
441 </form>
442 </td>
443 </tr>
444 <?php endforeach; ?>
445 </tbody>
446 </table>
447 </div>
448 <?php else: ?>
449 <?php acp_empty(t('acp.menu.empty'), 'fa-bars'); ?>
450 <?php endif; ?>
451 </div>
452</section>
453