1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2019 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16declare(strict_types=1); 17 18namespace Fisharebest\Webtrees\Module; 19 20use Fisharebest\Webtrees\Auth; 21use Fisharebest\Webtrees\Fact; 22use Fisharebest\Webtrees\Gedcom; 23use Fisharebest\Webtrees\GedcomTag; 24use Fisharebest\Webtrees\I18N; 25use Fisharebest\Webtrees\Individual; 26use Fisharebest\Webtrees\Menu; 27use Fisharebest\Webtrees\Services\ModuleService; 28use Fisharebest\Webtrees\Tree; 29use Symfony\Component\HttpFoundation\Request; 30 31/** 32 * Trait ModuleThemeTrait - default implementation of ModuleThemeInterface 33 */ 34trait ModuleThemeTrait 35{ 36 /** @var Request */ 37 protected $request; 38 39 /** @var Tree|null */ 40 protected $tree; 41 42 /** 43 * @param Request $request 44 * @param Tree|null $tree The current tree (if there is one). 45 */ 46 public function __construct(Request $request, ?Tree $tree) 47 { 48 $this->request = $request; 49 $this->tree = $tree; 50 } 51 52 /** 53 * Where are our CSS, JS and other assets? 54 * 55 * @deprecated - use the constant directly 56 * @return string A relative path, such as "themes/foo/" 57 */ 58 public function assetUrl(): string 59 { 60 return self::ASSET_DIR; 61 } 62 63 /** 64 * Add markup to the secondary menu. 65 * 66 * @return string 67 */ 68 public function formatSecondaryMenu(): string 69 { 70 return 71 '<ul class="nav wt-secondary-menu">' . 72 implode('', array_map(function (Menu $menu): string { 73 return $this->formatSecondaryMenuItem($menu); 74 }, $this->secondaryMenu())) . 75 '</ul>'; 76 } 77 78 /** 79 * Add markup to an item in the secondary menu. 80 * 81 * @param Menu $menu 82 * 83 * @return string 84 */ 85 public function formatSecondaryMenuItem(Menu $menu): string 86 { 87 return $menu->bootstrap4(); 88 } 89 90 /** 91 * Display an icon for this fact. 92 * 93 * @param Fact $fact 94 * 95 * @return string 96 */ 97 public function icon(Fact $fact): string 98 { 99 $icon = 'images/facts/' . $fact->getTag() . '.png'; 100 if (file_exists(self::ASSET_DIR . $icon)) { 101 return '<img src="' . self::ASSET_DIR . $icon . '" title="' . GedcomTag::getLabel($fact->getTag()) . '">'; 102 } 103 104 if (file_exists(self::ASSET_DIR . 'images/facts/NULL.png')) { 105 // Spacer image - for alignment - until we move to a sprite. 106 return '<img src="' . app()->make(ModuleThemeInterface::class)->assetUrl() . 'images/facts/NULL.png">'; 107 } 108 109 return ''; 110 } 111 112 /** 113 * Display an individual in a box - for charts, etc. 114 * 115 * @param Individual $individual 116 * 117 * @return string 118 */ 119 public function individualBox(Individual $individual): string 120 { 121 $person_box_class = self::PERSON_BOX_CLASSES[$individual->getSex()]; 122 123 if ($individual->canShow() && $individual->tree()->getPreference('SHOW_HIGHLIGHT_IMAGES')) { 124 $thumbnail = $individual->displayImage(40, 50, 'crop', []); 125 } else { 126 $thumbnail = ''; 127 } 128 129 $content = '<span class="namedef name1">' . $individual->getFullName() . '</span>'; 130 $icons = ''; 131 if ($individual->canShow()) { 132 $content = '<a href="' . e($individual->url()) . '">' . $content . '</a>' . 133 '<div class="namedef name1">' . $individual->getAddName() . '</div>'; 134 $icons = '<div class="icons">' . 135 '<span class="iconz icon-zoomin" title="' . I18N::translate('Zoom in/out on this box.') . '"></span>' . 136 '<div class="itr"><i class="icon-pedigree"></i><div class="popup">' . 137 '<ul class="' . $person_box_class . '">' . implode('', array_map(function (Menu $menu): string { 138 return $menu->bootstrap4(); 139 }, $this->individualBoxMenu($individual))) . '</ul>' . 140 '</div>' . 141 '</div>' . 142 '</div>'; 143 } 144 145 return 146 '<div data-xref="' . e($individual->xref()) . '" data-tree="' . e($individual->tree()->name()) . '" class="person_box_template ' . $person_box_class . ' box-style1" style="width: ' . $this->parameter('chart-box-x') . 'px; height: ' . $this->parameter('chart-box-y') . 'px">' . 147 $icons . 148 '<div class="chart_textbox" style="max-height:' . $this->parameter('chart-box-y') . 'px;">' . 149 $thumbnail . 150 $content . 151 '<div class="inout2 details1">' . $this->individualBoxFacts($individual) . '</div>' . 152 '</div>' . 153 '<div class="inout"></div>' . 154 '</div>'; 155 } 156 157 /** 158 * Display an empty box - for a missing individual in a chart. 159 * 160 * @return string 161 */ 162 public function individualBoxEmpty(): string 163 { 164 return '<div class="person_box_template person_boxNN box-style1" style="width: ' . $this->parameter('chart-box-x') . 'px; min-height: ' . $this->parameter('chart-box-y') . 'px"></div>'; 165 } 166 167 /** 168 * Display an individual in a box - for charts, etc. 169 * 170 * @param Individual $individual 171 * 172 * @return string 173 */ 174 public function individualBoxLarge(Individual $individual): string 175 { 176 $person_box_class = self::PERSON_BOX_CLASSES[$individual->getSex()]; 177 178 if ($individual->tree()->getPreference('SHOW_HIGHLIGHT_IMAGES')) { 179 $thumbnail = $individual->displayImage(40, 50, 'crop', []); 180 } else { 181 $thumbnail = ''; 182 } 183 184 $content = '<span class="namedef name1">' . $individual->getFullName() . '</span>'; 185 $icons = ''; 186 if ($individual->canShow()) { 187 $content = '<a href="' . e($individual->url()) . '">' . $content . '</a>' . 188 '<div class="namedef name2">' . $individual->getAddName() . '</div>'; 189 $icons = '<div class="icons">' . 190 '<span class="iconz icon-zoomin" title="' . I18N::translate('Zoom in/out on this box.') . '"></span>' . 191 '<div class="itr"><i class="icon-pedigree"></i><div class="popup">' . 192 '<ul class="' . $person_box_class . '">' . implode('', array_map(function (Menu $menu): string { 193 return $menu->bootstrap4(); 194 }, $this->individualBoxMenu($individual))) . '</ul>' . 195 '</div>' . 196 '</div>' . 197 '</div>'; 198 } 199 200 return 201 '<div data-xref="' . e($individual->xref()) . '" data-tree="' . e($individual->tree()->name()) . '" class="person_box_template ' . $person_box_class . ' box-style2">' . 202 $icons . 203 '<div class="chart_textbox" style="max-height:' . $this->parameter('chart-box-y') . 'px;">' . 204 $thumbnail . 205 $content . 206 '<div class="inout2 details2">' . $this->individualBoxFacts($individual) . '</div>' . 207 '</div>' . 208 '<div class="inout"></div>' . 209 '</div>'; 210 } 211 212 /** 213 * Display an individual in a box - for charts, etc. 214 * 215 * @param Individual $individual 216 * 217 * @return string 218 */ 219 public function individualBoxSmall(Individual $individual): string 220 { 221 $person_box_class = self::PERSON_BOX_CLASSES[$individual->getSex()]; 222 223 if ($individual->tree()->getPreference('SHOW_HIGHLIGHT_IMAGES')) { 224 $thumbnail = $individual->displayImage(40, 50, 'crop', []); 225 } else { 226 $thumbnail = ''; 227 } 228 229 return 230 '<div data-xref="' . $individual->xref() . '" class="person_box_template ' . $person_box_class . ' iconz box-style0" style="width: ' . $this->parameter('compact-chart-box-x') . 'px; min-height: ' . $this->parameter('compact-chart-box-y') . 'px">' . 231 '<div class="compact_view">' . 232 $thumbnail . 233 '<a href="' . e($individual->url()) . '">' . 234 '<span class="namedef name0">' . $individual->getFullName() . '</span>' . 235 '</a>' . 236 '<div class="inout2 details0">' . $individual->getLifeSpan() . '</div>' . 237 '</div>' . 238 '<div class="inout"></div>' . 239 '</div>'; 240 } 241 242 /** 243 * Display an individual in a box - for charts, etc. 244 * 245 * @return string 246 */ 247 public function individualBoxSmallEmpty(): string 248 { 249 return '<div class="person_box_template person_boxNN box-style1" style="width: ' . $this->parameter('compact-chart-box-x') . 'px; min-height: ' . $this->parameter('compact-chart-box-y') . 'px"></div>'; 250 } 251 252 /** 253 * Generate the facts, for display in charts. 254 * 255 * @param Individual $individual 256 * 257 * @return string 258 */ 259 public function individualBoxFacts(Individual $individual): string 260 { 261 $html = ''; 262 263 $opt_tags = preg_split('/\W/', $individual->tree()->getPreference('CHART_BOX_TAGS'), 0, PREG_SPLIT_NO_EMPTY); 264 // Show BIRT or equivalent event 265 foreach (Gedcom::BIRTH_EVENTS as $birttag) { 266 if (!in_array($birttag, $opt_tags)) { 267 $event = $individual->getFirstFact($birttag); 268 if ($event) { 269 $html .= $event->summary(); 270 break; 271 } 272 } 273 } 274 // Show optional events (before death) 275 foreach ($opt_tags as $key => $tag) { 276 if (!in_array($tag, Gedcom::DEATH_EVENTS)) { 277 $event = $individual->getFirstFact($tag); 278 if ($event !== null) { 279 $html .= $event->summary(); 280 unset($opt_tags[$key]); 281 } 282 } 283 } 284 // Show DEAT or equivalent event 285 foreach (Gedcom::DEATH_EVENTS as $deattag) { 286 $event = $individual->getFirstFact($deattag); 287 if ($event) { 288 $html .= $event->summary(); 289 if (in_array($deattag, $opt_tags)) { 290 unset($opt_tags[array_search($deattag, $opt_tags)]); 291 } 292 break; 293 } 294 } 295 // Show remaining optional events (after death) 296 foreach ($opt_tags as $tag) { 297 $event = $individual->getFirstFact($tag); 298 if ($event) { 299 $html .= $event->summary(); 300 } 301 } 302 303 return $html; 304 } 305 306 /** 307 * Links, to show in chart boxes; 308 * 309 * @param Individual $individual 310 * 311 * @return Menu[] 312 */ 313 public function individualBoxMenu(Individual $individual): array 314 { 315 $menus = array_merge( 316 $this->individualBoxMenuCharts($individual), 317 $this->individualBoxMenuFamilyLinks($individual) 318 ); 319 320 return $menus; 321 } 322 323 /** 324 * Chart links, to show in chart boxes; 325 * 326 * @param Individual $individual 327 * 328 * @return Menu[] 329 */ 330 public function individualBoxMenuCharts(Individual $individual): array 331 { 332 $menus = []; 333 foreach (app(ModuleService::class)->findByComponent('chart', $this->tree, Auth::user()) as $chart) { 334 $menu = $chart->chartBoxMenu($individual); 335 if ($menu) { 336 $menus[] = $menu; 337 } 338 } 339 340 usort($menus, function (Menu $x, Menu $y) { 341 return I18N::strcasecmp($x->getLabel(), $y->getLabel()); 342 }); 343 344 return $menus; 345 } 346 347 /** 348 * Family links, to show in chart boxes. 349 * 350 * @param Individual $individual 351 * 352 * @return Menu[] 353 */ 354 public function individualBoxMenuFamilyLinks(Individual $individual): array 355 { 356 $menus = []; 357 358 foreach ($individual->getSpouseFamilies() as $family) { 359 $menus[] = new Menu('<strong>' . I18N::translate('Family with spouse') . '</strong>', $family->url()); 360 $spouse = $family->getSpouse($individual); 361 if ($spouse && $spouse->canShowName()) { 362 $menus[] = new Menu($spouse->getFullName(), $spouse->url()); 363 } 364 foreach ($family->getChildren() as $child) { 365 if ($child->canShowName()) { 366 $menus[] = new Menu($child->getFullName(), $child->url()); 367 } 368 } 369 } 370 371 return $menus; 372 } 373 374 /** 375 * Generate a menu item to change the blocks on the current (index.php) page. 376 * 377 * @return Menu|null 378 */ 379 public function menuChangeBlocks() 380 { 381 if (Auth::check() && $this->request->get('route') === 'user-page') { 382 return new Menu(I18N::translate('Customize this page'), route('user-page-edit', ['ged' => $this->tree->name()]), 'menu-change-blocks'); 383 } 384 385 if (Auth::isManager($this->tree) && $this->request->get('route') === 'tree-page') { 386 return new Menu(I18N::translate('Customize this page'), route('tree-page-edit', ['ged' => $this->tree->name()]), 'menu-change-blocks'); 387 } 388 389 return null; 390 } 391 392 /** 393 * Generate a menu item for the control panel. 394 * 395 * @return Menu|null 396 */ 397 public function menuControlPanel() 398 { 399 if (Auth::isAdmin()) { 400 return new Menu(I18N::translate('Control panel'), route('admin-control-panel'), 'menu-admin'); 401 } 402 403 if (Auth::isManager($this->tree)) { 404 return new Menu(I18N::translate('Control panel'), route('admin-control-panel-manager'), 'menu-admin'); 405 } 406 407 return null; 408 } 409 410 /** 411 * A menu to show a list of available languages. 412 * 413 * @return Menu|null 414 */ 415 public function menuLanguages() 416 { 417 $menu = new Menu(I18N::translate('Language'), '#', 'menu-language'); 418 419 foreach (I18N::activeLocales() as $locale) { 420 $language_tag = $locale->languageTag(); 421 $class = 'menu-language-' . $language_tag . (WT_LOCALE === $language_tag ? ' active' : ''); 422 $menu->addSubmenu(new Menu($locale->endonym(), '#', $class, [ 423 'onclick' => 'return false;', 424 'data-language' => $language_tag, 425 ])); 426 } 427 428 if (count($menu->getSubmenus()) > 1) { 429 return $menu; 430 } 431 432 return null; 433 } 434 435 /** 436 * A login menu option (or null if we are already logged in). 437 * 438 * @return Menu|null 439 */ 440 public function menuLogin() 441 { 442 if (Auth::check()) { 443 return null; 444 } 445 446 // Return to this page after login... 447 $url = $this->request->getRequestUri(); 448 449 // ...but switch from the tree-page to the user-page 450 $url = str_replace('route=tree-page', 'route=user-page', $url); 451 452 return new Menu(I18N::translate('Sign in'), route('login', ['url' => $url]), 'menu-login', ['rel' => 'nofollow']); 453 } 454 455 /** 456 * A logout menu option (or null if we are already logged out). 457 * 458 * @return Menu|null 459 */ 460 public function menuLogout() 461 { 462 if (Auth::check()) { 463 return new Menu(I18N::translate('Sign out'), route('logout'), 'menu-logout'); 464 } 465 466 return null; 467 } 468 469 /** 470 * A link to allow users to edit their account settings. 471 * 472 * @return Menu|null 473 */ 474 public function menuMyAccount() 475 { 476 if (Auth::check()) { 477 return new Menu(I18N::translate('My account'), route('my-account')); 478 } 479 480 return null; 481 } 482 483 /** 484 * A link to the user's individual record (individual.php). 485 * 486 * @return Menu|null 487 */ 488 public function menuMyIndividualRecord() 489 { 490 $record = Individual::getInstance($this->tree->getUserPreference(Auth::user(), 'gedcomid'), $this->tree); 491 492 if ($record) { 493 return new Menu(I18N::translate('My individual record'), $record->url(), 'menu-myrecord'); 494 } 495 496 return null; 497 } 498 499 /** 500 * A link to the user's personal home page. 501 * 502 * @return Menu 503 */ 504 public function menuMyPage(): Menu 505 { 506 return new Menu(I18N::translate('My page'), route('user-page', ['ged' => $this->tree->name()]), 'menu-mypage'); 507 } 508 509 /** 510 * A menu for the user's personal pages. 511 * 512 * @return Menu|null 513 */ 514 public function menuMyPages() 515 { 516 if (Auth::id() && $this->tree !== null) { 517 return new Menu(I18N::translate('My pages'), '#', 'menu-mymenu', [], array_filter([ 518 $this->menuMyPage(), 519 $this->menuMyIndividualRecord(), 520 $this->menuMyPedigree(), 521 $this->menuMyAccount(), 522 $this->menuControlPanel(), 523 $this->menuChangeBlocks(), 524 ])); 525 } 526 527 return null; 528 } 529 530 /** 531 * A link to the user's individual record. 532 * 533 * @return Menu|null 534 */ 535 public function menuMyPedigree() 536 { 537 $gedcomid = $this->tree->getUserPreference(Auth::user(), 'gedcomid'); 538 539 $pedigree_chart = app(ModuleService::class)->findByComponent('chart', $this->tree, Auth::user()) 540 ->filter(function (ModuleInterface $module): bool { 541 return $module instanceof PedigreeChartModule; 542 }); 543 544 if ($gedcomid !== '' && $pedigree_chart instanceof PedigreeChartModule) { 545 return new Menu( 546 I18N::translate('My pedigree'), 547 route('pedigree', [ 548 'xref' => $gedcomid, 549 'ged' => $this->tree->name(), 550 ]), 551 'menu-mypedigree' 552 ); 553 } 554 555 return null; 556 } 557 558 /** 559 * Create a pending changes menu. 560 * 561 * @return Menu|null 562 */ 563 public function menuPendingChanges() 564 { 565 if ($this->pendingChangesExist()) { 566 $url = route('show-pending', [ 567 'ged' => $this->tree ? $this->tree->name() : '', 568 'url' => $this->request->getRequestUri(), 569 ]); 570 571 return new Menu(I18N::translate('Pending changes'), $url, 'menu-pending'); 572 } 573 574 return null; 575 } 576 577 /** 578 * Themes menu. 579 * 580 * @return Menu|null 581 */ 582 public function menuThemes() 583 { 584 $themes = app(ModuleService::class)->findByInterface(ModuleThemeInterface::class); 585 586 $current_theme = app()->make(ModuleThemeInterface::class); 587 588 if ($themes->count() > 1) { 589 $submenus = $themes->map(function (ModuleThemeInterface $theme) use ($current_theme): Menu { 590 $active = $theme->name() === $current_theme->name(); 591 $class = 'menu-theme-' . $theme->name() . ($active ? ' active' : ''); 592 593 return new Menu($theme->title(), '#', $class, [ 594 'onclick' => 'return false;', 595 'data-theme' => $theme->name(), 596 ]); 597 }); 598 599 return new Menu(I18N::translate('Theme'), '#', 'menu-theme', [], $submenus->all()); 600 } 601 602 return null; 603 } 604 605 /** 606 * Misecellaneous dimensions, fonts, styles, etc. 607 * 608 * @param string $parameter_name 609 * 610 * @return string|int|float 611 */ 612 public function parameter($parameter_name) 613 { 614 return ''; 615 } 616 617 /** 618 * Are there any pending changes for us to approve? 619 * 620 * @return bool 621 */ 622 public function pendingChangesExist(): bool 623 { 624 return $this->tree && $this->tree->hasPendingEdit() && Auth::isModerator($this->tree); 625 } 626 627 /** 628 * Generate a list of items for the main menu. 629 * 630 * @return Menu[] 631 */ 632 public function primaryMenu(): array 633 { 634 return app(ModuleService::class)->findByComponent('menu', $this->tree, Auth::user()) 635 ->map(function (ModuleMenuInterface $menu): ?Menu { 636 return $menu->getMenu($this->tree); 637 }) 638 ->filter() 639 ->all(); 640 } 641 642 /** 643 * Create the primary menu. 644 * 645 * @param Menu[] $menus 646 * 647 * @return string 648 */ 649 public function primaryMenuContent(array $menus): string 650 { 651 return implode('', array_map(function (Menu $menu): string { 652 return $menu->bootstrap4(); 653 }, $menus)); 654 } 655 656 /** 657 * Generate a list of items for the user menu. 658 * 659 * @return Menu[] 660 */ 661 public function secondaryMenu(): array 662 { 663 return array_filter([ 664 $this->menuPendingChanges(), 665 $this->menuMyPages(), 666 $this->menuThemes(), 667 $this->menuLanguages(), 668 $this->menuLogin(), 669 $this->menuLogout(), 670 ]); 671 } 672 673 /** 674 * A list of CSS files to include for this page. 675 * 676 * @return string[] 677 */ 678 public function stylesheets(): array 679 { 680 return []; 681 } 682} 683