9458867d4d
- Admin can set a per-locale intro paragraph for the next issue on
the preferences page; cleared automatically after a successful
send. Stored in module_setting (longText) so multi-paragraph
notes fit.
- Intro is rendered via webtrees' CommonMark factory (same flavour
as notes) with raw HTML escaped, supports {{first_name}},
{{last_name}}, {{username}}, {{email}} substitution per recipient.
- Two-column intro layout: tree contact user's linked Individual
becomes the editorial portrait on the left. Their avatar is
added to the per-recipient embed set so the inline image always
resolves rather than falling through to a tree-page login link.
- Masthead now shows the tree URL under the title.
- Avatar source dimensions bumped 96→192 px and JPEG quality 75→88
so portraits stay crisp at retina display ratios.
829 lines
38 KiB
PHTML
829 lines
38 KiB
PHTML
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Fisharebest\Webtrees\Date;
|
|
use Fisharebest\Webtrees\Fact;
|
|
use Fisharebest\Webtrees\Family;
|
|
use Fisharebest\Webtrees\Http\RequestHandlers\TreePage;
|
|
use Fisharebest\Webtrees\I18N;
|
|
use Fisharebest\Webtrees\Individual;
|
|
use Fisharebest\Webtrees\Registry;
|
|
use Fisharebest\Webtrees\Tree;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* @var Tree $tree
|
|
* @var Collection<int,Fact> $birthdays
|
|
* @var Collection<int,Fact>|null $anniversaries
|
|
* @var Collection<int,Fact>|null $historical
|
|
* @var bool $include_anniversaries
|
|
* @var int $window_days Shared lookahead window for living + deceased events
|
|
* @var int $generated_at
|
|
* @var array<string,string> $avatar_cids xref => CID name
|
|
* @var array<string,string> $relationships xref => "your mother" etc. (per-recipient)
|
|
* @var array<string,true> $detailed_xrefs xref-set — render in detail; others as summary bullet
|
|
* @var string $account_url
|
|
* @var string $intro Admin-supplied one-shot intro paragraph; "" = skip block
|
|
* @var Individual|null $intro_author Tree contact's linked record, if any — avatar source for the intro
|
|
*/
|
|
|
|
// ─── BockenTheme light-mode palette ─────────────────────────────────────
|
|
// Pulled from src/scss/theme.scss + config/_theme-variables.scss in the
|
|
// Bocken theme so the newsletter and the website read as one product.
|
|
$palette = [
|
|
'bg' => '#f8f6f1', // body / page background (--color-bg-primary)
|
|
'surface' => '#efecea', // raised section cards (--color-bg-secondary)
|
|
'elevated' => '#dfdcd8', // tertiary surface (--color-bg-elevated)
|
|
'border' => '#ddd', // hairline borders (--color-border)
|
|
'ink' => '#2a2a2a', // primary text (--color-text-primary)
|
|
'ink2' => '#555', // secondary text (--color-text-secondary)
|
|
'ink3' => '#777', // tertiary text (--color-text-tertiary)
|
|
'mute' => '#aaa', // muted text (--color-text-muted)
|
|
'link' => '#5E81AC', // nord10 — primary link
|
|
'link_hov' => '#81A1C1', // nord9
|
|
'accent' => '#BF616A', // nord11 — red FAB accent
|
|
'birth' => '#5E81AC', // nord10 — birth (blue)
|
|
'death' => '#4C566A', // nord3 — death (graphite)
|
|
'marr' => '#B48EAD', // nord15 — marriage (purple/pink)
|
|
'rail' => '#cdc7be', // muted warm grey for the timeline rail
|
|
'dot' => '#5E81AC', // single unified colour for every timeline dot
|
|
];
|
|
|
|
$font_stack = "'Open Sans', Helvetica, Arial, 'Noto Sans', sans-serif";
|
|
|
|
$avatar_size = 56;
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
$relationships = $relationships ?? [];
|
|
$detailed_xrefs = $detailed_xrefs ?? [];
|
|
|
|
/**
|
|
* Returns true if this fact's primary record should render as a full
|
|
* detailed row (avatar + icon + timeline), or false for the compact
|
|
* text-only summary bullet at the bottom of the section.
|
|
*
|
|
* A Family fact (anniversary) counts as detailed if either spouse is
|
|
* in the recipient's lineal set.
|
|
*/
|
|
$is_detailed = static function (Fact $fact) use ($detailed_xrefs): bool {
|
|
$record = $fact->record();
|
|
|
|
if ($record instanceof Individual) {
|
|
return isset($detailed_xrefs[$record->xref()]);
|
|
}
|
|
|
|
if ($record instanceof Family) {
|
|
foreach ([$record->husband(), $record->wife()] as $spouse) {
|
|
if ($spouse instanceof Individual && isset($detailed_xrefs[$spouse->xref()])) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Just the linked name (no relationship), used both inline and inside
|
|
* the multi-line name+relationship block below.
|
|
*/
|
|
$linked_name = static function (Individual $individual) use ($palette): string {
|
|
$name = strip_tags($individual->fullName());
|
|
$url = $individual->url();
|
|
$style = 'color:' . $palette['ink'] . ';text-decoration:none;'
|
|
. 'border-bottom:1px solid ' . $palette['border'] . ';'
|
|
. 'padding-bottom:1px;';
|
|
|
|
return '<a href="' . e($url) . '" style="' . $style . '">' . e($name) . '</a>';
|
|
};
|
|
|
|
/**
|
|
* Inline list of names ("Hans Doe" or "Hans Doe & Lotte Doe") plus
|
|
* the combined relationship line on a second line beneath.
|
|
*/
|
|
$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 !== []) {
|
|
// Drop near-duplicate labels ("your father", "your father")
|
|
// so couples don't print the same relationship twice.
|
|
$rels = array_values(array_unique(array_map(static fn ($r) => e($r), $rels)));
|
|
$html .= '<div style="margin-top:2px;color:' . $palette['ink3']
|
|
. ';font-style:italic;font-weight:400;font-size:13px;">'
|
|
. implode(' & ', $rels)
|
|
. '</div>';
|
|
}
|
|
|
|
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());
|
|
};
|
|
|
|
/**
|
|
* Split a Fact's event date into (day+month, year) for the timeline
|
|
* rail where the day-of-the-anniversary is the meaningful sort key
|
|
* and the year is supporting information.
|
|
*
|
|
* @return array{day_month:string,year:string}
|
|
*/
|
|
$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);
|
|
};
|
|
|
|
/**
|
|
* Compact inline SVG glyph for each event type. Coloured to match the
|
|
* BockenTheme Nord palette: birth in nord10 blue, death in nord3
|
|
* graphite, marriage in nord15 mauve.
|
|
*/
|
|
$event_icon = static function (string $kind) use ($palette): string {
|
|
$svg_open = '<svg xmlns="http://www.w3.org/2000/svg" style="vertical-align:-3px;flex:none;" ';
|
|
|
|
return match ($kind) {
|
|
// Eight-point sparkle — celebration.
|
|
'BIRT' => $svg_open . 'viewBox="0 0 24 24" width="18" height="18">'
|
|
. '<path fill="' . $palette['birth'] . '" d="M12 1 L13.4 9 L20.5 7.2 L15.4 12.5 L20.5 17.8 L13.4 16 L12 24 L10.6 16 L3.5 17.8 L8.6 12.5 L3.5 7.2 L10.6 9 Z"/>'
|
|
. '</svg>',
|
|
|
|
// Latin obelus — the typographic mark for "died" in obituaries.
|
|
'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">'
|
|
. '<rect x="10.5" y="2" width="3" height="20" fill="' . $palette['death'] . '"/>'
|
|
. '<rect x="5.5" y="7" width="13" height="3" fill="' . $palette['death'] . '"/>'
|
|
. '</svg>',
|
|
|
|
// Two interlocking rings — universal mark of marriage.
|
|
'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">'
|
|
. '<circle cx="13" cy="11" r="8" fill="none" stroke="' . $palette['marr'] . '" stroke-width="1.8"/>'
|
|
. '<circle cx="23" cy="11" r="8" fill="none" stroke="' . $palette['marr'] . '" stroke-width="1.8"/>'
|
|
. '</svg>',
|
|
|
|
default => '',
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Map an event type to the colour used for that row's timeline dot
|
|
* (so the right rail reads as a colour-coded ribbon).
|
|
*/
|
|
$event_color = static function (string $kind) use ($palette): string {
|
|
return match ($kind) {
|
|
'BIRT' => $palette['birth'],
|
|
'DEAT' => $palette['death'],
|
|
'MARR' => $palette['marr'],
|
|
default => $palette['ink3'],
|
|
};
|
|
};
|
|
|
|
$avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size, $palette, $font_stack): string {
|
|
if (!$individual instanceof Individual) {
|
|
return '';
|
|
}
|
|
|
|
$alt = e(strip_tags($individual->fullName()));
|
|
$shadow = 'box-shadow:0 0 0 1px ' . $palette['border'] . ',0 1px 3px rgba(0,0,0,0.08);';
|
|
|
|
if (isset($avatar_cids[$individual->xref()])) {
|
|
$cid = $avatar_cids[$individual->xref()];
|
|
$inner = '<img src="cid:' . e($cid) . '" alt="' . $alt . '"'
|
|
. ' width="' . $avatar_size . '" height="' . $avatar_size . '"'
|
|
. ' style="border-radius:50%;object-fit:cover;display:block;' . $shadow . '">';
|
|
} else {
|
|
// Initial-disc fallback. Hue is hashed from the xref so the
|
|
// same person keeps the same colour across editions.
|
|
$hue = hexdec(substr(md5($individual->xref()), 0, 2)) * 360 / 255;
|
|
$first = strip_tags($individual->getAllNames()[0]['givn'] ?? $individual->xref());
|
|
$last = strip_tags($individual->getAllNames()[0]['surn'] ?? '');
|
|
$initials = e(mb_strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1)));
|
|
|
|
$inner = '<span aria-label="' . $alt . '" style="'
|
|
. 'display:block;width:' . $avatar_size . 'px;height:' . $avatar_size . 'px;'
|
|
. 'border-radius:50%;background:hsl(' . (int) $hue . ',32%,60%);color:#fff;'
|
|
. "font:600 19px/{$avatar_size}px " . $font_stack . ';text-align:center;'
|
|
. 'letter-spacing:0.3px;' . $shadow . '">' . $initials . '</span>';
|
|
}
|
|
|
|
return '<a href="' . e($individual->url()) . '" style="text-decoration:none;">' . $inner . '</a>';
|
|
};
|
|
|
|
$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 '<table cellpadding="0" cellspacing="0" border="0"><tr>'
|
|
. '<td>' . ($parts[0] ?? '') . '</td>'
|
|
. (isset($parts[1]) ? '<td style="padding-left:8px;">' . $parts[1] . '</td>' : '')
|
|
. '</tr></table>';
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
// Locale-aware ordinal: German uses "N.", English uses st/nd/rd/th.
|
|
$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');
|
|
};
|
|
|
|
$masthead_date = static function (int $timestamp): string {
|
|
if (str_starts_with(I18N::languageTag(), 'de')) {
|
|
$months = [
|
|
1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
|
];
|
|
|
|
return (int) date('j', $timestamp) . '. '
|
|
. $months[(int) date('n', $timestamp)] . ' '
|
|
. date('Y', $timestamp);
|
|
}
|
|
|
|
return date('F j, Y', $timestamp);
|
|
};
|
|
|
|
// ─── Row styles ─────────────────────────────────────────────────────────
|
|
|
|
$section_title_style = 'margin:0 0 4px;'
|
|
. 'font-family:' . $font_stack . ';'
|
|
. 'font-weight:400;font-size:24px;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 = 18;
|
|
$card_corner_radius = 8;
|
|
$row_gap = 12; // vertical gap between consecutive cards
|
|
|
|
// Inner card table — full rounded surface containing the avatar +
|
|
// content cells. Each event row drops one of these into the outer
|
|
// section table's left column.
|
|
$card_outer_style = 'width:100%;'
|
|
. 'background:' . $palette['surface'] . ';'
|
|
. 'border:1px solid ' . $palette['border'] . ';'
|
|
. 'border-radius:' . $card_corner_radius . 'px;'
|
|
. 'box-shadow:0 1px 3px rgba(0,0,0,0.04);';
|
|
|
|
$avatar_inner_td = 'width:72px;vertical-align:middle;'
|
|
. 'padding:' . $card_padding_y . 'px 0 ' . $card_padding_y . 'px 18px;';
|
|
|
|
$content_inner_td = 'vertical-align:middle;'
|
|
. 'padding:' . $card_padding_y . 'px 18px;'
|
|
. 'font-family:' . $font_stack . ';font-size:15px;line-height:1.4;'
|
|
. 'font-weight:300;color:' . $palette['ink'] . ';';
|
|
|
|
// Outer row cells: card on the left, a small gutter, then the rail.
|
|
$outer_card_td = 'vertical-align:middle;padding-bottom:' . $row_gap . 'px;';
|
|
$outer_gutter_td = 'width:16px;padding-bottom:' . $row_gap . 'px;';
|
|
// `width:1%` is the standard email-safe trick to make a TD shrink
|
|
// to its content, giving the card column on the left whatever
|
|
// width is left over. Combined with white-space:nowrap, the cell
|
|
// is exactly as wide as the longest "MONTH 1898" date line.
|
|
$outer_rail_td = 'width:1%;vertical-align:middle;'
|
|
. 'padding:0 4px ' . $row_gap . 'px 24px;'
|
|
. 'border-left:4px solid ' . $palette['rail'] . ';'
|
|
. 'font-family:' . $font_stack . ';'
|
|
. 'color:' . $palette['ink3'] . ';white-space:nowrap;';
|
|
|
|
// Renders one event row in the consistent 3-column layout shared by
|
|
// every section (avatar | content | date on the timeline rail).
|
|
$summary_kicker_style = 'margin:18px 0 8px;'
|
|
. 'font-family:' . $font_stack . ';'
|
|
. 'font-weight:500;font-size:11px;letter-spacing:0.16em;text-transform:uppercase;'
|
|
. 'color:' . $palette['ink3'] . ';'
|
|
. 'padding-top:10px;border-top:1px dashed ' . $palette['border'] . ';';
|
|
|
|
$summary_list_style = 'list-style:none;margin:0;padding:0;'
|
|
. 'font-family:' . $font_stack . ';font-size:13px;line-height:1.7;font-weight:300;'
|
|
. 'color:' . $palette['ink2'] . ';';
|
|
|
|
$summary_item_style = 'padding:3px 0;';
|
|
|
|
// Renders one event row in the 4-column layout
|
|
// [avatar] [content] [gutter] [timeline rail + date]
|
|
//
|
|
// The avatar+content TDs carry the card surface (background + side
|
|
// borders + corner radii on the first/last rows so the visual card
|
|
// wraps just those two cells). The timeline TD sits on the page
|
|
// background with a thick coloured rail on its left edge.
|
|
//
|
|
// $position is 'first', 'middle', or 'last' — used to add corner
|
|
// radii and outer borders only where they belong.
|
|
//
|
|
// $show_dot suppresses the dot on rows whose upcoming-day matches
|
|
// the previous row, so each calendar day has exactly one dot.
|
|
// Each event row builds a fully-rounded inner card (a nested table)
|
|
// and parks it in the outer row's left TD; the rail TD on the right
|
|
// carries the continuous timeline rail as a left-border. The outer
|
|
// table uses border-collapse:collapse so adjacent rail TDs merge
|
|
// their left-borders into ONE unbroken line across the row gaps.
|
|
//
|
|
// $show_dot suppresses the dot AND the day+month line on rows whose
|
|
// upcoming-day matches the previous (only the year is then shown).
|
|
$event_row = static function (
|
|
Fact $fact,
|
|
string $body_html,
|
|
bool $show_dot,
|
|
)
|
|
use (
|
|
$record_avatars,
|
|
$date_parts,
|
|
$card_outer_style,
|
|
$avatar_inner_td,
|
|
$content_inner_td,
|
|
$outer_card_td,
|
|
$outer_gutter_td,
|
|
$outer_rail_td,
|
|
$palette,
|
|
): string {
|
|
$card_html =
|
|
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" '
|
|
. 'style="' . $card_outer_style . '"><tr>'
|
|
. '<td style="' . $avatar_inner_td . '">' . $record_avatars($fact) . '</td>'
|
|
. '<td style="' . $content_inner_td . '">' . $body_html . '</td>'
|
|
. '</tr></table>';
|
|
|
|
$parts = $date_parts($fact);
|
|
|
|
// The rail TD has padding-left:24 + border-left:4. Inline
|
|
// content therefore starts 26px right of the rail centre
|
|
// (with border-collapse:collapse the 4px border straddles the
|
|
// cell boundary, putting half its width into our cell). A
|
|
// 14px-wide dot wants margin-left = -(26 + 7) = -33 to land
|
|
// its centre on the rail.
|
|
$dot_html = $show_dot
|
|
? '<span style="display:inline-block;width:14px;height:14px;background:'
|
|
. $palette['dot']
|
|
. ';border-radius:50%;margin-left:-33px;margin-right:16px;'
|
|
. 'vertical-align:middle;box-shadow:0 0 0 4px ' . $palette['bg'] . ';"></span>'
|
|
: '<span style="display:inline-block;width:14px;margin-left:-33px;'
|
|
. 'margin-right:16px;vertical-align:middle;"></span>';
|
|
|
|
if ($show_dot) {
|
|
$date_html =
|
|
'<span style="display:inline-block;vertical-align:middle;">'
|
|
. '<div style="font-weight:600;font-size:13px;letter-spacing:0.12em;color:'
|
|
. $palette['ink'] . ';">' . e($parts['day_month']) . '</div>'
|
|
. '<div style="font-weight:400;font-size:13px;color:'
|
|
. $palette['ink3'] . ';margin-top:2px;">' . e($parts['year']) . '</div>'
|
|
. '</span>';
|
|
} else {
|
|
$date_html =
|
|
'<span style="display:inline-block;vertical-align:middle;'
|
|
. 'font-weight:400;font-size:14px;color:' . $palette['ink2'] . ';">'
|
|
. e($parts['year'])
|
|
. '</span>';
|
|
}
|
|
|
|
return '<tr>'
|
|
. '<td style="' . $outer_card_td . '">' . $card_html . '</td>'
|
|
. '<td style="' . $outer_gutter_td . '"></td>'
|
|
. '<td style="' . $outer_rail_td . '">' . $dot_html . $date_html . '</td>'
|
|
. '</tr>';
|
|
};
|
|
|
|
// Outer section table uses border-collapse:collapse so the rail
|
|
// border-lefts merge into a continuous line across all row gaps.
|
|
$card_open = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" '
|
|
. 'style="width:100%;border-collapse:collapse;">';
|
|
$card_close = '</table>';
|
|
|
|
// The rail visual is built from three pieces:
|
|
//
|
|
// 1. $timeline_top_cap — a 4px tall stub at the start of the
|
|
// section whose border-top-left-radius rounds the top end of
|
|
// the rail.
|
|
// 2. Event rows — each carries the rail as a border-left
|
|
// on its right-hand TD; with border-collapse:collapse those
|
|
// adjacent borders merge into one continuous line.
|
|
// 3. $timeline_bottom_cap — a longer stub after the last event
|
|
// that extends the rail visually below the cards, with a
|
|
// border-bottom-left-radius rounding the bottom end.
|
|
//
|
|
// Then $timeline_arrow_row sits below the rail with no border (so
|
|
// the rail terminates cleanly and the arrow is visually separated)
|
|
// and renders the chevron arrowhead in inline SVG.
|
|
$timeline_top_cap = '<tr>'
|
|
. '<td></td><td></td>'
|
|
. '<td style="height:4px;padding:0;'
|
|
. 'border-left:4px solid ' . $palette['rail'] . ';'
|
|
. 'border-top-left-radius:4px;border-top-right-radius:4px;'
|
|
. 'font-size:0;line-height:0;"></td>'
|
|
. '</tr>';
|
|
|
|
$timeline_bottom_cap = '<tr>'
|
|
. '<td></td><td></td>'
|
|
. '<td style="height:18px;padding:0;'
|
|
. 'border-left:4px solid ' . $palette['rail'] . ';'
|
|
. 'border-bottom-left-radius:4px;border-bottom-right-radius:4px;'
|
|
. 'font-size:0;line-height:0;"></td>'
|
|
. '</tr>';
|
|
|
|
// Chevron arrowhead — inline SVG, stroke-linecap:round +
|
|
// stroke-linejoin:round so the strokes have soft ends. Sits below
|
|
// the rail's rounded tail in its own row so the rail terminates
|
|
// cleanly without the chevron's strokes painting over the round.
|
|
$arrow_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="22"'
|
|
. ' viewBox="0 0 26 22" fill="none" stroke="' . $palette['rail'] . '"'
|
|
. ' stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'
|
|
. '<path d="M5 6 L13 16 L21 6"/>'
|
|
. '</svg>';
|
|
|
|
$timeline_arrow_row = '<tr>'
|
|
. '<td></td><td></td>'
|
|
. '<td style="height:32px;padding:0 0 0 24px;'
|
|
. 'font-size:0;line-height:0;vertical-align:top;text-align:left;">'
|
|
. '<span style="display:inline-block;margin-left:-37px;vertical-align:top;margin-top:-14px;">'
|
|
. $arrow_svg
|
|
. '</span>'
|
|
. '</td></tr>';
|
|
|
|
?><!doctype html>
|
|
<html lang="<?= e(I18N::languageTag()) ?>">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title><?= e(I18N::translate('Family newsletter — %s', $masthead_date($generated_at))) ?></title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400&display=swap');
|
|
body { margin: 0; padding: 0; background: <?= $palette['bg'] ?>; }
|
|
a:hover { color: <?= $palette['link_hov'] ?> !important; }
|
|
.nl-tr:last-child { border-bottom: 0 !important; }
|
|
</style>
|
|
</head>
|
|
<body style="margin:0;padding:0;background:<?= $palette['bg'] ?>;color:<?= $palette['ink'] ?>;">
|
|
|
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
|
style="width:100%;background:<?= $palette['bg'] ?>;">
|
|
<tr>
|
|
<td align="center" style="padding:36px 16px;">
|
|
|
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
|
style="width:100%;max-width:720px;background:<?= $palette['bg'] ?>;font-family:<?= $font_stack ?>;">
|
|
|
|
<!-- Masthead ─────────────────────────────────────────── -->
|
|
<tr>
|
|
<td style="padding:8px 8px 24px;">
|
|
<div style="font-size:11px;font-weight:600;letter-spacing:0.22em;text-transform:uppercase;color:<?= $palette['link'] ?>;">
|
|
<?= e(I18N::translate('Family Chronicle')) ?>
|
|
</div>
|
|
<h1 style="margin:10px 0 6px;font-weight:300;font-size:38px;line-height:1.1;letter-spacing:-0.015em;color:<?= $palette['ink'] ?>;">
|
|
<?= e($tree->title()) ?>
|
|
</h1>
|
|
<?php
|
|
// Strip the leading scheme so the link
|
|
// reads as a clean hostname/path — the
|
|
// anchor still points at the absolute URL.
|
|
$tree_url = route(TreePage::class, ['tree' => $tree->name()]);
|
|
$tree_url_lbl = preg_replace('~^https?://~i', '', rtrim($tree_url, '/'));
|
|
?>
|
|
<div style="margin-top:4px;font-size:13px;font-weight:400;letter-spacing:0.01em;">
|
|
<a href="<?= e($tree_url) ?>"
|
|
style="color:<?= $palette['link'] ?>;text-decoration:none;border-bottom:1px solid <?= $palette['link'] ?>33;">
|
|
<?= e($tree_url_lbl) ?>
|
|
</a>
|
|
</div>
|
|
<div style="margin-top:6px;font-size:13px;font-weight:300;color:<?= $palette['ink3'] ?>;">
|
|
<?= e($masthead_date($generated_at)) ?>
|
|
<span style="color:<?= $palette['mute'] ?>;">·</span>
|
|
<?= e(I18N::translate('Events in the next %d days.', $window_days)) ?>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
|
|
<?php if (trim($intro) !== '') : ?>
|
|
<!-- Editorial: one-shot intro paragraph ──────────── -->
|
|
<?php
|
|
// Render via webtrees' Markdown factory: CommonMark
|
|
// with autolinks, the same flavour used elsewhere
|
|
// in the site. Raw HTML in the source is escaped
|
|
// by the factory's HtmlFilter::ESCAPE setting, so
|
|
// a stray "<" can't break the email layout.
|
|
$intro_html = Registry::markdownFactory()->markdown(trim($intro), $tree);
|
|
$intro_inner = '<div style="border-left:3px solid ' . $palette['accent'] . ';padding:6px 0 6px 16px;'
|
|
. 'font-size:15px;line-height:1.55;font-weight:300;color:' . $palette['ink'] . ';'
|
|
. 'font-style:italic;">' . $intro_html . '</div>';
|
|
?>
|
|
<tr>
|
|
<td style="padding:0 8px 24px;">
|
|
<?php if ($intro_author instanceof Individual) : ?>
|
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
|
style="width:100%;border-collapse:collapse;">
|
|
<tr>
|
|
<td style="width:72px;vertical-align:top;padding-top:4px;">
|
|
<?= $avatar($intro_author) ?>
|
|
</td>
|
|
<td style="vertical-align:top;">
|
|
<?= $intro_inner ?>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<?php else : ?>
|
|
<?= $intro_inner ?>
|
|
<?php endif ?>
|
|
</td>
|
|
</tr>
|
|
<?php endif ?>
|
|
|
|
<?php if (!$birthdays->isEmpty()) : ?>
|
|
<?php
|
|
$detailed = [];
|
|
$summary = [];
|
|
foreach ($birthdays as $fact) {
|
|
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
|
|
}
|
|
?>
|
|
<tr><td style="padding:8px 0 0;">
|
|
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
|
|
<p style="<?= $section_kicker_style ?>">
|
|
<?= e(I18N::translate('Living kin who will celebrate this fortnight.')) ?>
|
|
</p>
|
|
<?php if ($detailed !== []) : ?>
|
|
<?= $card_open ?>
|
|
<?= $timeline_top_cap ?>
|
|
<?php $prev_jd = null; ?>
|
|
<?php foreach ($detailed as $fact) : ?>
|
|
<?php
|
|
$age = $upcoming_age($fact);
|
|
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('BIRT') . '</span>'
|
|
. '<span style="vertical-align:middle;">'
|
|
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
|
|
. '<div style="margin-top:2px;color:' . $palette['ink2'] . ';font-weight:300;">' . e($birthday_label($age)) . '</div>'
|
|
. '</span>';
|
|
$show_dot = ($fact->jd ?? 0) !== $prev_jd;
|
|
$prev_jd = $fact->jd ?? 0;
|
|
echo $event_row($fact, $body, $show_dot);
|
|
?>
|
|
<?php endforeach ?>
|
|
<?= $timeline_bottom_cap ?>
|
|
<?= $timeline_arrow_row ?>
|
|
<?= $card_close ?>
|
|
<?php endif ?>
|
|
<?php if ($summary !== []) : ?>
|
|
<div style="<?= $summary_kicker_style ?>">
|
|
<?= e(I18N::translate('Other birthdays')) ?>
|
|
</div>
|
|
<ul style="<?= $summary_list_style ?>">
|
|
<?php foreach ($summary as $fact) : ?>
|
|
<?php $age = $upcoming_age($fact); ?>
|
|
<li style="<?= $summary_item_style ?>">
|
|
<?= $record_label($fact) ?>
|
|
<span style="color:<?= $palette['ink3'] ?>;"><?= e($birthday_label($age)) ?></span>
|
|
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
|
|
</li>
|
|
<?php endforeach ?>
|
|
</ul>
|
|
<?php endif ?>
|
|
</td></tr>
|
|
<?php endif ?>
|
|
|
|
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
|
|
<?php
|
|
$detailed = [];
|
|
$summary = [];
|
|
foreach ($anniversaries as $fact) {
|
|
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
|
|
}
|
|
?>
|
|
<tr><td style="padding:32px 0 0;">
|
|
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
|
|
<p style="<?= $section_kicker_style ?>">
|
|
<?= e(I18N::translate('Marriages still intact.')) ?>
|
|
</p>
|
|
<?php if ($detailed !== []) : ?>
|
|
<?= $card_open ?>
|
|
<?= $timeline_top_cap ?>
|
|
<?php $prev_jd = null; ?>
|
|
<?php foreach ($detailed as $fact) : ?>
|
|
<?php
|
|
$age = $upcoming_age($fact);
|
|
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('MARR') . '</span>'
|
|
. '<span style="vertical-align:middle;">'
|
|
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
|
|
. '<div style="margin-top:2px;color:' . $palette['ink2'] . ';font-weight:300;">' . e($anniversary_label($age)) . '</div>'
|
|
. '</span>';
|
|
$show_dot = ($fact->jd ?? 0) !== $prev_jd;
|
|
$prev_jd = $fact->jd ?? 0;
|
|
echo $event_row($fact, $body, $show_dot);
|
|
?>
|
|
<?php endforeach ?>
|
|
<?= $timeline_bottom_cap ?>
|
|
<?= $timeline_arrow_row ?>
|
|
<?= $card_close ?>
|
|
<?php endif ?>
|
|
<?php if ($summary !== []) : ?>
|
|
<div style="<?= $summary_kicker_style ?>">
|
|
<?= e(I18N::translate('Other anniversaries')) ?>
|
|
</div>
|
|
<ul style="<?= $summary_list_style ?>">
|
|
<?php foreach ($summary as $fact) : ?>
|
|
<?php $age = $upcoming_age($fact); ?>
|
|
<li style="<?= $summary_item_style ?>">
|
|
<?= $record_label($fact) ?>
|
|
<span style="color:<?= $palette['ink3'] ?>;"><?= e($anniversary_label($age)) ?></span>
|
|
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
|
|
</li>
|
|
<?php endforeach ?>
|
|
</ul>
|
|
<?php endif ?>
|
|
</td></tr>
|
|
<?php endif ?>
|
|
|
|
<?php if ($historical !== null && !$historical->isEmpty()) : ?>
|
|
<?php
|
|
$detailed = [];
|
|
$summary = [];
|
|
foreach ($historical as $fact) {
|
|
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
|
|
}
|
|
?>
|
|
<tr><td style="padding:32px 0 0;">
|
|
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('On this month in history')) ?></h2>
|
|
<p style="<?= $section_kicker_style ?>">
|
|
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $window_days)) ?>
|
|
</p>
|
|
<?php if ($detailed !== []) : ?>
|
|
<?= $card_open ?>
|
|
<?= $timeline_top_cap ?>
|
|
<?php $prev_jd = null; ?>
|
|
<?php foreach ($detailed as $fact) : ?>
|
|
<?php
|
|
$kind = $event_kind($fact);
|
|
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>'
|
|
. '<span style="vertical-align:middle;">'
|
|
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
|
|
. '<div style="margin-top:2px;color:' . $palette['ink2'] . ';font-weight:300;">' . e($fact->label()) . '</div>'
|
|
. '</span>';
|
|
$show_dot = ($fact->jd ?? 0) !== $prev_jd;
|
|
$prev_jd = $fact->jd ?? 0;
|
|
echo $event_row($fact, $body, $show_dot);
|
|
?>
|
|
<?php endforeach ?>
|
|
<?= $timeline_bottom_cap ?>
|
|
<?= $timeline_arrow_row ?>
|
|
<?= $card_close ?>
|
|
<?php endif ?>
|
|
<?php if ($summary !== []) : ?>
|
|
<div style="<?= $summary_kicker_style ?>">
|
|
<?= e(I18N::translate('Other historical events')) ?>
|
|
</div>
|
|
<ul style="<?= $summary_list_style ?>">
|
|
<?php foreach ($summary as $fact) : ?>
|
|
<li style="<?= $summary_item_style ?>">
|
|
<?= $record_label($fact) ?>
|
|
<span style="color:<?= $palette['ink3'] ?>;"><?= e($fact->label()) ?></span>
|
|
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
|
|
</li>
|
|
<?php endforeach ?>
|
|
</ul>
|
|
<?php endif ?>
|
|
</td></tr>
|
|
<?php endif ?>
|
|
|
|
<!-- Footer ─────────────────────────────────────────── -->
|
|
<tr><td style="padding:40px 8px 8px;">
|
|
<div style="height:1px;background:<?= $palette['border'] ?>;margin-bottom:18px;"></div>
|
|
<p style="margin:0;font-size:12px;line-height:1.6;font-weight:300;color:<?= $palette['ink3'] ?>;">
|
|
<?= e(I18N::translate('You are receiving this email because you subscribed to the %s newsletter.', $tree->title())) ?>
|
|
<br>
|
|
<?= I18N::translate(
|
|
'You can change how often you receive this email, or unsubscribe entirely, in the “Newsletter subscription” section on your %s page.',
|
|
'<a href="' . e($account_url) . '" style="color:' . $palette['link'] . ';text-decoration:none;border-bottom:1px solid ' . $palette['link'] . ';">' . e(I18N::translate('My account')) . '</a>',
|
|
) ?>
|
|
</p>
|
|
</td></tr>
|
|
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
</body>
|
|
</html>
|