layouts.php

main 519 lines · 17.9 KB Raw
Alex Alex Commit Initial commit 01/10/2026 09:20
1<?php
2/**
3 * Title: Layout
4 * Icon: fa-paint-brush
5 * Group: Settings
6 * Order: 10
7 * Description: Pick the theme the public site is dressed in.
8 */
9
10if (!defined('ACP_ROOT')) {
11 http_response_code(403);
12 die('Direct access denied.');
13}
14
15if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['clear_layout_repository_cache'])) {
16 if (theme_repository_clear_cache()) {
17 acp_log('layouts.cache_clear');
18 acp_flash_success(t('acp.laybr.cache_deleted'));
19 } else {
20 acp_flash_error(t('acp.laybr.cache_delete_failed', [
21 'path' => '<code>' . h(theme_repository_cache_path()) . '</code>',
22 ]));
23 }
24
25 acp_redirect('layouts', array('tab' => 'browse', 'refresh' => 1));
26}
27
28// ---------------------------------------------------------------------------
29// Activate a theme
30// ---------------------------------------------------------------------------
31if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['install'])) {
32
33 $key = theme_sanitize((string)$_POST['install']);
34 $overwrite = !empty($_POST['overwrite']);
35 $result = theme_repository_install($key, $overwrite);
36
37 if ($result === '') {
38 acp_log('layouts.install', $key);
39 acp_flash_success(t('acp.lay.installed', [
40 'theme' => '<strong>' . h($key) . '</strong>',
41 'path' => '<code>layouts/' . h($key) . '/</code>',
42 ]));
43 } elseif ($result === 'already-installed') {
44 acp_flash_error(t('acp.lay.already_installed', ['theme' => '<strong>' . h($key) . '</strong>']));
45 } else {
46 acp_flash_error(t('acp.lay.install_failed', ['error' => h($result)]));
47 }
48
49 acp_redirect('layouts', array('tab' => 'browse'));
50}
51
52if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['uninstall'])) {
53
54 $key = theme_sanitize((string)$_POST['uninstall']);
55 $result = theme_uninstall($key);
56
57 if ($result === '') {
58 acp_log('layouts.uninstall', $key);
59 acp_flash_success(t('acp.lay.removed', [
60 'theme' => '<strong>' . h($key) . '</strong>',
61 'path' => '<code>layouts/</code>',
62 ]));
63 } else {
64 acp_flash_error(t('acp.lay.remove_failed', ['error' => h($result)]));
65 }
66
67 acp_redirect('layouts');
68}
69
70if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['theme_options'])) {
71
72 $target = theme_sanitize((string)$_POST['theme_options']);
73 $themes = theme_list();
74 $options = isset($themes[$target]) ? theme_options($target) : array();
75
76 if ($options === array()) {
77 acp_flash_error(t('acp.lay.no_options'));
78 acp_redirect('layouts');
79 }
80
81 $saved = 0;
82 $failed = 0;
83 $savedKeys = array();
84 $uploads = 0;
85 $errors = array();
86
87 foreach ($options as $key => $option) {
88
89 if ($option['type'] === 'checkbox') {
90 $value = empty($_POST['opt'][$key]) ? '' : '1';
91 } else {
92 $value = trim((string)($_POST['opt'][$key] ?? ''));
93 }
94
95 // An uploaded image wins over whatever the path field says: the admin
96 // picked a file, so that is the intent.
97 if ($option['type'] === 'image') {
98 $file = $_FILES['optfile']['tmp_name'][$key] ?? '';
99 $code = $_FILES['optfile']['error'][$key] ?? UPLOAD_ERR_NO_FILE;
100
101 if ($code === UPLOAD_ERR_OK && is_uploaded_file((string)$file)) {
102 $error = null;
103 $stored = theme_image_store($target, $key, (string)$file, $error);
104
105 if ($stored !== '') {
106 $value = $stored;
107 $uploads++;
108 } else {
109 $errors[] = $option['label'] . ': ' . (string)$error;
110 continue;
111 }
112 } elseif ($code === UPLOAD_ERR_INI_SIZE || $code === UPLOAD_ERR_FORM_SIZE) {
113 $errors[] = $option['label'] . ': ' . t('acp.lay.file_too_large');
114 continue;
115 }
116 }
117
118 if (setting_set(theme_option_key($target, $key), $value)) {
119 $saved++;
120 $savedKeys[] = $key;
121 } else $failed++;
122 }
123
124 foreach ($errors as $message) {
125 acp_flash_error($message);
126 }
127
128 if ($uploads > 0) {
129 acp_flash_info(t('acp.lay.images_uploaded', [
130 'n' => $uploads,
131 'path' => '<code>engine/img/theme/' . h($target) . '/</code>',
132 ]));
133 }
134
135 if ($saved > 0) {
136 acp_log('layouts.options_save', $target, ['fields' => $savedKeys, 'uploads' => $uploads, 'failed' => $failed]);
137 }
138 if ($failed > 0) {
139 acp_flash_error(t('acp.lay.save_failed', ['n' => $failed, 'table' => '<code>znote_config</code>']));
140 } else {
141 acp_flash_success(t('acp.lay.options_saved', ['theme' => '<strong>' . h($themes[$target]['name']) . '</strong>']));
142 }
143
144 acp_redirect('layouts', array('options' => $target));
145}
146
147if ($_SERVER['REQUEST_METHOD'] === 'POST') {
148 $requested = theme_sanitize((string)($_POST['layout'] ?? ''));
149 $themes = theme_list();
150
151 if ($requested === '' || !isset($themes[$requested])) {
152 acp_flash_error(t('acp.lay.unknown_theme'));
153 } elseif (!$themes[$requested]['compatible']) {
154 acp_flash_error(h(implode(' ', $themes[$requested]['compatibility_errors'])));
155 } elseif (theme_file_exists_in($requested, 'shells/default.php') === false) {
156 acp_flash_error(t('acp.lay.no_shell', [
157 'theme' => '<strong>' . h($themes[$requested]['name']) . '</strong>',
158 'file' => '<code>shells/default.php</code>',
159 ]));
160 } elseif (setting_set('layout', $requested)) {
161 acp_log('layouts.activate', $requested);
162 acp_flash_success(t('acp.lay.switched', ['theme' => '<strong>' . h($themes[$requested]['name']) . '</strong>']));
163 } else {
164 acp_flash_error(t('acp.lay.save_setting_failed', ['table' => '<code>znote_config</code>']));
165 }
166
167 acp_redirect('layouts');
168}
169
170/** A theme is only usable if it can produce a page frame. */
171function theme_file_exists_in(string $theme, string $relative): bool {
172 return is_file(theme_root() . '/' . $theme . '/' . ltrim($relative, '/'));
173}
174
175/**
176 * How many shells / views / pages a theme ships, for the card summary.
177 *
178 * readdir rather than glob: measured on Windows with 100 themes installed,
179 * glob takes 159ms and readdir 37ms for the same 309 directory reads. glob
180 * builds and sorts a match array we immediately throw away.
181 */
182function acp_theme_counts(string $theme): array {
183 static $cache = [];
184 if (isset($cache[$theme])) {
185 return $cache[$theme];
186 }
187
188 $counts = ['shells' => 0, 'views' => 0, 'pages' => 0];
189 $base = theme_root() . '/' . $theme;
190
191 foreach (array_keys($counts) as $sub) {
192 $handle = @opendir($base . '/' . $sub);
193 if ($handle === false) {
194 continue;
195 }
196 while (($entry = readdir($handle)) !== false) {
197 if (substr($entry, -4) === '.php') {
198 $counts[$sub]++;
199 }
200 }
201 closedir($handle);
202 }
203
204 return $cache[$theme] = $counts;
205}
206
207$themes = theme_list();
208$active = theme_active();
209
210// The Browse tab is a separate view over the same module.
211if (($_GET['tab'] ?? '') === 'browse') {
212 // "Refresh catalogue" also wipes the local caches - a stale cache is what
213 // usually keeps a just-installed or updated theme (and its options/locale)
214 // from showing correctly.
215 if (isset($_GET['refresh']) && function_exists('znote_cache_flush')) {
216 znote_cache_flush();
217 acp_log('layouts.catalogue_refresh');
218 }
219 include ACP_ROOT . '/modules/_partials/layouts_browse.php';
220 return;
221}
222
223// Options get a page of their own. Resolved against the full theme list, before
224// the search and paging below narrow it - a theme on page 3 has options too.
225$optionTheme = theme_sanitize((string)($_GET['options'] ?? ''));
226if ($optionTheme !== '' && isset($themes[$optionTheme])) {
227 $optionList = theme_options($optionTheme);
228
229 if ($optionList) {
230 include ACP_ROOT . '/modules/_partials/layouts_options.php';
231 return;
232 }
233
234 acp_flash_error('That theme has no options to set.');
235 acp_redirect('layouts');
236}
237
238// ---------------------------------------------------------------------------
239// Search and paging.
240//
241// Both server side on purpose: only the themes actually on screen get their
242// shells/views/pages counted, so the page costs the same with 5 themes
243// installed or 500. A client-side filter would have had to render every card.
244// ---------------------------------------------------------------------------
245const ACP_THEMES_PER_PAGE = 12;
246
247$search = trim((string)($_GET['q'] ?? ''));
248
249// The _-prefixed folders are references to copy, not themes to run. They get a
250// line in the "making a theme" card at the bottom instead of a card each up
251// here, where they only push real themes onto a second page. One that somehow
252// ended up active still shows, so it can be switched away from.
253$examples = array_filter($themes, static function (array $t) use ($active): bool {
254 return !empty($t['is_example']) && $t['key'] !== $active;
255});
256$themes = array_diff_key($themes, $examples);
257
258$total = count($themes);
259
260if ($search !== '') {
261 $needle = strtolower($search);
262 $themes = array_filter($themes, static function (array $t) use ($needle): bool {
263 return str_contains(strtolower($t['name'] . ' ' . $t['key'] . ' ' . $t['author'] . ' ' . $t['description']), $needle);
264 });
265}
266
267$matched = count($themes);
268$pageCount = max(1, (int)ceil($matched / ACP_THEMES_PER_PAGE));
269$page = max(1, min($pageCount, intv($_GET['tp'] ?? 1)));
270$themes = array_slice($themes, ($page - 1) * ACP_THEMES_PER_PAGE, ACP_THEMES_PER_PAGE, true);
271
272/** Keep the current search when building a paging link. */
273function acp_theme_page_url(int $page, string $search): string {
274 $params = ['tp' => $page];
275 if ($search !== '') {
276 $params['q'] = $search;
277 }
278 return acp_url('layouts', $params);
279}
280$hasTable = znote_table_exists('znote_config');
281?>
282
283<?php if (!$hasTable): ?>
284 <div class="acp-flash acp-flash--error">
285 <i class="fa fa-exclamation-triangle"></i>
286 <span>
287 <?= t('acp.lay.table_missing_banner', [
288 'table' => '<code>znote_config</code>',
289 'default' => '<strong>default</strong>',
290 'file' => '<code>SQL/migrations/2.0.0_znote_config.sql</code>',
291 ]) ?>
292 </span>
293 </div>
294<?php endif; ?>
295
296<div class="acp-toolbar">
297 <div></div>
298 <div class="acp-actions is-tight">
299 <?php if (theme_repository_config()['enabled']): ?>
300 <a class="acp-btn" href="<?= h(acp_url('layouts', array('tab' => 'browse'))) ?>">
301 <i class="fa fa-cloud-download"></i> <?= t('acp.lay.browse_themes') ?>
302 </a>
303 <?php endif; ?>
304 </div>
305</div>
306
307<div class="acp-stats">
308 <?php
309 acp_stat(t('acp.lay.stat_installed'), count($themes), 'fa-paint-brush', null, 'purple');
310 acp_stat(t('acp.lay.stat_active'), $themes[$active]['name'] ?? $active, 'fa-check-circle', null, 'green');
311 ?>
312</div>
313
314<?php if ($total > ACP_THEMES_PER_PAGE || $search !== ''): ?>
315 <div class="acp-toolbar">
316 <form method="get" style="display:flex;gap:8px;flex:1 1 320px;max-width:460px;">
317 <input type="hidden" name="p" value="layouts">
318 <input class="acp-input" type="search" name="q" value="<?= h($search) ?>"
319 placeholder="<?= h(t('acp.lay.search_placeholder')) ?>">
320 <button class="acp-btn" type="submit"><i class="fa fa-search"></i></button>
321 <?php if ($search !== ''): ?>
322 <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('layouts')) ?>"><?= t('acp.lay.clear') ?></a>
323 <?php endif; ?>
324 </form>
325 <span class="is-muted">
326 <?php if ($search !== ''): ?>
327 <?= t('acp.lay.matched_of_total', ['matched' => (int)$matched, 'total' => (int)$total]) ?>
328 <?php else: ?>
329 <?= t('acp.lay.total_themes', ['total' => (int)$total]) ?>
330 <?php endif; ?>
331 <?php if ($pageCount > 1): ?>
332 &middot; <?= t('acp.lay.page_of', ['page' => (int)$page, 'pageCount' => (int)$pageCount]) ?>
333 <?php endif; ?>
334 </span>
335 </div>
336<?php endif; ?>
337
338<div class="acp-media">
339 <?php foreach ($themes as $key => $theme):
340 $isActive = ($key === $active);
341 $isUsable = theme_file_exists_in($key, 'shells/default.php') && $theme['compatible'];
342 $counts = acp_theme_counts($key);
343 $views = $counts['views'];
344 $pages = $counts['pages'];
345 $shells = $counts['shells'];
346 ?>
347 <article class="acp-media-item"<?= $isActive ? ' style="border-color:var(--acp-green);"' : '' ?>>
348 <?php if (!empty($theme['screenshot'])): ?>
349 <img src="<?= h(acp_site($theme['screenshot'])) ?>" alt="<?= h($theme['name']) ?>" loading="lazy">
350 <?php else: ?>
351 <div style="display:grid;place-items:center;height:170px;background:var(--acp-panel-2);color:var(--acp-fg-muted);">
352 <span><i class="fa fa-picture-o"></i> &nbsp;no screenshot.png</span>
353 </div>
354 <?php endif; ?>
355
356 <div class="acp-media-body">
357 <h3>
358 <?= h($theme['name']) ?>
359 <?php if ($isActive): ?>
360 <span class="acp-pill acp-pill--green"><?= t('acp.lay.active_pill') ?></span>
361 <?php endif; ?>
362 <?php if (!empty($theme['is_example'])): ?>
363 <span class="acp-pill acp-pill--grey"><?= t('acp.lay.template_pill') ?></span>
364 <?php endif; ?>
365 <?php if (!$theme['compatible']): ?>
366 <span class="acp-pill acp-pill--red"><?= h(t_default('acp.lay.incompatible', 'Incompatible')) ?></span>
367 <?php endif; ?>
368 </h3>
369
370 <p>
371 <?= h($theme['description']) ?>
372 </p>
373
374 <p class="is-muted" style="font-size:12px;">
375 <code>layouts/<?= h($key) ?>/</code>
376 <?php if ($theme['author'] !== ''): ?>
377 &middot; <?= t('acp.lay.by_author', ['author' => h($theme['author'])]) ?>
378 <?php endif; ?>
379 <?php if ($theme['version'] !== ''): ?>
380 &middot; v<?= h($theme['version']) ?>
381 <?php endif; ?>
382 </p>
383
384 <p style="font-size:12px;">
385 <span class="acp-pill acp-pill--blue"><?= t('acp.lay.shell_count', ['n' => $shells]) ?></span>
386 <span class="acp-pill acp-pill--grey"><?= t('acp.lay.view_count', ['n' => $views]) ?></span>
387 <span class="acp-pill acp-pill--grey"><?= t('acp.lay.page_count', ['n' => $pages]) ?></span>
388 </p>
389
390 <?php if (!$isUsable): ?>
391 <p class="is-muted" style="font-size:12px;color:var(--acp-red);">
392 <?= $theme['compatible']
393 ? t('acp.lay.not_usable', ['file' => '<code>shells/default.php</code>'])
394 : h(implode(' ', $theme['compatibility_errors'])) ?>
395 </p>
396 <?php elseif ($views === 0): ?>
397 <p class="is-muted" style="font-size:12px;">
398 <?= t('acp.lay.no_views') ?>
399 </p>
400 <?php endif; ?>
401 </div>
402
403 <div class="acp-media-foot">
404 <?php if ($isActive): ?>
405 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_site('index.php')) ?>" target="_blank" rel="noopener">
406 <i class="fa fa-external-link"></i> <?= t('acp.lay.view_site') ?>
407 </a>
408 <?php else: ?>
409 <form class="acp-inline-form" method="post"
410 data-confirm="<?= h(t('acp.lay.confirm_switch')) ?>">
411 <?= acp_csrf_field() ?>
412 <input type="hidden" name="layout" value="<?= h($key) ?>">
413 <button class="acp-btn acp-btn--sm" type="submit" <?= $isUsable ? '' : 'disabled' ?>>
414 <i class="fa fa-check"></i> <?= t('acp.lay.activate') ?>
415 </button>
416 </form>
417 <?php endif; ?>
418
419 <?php if (theme_options($key)): ?>
420 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('layouts', array('options' => $key))) ?>">
421 <i class="fa fa-sliders"></i> <?= t('acp.lay.options') ?>
422 </a>
423 <?php endif; ?>
424
425 <?php if (!$isActive && $key !== 'default' && empty($theme['is_example'])): ?>
426 <form class="acp-inline-form" method="post"
427 data-confirm="<?= h(t('acp.lay.confirm_delete', ['path' => 'layouts/' . $key . '/'])) ?>">
428 <?= acp_csrf_field() ?>
429 <input type="hidden" name="uninstall" value="<?= h($key) ?>">
430 <button class="acp-btn acp-btn--red acp-btn--sm" type="submit">
431 <i class="fa fa-trash"></i> <?= t('acp.lay.remove') ?>
432 </button>
433 </form>
434 <?php endif; ?>
435 </div>
436 </article>
437 <?php endforeach; ?>
438</div>
439
440<?php if ($matched === 0): ?>
441 <section class="acp-card">
442 <div class="acp-card-body">
443 <?php acp_empty(t('acp.lay.no_match', ['search' => $search]), 'fa-search'); ?>
444 <div class="acp-actions" style="justify-content:center;">
445 <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('layouts')) ?>"><?= t('acp.lay.show_all') ?></a>
446 </div>
447 </div>
448 </section>
449<?php endif; ?>
450
451<?php if ($pageCount > 1): ?>
452 <nav class="acp-actions" style="justify-content:center;margin:18px 0 24px;" aria-label="<?= h(t('acp.lay.pages_label')) ?>">
453 <a class="acp-btn acp-btn--ghost acp-btn--sm<?= $page <= 1 ? ' is-disabled' : '' ?>"
454 href="<?= h(acp_theme_page_url(max(1, $page - 1), $search)) ?>"
455 <?= $page <= 1 ? 'aria-disabled="true" tabindex="-1"' : '' ?>>
456 <i class="fa fa-angle-left"></i> <?= t('acp.lay.previous') ?>
457 </a>
458
459 <?php for ($i = 1; $i <= $pageCount; $i++): ?>
460 <?php if ($i === $page): ?>
461 <span class="acp-btn acp-btn--sm"><?= $i ?></span>
462 <?php else: ?>
463 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_theme_page_url($i, $search)) ?>"><?= $i ?></a>
464 <?php endif; ?>
465 <?php endfor; ?>
466
467 <a class="acp-btn acp-btn--ghost acp-btn--sm<?= $page >= $pageCount ? ' is-disabled' : '' ?>"
468 href="<?= h(acp_theme_page_url(min($pageCount, $page + 1), $search)) ?>"
469 <?= $page >= $pageCount ? 'aria-disabled="true" tabindex="-1"' : '' ?>>
470 <?= t('acp.lay.next') ?> <i class="fa fa-angle-right"></i>
471 </a>
472 </nav>
473<?php endif; ?>
474
475<section class="acp-card">
476 <header class="acp-card-head">
477 <h2>Making a theme</h2>
478 <p>The short version</p>
479 </header>
480 <div class="acp-card-body">
481 <p>
482 Copy <code>layouts/_example/</code>, rename the folder, edit
483 <code>theme.json</code>. Your theme is listed here immediately.
484 </p>
485 <p>
486 The only file a theme truly needs is <code>shells/default.php</code> &mdash; the frame
487 your pages render inside. Anything else you leave out is taken from the default theme,
488 so a stylesheet and a shell are enough to redress the entire site.
489 </p>
490 <p>
491 The picture on these cards is a file named <code>screenshot.png</code> at the root of
492 the theme folder &mdash; nothing to declare, drop it in and reload this page.
493 Any size works; roughly 3:2 shows best.
494 </p>
495 <?php if ($examples): ?>
496 <p>
497 Two folders in <code>layouts/</code> are starting points rather than themes, so they
498 are kept out of the grid above:
499 </p>
500 <ul>
501 <?php foreach ($examples as $exampleKey => $example): ?>
502 <li>
503 <code><?= h('layouts/' . $exampleKey . '/') ?></code>
504 &mdash; <?= h($example['description'] !== '' ? $example['description'] : $example['name']) ?>
505 </li>
506 <?php endforeach; ?>
507 </ul>
508 <p>
509 Copy one, rename the folder, and it appears above on the next page load. Never edit
510 them in place: they are the reference every other theme is copied from.
511 </p>
512 <?php endif; ?>
513 <p>
514 Full instructions, including the list of CSS classes the pages emit, are in
515 <code>layouts/README.md</code>.
516 </p>
517 </div>
518</section>
519
Top