Files
WebtreesBockenTheme/BockenTheme.php
Alexander Bocken 77f067f8bc Hide family link facts (FAMC/FAMS/HUSB/WIFE/CHIL) from Facts & Events tab
Override fact.phtml to add bocken-family-fact class on family link rows,
then hide via CSS. Language-agnostic since it matches GEDCOM tags, not
translated labels. Info remains available in Relatives tab and sidebar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:39:45 +01:00

441 lines
19 KiB
PHP
Raw Permalink 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">
<style>
.bocken-cloak .wt-main-wrapper { opacity: 0; }
.bocken-ready .wt-main-wrapper { transition: opacity 0.05s ease; opacity: 1; }
</style>
<script>
(function() {
var saved = localStorage.getItem('bocken-theme');
if (saved === 'dark' || saved === 'light') {
document.documentElement.setAttribute('data-theme', saved);
}
document.documentElement.classList.add('bocken-cloak');
})();
</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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1v32c0 8.8 7.2 16 16 16h32zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"/></svg>';
editBtn.appendChild(iconSpan);
}
}
}
// --- 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();
}
// Reveal page after DOM manipulations are done
document.documentElement.classList.remove('bocken-cloak');
document.documentElement.classList.add('bocken-ready');
}
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('::fact', $this->name() . '::fact');
View::registerCustomView('::individual-page-images', $this->name() . '::individual-page-images');
View::registerCustomView('::individual-page-menu', $this->name() . '::individual-page-menu');
View::registerCustomView('::modules/family_nav/sidebar-family', $this->name() . '::modules/family_nav/sidebar-family');
}
}