Files
WebtreesBockenTheme/BockenTheme.php
T
Alexander 02599e5876 Modernize individual page, add dark mode fixes, and restyle gallery
- Redesign individual page header: photo beside name/year/age/username,
  pencil icon edit button, stacked vertical fact cards, iOS toggle,
  underline tabs, and responsive mobile layout
- Add global dark mode fixes for modals, forms, dropdowns (both
  data-theme=dark and prefers-color-scheme auto mode)
- Restyle colorbox gallery: blurred backdrop, centered image, no zoom
  animation, clean controls with drop-shadow
- Override family navigator with thumbnails linking to individual pages
  instead of raw images, matching full diagram gender colors
- Add high-contrast link colors and silhouette placeholder sizing
2026-03-15 15:35:42 +01:00

433 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
$sheets[] = $this->assetUrl('css/dark-fixes.css');
$sheets[] = $this->assetUrl('css/individual.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
{
$logoSvg = file_get_contents(self::MODULE_RESOURCE_PATH . 'img/logo.svg');
// Strip XML declaration, collapse to single line for safe JS string embedding
$logoSvg = preg_replace('/<\?xml[^?]*\?>/', '', $logoSvg);
$logoSvg = preg_replace('/\s+/', ' ', trim($logoSvg));
$logoSvgJs = addcslashes($logoSvg, "'\\");
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"><div class="bocken-logo">{$logoSvgJs}</div></a>';
titleEl.parentNode.insertBefore(logoContainer, titleEl);
updateLogoFill();
}
// --- 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 lang = document.documentElement.getAttribute('lang') || 'en';
var placeholders = {
'de': 'Namen, Orte, Quellen durchsuchen…',
'nl': 'Zoek namen, plaatsen, bronnen…'
};
var placeholder = placeholders[lang] || 'Search names, places, sources…';
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="' + placeholder + '" autocomplete="off">' +
'<span class="bocken-search-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M221.09 64a157.09 157.09 0 10157.09 157.09A157.1 157.1 0 00221.09 64z" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32" d="m338.29 338.29 105.25 105.25"/></svg></span>' +
'<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');
}
}
// --- Reformat individual page header ---
function reformatIndividualHeader() {
if (!document.body.classList.contains('wt-route-IndividualPage')) return;
var h2 = document.querySelector('.wt-page-title');
if (!h2 || h2.dataset.reformatted) return;
h2.dataset.reformatted = '1';
// --- Reformat title text ---
var nameEl = h2.querySelector('.NAME');
if (!nameEl) return;
var name = nameEl.textContent.trim();
var fullText = h2.textContent;
var yearMatch = fullText.match(/,\s*(\d{4})[\-](\d{4})?/);
var yearPart = '';
if (yearMatch) {
yearPart = yearMatch[1] + '' + (yearMatch[2] || '');
}
// Match any parenthesized text containing a number (language-agnostic)
var ageMatch = fullText.match(/\([^)]*\d+[^)]*\)/);
var agePart = ageMatch ? ageMatch[0] : '';
var userLink = h2.querySelector('a');
var userHtml = '';
if (userLink) {
userHtml = userLink.outerHTML;
}
h2.innerHTML =
'<span class="bocken-title-name">' + name + '</span>' +
(yearPart || agePart ? '<span class="bocken-title-meta">' + yearPart + (agePart ? ' ' + agePart : '') + '</span>' : '') +
(userHtml ? '<span class="bocken-title-user">' + userHtml + '</span>' : '');
// --- Move photo into the title bar ---
var titleBar = h2.closest('.d-flex.mb-4');
var photoCol = document.querySelector('.wt-route-IndividualPage .row.mb-4 > .col-sm-3');
if (titleBar && photoCol) {
titleBar.classList.add('bocken-individual-header');
titleBar.insertBefore(photoCol, titleBar.firstChild);
}
// --- Replace edit button text with pencil icon ---
var editBtn = document.querySelector('.wt-page-menu-button');
if (editBtn) {
var iconSpan = editBtn.querySelector('.wt-icon-menu');
if (iconSpan) {
editBtn.innerHTML = '';
iconSpan.innerHTML = '<i data-lucide="pencil"></i>';
editBtn.appendChild(iconSpan);
if (typeof lucide !== 'undefined') {
lucide.createIcons({ nodes: [editBtn] });
}
}
}
}
// --- Fix colorbox: disable zoom animation ---
function fixColorbox() {
if (typeof jQuery !== 'undefined') {
var waitForColorbox = setInterval(function() {
if (jQuery.fn.colorbox) {
var origColorbox = jQuery.fn.colorbox;
jQuery.fn.colorbox = function(opts) {
opts = jQuery.extend({}, opts, { transition: 'none', speed: 0 });
return origColorbox.call(this, opts);
};
jQuery.fn.colorbox.settings = origColorbox.settings;
jQuery.colorbox = origColorbox;
clearInterval(waitForColorbox);
}
}, 50);
}
}
// --- Init ---
function init() {
injectLogo();
injectSeparator();
injectToggle();
shortenLanguage();
injectPageSearch();
highlightActiveNav();
reformatIndividualHeader();
fixColorbox();
// 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');
View::registerCustomView('::modules/family_nav/sidebar-family', $this->name() . '::modules/family_nav/sidebar-family');
}
}