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>
441 lines
19 KiB
PHP
441 lines
19 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');
|
||
$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');
|
||
}
|
||
}
|