Files
WebtreesBockenTheme/BockenTheme.php
Alexander Bocken ce11b25da4 Initial commit: Bocken theme for webtrees
Nord-themed dark/light mode family tree theme with:
- Floating glass-morphism header bar
- Auto/light/dark theme toggle with Lucide icons
- Smart SVG logo with theme-aware fill colors
- Active page highlighting with per-menu Nord icon colors
- Language button showing 2-letter abbreviation
- Start page search form
- Mobile responsive icon-only nav
- Custom views inherited from ArgonLight
2026-03-14 09:53:43 +01:00

352 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Bocken\Themes;
use Fisharebest\Webtrees\Module\AbstractModule;
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
use Fisharebest\Webtrees\Module\ModuleGlobalInterface;
use Fisharebest\Webtrees\Module\ModuleGlobalTrait;
use Fisharebest\Webtrees\Module\ModuleThemeInterface;
use Fisharebest\Webtrees\Module\ModuleThemeTrait;
use Fisharebest\Webtrees\View;
class BockenTheme extends AbstractModule implements ModuleCustomInterface, ModuleGlobalInterface, ModuleThemeInterface
{
use ModuleCustomTrait, ModuleGlobalTrait, ModuleThemeTrait;
public const string MODULE_TITLE = "Bocken";
public const string MODULE_DESCRIPTION = "Nord-themed dark/light mode family tree theme";
public const string MODULE_AUTHOR = 'Alexander Bocken';
public const string MODULE_VERSION = '1.0.0';
public const string MODULE_RESOURCE_PATH = __DIR__ . '/resources/';
public function title(): string
{
return self::MODULE_TITLE;
}
public function description(): string
{
return self::MODULE_DESCRIPTION;
}
public function customModuleAuthorName(): string
{
return self::MODULE_AUTHOR;
}
public function customModuleVersion(): string
{
return self::MODULE_VERSION;
}
public function resourcesFolder(): string
{
return self::MODULE_RESOURCE_PATH;
}
public function stylesheets(): array
{
// imports.css must be built from within the webtrees installation
// (it references vendor/fisharebest/webtrees/resources/css/_base.css)
$sheets = [];
$importsPath = self::MODULE_RESOURCE_PATH . 'css/imports.css';
if (file_exists($importsPath)) {
$sheets[] = $this->assetUrl('css/imports.css');
}
$sheets[] = $this->assetUrl('css/fonts.css');
$sheets[] = $this->assetUrl('css/theme.css');
return $sheets;
}
public function headContent(): string
{
$faviconUrl = $this->assetUrl('img/favicon.svg');
return <<<HTML
<link rel="icon" href="{$faviconUrl}" type="image/svg+xml">
<script>
(function() {
var saved = localStorage.getItem('bocken-theme');
if (saved === 'dark' || saved === 'light') {
document.documentElement.setAttribute('data-theme', saved);
}
})();
</script>
HTML;
}
public function bodyContent(): string
{
$logoUrl = $this->assetUrl('img/logo.svg');
return <<<HTML
<script src="https://unpkg.com/lucide@latest"></script>
<script>
(function() {
'use strict';
// --- Theme toggle ---
var THEMES = ['auto', 'light', 'dark'];
var ICONS = {
auto: 'sun-moon',
light: 'sun',
dark: 'moon'
};
function getTheme() {
return localStorage.getItem('bocken-theme') || 'auto';
}
function applyTheme(theme) {
localStorage.setItem('bocken-theme', theme);
if (theme === 'auto') {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', theme);
}
updateToggleButton();
updateLogoFill();
}
function cycleTheme() {
var current = getTheme();
var idx = THEMES.indexOf(current);
var next = THEMES[(idx + 1) % THEMES.length];
applyTheme(next);
}
function updateToggleButton() {
var btn = document.getElementById('bocken-theme-toggle');
if (!btn) return;
var theme = getTheme();
btn.setAttribute('title', 'Theme: ' + theme);
btn.setAttribute('aria-label', 'Toggle theme (' + theme + ')');
btn.innerHTML = '<i data-lucide="' + ICONS[theme] + '"></i>';
if (typeof lucide !== 'undefined') {
lucide.createIcons({ nodes: [btn] });
}
}
// --- Logo injection ---
function updateLogoFill() {
var svg = document.querySelector('.bocken-logo svg');
if (!svg) return;
var theme = getTheme();
var isDark;
if (theme === 'dark') {
isDark = true;
} else if (theme === 'light') {
isDark = false;
} else {
// auto — let the SVG's own prefers-color-scheme handle it
svg.style.removeProperty('--fill');
return;
}
svg.style.setProperty('--fill', isDark ? '#D8DEE9' : '#2E3440');
}
function injectLogo() {
var titleEl = document.querySelector('.wt-site-title');
if (!titleEl) return;
var logoContainer = document.createElement('div');
logoContainer.className = 'bocken-logo-container';
logoContainer.innerHTML = '<a href="https://bocken.org" class="bocken-logo-link" aria-label="Bocken.org"></a>';
// Fetch and inline the SVG so parent CSS variables work
fetch('{$logoUrl}')
.then(function(r) { return r.text(); })
.then(function(svgText) {
var wrapper = document.createElement('div');
wrapper.className = 'bocken-logo';
wrapper.innerHTML = svgText;
logoContainer.querySelector('a').appendChild(wrapper);
updateLogoFill();
});
titleEl.parentNode.insertBefore(logoContainer, titleEl);
}
// --- Theme toggle button injection ---
function injectToggle() {
var nav = document.querySelector('.wt-secondary-navigation .nav');
if (!nav) return;
var li = document.createElement('li');
li.className = 'nav-item bocken-theme-toggle-item';
var btn = document.createElement('button');
btn.id = 'bocken-theme-toggle';
btn.className = 'bocken-theme-btn';
btn.type = 'button';
btn.addEventListener('click', cycleTheme);
li.appendChild(btn);
nav.insertBefore(li, nav.firstChild);
updateToggleButton();
}
// --- Language abbreviation ---
function shortenLanguage() {
var langItem = document.querySelector('.menu-language > .nav-link');
if (!langItem) return;
// Find the active language from dropdown items
var activeLang = document.querySelector('.menu-language .dropdown-item.active');
var code = 'EN';
if (activeLang) {
// Extract from class like "menu-language-de" or "menu-language-en-US"
var cls = Array.from(activeLang.classList).find(function(c) {
return c.startsWith('menu-language-');
});
if (cls) {
code = cls.replace('menu-language-', '').split('-')[0].toUpperCase();
}
}
// Replace link content with just the abbreviation
var caret = langItem.querySelector('.caret');
langItem.textContent = '';
langItem.appendChild(document.createTextNode(code));
if (caret) langItem.appendChild(caret);
}
// --- Search on start page ---
function injectPageSearch() {
// Only on the tree page (start page)
if (!document.body.classList.contains('wt-route-TreePage')) return;
var mainContent = document.querySelector('.wt-main-container .flash-messages');
if (!mainContent) return;
var csrf = document.querySelector('meta[name="csrf"]');
var csrfValue = csrf ? csrf.getAttribute('content') : '';
// Derive the search action URL from the current tree path
var path = window.location.pathname;
var searchAction = path.replace(/\/?$/, '/search-quick');
var searchDiv = document.createElement('div');
searchDiv.className = 'bocken-page-search';
searchDiv.innerHTML =
'<form method="post" action="' + searchAction + '" class="bocken-search-form" role="search">' +
'<input type="search" class="bocken-search-input" name="query" placeholder="Search names, places, sources..." autocomplete="off">' +
'<button type="submit" class="bocken-search-btn" aria-label="Search">' +
'<i data-lucide="search"></i>' +
'</button>' +
'<input type="hidden" name="_csrf" value="' + csrfValue + '">' +
'</form>';
mainContent.parentNode.insertBefore(searchDiv, mainContent.nextSibling);
}
// --- Separator between primary nav and secondary nav ---
function injectSeparator() {
var headerContent = document.querySelector('.wt-header-content');
var secondaryNav = document.querySelector('.wt-secondary-navigation');
if (!headerContent || !secondaryNav) return;
// Walk up to find the direct child of headerContent that contains secondaryNav
var target = secondaryNav;
while (target.parentNode && target.parentNode !== headerContent) {
target = target.parentNode;
}
// If we couldn't walk up to a direct child, bail
if (target.parentNode !== headerContent) return;
var spacer = document.createElement('div');
spacer.className = 'bocken-nav-spacer';
headerContent.insertBefore(spacer, target);
}
// --- Highlight active nav item ---
function highlightActiveNav() {
// Map body route classes to nav menu classes
var routeMap = {
'TreePage': 'menu-tree',
'Chart': 'menu-chart',
'Individual': 'menu-tree',
'Family': 'menu-tree',
'Branches': 'menu-list',
'FamilyList': 'menu-list',
'IndividualList': 'menu-list',
'MediaList': 'menu-list',
'NoteList': 'menu-list',
'RepositoryList': 'menu-list',
'SourceList': 'menu-list',
'PlaceList': 'menu-list',
'Calendar': 'menu-calendar',
'Report': 'menu-report',
'Search': 'menu-search',
'Story': 'menu-story',
'Faq': 'menu-faq',
'Clippings': 'menu-clippings'
};
var bodyClasses = document.body.className;
var activeMenu = null;
// Find matching route
var keys = Object.keys(routeMap);
for (var i = 0; i < keys.length; i++) {
if (bodyClasses.indexOf('wt-route-' + keys[i]) !== -1) {
activeMenu = routeMap[keys[i]];
break;
}
}
if (!activeMenu) return;
var navItem = document.querySelector('.wt-primary-navigation .' + activeMenu);
if (navItem) {
navItem.classList.add('bocken-nav-active');
}
}
// --- Init ---
function init() {
injectLogo();
injectSeparator();
injectToggle();
shortenLanguage();
injectPageSearch();
highlightActiveNav();
// Init all Lucide icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
HTML;
}
public function boot(): void
{
View::registerNamespace($this->name(), $this->resourcesFolder() . 'views/');
// Views inherited from ArgonLight
View::registerCustomView('::modules/block-template', $this->name() . '::modules/block-template');
View::registerCustomView('::modules/recent_changes/changes-list', $this->name() . '::modules/recent_changes/changes-list');
View::registerCustomView('::modules/lightbox/tab', $this->name() . '::modules/lightbox/tab');
View::registerCustomView('::modules/descendancy/sidebar', $this->name() . '::modules/descendancy/sidebar');
View::registerCustomView('::modules/lifespans-chart/chart', $this->name() . '::modules/lifespans-chart/chart');
View::registerCustomView('::modules/faq/show', $this->name() . '::modules/faq/show');
View::registerCustomView('::modules/place-hierarchy/list', $this->name() . '::modules/place-hierarchy/list');
View::registerCustomView('::lists/individuals-table', $this->name() . '::lists/individuals-table');
View::registerCustomView('::lists/families-table', $this->name() . '::lists/families-table');
View::registerCustomView('::individual-page-menu', $this->name() . '::individual-page-menu');
}
}