diff --git a/resources/views/block.phtml b/resources/views/block.phtml new file mode 100644 index 0000000..4b5641a --- /dev/null +++ b/resources/views/block.phtml @@ -0,0 +1,581 @@ + $birthdays + * @var Collection|null $anniversaries + * @var Collection|null $historical + * @var bool $include_anniversaries + * @var int $window_days + * @var array $avatar_srcs xref => https URL of the highlight image + * @var array $relationships xref => "your mother" (per-viewer when signed-in + linked) + */ + +// ─── Palette ──────────────────────────────────────────────────────────── +// +// Bind to BockenTheme CSS custom properties instead of fixed hex values +// so the block automatically tracks the user's light/dark preference +// (and any future theme tweak) without us re-implementing dark-mode +// overrides. Where the theme has no semantic token for a particular +// role — the rail or the dot, for instance — we lean on `--color-border` +// (subtle in both modes) and `--color-link` (the Nord blue swap-pair). +$palette = [ + 'bg' => 'var(--color-bg-primary)', + 'surface' => 'var(--color-bg-secondary)', + 'border' => 'var(--color-border)', + 'ink' => 'var(--color-text-primary)', + 'ink2' => 'var(--color-text-secondary)', + 'ink3' => 'var(--color-text-tertiary)', + 'mute' => 'var(--color-text-muted)', + 'link' => 'var(--color-link)', + 'link_hov' => 'var(--color-link-hover)', + 'accent' => 'var(--color-accent)', + 'birth' => 'var(--color-link)', + 'death' => 'var(--color-text-secondary)', + 'marr' => 'var(--nord15)', + 'rail' => 'var(--color-border)', + 'dot' => 'var(--color-link)', +]; + +$font_stack = "inherit"; +$avatar_size = 56; + +$relationships = $relationships ?? []; +$avatar_srcs = $avatar_srcs ?? []; + +// ─── Helpers ──────────────────────────────────────────────────────────── + +$linked_name = static function (Individual $individual) use ($palette): string { + $name = strip_tags($individual->fullName()); + $url = $individual->url(); + // No bottom border — link affordance comes from the color token + // and the theme's :hover underline, keeping the card surface + // visually quiet. + $style = 'color:' . $palette['ink'] . ';text-decoration:none;'; + + return '' . e($name) . ''; +}; + +$record_label = static function (Fact $fact) use ($linked_name, $relationships, $palette): string { + $record = $fact->record(); + + $names = []; + $rels = []; + + if ($record instanceof Individual) { + $names[] = $linked_name($record); + if (isset($relationships[$record->xref()])) { + $rels[] = strip_tags($relationships[$record->xref()]); + } + } elseif ($record instanceof Family) { + foreach ([$record->husband(), $record->wife()] as $spouse) { + if ($spouse instanceof Individual) { + $names[] = $linked_name($spouse); + if (isset($relationships[$spouse->xref()])) { + $rels[] = strip_tags($relationships[$spouse->xref()]); + } + } + } + } else { + return e($record->xref()); + } + + $html = implode(' & ', $names); + + if ($rels !== []) { + $rels = array_values(array_unique(array_map(static fn ($r) => e($r), $rels))); + $html .= '
' + . implode(' & ', $rels) + . '
'; + } + + return $html; +}; + +$event_date_display = static function (Fact $fact): string { + $date = $fact->date(); + + if (!$date instanceof Date || !$date->isOK()) { + return ''; + } + + return strip_tags($date->display()); +}; + +$date_parts = static function (Fact $fact): array { + static $gregorian = null; + static $months_de = [ + 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', + ]; + static $months_en = [ + 1 => 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + + $gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar(); + + $date = $fact->date(); + + if (!$date->isOK()) { + return ['day_month' => '', 'year' => '']; + } + + [$year, $month, $day] = $gregorian->jdToYmd($date->minimumJulianDay()); + + if (str_starts_with(I18N::languageTag(), 'de')) { + $day_month = $day . '. ' . mb_strtoupper($months_de[$month] ?? ''); + } else { + $day_month = mb_strtoupper(($months_en[$month] ?? '') . ' ' . $day); + } + + return ['day_month' => $day_month, 'year' => (string) $year]; +}; + +$event_kind = static function (Fact $fact): string { + $parts = explode(':', $fact->tag()); + + return end($parts); +}; + +// SVG attribute values can't reference CSS custom properties directly +// — only the `style` attribute does — so each glyph carries its +// fill/stroke as inline style and inherits the right Nord shade from +// BockenTheme automatically. +$event_icon = static function (string $kind) use ($palette): string { + $svg_open = ' $svg_open . 'viewBox="0 0 24 24" width="18" height="18">' + . '' + . '', + + 'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">' + . '' + . '' + . '', + + 'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">' + . '' + . '' + . '', + + default => '', + }; +}; + +$avatar = static function (Individual|null $individual) use ($avatar_srcs, $avatar_size, $palette, $font_stack): string { + if (!$individual instanceof Individual) { + return ''; + } + + $alt = e(strip_tags($individual->fullName())); + + if (isset($avatar_srcs[$individual->xref()])) { + $src = $avatar_srcs[$individual->xref()]; + $inner = '' . $alt . ''; + } else { + // Gendered silhouette placeholder — same artwork as the + // full-diagram plugin's `.photo-placeholder` + `.silhouette` + // shapes (see resources/css/full-diagram.css). Sex token + // gives the bg pastel; the silhouette stays the same shape + // regardless. + // Reuse the BockenTheme `.person-card .photo-placeholder` + + // `.silhouette` rules verbatim so the avatar shading matches + // the full-diagram plugin exactly (including dark mode). + $sex = strtolower($individual->sex()); + $wrap_cls = 'person-card' . ($sex === 'm' ? ' sex-m' : ($sex === 'f' ? ' sex-f' : '')); + $inner = '' + . '' + . '' + . '' + . ''; + } + + return '' . $inner . ''; +}; + +$record_avatars = static function (Fact $fact) use ($avatar): string { + $record = $fact->record(); + + if ($record instanceof Individual) { + return $avatar($record); + } + + if ($record instanceof Family) { + $parts = []; + + foreach ([$record->husband(), $record->wife()] as $spouse) { + if ($spouse instanceof Individual) { + $parts[] = $avatar($spouse); + } + } + + return '' + . '' + . (isset($parts[1]) ? '' : '') + . '
' . ($parts[0] ?? '') . '' . $parts[1] . '
'; + } + + return ''; +}; + +$ordinal = static function (int $n): string { + if (str_starts_with(I18N::languageTag(), 'de')) { + return $n . '.'; + } + + $abs = abs($n); + $mod100 = $abs % 100; + + if ($mod100 >= 11 && $mod100 <= 13) { + return $n . 'th'; + } + + return $n . match ($abs % 10) { + 1 => 'st', + 2 => 'nd', + 3 => 'rd', + default => 'th', + }; +}; + +$upcoming_age = static function (Fact $fact): int { + static $gregorian = null; + $gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar(); + + $date = $fact->date(); + + if (!$date->isOK()) { + return 0; + } + + $event_year = $date->gregorianYear(); + $upcoming_jd = $fact->jd ?? 0; + + if ($upcoming_jd > 0) { + [$upcoming_year] = $gregorian->jdToYmd($upcoming_jd); + } else { + $upcoming_year = (int) date('Y'); + } + + return max(0, $upcoming_year - $event_year); +}; + +$birthday_label = static function (int $age) use ($ordinal): string { + return $age > 0 + ? I18N::translate('%s birthday', $ordinal($age)) + : I18N::translate('Birthday'); +}; + +$anniversary_label = static function (int $age) use ($ordinal): string { + return $age > 0 + ? I18N::translate('%s wedding anniversary', $ordinal($age)) + : I18N::translate('Wedding anniversary'); +}; + +// ─── Row styles ───────────────────────────────────────────────────────── + +$section_title_style = 'margin:0 0 4px;' + . 'font-family:' . $font_stack . ';' + . 'font-weight:400;font-size:22px;line-height:1.2;' + . 'color:' . $palette['ink'] . ';' + . 'letter-spacing:-0.005em;'; + +$section_kicker_style = 'margin:0 0 18px;' + . 'font-family:' . $font_stack . ';' + . 'font-style:italic;font-weight:300;font-size:14px;' + . 'color:' . $palette['ink3'] . ';'; + +$card_padding_y = 14; +$row_gap = 6; + +// Card surface follows the IndividualPage facts-table treatment in +// BockenTheme: surface tone + 8 px radius + a soft drop shadow for +// the visual lift. The class hook `nl-card` carries everything so +// it survives Bootstrap's `.table-bordered` overrides — see the +// + + diff --git a/resources/views/email.phtml b/resources/views/email.phtml index 999da03..c88a960 100644 --- a/resources/views/email.phtml +++ b/resources/views/email.phtml @@ -689,6 +689,16 @@ $timeline_arrow_row = '' // by the factory's HtmlFilter::ESCAPE setting, so // a stray "<" can't break the email layout. $intro_html = Registry::markdownFactory()->markdown(trim($intro), $tree); + // Force every Markdown-rendered to fit + // inside the intro container — many email + // clients honour neither