'' . h(theme_repository_cache_path()) . '', ])); } acp_redirect('layouts', array('tab' => 'browse', 'refresh' => 1)); } // --------------------------------------------------------------------------- // Activate a theme // --------------------------------------------------------------------------- if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['install'])) { $key = theme_sanitize((string)$_POST['install']); $overwrite = !empty($_POST['overwrite']); $result = theme_repository_install($key, $overwrite); if ($result === '') { acp_log('layouts.install', $key); acp_flash_success(t('acp.lay.installed', [ 'theme' => '' . h($key) . '', 'path' => 'layouts/' . h($key) . '/', ])); } elseif ($result === 'already-installed') { acp_flash_error(t('acp.lay.already_installed', ['theme' => '' . h($key) . ''])); } else { acp_flash_error(t('acp.lay.install_failed', ['error' => h($result)])); } acp_redirect('layouts', array('tab' => 'browse')); } if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['uninstall'])) { $key = theme_sanitize((string)$_POST['uninstall']); $result = theme_uninstall($key); if ($result === '') { acp_log('layouts.uninstall', $key); acp_flash_success(t('acp.lay.removed', [ 'theme' => '' . h($key) . '', 'path' => 'layouts/', ])); } else { acp_flash_error(t('acp.lay.remove_failed', ['error' => h($result)])); } acp_redirect('layouts'); } if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['theme_options'])) { $target = theme_sanitize((string)$_POST['theme_options']); $themes = theme_list(); $options = isset($themes[$target]) ? theme_options($target) : array(); if ($options === array()) { acp_flash_error(t('acp.lay.no_options')); acp_redirect('layouts'); } $saved = 0; $failed = 0; $savedKeys = array(); $uploads = 0; $errors = array(); foreach ($options as $key => $option) { if ($option['type'] === 'checkbox') { $value = empty($_POST['opt'][$key]) ? '' : '1'; } else { $value = trim((string)($_POST['opt'][$key] ?? '')); } // An uploaded image wins over whatever the path field says: the admin // picked a file, so that is the intent. if ($option['type'] === 'image') { $file = $_FILES['optfile']['tmp_name'][$key] ?? ''; $code = $_FILES['optfile']['error'][$key] ?? UPLOAD_ERR_NO_FILE; if ($code === UPLOAD_ERR_OK && is_uploaded_file((string)$file)) { $error = null; $stored = theme_image_store($target, $key, (string)$file, $error); if ($stored !== '') { $value = $stored; $uploads++; } else { $errors[] = $option['label'] . ': ' . (string)$error; continue; } } elseif ($code === UPLOAD_ERR_INI_SIZE || $code === UPLOAD_ERR_FORM_SIZE) { $errors[] = $option['label'] . ': ' . t('acp.lay.file_too_large'); continue; } } if (setting_set(theme_option_key($target, $key), $value)) { $saved++; $savedKeys[] = $key; } else $failed++; } foreach ($errors as $message) { acp_flash_error($message); } if ($uploads > 0) { acp_flash_info(t('acp.lay.images_uploaded', [ 'n' => $uploads, 'path' => 'engine/img/theme/' . h($target) . '/', ])); } if ($saved > 0) { acp_log('layouts.options_save', $target, ['fields' => $savedKeys, 'uploads' => $uploads, 'failed' => $failed]); } if ($failed > 0) { acp_flash_error(t('acp.lay.save_failed', ['n' => $failed, 'table' => 'znote_config'])); } else { acp_flash_success(t('acp.lay.options_saved', ['theme' => '' . h($themes[$target]['name']) . ''])); } acp_redirect('layouts', array('options' => $target)); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $requested = theme_sanitize((string)($_POST['layout'] ?? '')); $themes = theme_list(); if ($requested === '' || !isset($themes[$requested])) { acp_flash_error(t('acp.lay.unknown_theme')); } elseif (!$themes[$requested]['compatible']) { acp_flash_error(h(implode(' ', $themes[$requested]['compatibility_errors']))); } elseif (theme_file_exists_in($requested, 'shells/default.php') === false) { acp_flash_error(t('acp.lay.no_shell', [ 'theme' => '' . h($themes[$requested]['name']) . '', 'file' => 'shells/default.php', ])); } elseif (setting_set('layout', $requested)) { acp_log('layouts.activate', $requested); acp_flash_success(t('acp.lay.switched', ['theme' => '' . h($themes[$requested]['name']) . ''])); } else { acp_flash_error(t('acp.lay.save_setting_failed', ['table' => 'znote_config'])); } acp_redirect('layouts'); } /** A theme is only usable if it can produce a page frame. */ function theme_file_exists_in(string $theme, string $relative): bool { return is_file(theme_root() . '/' . $theme . '/' . ltrim($relative, '/')); } /** * How many shells / views / pages a theme ships, for the card summary. * * readdir rather than glob: measured on Windows with 100 themes installed, * glob takes 159ms and readdir 37ms for the same 309 directory reads. glob * builds and sorts a match array we immediately throw away. */ function acp_theme_counts(string $theme): array { static $cache = []; if (isset($cache[$theme])) { return $cache[$theme]; } $counts = ['shells' => 0, 'views' => 0, 'pages' => 0]; $base = theme_root() . '/' . $theme; foreach (array_keys($counts) as $sub) { $handle = @opendir($base . '/' . $sub); if ($handle === false) { continue; } while (($entry = readdir($handle)) !== false) { if (substr($entry, -4) === '.php') { $counts[$sub]++; } } closedir($handle); } return $cache[$theme] = $counts; } $themes = theme_list(); $active = theme_active(); // The Browse tab is a separate view over the same module. if (($_GET['tab'] ?? '') === 'browse') { // "Refresh catalogue" also wipes the local caches - a stale cache is what // usually keeps a just-installed or updated theme (and its options/locale) // from showing correctly. if (isset($_GET['refresh']) && function_exists('znote_cache_flush')) { znote_cache_flush(); acp_log('layouts.catalogue_refresh'); } include ACP_ROOT . '/modules/_partials/layouts_browse.php'; return; } // Options get a page of their own. Resolved against the full theme list, before // the search and paging below narrow it - a theme on page 3 has options too. $optionTheme = theme_sanitize((string)($_GET['options'] ?? '')); if ($optionTheme !== '' && isset($themes[$optionTheme])) { $optionList = theme_options($optionTheme); if ($optionList) { include ACP_ROOT . '/modules/_partials/layouts_options.php'; return; } acp_flash_error('That theme has no options to set.'); acp_redirect('layouts'); } // --------------------------------------------------------------------------- // Search and paging. // // Both server side on purpose: only the themes actually on screen get their // shells/views/pages counted, so the page costs the same with 5 themes // installed or 500. A client-side filter would have had to render every card. // --------------------------------------------------------------------------- const ACP_THEMES_PER_PAGE = 12; $search = trim((string)($_GET['q'] ?? '')); // The _-prefixed folders are references to copy, not themes to run. They get a // line in the "making a theme" card at the bottom instead of a card each up // here, where they only push real themes onto a second page. One that somehow // ended up active still shows, so it can be switched away from. $examples = array_filter($themes, static function (array $t) use ($active): bool { return !empty($t['is_example']) && $t['key'] !== $active; }); $themes = array_diff_key($themes, $examples); $total = count($themes); if ($search !== '') { $needle = strtolower($search); $themes = array_filter($themes, static function (array $t) use ($needle): bool { return str_contains(strtolower($t['name'] . ' ' . $t['key'] . ' ' . $t['author'] . ' ' . $t['description']), $needle); }); } $matched = count($themes); $pageCount = max(1, (int)ceil($matched / ACP_THEMES_PER_PAGE)); $page = max(1, min($pageCount, intv($_GET['tp'] ?? 1))); $themes = array_slice($themes, ($page - 1) * ACP_THEMES_PER_PAGE, ACP_THEMES_PER_PAGE, true); /** Keep the current search when building a paging link. */ function acp_theme_page_url(int $page, string $search): string { $params = ['tp' => $page]; if ($search !== '') { $params['q'] = $search; } return acp_url('layouts', $params); } $hasTable = znote_table_exists('znote_config'); ?>
'znote_config', 'default' => 'default', 'file' => 'SQL/migrations/2.0.0_znote_config.sql', ]) ?>
ACP_THEMES_PER_PAGE || $search !== ''): ?>
(int)$matched, 'total' => (int)$total]) ?> (int)$total]) ?> 1): ?> · (int)$page, 'pageCount' => (int)$pageCount]) ?>
$theme): $isActive = ($key === $active); $isUsable = theme_file_exists_in($key, 'shells/default.php') && $theme['compatible']; $counts = acp_theme_counts($key); $views = $counts['views']; $pages = $counts['pages']; $shells = $counts['shells']; ?>
> <?= h($theme['name']) ?>
 no screenshot.png

layouts// · h($theme['author'])]) ?> · v

$shells]) ?> $views]) ?> $pages]) ?>

'shells/default.php']) : h(implode(' ', $theme['compatibility_errors'])) ?>

$search]), 'fa-search'); ?>
1): ?>

Making a theme

The short version

Copy layouts/_example/, rename the folder, edit theme.json. Your theme is listed here immediately.

The only file a theme truly needs is shells/default.php — the frame your pages render inside. Anything else you leave out is taken from the default theme, so a stylesheet and a shell are enough to redress the entire site.

The picture on these cards is a file named screenshot.png at the root of the theme folder — nothing to declare, drop it in and reload this page. Any size works; roughly 3:2 shows best.

Two folders in layouts/ are starting points rather than themes, so they are kept out of the grid above:

Copy one, rename the folder, and it appears above on the next page load. Never edit them in place: they are the reference every other theme is copied from.

Full instructions, including the list of CSS classes the pages emit, are in layouts/README.md.