'' . 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');
?>
= t('acp.lay.table_missing_banner', [
'table' => 'znote_config',
'default' => 'default ',
'file' => 'SQL/migrations/2.0.0_znote_config.sql',
]) ?>
ACP_THEMES_PER_PAGE || $search !== ''): ?>
$search]), 'fa-search'); ?>
1): ?>
>
= t('acp.lay.previous') ?>
= $i ?>
= $i ?>
= $pageCount ? 'aria-disabled="true" tabindex="-1"' : '' ?>>
= t('acp.lay.next') ?>
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:
$example): ?>
= h('layouts/' . $exampleKey . '/') ?>
— = h($example['description'] !== '' ? $example['description'] : $example['name']) ?>
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.