diff options
31 files changed, 814 insertions, 293 deletions
diff --git a/app/Http/Controllers/Admin/ModuleController.php b/app/Http/Controllers/Admin/ModuleController.php index 3d34df35ca..2fbcfeb1c3 100644 --- a/app/Http/Controllers/Admin/ModuleController.php +++ b/app/Http/Controllers/Admin/ModuleController.php @@ -22,6 +22,7 @@ use Fisharebest\Webtrees\I18N; use Fisharebest\Webtrees\Module; use Fisharebest\Webtrees\Module\ModuleBlockInterface; use Fisharebest\Webtrees\Module\ModuleChartInterface; +use Fisharebest\Webtrees\Module\ModuleFooterInterface; use Fisharebest\Webtrees\Module\ModuleInterface; use Fisharebest\Webtrees\Module\ModuleMenuInterface; use Fisharebest\Webtrees\Module\ModuleReportInterface; @@ -39,6 +40,22 @@ use Symfony\Component\HttpFoundation\Response; */ class ModuleController extends AbstractAdminController { + private const COMPONENTS_WITH_ACCESS = [ + 'block', + 'chart', + 'menu', + 'report', + 'sidebar', + 'tab', + ]; + + private const COMPONENTS_WITH_SORT = [ + 'footer', + 'menu', + 'sidebar', + 'tab', + ]; + /** * Show the administrator a list of modules. * @@ -89,6 +106,14 @@ class ModuleController extends AbstractAdminController /** * @return Response */ + public function listFooters(): Response + { + return $this->listComponents(ModuleFooterInterface::class, 'footer', I18N::translate('Footers')); + } + + /** + * @return Response + */ public function listMenus(): Response { return $this->listComponents(ModuleMenuInterface::class, 'menu', I18N::translate('Menus')); @@ -127,11 +152,16 @@ class ModuleController extends AbstractAdminController */ private function listComponents(string $interface, string $component, string $title): Response { + $uses_access = in_array($component, self::COMPONENTS_WITH_ACCESS); + $uses_sorting = in_array($component, self::COMPONENTS_WITH_SORT); + return $this->viewResponse('admin/components', [ - 'component' => $component, - 'modules' => Module::findByInterface($interface, true), - 'title' => $title, - 'trees' => Tree::all(), + 'component' => $component, + 'modules' => Module::findByInterface($interface, true), + 'title' => $title, + 'trees' => Tree::all(), + 'uses_access' => $uses_access, + 'uses_sorting' => $uses_sorting, ]); } @@ -201,6 +231,21 @@ class ModuleController extends AbstractAdminController * * @return RedirectResponse */ + public function updateFooters(Request $request): RedirectResponse + { + $modules = Module::findByInterface(ModuleFooterInterface::class, true); + + $this->updateStatus($modules, $request); + $this->updateOrder($modules, 'footer_order', $request); + + return new RedirectResponse(route('footers')); + } + + /** + * @param Request $request + * + * @return RedirectResponse + */ public function updateMenus(Request $request): RedirectResponse { $modules = Module::findByInterface(ModuleMenuInterface::class, true); diff --git a/app/Module.php b/app/Module.php index 9cf7d0c327..8e78f2903b 100644 --- a/app/Module.php +++ b/app/Module.php @@ -34,6 +34,8 @@ use Fisharebest\Webtrees\Module\ChartsMenuModule; use Fisharebest\Webtrees\Module\CkeditorModule; use Fisharebest\Webtrees\Module\ClippingsCartModule; use Fisharebest\Webtrees\Module\CompactTreeChartModule; +use Fisharebest\Webtrees\Module\ContactsFooterModule; +use Fisharebest\Webtrees\Module\CookieWarningModule; use Fisharebest\Webtrees\Module\DeathReportModule; use Fisharebest\Webtrees\Module\DescendancyChartModule; use Fisharebest\Webtrees\Module\DescendancyModule; @@ -50,6 +52,7 @@ use Fisharebest\Webtrees\Module\FanChartModule; use Fisharebest\Webtrees\Module\FrequentlyAskedQuestionsModule; use Fisharebest\Webtrees\Module\GoogleAnalyticsModule; use Fisharebest\Webtrees\Module\GoogleWebmasterToolsModule; +use Fisharebest\Webtrees\Module\HitCountFooterModule; use Fisharebest\Webtrees\Module\HourglassChartModule; use Fisharebest\Webtrees\Module\HtmlBlockModule; use Fisharebest\Webtrees\Module\IndividualFactsTabModule; @@ -67,6 +70,7 @@ use Fisharebest\Webtrees\Module\MissingFactsReportModule; use Fisharebest\Webtrees\Module\ModuleBlockInterface; use Fisharebest\Webtrees\Module\ModuleChartInterface; use Fisharebest\Webtrees\Module\ModuleCustomInterface; +use Fisharebest\Webtrees\Module\ModuleFooterInterface; use Fisharebest\Webtrees\Module\ModuleInterface; use Fisharebest\Webtrees\Module\ModuleMenuInterface; use Fisharebest\Webtrees\Module\ModuleReportInterface; @@ -104,6 +108,7 @@ use Fisharebest\Webtrees\Module\UserFavoritesModule; use Fisharebest\Webtrees\Module\UserJournalModule; use Fisharebest\Webtrees\Module\UserMessagesModule; use Fisharebest\Webtrees\Module\UserWelcomeModule; +use Fisharebest\Webtrees\Module\PoweredByWebtreesModule; use Fisharebest\Webtrees\Module\WelcomeBlockModule; use Fisharebest\Webtrees\Module\YahrzeitModule; use Illuminate\Database\Capsule\Manager as DB; @@ -144,6 +149,8 @@ class Module 'ckeditor' => CkeditorModule::class, 'clippings' => ClippingsCartModule::class, 'compact-chart' => CompactTreeChartModule::class, + 'contact-links' => ContactsFooterModule::class, + 'cookie-warning' => CookieWarningModule::class, 'death_report' => DeathReportModule::class, 'descendancy' => DescendancyModule::class, 'descendancy_chart' => DescendancyChartModule::class, @@ -161,6 +168,7 @@ class Module 'gedcom_stats' => FamilyTreeStatisticsModule::class, 'google-analytics' => GoogleAnalyticsModule::class, 'google-webmaster-tools' => GoogleWebmasterToolsModule::class, + 'hit-counter' => HitCountFooterModule::class, 'hourglass_chart' => HourglassChartModule::class, 'html' => HtmlBlockModule::class, 'individual_ext_report' => IndividualFamiliesReportModule::class, @@ -181,6 +189,7 @@ class Module 'pedigree_report' => PedigreeReportModule::class, 'personal_facts' => IndividualFactsTabModule::class, 'places' => PlacesModule::class, + 'powered-by-webtrees' => PoweredByWebtreesModule::class, 'random_media' => SlideShowModule::class, 'recent_changes' => RecentChangesModule::class, 'relationships_chart' => RelationshipsChartModule::class, @@ -294,6 +303,10 @@ class Module if ($info instanceof stdClass) { $module->setEnabled($info->status === 'enabled'); + if ($module instanceof ModuleFooterInterface && $info->footer_order !== null) { + $module->setFooterOrder((int) $info->footer_order); + } + if ($module instanceof ModuleMenuInterface && $info->menu_order !== null) { $module->setMenuOrder((int) $info->menu_order); } @@ -316,6 +329,23 @@ class Module } /** + * Boot all the modules. + */ + public static function boot(): void + { + foreach (self::all() as $module) { + if (method_exists($module, 'boot')) { + try { + app()->dispatch($module, 'boot'); + } catch (Throwable $ex) { + $message = '<pre>' . e($ex->getMessage()) . "\n" . e($ex->getTraceAsString()) . '</pre>'; + FlashMessages::addMessage($message, 'danger'); + } + } + } + } + + /** * Load a module in a separate scope, to prevent it from modifying local variables. * * @param string $filename @@ -340,6 +370,18 @@ class Module } /** + * A function to sort footers + * + * @return Closure + */ + private static function footerSorter(): Closure + { + return function (ModuleFooterInterface $x, ModuleFooterInterface $y): int { + return $x->getFooterOrder() <=> $y->getFooterOrder(); + }; + } + + /** * A function to sort menus * * @return Closure @@ -413,6 +455,9 @@ class Module }); switch ($interface) { + case ModuleFooterInterface::class: + return $modules->sort(self::footerSorter()); + case ModuleMenuInterface::class: return $modules->sort(self::menuSorter()); diff --git a/app/Module/ContactsFooterModule.php b/app/Module/ContactsFooterModule.php new file mode 100644 index 0000000000..5f955a088f --- /dev/null +++ b/app/Module/ContactsFooterModule.php @@ -0,0 +1,193 @@ +<?php +/** + * webtrees: online genealogy + * Copyright (C) 2019 webtrees development team + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Module; + +use Fisharebest\Webtrees\Auth; +use Fisharebest\Webtrees\I18N; +use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\User; +use Symfony\Component\HttpFoundation\Request; + +/** + * Class ContactsFooterModule - provide a link to the site owner. + */ +class ContactsFooterModule extends AbstractModule implements ModuleFooterInterface +{ + use ModuleFooterTrait; + + /** @var Request */ + protected $request; + + /** @var Tree|null */ + protected $tree; + + /** @var User */ + protected $user; + + /** + * How should this module be labelled on tabs, footers, etc.? + * + * @return string + */ + public function title(): string + { + /* I18N: Name of a module */ + return I18N::translate('Contact information'); + } + + /** + * A sentence describing what this module does. + * + * @return string + */ + public function description(): string + { + /* I18N: Description of the “Hit counters” module */ + return I18N::translate('A link to the site contacts.'); + } + + /** + * Dependency injection. + * + * @param Tree|null $tree + * @param User $user + * @param Request $request + */ + public function boot(?Tree $tree, User $user, Request $request): void + { + $this->tree = $tree; + $this->user = $user; + $this->request = $request; + } + + /** + * The default position for this footer. It can be changed in the control panel. + * + * @return int + */ + public function defaultFooterOrder(): int + { + return 2; + } + + /** + * A footer, to be added at the bottom of every page. + * + * @return string + */ + public function getFooter(): string + { + if ($this->tree === null) { + return ''; + } + + $contact_user = User::find((int) $this->tree->getPreference('CONTACT_USER_ID')); + $webmaster_user = User::find((int) $this->tree->getPreference('WEBMASTER_USER_ID')); + + if ($contact_user instanceof User && $contact_user === $webmaster_user) { + return view('modules/contact-links/footer', [ + 'contact_links' => $this->contactLinkEverything($contact_user), + ]); + } + + if ($contact_user instanceof User && $webmaster_user instanceof User) { + return view('modules/contact-links/footer', [ + 'contact_links' => $this->contactLinkGenealogy($contact_user) . '<br>' . $this->contactLinkTechnical($webmaster_user), + ]); + } + + if ($contact_user instanceof User) { + return view('modules/contact-links/footer', [ + 'contact_links' => $this->contactLinkGenealogy($contact_user), + ]); + } + + if ($webmaster_user instanceof User) { + return view('modules/contact-links/footer', [ + 'contact_links' => $this->contactLinkTechnical($contact_user), + ]); + } + + return ''; + } + + /** + * Create contact link for both technical and genealogy support. + * + * @param User $user + * + * @return string + */ + public function contactLinkEverything(User $user): string + { + return I18N::translate('For technical support or genealogy questions contact %s.', $this->contactLink($user)); + } + + /** + * Create contact link for genealogy support. + * + * @param User $user + * + * @return string + */ + public function contactLinkGenealogy(User $user): string + { + return I18N::translate('For help with genealogy questions contact %s.', $this->contactLink($user)); + } + + /** + * Create contact link for technical support. + * + * @param User $user + * + * @return string + */ + public function contactLinkTechnical(User $user): string + { + return I18N::translate('For technical support and information contact %s.', $this->contactLink($user)); + } + + /** + * Create a contact link for a user. + * + * @param User $user + * + * @return string + */ + private function contactLink(User $user): string + { + $method = $user->getPreference('contactmethod'); + + switch ($method) { + case 'none': + return ''; + + case 'mailto': + return '<a href="mailto:' . e($user->getEmail()) . '">' . e($user->getRealName()) . '</a>'; + + default: + $url = route(Auth::check() ? 'message' : 'contact', [ + 'ged' => $this->tree->name(), + 'to' => $user->getUserName(), + 'url' => $this->request->getRequestUri(), + ]); + + return '<a href="' . e($url) . '">' . e($user->getRealName()) . '</a>'; + } + } +} diff --git a/app/Module/CookieWarningModule.php b/app/Module/CookieWarningModule.php new file mode 100644 index 0000000000..a425051d1c --- /dev/null +++ b/app/Module/CookieWarningModule.php @@ -0,0 +1,131 @@ +<?php +/** + * webtrees: online genealogy + * Copyright (C) 2019 webtrees development team + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Module; + +use Fisharebest\Webtrees\I18N; +use Fisharebest\Webtrees\Module; +use Fisharebest\Webtrees\Site; +use Symfony\Component\HttpFoundation\Request; + +/** + * Class PoweredByWebtreesModule - show a cookie warning, to comply with the GDPR. + */ +class CookieWarningModule extends AbstractModule implements ModuleFooterInterface +{ + use ModuleFooterTrait; + + /** @var Request */ + protected $request; + + // We only need to show a warning if we are using tracking + protected const TRACKING_MODULES = [ + GoogleAnalyticsModule::class, + MatomoAnalyticsModule::class, + StatcounterModule::class, + ]; + + /** + * How should this module be labelled on tabs, footers, etc.? + * + * @return string + */ + public function title(): string + { + /* I18N: Name of a module */ + return I18N::translate('Cookie warning'); + } + + /** + * A sentence describing what this module does. + * + * @return string + */ + public function description(): string + { + /* I18N: Description of the “Cookie warning” module */ + return I18N::translate('Tell visitors why this site uses cookies.'); + } + + /** + * Dependency injection. + * + * @param Request $request + */ + public function boot(Request $request): void + { + $this->request = $request; + } + + /** + * The default position for this footer. It can be changed in the control panel. + * + * @return int + */ + public function defaultFooterOrder(): int + { + return 4; + } + + /** + * A footer, to be added at the bottom of every page. + * + * @return string + */ + public function getFooter(): string + { + if ($this->isCookieWarningAcknowledged()) { + return ''; + } + + if ($this->siteUsesAnalyticss()) { + return view('modules/cookie-warning/footer'); + } + return ''; + } + + /** + * @return bool + */ + protected function isCookieWarningAcknowledged(): bool + { + return $this->request->cookies->get('cookie', '') !== ''; + } + + /** + * @return bool + */ + protected function siteUsesAnalyticss(): bool + { + // If the browser sets the DNT header, then we won't use analytics. + if ($this->request->server->get('HTTP_DNT') === '1') { + return false; + } + + foreach (self::TRACKING_MODULES as $class) { + $module = Module::findByClass($class); + + if ($module instanceof ModuleAnalyticsInterface) { + if ($module->analyticsCanShow()) { + return true; + } + } + } + + return false; + } +} diff --git a/app/Http/Middleware/PageHitCounter.php b/app/Module/HitCountFooterModule.php index b0b3f2cdc5..23e1d83700 100644 --- a/app/Http/Middleware/PageHitCounter.php +++ b/app/Module/HitCountFooterModule.php @@ -15,26 +15,25 @@ */ declare(strict_types=1); -namespace Fisharebest\Webtrees\Http\Middleware; +namespace Fisharebest\Webtrees\Module; -use Closure; +use Fisharebest\Webtrees\I18N; use Fisharebest\Webtrees\Session; use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\User; -use Fisharebest\Webtrees\View; use Illuminate\Database\Capsule\Manager as DB; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Throwable; /** - * Middleware to count requests for particular pages. - * For historical reasons, we record the names of the original webtrees script and parameter. + * Class HitCountFooterModule - show the popular sites in the footer. */ -class PageHitCounter implements MiddlewareInterface +class HitCountFooterModule extends AbstractModule implements ModuleFooterInterface { + use ModuleFooterTrait; + // Which pages/routes do we count? - private const PAGE_NAMES = [ + // For historical reasons, we record the names of the original webtrees script and parameter. + protected const PAGE_NAMES = [ 'family' => 'family.php', 'individual' => 'individual.php', 'media' => 'mediaviewer.php', @@ -45,34 +44,52 @@ class PageHitCounter implements MiddlewareInterface 'user-page' => 'index.php', ]; + /** @var Request */ + protected $request; + /** @var Tree|null */ - private $tree; + protected $tree; /** @var User */ - private $user; + protected $user; + + /** @var int */ + protected $page_hits = 0; /** - * PageHitCounter constructor. + * How should this module be labelled on tabs, footers, etc.? * - * @param User $user - * @param Tree|null $tree + * @return string */ - public function __construct(User $user, ?Tree $tree) + public function title(): string { - $this->tree = $tree; - $this->user = $user; + /* I18N: Name of a module */ + return I18N::translate('Hit counters'); } /** - * @param Request $request - * @param Closure $next + * A sentence describing what this module does. * - * @return Response - * @throws Throwable + * @return string */ - public function handle(Request $request, Closure $next): Response + public function description(): string { - $page_hits = 0; + /* I18N: Description of the “Hit counters” module */ + return I18N::translate('Count the visits to each page'); + } + + /** + * Dependency injection. + * + * @param Tree|null $tree + * @param User $user + * @param Request $request + */ + public function boot(?Tree $tree, User $user, Request $request): void + { + $this->tree = $tree; + $this->user = $user; + $this->request = $request; if ($this->tree !== null && $this->tree->getPreference('SHOW_COUNTER')) { $route = $request->get('route'); @@ -86,37 +103,59 @@ class PageHitCounter implements MiddlewareInterface case 'note': case 'repository': case 'source': - $page_hits = $this->countHit($this->tree, $page_name, $request->get('xref', '')); + $this->page_hits = $this->countHit($page_name, $request->get('xref', '')); break; case 'tree-page': - $page_hits = $this->countHit($this->tree, $page_name, 'gedcom:' . $this->tree->id()); + $this->page_hits = $this->countHit($page_name, 'gedcom:' . $this->tree->id()); break; case 'user-page': - $page_hits = $this->countHit($this->tree, $page_name, 'user:' . $this->user->id()); + $this->page_hits = $this->countHit($page_name, 'user:' . $this->user->id()); break; } } + } + + /** + * The default position for this footer. It can be changed in the control panel. + * + * @return int + */ + public function defaultFooterOrder(): int + { + return 3; + } + + /** + * A footer, to be added at the bottom of every page. + * + * @return string + */ + public function getFooter(): string + { + if ($this->tree === null || $this->page_hits === 0) { + return ''; + } - // Make the count available to the layout. - View::share('page_hits', $page_hits); + $digits = '<span class="odometer">' . I18N::digits($this->page_hits) . '</span>'; - return $next($request); + return view('modules/hit-counter/footer', [ + 'hit_counter' => I18N::plural('This page has been viewed %s time.', 'This page has been viewed %s times.', $this->page_hits, $digits), + ]); } /** * Increment the page count. * - * @param Tree $tree * @param string $page * @param string $parameter * * @return int */ - private function countHit(Tree $tree, $page, $parameter): int + protected function countHit($page, $parameter): int { - $gedcom_id = $tree->id(); + $gedcom_id = $this->tree->id(); // Don't increment the counter while we stay on the same page. if ( @@ -127,12 +166,16 @@ class PageHitCounter implements MiddlewareInterface return Session::get('last_count'); } - $count = $this->getCount($tree, $page, $parameter); + $count = (int) DB::table('hit_counter') + ->where('gedcom_id', '=', $this->tree->id()) + ->where('page_name', '=', $page) + ->where('page_parameter', '=', $parameter) + ->value('page_count');; $count++; DB::table('hit_counter')->updateOrInsert([ - 'gedcom_id' => $tree->id(), + 'gedcom_id' => $this->tree->id(), 'page_name' => $page, 'page_parameter' => $parameter, ], [ @@ -146,22 +189,4 @@ class PageHitCounter implements MiddlewareInterface return $count; } - - /** - * How many times has a page been viewed - * - * @param Tree $tree - * @param string $page - * @param string $parameter - * - * @return int - */ - public function getCount(Tree $tree, $page, $parameter): int - { - return (int) DB::table('hit_counter') - ->where('gedcom_id', '=', $tree->id()) - ->where('page_name', '=', $page) - ->where('page_parameter', '=', $parameter) - ->value('page_count'); - } } diff --git a/app/Module/ModuleFooterInterface.php b/app/Module/ModuleFooterInterface.php new file mode 100644 index 0000000000..fc3c398611 --- /dev/null +++ b/app/Module/ModuleFooterInterface.php @@ -0,0 +1,54 @@ +<?php +/** + * webtrees: online genealogy + * Copyright (C) 2019 webtrees development team + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Module; + +/** + * Interface ModuleFooterInterface - Add content to the bottom of every page. + */ +interface ModuleFooterInterface extends ModuleInterface +{ + /** + * Users change change the order of footers using the control panel. + * + * @param int $footer_order + * + * @return void + */ + public function setFooterOrder(int $footer_order): void; + + /** + * Users change change the order of footers using the control panel. + * + * @return int + */ + public function getFooterOrder(): int; + + /** + * The default position for this footer. It can be changed in the control panel. + * + * @return int + */ + public function defaultFooterOrder(): int; + + /** + * A footer, to be added at the bottom of every page. + * + * @return string + */ + public function getFooter(): string; +} diff --git a/app/Module/ModuleFooterTrait.php b/app/Module/ModuleFooterTrait.php new file mode 100644 index 0000000000..8889655744 --- /dev/null +++ b/app/Module/ModuleFooterTrait.php @@ -0,0 +1,69 @@ +<?php +/** + * webtrees: online genealogy + * Copyright (C) 2019 webtrees development team + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Module; + +/** + * Trait ModuleFooterTrait - default implementation of ModuleFooterInterface + */ +trait ModuleFooterTrait +{ + /** @var int The default position for this footer. It can be changed in the control panel. */ + protected $footer_order = 0; + + /** + * Users change change the order of footers using the control panel. + * + * @param int $footer_order + * + * @return void + */ + public function setFooterOrder(int $footer_order): void + { + $this->footer_order = $footer_order; + } + + /** + * Users change change the order of footers using the control panel. + * + * @return int + */ + public function getFooterOrder(): int + { + return $this->footer_order ?? $this->defaultFooterOrder(); + } + + /** + * The default position for this footer. + * + * @return int + */ + public function defaultFooterOrder(): int + { + return 9999; + } + + /** + * A footer, to be added at the bottom of every page. + * + * @return string + */ + public function getFooter(): string + { + return ''; + } +} diff --git a/app/Module/ModuleMenuTrait.php b/app/Module/ModuleMenuTrait.php index 9b7d57a80f..0f8f81e716 100644 --- a/app/Module/ModuleMenuTrait.php +++ b/app/Module/ModuleMenuTrait.php @@ -17,6 +17,9 @@ declare(strict_types=1); namespace Fisharebest\Webtrees\Module; +use Fisharebest\Webtrees\Menu; +use Fisharebest\Webtrees\Tree; + /** * Trait ModuleMenuTrait - default implementation of ModuleMenuInterface */ @@ -56,4 +59,16 @@ trait ModuleMenuTrait { return 9999; } + + /** + * A menu, to be added to the main application menu. + * + * @param Tree $tree + * + * @return Menu|null + */ + public function getMenu(Tree $tree): ?Menu + { + return null; + } } diff --git a/app/Module/PoweredByWebtreesModule.php b/app/Module/PoweredByWebtreesModule.php new file mode 100644 index 0000000000..c219ffbada --- /dev/null +++ b/app/Module/PoweredByWebtreesModule.php @@ -0,0 +1,78 @@ +<?php +/** + * webtrees: online genealogy + * Copyright (C) 2019 webtrees development team + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Module; + +use Fisharebest\Webtrees\I18N; +use Fisharebest\Webtrees\Footer; +use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\User; +use Fisharebest\Webtrees\Webtrees; +use Symfony\Component\HttpFoundation\Request; + +/** + * Class PoweredByWebtreesModule - provide a link to the project home page. + */ +class PoweredByWebtreesModule extends AbstractModule implements ModuleFooterInterface +{ + use ModuleFooterTrait; + + /** + * How should this module be labelled on tabs, footers, etc.? + * + * @return string + */ + public function title(): string + { + /* I18N: Name of a module */ + return I18N::translate('Powered by webtrees™'); + } + + /** + * A sentence describing what this module does. + * + * @return string + */ + public function description(): string + { + /* I18N: Description of the “webtrees” module */ + return I18N::translate('A link to the webtrees home page.'); + } + + /** + * The default position for this footer. It can be changed in the control panel. + * + * @return int + */ + public function defaultFooterOrder(): int + { + return 1; + } + + /** + * A footer, to be added at the bottom of every page. + * + * @return string + */ + public function getFooter(): string + { + return view('modules/powered-by-webtrees/footer', [ + 'name' => Webtrees::NAME, + 'url' => Webtrees::URL, + ]); + } +} diff --git a/app/Schema/Migration41.php b/app/Schema/Migration41.php new file mode 100644 index 0000000000..a7a5145d53 --- /dev/null +++ b/app/Schema/Migration41.php @@ -0,0 +1,39 @@ +<?php +/** + * webtrees: online genealogy + * Copyright (C) 2019 webtrees development team + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Schema; + +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Schema\Blueprint; + +/** + * Upgrade the database schema from version 41 to version 42. + */ +class Migration41 implements MigrationInterface +{ + /** + * Upgrade to to the next version + * + * @return void + */ + public function upgrade(): void + { + DB::schema()->table('module', function (Blueprint $table): void { + $table->integer('footer_order')->nullable()->after('sidebar_order'); + }); + } +} diff --git a/app/Theme/AbstractTheme.php b/app/Theme/AbstractTheme.php index de3f0c2d0d..aea3c29d3b 100644 --- a/app/Theme/AbstractTheme.php +++ b/app/Theme/AbstractTheme.php @@ -101,138 +101,6 @@ abstract class AbstractTheme } /** - * Create a contact link for a user. - * - * @param User $user - * - * @return string - */ - public function contactLink(User $user): string - { - $method = $user->getPreference('contactmethod'); - - switch ($method) { - case 'none': - return ''; - case 'mailto': - return '<a href="mailto:' . e($user->getEmail()) . '">' . e($user->getRealName()) . '</a>'; - default: - $url = route(Auth::check() ? 'message' : 'contact', [ - 'ged' => $this->tree->name(), - 'to' => $user->getUserName(), - 'url' => $this->request->getRequestUri(), - ]); - - return '<a href="' . e($url) . '">' . e($user->getRealName()) . '</a>'; - } - } - - /** - * Create contact link for both technical and genealogy support. - * - * @param User $user - * - * @return string - */ - public function contactLinkEverything(User $user): string - { - return I18N::translate('For technical support or genealogy questions contact %s.', $this->contactLink($user)); - } - - /** - * Create contact link for genealogy support. - * - * @param User $user - * - * @return string - */ - public function contactLinkGenealogy(User $user): string - { - return I18N::translate('For help with genealogy questions contact %s.', $this->contactLink($user)); - } - - /** - * Create contact link for technical support. - * - * @param User $user - * - * @return string - */ - public function contactLinkTechnical(User $user): string - { - return I18N::translate('For technical support and information contact %s.', $this->contactLink($user)); - } - - /** - * Create contact links for the page footer. - * - * @return string - */ - public function contactLinks() - { - $contact_user = User::find((int) $this->tree->getPreference('CONTACT_USER_ID')); - $webmaster_user = User::find((int) $this->tree->getPreference('WEBMASTER_USER_ID')); - - if ($contact_user instanceof User && $contact_user === $webmaster_user) { - return $this->contactLinkEverything($contact_user); - } - - if ($contact_user instanceof User && $webmaster_user instanceof User) { - return $this->contactLinkGenealogy($contact_user) . '<br>' . $this->contactLinkTechnical($webmaster_user); - } - - if ($contact_user instanceof User) { - return $this->contactLinkGenealogy($contact_user); - } - - if ($webmaster_user instanceof User) { - return $this->contactLinkTechnical($webmaster_user); - } - - return ''; - } - - /** - * Create a cookie warning. - * - * @return string - */ - public function cookieWarning() - { - // Do not track? - if ($this->request->server->get('HTTP_DNT', '')) { - return ''; - } - - // Cookies accepted? - if ($this->request->cookies->get('cookie', '')) { - return ''; - } - - // Not using trackers or analytics? - if (Site::getPreference('GOOGLE_ANALYTICS_ID') !== '1' && Site::getPreference('PIWIK_SITE_ID') !== '1' && Site::getPreference('STATCOUNTER_PROJECT_ID') !== '1') { - return ''; - } - - return - '<div class="wt-cookie-warning">' . - I18N::translate('Cookies') . ' - ' . - I18N::translate('This website uses cookies to learn about visitor behaviour.') . ' ' . - '<button onclick="document.cookie=\'cookie=1\'; this.parentNode.classList.add(\'hidden\');">' . I18N::translate('continue') . '</button>' . - '</div>'; - } - - /** - * Create the <DOCTYPE> tag. - * - * @return string - */ - public function doctype(): string - { - return '<!DOCTYPE html>'; - } - - /** * Add markup to a flash message. * * @param stdClass $message @@ -268,20 +136,6 @@ abstract class AbstractTheme } /** - * Add markup to the contact links. - * - * @return string - */ - public function formatContactLinks() - { - if ($this->tree) { - return '<div class="wt-contact-links">' . $this->contactLinks() . '</div>'; - } - - return ''; - } - - /** * Create a pending changes link for the page footer. * * @return string @@ -661,16 +515,6 @@ abstract class AbstractTheme } /** - * A small "powered by webtrees" logo for the footer. - * - * @return string - */ - public function logoPoweredBy(): string - { - return '<a href="' . e(Webtrees::URL) . '" class="wt-powered-by-webtrees" dir="ltr">' . e(Webtrees::NAME) . '</a>'; - } - - /** * Generate a menu item to change the blocks on the current (index.php) page. * * @return Menu|null diff --git a/app/Theme/ThemeInterface.php b/app/Theme/ThemeInterface.php index fd6d1469d0..014bc0a8ce 100644 --- a/app/Theme/ThemeInterface.php +++ b/app/Theme/ThemeInterface.php @@ -30,29 +30,6 @@ use Symfony\Component\HttpFoundation\Request; interface ThemeInterface { /** - * Create a contact link for a user. - * - * @param User $user - * - * @return string - */ - public function contactLink(User $user): string; - - /** - * Create a cookie warning. - * - * @return string - */ - public function cookieWarning(); - - /** - * Add markup to the contact links. - * - * @return string - */ - public function formatContactLinks(); - - /** * Display an icon for this fact. * * @param Fact $fact @@ -123,13 +100,6 @@ interface ThemeInterface public function individualBoxMenu(Individual $individual): array; /** - * A small "powered by webtrees" logo for the footer. - * - * @return string - */ - public function logoPoweredBy(): string; - - /** * Themes menu. * * @return Menu|null diff --git a/app/Webtrees.php b/app/Webtrees.php index 41569c4e62..62889f883e 100644 --- a/app/Webtrees.php +++ b/app/Webtrees.php @@ -40,7 +40,7 @@ class Webtrees public const NAME = 'webtrees'; // Required version of database tables/columns/indexes/etc. - public const SCHEMA_VERSION = 41; + public const SCHEMA_VERSION = 42; // e.g. "dev", "alpha", "beta.3", etc. public const STABILITY = 'alpha.5'; @@ -26,9 +26,9 @@ use Fisharebest\Webtrees\Http\Middleware\CheckCsrf; use Fisharebest\Webtrees\Http\Middleware\CheckForMaintenanceMode; use Fisharebest\Webtrees\Http\Middleware\DebugBarData; use Fisharebest\Webtrees\Http\Middleware\Housekeeping; -use Fisharebest\Webtrees\Http\Middleware\PageHitCounter; use Fisharebest\Webtrees\Http\Middleware\UseTransaction; use Fisharebest\Webtrees\I18N; +use Fisharebest\Webtrees\Module; use Fisharebest\Webtrees\Services\TimeoutService; use Fisharebest\Webtrees\Session; use Fisharebest\Webtrees\Site; @@ -267,7 +267,6 @@ try { } if ($request->getMethod() === Request::METHOD_GET) { - $middleware_stack[] = PageHitCounter::class; $middleware_stack[] = Housekeeping::class; } @@ -276,6 +275,9 @@ try { $middleware_stack[] = CheckCsrf::class; } + // Boot the modules. + Module::boot(); + // Apply the middleware using the "onion" pattern. $pipeline = array_reduce($middleware_stack, function (Closure $next, string $middleware): Closure { // Create a closure to apply the middleware. diff --git a/resources/views/admin/components.phtml b/resources/views/admin/components.phtml index 6aeb20f4fd..b90650c0d5 100644 --- a/resources/views/admin/components.phtml +++ b/resources/views/admin/components.phtml @@ -15,8 +15,10 @@ use Fisharebest\Webtrees\View; ?> <tr> <th><?= I18N::translate('Menu') ?></th> <th class="text-center"><?= I18N::translate('Enabled') ?></th> - <th class="text-center"><?= I18N::translate('Access level') ?></th> - <?php if ($component === 'menu' || $component === 'sidebar' || $component === 'tab') : ?> + <?php if ($uses_access) : ?> + <th class="text-center"><?= I18N::translate('Access level') ?></th> + <?php endif ?> + <?php if ($uses_sorting) : ?> <th class="text-center"><?= I18N::translate('Move up') ?></th> <th class="text-center"><?= I18N::translate('Move down') ?></th> <?php endif ?> @@ -28,7 +30,9 @@ use Fisharebest\Webtrees\View; ?> <tr> <th scope="col"> <input type="hidden" name="order[]" value="<?= e($module->name()) ?>"?> - <?= $module->title() ?> + <span title="<?= e($module->description()) ?>"> + <?= $module->title() ?> + </span> <?php if ($module instanceof ModuleConfigInterface) : ?> <a href="<?= e($module->getConfigLink()) ?>" title="<?= I18N::translate('Preferences') ?>"> <?= view('icons/preferences') ?> @@ -48,7 +52,8 @@ use Fisharebest\Webtrees\View; ?> </label> </td> - <td class="text-center"> + <?php if ($uses_access) : ?> + <td class="text-center"> <div class="modal fade" id="access-level-<?= $module->name() ?>" tabindex="-1" role="dialog"> <div class="modal-dialog" role="document"> <div class="modal-content"> @@ -90,8 +95,9 @@ use Fisharebest\Webtrees\View; ?> </span> </button> </td> + <?php endif ?> - <?php if ($component === 'menu' || $component === 'sidebar' || $component === 'tab') : ?> + <?php if ($uses_sorting) : ?> <td class="move up text-center"> <a href="#" title="<?= I18N::translate('Move up') ?>"> <?= view('icons/arrow-up') ?> diff --git a/resources/views/admin/control-panel.phtml b/resources/views/admin/control-panel.phtml index 678b249958..43b04710d1 100644 --- a/resources/views/admin/control-panel.phtml +++ b/resources/views/admin/control-panel.phtml @@ -337,6 +337,12 @@ <?= I18N::translate('Reports') ?> </a> </li> + <li> + <span class="fa-li"><?= view('icons/footer') ?></span> + <a href="<?= e(route('footers')) ?>"> + <?= I18N::translate('Footers') ?> + </a> + </li> </ul> </div> <div class="col-sm-6"> diff --git a/resources/views/admin/modules.phtml b/resources/views/admin/modules.phtml index 20ceea1f0e..feb529004b 100644 --- a/resources/views/admin/modules.phtml +++ b/resources/views/admin/modules.phtml @@ -5,6 +5,7 @@ <?php use Fisharebest\Webtrees\Module\ModuleChartInterface; ?> <?php use Fisharebest\Webtrees\Module\ModuleConfigInterface; ?> <?php use Fisharebest\Webtrees\Module\ModuleCustomInterface; ?> +<?php use Fisharebest\Webtrees\Module\ModuleFooterInterface; ?> <?php use Fisharebest\Webtrees\Module\ModuleMenuInterface; ?> <?php use Fisharebest\Webtrees\Module\ModuleReportInterface; ?> <?php use Fisharebest\Webtrees\Module\ModuleSidebarInterface; ?> @@ -75,6 +76,10 @@ <?= view('icons/report') ?> <span class="sr-only"><?= I18N::translate('Reports') ?></span> </th> + <th title="<?= I18N::translate('Footers') ?>"> + <?= view('icons/footer') ?> + <span class="sr-only"><?= I18N::translate('Footers') ?></span> + </th> <th title="<?= I18N::translate('Tracking and analytics') ?>"> <?= view('icons/analytics') ?> <span class="sr-only"><?= I18N::translate('Tracking and analytics') ?></span> @@ -184,6 +189,14 @@ - <?php endif ?> </td> + <td class="text-center text-muted" title="<?= I18N::translate('Footer') ?>"> + <?php if ($module instanceof ModuleFooterInterface) : ?> + <?= view('icons/footer') ?> + <span class="sr-only"><?= I18N::translate('Footer') ?></span> + <?php else : ?> + - + <?php endif ?> + </td> <td class="text-center text-muted" title="<?= I18N::translate('Tracking and analytics') ?>"> <?php if ($module instanceof ModuleAnalyticsInterface) : ?> <?= view('icons/analytics') ?> diff --git a/resources/views/icons/footer.phtml b/resources/views/icons/footer.phtml new file mode 100644 index 0000000000..a43d75faa5 --- /dev/null +++ b/resources/views/icons/footer.phtml @@ -0,0 +1 @@ +<i class="fas fa-grip-lines fa-fw wt-icon-footer" aria-hidden="true"></i> diff --git a/resources/views/layouts/default.phtml b/resources/views/layouts/default.phtml index 236c6f3358..ba197351ca 100644 --- a/resources/views/layouts/default.phtml +++ b/resources/views/layouts/default.phtml @@ -4,6 +4,7 @@ <?php use Fisharebest\Webtrees\I18N; ?> <?php use Fisharebest\Webtrees\Module; ?> <?php use Fisharebest\Webtrees\Module\ModuleAnalyticsInterface; ?> +<?php use Fisharebest\Webtrees\Module\ModuleFooterInterface; ?> <?php use Fisharebest\Webtrees\Theme; ?> <?php use Fisharebest\Webtrees\View; ?> <?php use Fisharebest\Webtrees\Webtrees; ?> @@ -119,21 +120,7 @@ <footer class="wt-footer-container"> <div class="wt-footer-content container d-print-none"> - <?= Theme::theme()->formatContactLinks() ?> - <?= Theme::theme()->logoPoweredBy() ?> - - <?php if ($page_hits ?? 0 > 0) : ?> - <div class="wt-page-views"> - <?= I18N::plural( - 'This page has been viewed %s time.', - 'This page has been viewed %s times.', - $page_hits, - '<span class="odometer">' . I18N::digits($page_hits) . '</span>' - ) ?> - </div> - <?php endif ?> - - <?= Theme::theme()->cookieWarning()?> + <?= Module::findByInterface(ModuleFooterInterface::class)->map(function (ModuleFooterInterface $module) use ($tree): string { return app()->dispatch($module, 'getFooter'); })->implode('') ?> </div> </footer> diff --git a/resources/views/modules/contact-links/footer.phtml b/resources/views/modules/contact-links/footer.phtml new file mode 100644 index 0000000000..23efa3c6e6 --- /dev/null +++ b/resources/views/modules/contact-links/footer.phtml @@ -0,0 +1,3 @@ +<div class="wt-footer-contact-links"> + <?= $contact_links ?> +</div> diff --git a/resources/views/modules/cookie-warning/footer.phtml b/resources/views/modules/cookie-warning/footer.phtml new file mode 100644 index 0000000000..df4331e25c --- /dev/null +++ b/resources/views/modules/cookie-warning/footer.phtml @@ -0,0 +1,13 @@ +<?php use Fisharebest\Webtrees\I18N; ?> + +<div class="wt-footer-cookie-warning"> + <div class="wt-cookie-warning"> + <?= I18N::translate('Cookies') ?> + - + <?= I18N::translate('This website uses cookies to learn about visitor behaviour.') ?> + + <button onclick="document.cookie='cookie=1'; this.parentNode.classList.add('d-none');"> + <?= I18N::translate('continue') ?> + </button> + </div> +</div> diff --git a/resources/views/modules/hit-counter/footer.phtml b/resources/views/modules/hit-counter/footer.phtml new file mode 100644 index 0000000000..21d473c484 --- /dev/null +++ b/resources/views/modules/hit-counter/footer.phtml @@ -0,0 +1,3 @@ +<div class="wt-footer-hit-counter"> + <?= $hit_counter ?> +</div> diff --git a/resources/views/modules/powered-by-webtrees/footer.phtml b/resources/views/modules/powered-by-webtrees/footer.phtml new file mode 100644 index 0000000000..d9ea7cc288 --- /dev/null +++ b/resources/views/modules/powered-by-webtrees/footer.phtml @@ -0,0 +1,5 @@ +<div class="wt-footer-powered-by-webtrees"> + <a href="<?= e($url) ?>" class="wt-powered-by-webtrees" dir="ltr"> + <?= e($name) ?> + </a> +</div> diff --git a/routes/web.php b/routes/web.php index 7ecd2debfe..c86bfe93d3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -33,6 +33,8 @@ if (Auth::isAdmin()) { 'POST:blocks' => 'Admin\\ModuleController@updateBlocks', 'GET:charts' => 'Admin\\ModuleController@listCharts', 'POST:charts' => 'Admin\\ModuleController@updateCharts', + 'GET:footers' => 'Admin\\ModuleController@listFooters', + 'POST:footers' => 'Admin\\ModuleController@updateFooters', 'GET:menus' => 'Admin\\ModuleController@listMenus', 'POST:menus' => 'Admin\\ModuleController@updateMenus', 'GET:reports' => 'Admin\\ModuleController@listReports', diff --git a/themes/_common/css-2.0.0/style.css b/themes/_common/css-2.0.0/style.css index 86a706e6b1..64852758f7 100755 --- a/themes/_common/css-2.0.0/style.css +++ b/themes/_common/css-2.0.0/style.css @@ -42,10 +42,6 @@ * | +---wt-page-content * +---wt-footer-container * +---wt-footer-content - * +---wt-contact-links - * +---wt-powered-by-webtrees - * +---wt-page-views - * +---wt-cookie-warning */ .wt-global { diff --git a/themes/clouds/css-2.0.0/style.css b/themes/clouds/css-2.0.0/style.css index 854c87b850..1bbd748da3 100755 --- a/themes/clouds/css-2.0.0/style.css +++ b/themes/clouds/css-2.0.0/style.css @@ -278,10 +278,6 @@ a > .wt-icon-arrow-up:hover::before { * | +---wt-page-content * +---wt-footer-container * +---wt-footer-content - * +---wt-contact-links - * +---wt-powered-by-webtrees - * +---wt-page-views - * +---wt-cookie-warning */ .wt-global { diff --git a/themes/colors/css-2.0.0/style.css b/themes/colors/css-2.0.0/style.css index 2b4261e56c..bdb40c5a9b 100755 --- a/themes/colors/css-2.0.0/style.css +++ b/themes/colors/css-2.0.0/style.css @@ -323,10 +323,6 @@ i[class*="wt-icon-media"] { * | +---wt-page-content * +---wt-footer-container * +---wt-footer-content - * +---wt-contact-links - * +---wt-powered-by-webtrees - * +---wt-page-views - * +---wt-cookie-warning */ .wt-block-header::before { diff --git a/themes/fab/css-2.0.0/style.css b/themes/fab/css-2.0.0/style.css index 284c56a7af..8970c49ff8 100755 --- a/themes/fab/css-2.0.0/style.css +++ b/themes/fab/css-2.0.0/style.css @@ -114,10 +114,6 @@ table { * | +---wt-page-content * +---wt-footer-container * +---wt-footer-content - * +---wt-contact-links - * +---wt-powered-by-webtrees - * +---wt-page-views - * +---wt-cookie-warning */ .wt-global { diff --git a/themes/minimal/css-2.0.0/style.css b/themes/minimal/css-2.0.0/style.css index f48d0366d3..2d4817c24c 100755 --- a/themes/minimal/css-2.0.0/style.css +++ b/themes/minimal/css-2.0.0/style.css @@ -114,10 +114,6 @@ table { * | +---wt-page-content * +---wt-footer-container * +---wt-footer-content - * +---wt-contact-links - * +---wt-powered-by-webtrees - * +---wt-page-views - * +---wt-cookie-warning */ .wt-global { diff --git a/themes/webtrees/css-2.0.0/style.css b/themes/webtrees/css-2.0.0/style.css index 97dc31ab0a..71dd42d340 100755 --- a/themes/webtrees/css-2.0.0/style.css +++ b/themes/webtrees/css-2.0.0/style.css @@ -268,10 +268,6 @@ a > .wt-icon-arrow-up:hover::before { * | +---wt-page-content * +---wt-footer-container * +---wt-footer-content - * +---wt-contact-links - * +---wt-powered-by-webtrees - * +---wt-page-views - * +---wt-cookie-warning */ .wt-global { diff --git a/themes/xenea/css-2.0.0/style.css b/themes/xenea/css-2.0.0/style.css index 7e99e397c4..cc473128ec 100755 --- a/themes/xenea/css-2.0.0/style.css +++ b/themes/xenea/css-2.0.0/style.css @@ -268,10 +268,6 @@ a > .wt-icon-arrow-up:hover::before { * | +---wt-page-content * +---wt-footer-container * +---wt-footer-content - * +---wt-contact-links - * +---wt-powered-by-webtrees - * +---wt-page-views - * +---wt-cookie-warning */ .wt-global { |
