Add tree-home block; merge birthday/anniversary timeline
- New "Upcoming family events" block for the tree home page, rendering the same card + timeline visualisation as the newsletter email but adapted for web context: avatars resolve to media-file URLs (no CID), the silhouette placeholder reuses BockenTheme's .person-card .photo-placeholder rules so the Nord-mixed shades and dark-mode handling stay in sync with the full-diagram plugin, and per-viewer relationship labels surface when the signed-in user is linked to an Individual on the tree. - Default window 30 days, configurable via the standard block config UI. Wide-screen wrapper caps at 760 px with a small right-side breathing margin. - Block renders via AJAX and caches its HTML for 5 minutes per (tree, window, viewer, locale), so the tree home page paints instantly and repeat visits skip the heavy event/query + relationship-BFS work. - Living-kin section is now a single date-sorted timeline that mixes birthdays and intact-couple anniversaries. Each row's icon + label key off the fact's tag, so a mixed run shares one rail. Applies to both block and email. - Newsletter subscription menu entry removed from the header; the form is still reachable on the standard /my-account page via the registerCustomView override.
This commit is contained in:
@@ -0,0 +1,581 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Fisharebest\Webtrees\Date;
|
||||||
|
use Fisharebest\Webtrees\Fact;
|
||||||
|
use Fisharebest\Webtrees\Family;
|
||||||
|
use Fisharebest\Webtrees\I18N;
|
||||||
|
use Fisharebest\Webtrees\Individual;
|
||||||
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upcoming-events block for the tree home page.
|
||||||
|
*
|
||||||
|
* Renders the same editorial card-and-timeline visualisation as the
|
||||||
|
* newsletter email, with two adaptations for web context:
|
||||||
|
* - avatars resolve to https URLs (`$avatar_srcs`) rather than the
|
||||||
|
* email's `cid:` MIME-part references;
|
||||||
|
* - everyone is "detailed" — there's no per-recipient kin-distance
|
||||||
|
* filter, since the block is rendered once for the whole tree.
|
||||||
|
*
|
||||||
|
* Keep this file structurally close to email.phtml so the two stay
|
||||||
|
* visually identical when one is tweaked.
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
* @var array<string,string> $avatar_srcs xref => https URL of the highlight image
|
||||||
|
* @var array<string,string> $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 '<a href="' . e($url) . '" style="' . $style . '">' . e($name) . '</a>';
|
||||||
|
};
|
||||||
|
|
||||||
|
$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 .= '<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());
|
||||||
|
};
|
||||||
|
|
||||||
|
$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 xmlns="http://www.w3.org/2000/svg" style="vertical-align:-3px;flex:none;" ';
|
||||||
|
|
||||||
|
return match ($kind) {
|
||||||
|
'BIRT' => $svg_open . 'viewBox="0 0 24 24" width="18" height="18">'
|
||||||
|
. '<path style="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>',
|
||||||
|
|
||||||
|
'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">'
|
||||||
|
. '<rect x="10.5" y="2" width="3" height="20" style="fill:' . $palette['death'] . ';"/>'
|
||||||
|
. '<rect x="5.5" y="7" width="13" height="3" style="fill:' . $palette['death'] . ';"/>'
|
||||||
|
. '</svg>',
|
||||||
|
|
||||||
|
'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">'
|
||||||
|
. '<circle cx="13" cy="11" r="8" fill="none" style="stroke:' . $palette['marr'] . ';" stroke-width="1.8"/>'
|
||||||
|
. '<circle cx="23" cy="11" r="8" fill="none" style="stroke:' . $palette['marr'] . ';" stroke-width="1.8"/>'
|
||||||
|
. '</svg>',
|
||||||
|
|
||||||
|
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 = '<img src="' . e($src) . '" alt="' . $alt . '"'
|
||||||
|
. ' width="' . $avatar_size . '" height="' . $avatar_size . '"'
|
||||||
|
. ' style="border-radius:50%;object-fit:cover;display:block;">';
|
||||||
|
} 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 = '<svg class="' . $wrap_cls . '" xmlns="http://www.w3.org/2000/svg"'
|
||||||
|
. ' viewBox="0 0 56 56"'
|
||||||
|
. ' width="' . $avatar_size . '" height="' . $avatar_size . '"'
|
||||||
|
. ' aria-label="' . $alt . '"'
|
||||||
|
. ' style="display:block;border-radius:50%;">'
|
||||||
|
. '<circle class="photo-placeholder" cx="28" cy="28" r="28"/>'
|
||||||
|
. '<circle class="silhouette" cx="28" cy="22" r="10"/>'
|
||||||
|
. '<ellipse class="silhouette" cx="28" cy="48" rx="16" ry="12"/>'
|
||||||
|
. '</svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '';
|
||||||
|
};
|
||||||
|
|
||||||
|
$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
|
||||||
|
// <style> block at the top of this view.
|
||||||
|
$card_outer_style = 'width:100%;border-radius:0.5rem;overflow:hidden;';
|
||||||
|
|
||||||
|
$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_card_td = 'vertical-align:middle;padding-bottom:' . $row_gap . 'px;';
|
||||||
|
$outer_gutter_td = 'width:16px;padding-bottom:' . $row_gap . 'px;';
|
||||||
|
// border-left lives on the `.nl-rail` class (see <style> block at the
|
||||||
|
// top of this view) so it survives the global Bootstrap-borders reset.
|
||||||
|
$outer_rail_td = 'width:1%;vertical-align:middle;'
|
||||||
|
. 'padding:0 4px ' . $row_gap . 'px 24px;'
|
||||||
|
. 'font-family:' . $font_stack . ';'
|
||||||
|
. 'color:' . $palette['ink3'] . ';white-space:nowrap;';
|
||||||
|
|
||||||
|
$summary_kicker_style = 'margin:24px 0 8px;'
|
||||||
|
. 'font-family:' . $font_stack . ';'
|
||||||
|
. 'font-weight:500;font-size:11px;letter-spacing:0.16em;text-transform:uppercase;'
|
||||||
|
. 'color:' . $palette['ink3'] . ';';
|
||||||
|
|
||||||
|
$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;';
|
||||||
|
|
||||||
|
$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 class="nl-card" 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);
|
||||||
|
|
||||||
|
$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 class="nl-rail" style="' . $outer_rail_td . '">' . $dot_html . $date_html . '</td>'
|
||||||
|
. '</tr>';
|
||||||
|
};
|
||||||
|
|
||||||
|
$card_open = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" '
|
||||||
|
. 'style="width:100%;border-collapse:collapse;">';
|
||||||
|
$card_close = '</table>';
|
||||||
|
|
||||||
|
$timeline_top_cap = '<tr>'
|
||||||
|
. '<td></td><td></td>'
|
||||||
|
. '<td class="nl-rail" style="height:4px;padding:0;'
|
||||||
|
. '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 class="nl-rail" style="height:18px;padding:0;'
|
||||||
|
. 'border-bottom-left-radius:4px;border-bottom-right-radius:4px;'
|
||||||
|
. 'font-size:0;line-height:0;"></td>'
|
||||||
|
. '</tr>';
|
||||||
|
|
||||||
|
// Stroke as inline style so the CSS variable resolves; the attribute
|
||||||
|
// form can only take a literal colour.
|
||||||
|
$arrow_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="22"'
|
||||||
|
. ' viewBox="0 0 26 22" fill="none" style="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>';
|
||||||
|
|
||||||
|
$nothing_to_show = $birthdays->isEmpty()
|
||||||
|
&& ($anniversaries === null || $anniversaries->isEmpty())
|
||||||
|
&& ($historical === null || $historical->isEmpty());
|
||||||
|
?>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* BockenTheme applies `@extend .table; @extend .table-bordered;`
|
||||||
|
* to every <table> on the page, which (a) paints 1px hairline
|
||||||
|
* borders on every cell and (b) sets `--bs-table-bg` to the body
|
||||||
|
* bg so cells get repainted with the page colour — wiping out
|
||||||
|
* any inline card background. Reset both, scoped to this block.
|
||||||
|
* The timeline rail is then re-added via the `.nl-rail` class on
|
||||||
|
* the relevant TDs.
|
||||||
|
*/
|
||||||
|
.email-newsletter-block table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.email-newsletter-block table,
|
||||||
|
.email-newsletter-block tr,
|
||||||
|
.email-newsletter-block td,
|
||||||
|
.email-newsletter-block th {
|
||||||
|
border: 0 !important;
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
/* Card surface — matches the IndividualPage `.wt-facts-table > tr`
|
||||||
|
treatment in BockenTheme: a quiet surface tone lifted by a soft
|
||||||
|
drop shadow. Cells inside are transparent so the table-level
|
||||||
|
bg shows through (and so Bootstrap's `--bs-table-bg` can't
|
||||||
|
repaint them with the page color). */
|
||||||
|
.email-newsletter-block .nl-card {
|
||||||
|
background: var(--color-surface, #efecea);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.email-newsletter-block .nl-card > tbody > tr > td {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
/* Timeline rail — re-add the 4 px left border the global reset
|
||||||
|
killed. !important is needed to beat the reset above. */
|
||||||
|
.email-newsletter-block .nl-rail {
|
||||||
|
border-left: 4px solid var(--color-border) !important;
|
||||||
|
}
|
||||||
|
/* Avatar fallback piggybacks on BockenTheme's `.person-card`
|
||||||
|
placeholder rules (see theme.scss "FULL DIAGRAM PLUGIN"
|
||||||
|
section), so light/dark shading stays in sync with the
|
||||||
|
full-diagram plugin automatically — no styles needed here. */
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="email-newsletter-block"
|
||||||
|
style="max-width:760px;padding-right:clamp(0px,1vw,6px);">
|
||||||
|
<?php if ($nothing_to_show) : ?>
|
||||||
|
<p style="margin:8px 0;color:<?= $palette['ink3'] ?>;font-style:italic;">
|
||||||
|
<?= e(I18N::translate('No upcoming family events in the next %d days.', $window_days)) ?>
|
||||||
|
</p>
|
||||||
|
<?php else : ?>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
||||||
|
style="width:100%;font-family:<?= $font_stack ?>;color:<?= $palette['ink'] ?>;">
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// Merge living-kin events (birthdays + intact-couple
|
||||||
|
// anniversaries) into one date-sorted timeline. Icon
|
||||||
|
// and label key off the fact's tag so a mixed row of
|
||||||
|
// BIRT and MARR shares a single rail.
|
||||||
|
$living = collect($birthdays);
|
||||||
|
if ($include_anniversaries && $anniversaries !== null) {
|
||||||
|
$living = $living->merge($anniversaries);
|
||||||
|
}
|
||||||
|
$living = $living->sortBy(static fn (Fact $f): int => $f->jd ?? 0)->values();
|
||||||
|
?>
|
||||||
|
<?php if (!$living->isEmpty()) : ?>
|
||||||
|
<tr><td style="padding:8px 0 0;">
|
||||||
|
<h3 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming events')) ?></h3>
|
||||||
|
<p style="<?= $section_kicker_style ?>">
|
||||||
|
<?= e(I18N::translate('Birthdays of living kin and anniversaries of intact couples in the next %d days.', $window_days)) ?>
|
||||||
|
</p>
|
||||||
|
<?= $card_open ?>
|
||||||
|
<?= $timeline_top_cap ?>
|
||||||
|
<?php $prev_jd = null; ?>
|
||||||
|
<?php foreach ($living as $fact) : ?>
|
||||||
|
<?php
|
||||||
|
$kind = $event_kind($fact);
|
||||||
|
$age = $upcoming_age($fact);
|
||||||
|
$label = $kind === 'MARR' ? $anniversary_label($age) : $birthday_label($age);
|
||||||
|
$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($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 ?>
|
||||||
|
</td></tr>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<?php if ($historical !== null && !$historical->isEmpty()) : ?>
|
||||||
|
<tr><td style="padding:32px 0 0;">
|
||||||
|
<h3 style="<?= $section_title_style ?>"><?= e(I18N::translate('On this month in history')) ?></h3>
|
||||||
|
<p style="<?= $section_kicker_style ?>">
|
||||||
|
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $window_days)) ?>
|
||||||
|
</p>
|
||||||
|
<?= $card_open ?>
|
||||||
|
<?= $timeline_top_cap ?>
|
||||||
|
<?php $prev_jd = null; ?>
|
||||||
|
<?php foreach ($historical 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 ?>
|
||||||
|
</td></tr>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
+33
-58
@@ -689,6 +689,16 @@ $timeline_arrow_row = '<tr>'
|
|||||||
// by the factory's HtmlFilter::ESCAPE setting, so
|
// by the factory's HtmlFilter::ESCAPE setting, so
|
||||||
// a stray "<" can't break the email layout.
|
// a stray "<" can't break the email layout.
|
||||||
$intro_html = Registry::markdownFactory()->markdown(trim($intro), $tree);
|
$intro_html = Registry::markdownFactory()->markdown(trim($intro), $tree);
|
||||||
|
// Force every Markdown-rendered <img> to fit
|
||||||
|
// inside the intro container — many email
|
||||||
|
// clients honour neither <style> blocks nor
|
||||||
|
// CSS class hooks reliably, so inline width
|
||||||
|
// constraints are the only portable fix.
|
||||||
|
$intro_html = preg_replace(
|
||||||
|
'/<img\b/i',
|
||||||
|
'<img style="max-width:100%;height:auto;display:block;border-radius:6px;margin:8px 0;"',
|
||||||
|
$intro_html,
|
||||||
|
) ?? $intro_html;
|
||||||
$intro_inner = '<div style="border-left:3px solid ' . $palette['accent'] . ';padding:6px 0 6px 16px;'
|
$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-size:15px;line-height:1.55;font-weight:300;color:' . $palette['ink'] . ';'
|
||||||
. 'font-style:italic;">' . $intro_html . '</div>';
|
. 'font-style:italic;">' . $intro_html . '</div>';
|
||||||
@@ -714,18 +724,29 @@ $timeline_arrow_row = '<tr>'
|
|||||||
</tr>
|
</tr>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
<?php if (!$birthdays->isEmpty()) : ?>
|
<?php
|
||||||
|
// Merge living-kin events (birthdays + intact-couple
|
||||||
|
// anniversaries) into a single date-sorted timeline.
|
||||||
|
// The icon and label come from the fact's tag, so a
|
||||||
|
// mixed row of BIRT and MARR facts shares one rail.
|
||||||
|
$living = collect($birthdays);
|
||||||
|
if ($include_anniversaries && $anniversaries !== null) {
|
||||||
|
$living = $living->merge($anniversaries);
|
||||||
|
}
|
||||||
|
$living = $living->sortBy(static fn (Fact $f): int => $f->jd ?? 0)->values();
|
||||||
|
?>
|
||||||
|
<?php if (!$living->isEmpty()) : ?>
|
||||||
<?php
|
<?php
|
||||||
$detailed = [];
|
$detailed = [];
|
||||||
$summary = [];
|
$summary = [];
|
||||||
foreach ($birthdays as $fact) {
|
foreach ($living as $fact) {
|
||||||
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
|
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<tr><td style="padding:8px 0 0;">
|
<tr><td style="padding:8px 0 0;">
|
||||||
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
|
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming events')) ?></h2>
|
||||||
<p style="<?= $section_kicker_style ?>">
|
<p style="<?= $section_kicker_style ?>">
|
||||||
<?= e(I18N::translate('Living kin who will celebrate this fortnight.')) ?>
|
<?= e(I18N::translate('Birthdays of living kin and anniversaries of intact couples in the next %d days.', $window_days)) ?>
|
||||||
</p>
|
</p>
|
||||||
<?php if ($detailed !== []) : ?>
|
<?php if ($detailed !== []) : ?>
|
||||||
<?= $card_open ?>
|
<?= $card_open ?>
|
||||||
@@ -733,11 +754,13 @@ $timeline_arrow_row = '<tr>'
|
|||||||
<?php $prev_jd = null; ?>
|
<?php $prev_jd = null; ?>
|
||||||
<?php foreach ($detailed as $fact) : ?>
|
<?php foreach ($detailed as $fact) : ?>
|
||||||
<?php
|
<?php
|
||||||
|
$kind = $event_kind($fact);
|
||||||
$age = $upcoming_age($fact);
|
$age = $upcoming_age($fact);
|
||||||
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('BIRT') . '</span>'
|
$label = $kind === 'MARR' ? $anniversary_label($age) : $birthday_label($age);
|
||||||
|
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>'
|
||||||
. '<span style="vertical-align:middle;">'
|
. '<span style="vertical-align:middle;">'
|
||||||
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
|
. '<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>'
|
. '<div style="margin-top:2px;color:' . $palette['ink2'] . ';font-weight:300;">' . e($label) . '</div>'
|
||||||
. '</span>';
|
. '</span>';
|
||||||
$show_dot = ($fact->jd ?? 0) !== $prev_jd;
|
$show_dot = ($fact->jd ?? 0) !== $prev_jd;
|
||||||
$prev_jd = $fact->jd ?? 0;
|
$prev_jd = $fact->jd ?? 0;
|
||||||
@@ -750,66 +773,18 @@ $timeline_arrow_row = '<tr>'
|
|||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<?php if ($summary !== []) : ?>
|
<?php if ($summary !== []) : ?>
|
||||||
<div style="<?= $summary_kicker_style ?>">
|
<div style="<?= $summary_kicker_style ?>">
|
||||||
<?= e(I18N::translate('Other birthdays')) ?>
|
<?= e(I18N::translate('Other upcoming events')) ?>
|
||||||
</div>
|
</div>
|
||||||
<ul style="<?= $summary_list_style ?>">
|
<ul style="<?= $summary_list_style ?>">
|
||||||
<?php foreach ($summary as $fact) : ?>
|
<?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
|
<?php
|
||||||
|
$kind = $event_kind($fact);
|
||||||
$age = $upcoming_age($fact);
|
$age = $upcoming_age($fact);
|
||||||
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('MARR') . '</span>'
|
$label = $kind === 'MARR' ? $anniversary_label($age) : $birthday_label($age);
|
||||||
. '<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 ?>">
|
<li style="<?= $summary_item_style ?>">
|
||||||
<?= $record_label($fact) ?>
|
<?= $record_label($fact) ?>
|
||||||
<span style="color:<?= $palette['ink3'] ?>;"><?= e($anniversary_label($age)) ?></span>
|
<span style="color:<?= $palette['ink3'] ?>;"><?= e($label) ?></span>
|
||||||
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
|
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
|
|||||||
+224
-31
@@ -11,43 +11,55 @@ declare(strict_types=1);
|
|||||||
namespace EmailNewsletter;
|
namespace EmailNewsletter;
|
||||||
|
|
||||||
use EmailNewsletter\Http\AccountUpdateDecorator;
|
use EmailNewsletter\Http\AccountUpdateDecorator;
|
||||||
|
use EmailNewsletter\Services\EventQueryService;
|
||||||
use EmailNewsletter\Services\NewsletterDispatchService;
|
use EmailNewsletter\Services\NewsletterDispatchService;
|
||||||
|
use EmailNewsletter\Services\RelationshipPathFinder;
|
||||||
use Fisharebest\Webtrees\Auth;
|
use Fisharebest\Webtrees\Auth;
|
||||||
|
use Fisharebest\Webtrees\Contracts\UserInterface;
|
||||||
use Fisharebest\Webtrees\FlashMessages;
|
use Fisharebest\Webtrees\FlashMessages;
|
||||||
use Fisharebest\Webtrees\Http\Exceptions\HttpAccessDeniedException;
|
use Fisharebest\Webtrees\Http\Exceptions\HttpAccessDeniedException;
|
||||||
use Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit;
|
|
||||||
use Fisharebest\Webtrees\Http\RequestHandlers\AccountUpdate;
|
use Fisharebest\Webtrees\Http\RequestHandlers\AccountUpdate;
|
||||||
use Fisharebest\Webtrees\I18N;
|
use Fisharebest\Webtrees\I18N;
|
||||||
use Fisharebest\Webtrees\Menu;
|
use Fisharebest\Webtrees\Individual;
|
||||||
use Fisharebest\Webtrees\Registry;
|
use Fisharebest\Webtrees\Registry;
|
||||||
use Fisharebest\Webtrees\Module\AbstractModule;
|
use Fisharebest\Webtrees\Module\AbstractModule;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleBlockInterface;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleBlockTrait;
|
||||||
use Fisharebest\Webtrees\Module\ModuleConfigInterface;
|
use Fisharebest\Webtrees\Module\ModuleConfigInterface;
|
||||||
use Fisharebest\Webtrees\Module\ModuleConfigTrait;
|
use Fisharebest\Webtrees\Module\ModuleConfigTrait;
|
||||||
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
|
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
|
||||||
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
|
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
|
||||||
use Fisharebest\Webtrees\Module\ModuleMenuInterface;
|
|
||||||
use Fisharebest\Webtrees\Module\ModuleMenuTrait;
|
|
||||||
use Fisharebest\Webtrees\Services\TreeService;
|
use Fisharebest\Webtrees\Services\TreeService;
|
||||||
use Fisharebest\Webtrees\Services\UserService;
|
use Fisharebest\Webtrees\Services\UserService;
|
||||||
use Fisharebest\Webtrees\User;
|
|
||||||
use Fisharebest\Webtrees\Tree;
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
use Fisharebest\Webtrees\User;
|
||||||
use Fisharebest\Webtrees\Validator;
|
use Fisharebest\Webtrees\Validator;
|
||||||
use Fisharebest\Webtrees\View;
|
use Fisharebest\Webtrees\View;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
class Module extends AbstractModule implements ModuleCustomInterface, ModuleConfigInterface, ModuleMenuInterface
|
class Module extends AbstractModule implements ModuleCustomInterface, ModuleConfigInterface, ModuleBlockInterface
|
||||||
{
|
{
|
||||||
use ModuleCustomTrait;
|
use ModuleCustomTrait;
|
||||||
use ModuleConfigTrait;
|
use ModuleConfigTrait;
|
||||||
use ModuleMenuTrait;
|
use ModuleBlockTrait;
|
||||||
|
|
||||||
private const string SETTING_CRON_TOKEN = Configuration::MODULE_PREF_CRON_TOKEN;
|
private const string SETTING_CRON_TOKEN = Configuration::MODULE_PREF_CRON_TOKEN;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default look-ahead window for the tree-home block. Distinct from
|
||||||
|
* the per-tree newsletter cadence — the block always shows the
|
||||||
|
* next 30 days regardless of how often the email is sent.
|
||||||
|
*/
|
||||||
|
private const int BLOCK_DEFAULT_WINDOW_DAYS = 30;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly NewsletterDispatchService $dispatch_service,
|
private readonly NewsletterDispatchService $dispatch_service,
|
||||||
private readonly TreeService $tree_service,
|
private readonly TreeService $tree_service,
|
||||||
private readonly UserService $user_service,
|
private readonly UserService $user_service,
|
||||||
|
private readonly EventQueryService $event_query_service,
|
||||||
|
private readonly RelationshipPathFinder $relationship_finder,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +140,10 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
|||||||
'Other birthdays' => 'Weitere Geburtstage',
|
'Other birthdays' => 'Weitere Geburtstage',
|
||||||
'Other anniversaries' => 'Weitere Hochzeitstage',
|
'Other anniversaries' => 'Weitere Hochzeitstage',
|
||||||
'Other historical events' => 'Weitere historische Ereignisse',
|
'Other historical events' => 'Weitere historische Ereignisse',
|
||||||
|
'Upcoming events' => 'Anstehende Ereignisse',
|
||||||
|
'Birthdays of living kin and anniversaries of intact couples in the next %d days.'
|
||||||
|
=> 'Geburtstage lebender Verwandter und Hochzeitstage bestehender Paare in den nächsten %d Tagen.',
|
||||||
|
'Other upcoming events' => 'Weitere anstehende Ereignisse',
|
||||||
'Detailed view distance' => 'Detailansicht-Abstand',
|
'Detailed view distance' => 'Detailansicht-Abstand',
|
||||||
'A person is shown in detail (avatar, icon, timeline) when they sit within this many descent-steps of the recipient\'s direct lineage. Examples relative to the recipient: a sibling is distance 1 (one step down from the recipient\'s parent), a great-aunt is distance 1 (one step down from a great-grandparent), a nephew is distance 2, a first cousin is distance 2. Spouses share their partner\'s distance. Everyone outside this radius appears as a compact text bullet at the bottom of each section. Set to 0 to render the whole newsletter as text; recipients with no linked tree record always see the full detailed view.'
|
'A person is shown in detail (avatar, icon, timeline) when they sit within this many descent-steps of the recipient\'s direct lineage. Examples relative to the recipient: a sibling is distance 1 (one step down from the recipient\'s parent), a great-aunt is distance 1 (one step down from a great-grandparent), a nephew is distance 2, a first cousin is distance 2. Spouses share their partner\'s distance. Everyone outside this radius appears as a compact text bullet at the bottom of each section. Set to 0 to render the whole newsletter as text; recipients with no linked tree record always see the full detailed view.'
|
||||||
=> 'Eine Person erscheint in der Detailansicht (Profilbild, Symbol, Zeitachse), wenn sie innerhalb dieser Anzahl Abstammungsschritte von der direkten Linie des Empfängers entfernt liegt. Beispiele bezogen auf den Empfänger: ein Geschwister hat Abstand 1 (ein Schritt abwärts vom Elternteil), eine Großtante hat Abstand 1 (ein Schritt abwärts vom Urgroßelternteil), ein Neffe hat Abstand 2, eine Cousine ersten Grades hat Abstand 2. Ehepartner teilen den Abstand ihres Partners. Alle außerhalb dieses Radius erscheinen als kompakte Textzeile am Ende des jeweiligen Abschnitts. Auf 0 setzen, um den gesamten Newsletter als Text darzustellen. Empfänger ohne verknüpftes Baumprofil sehen stets die vollständige Detailansicht.',
|
=> 'Eine Person erscheint in der Detailansicht (Profilbild, Symbol, Zeitachse), wenn sie innerhalb dieser Anzahl Abstammungsschritte von der direkten Linie des Empfängers entfernt liegt. Beispiele bezogen auf den Empfänger: ein Geschwister hat Abstand 1 (ein Schritt abwärts vom Elternteil), eine Großtante hat Abstand 1 (ein Schritt abwärts vom Urgroßelternteil), ein Neffe hat Abstand 2, eine Cousine ersten Grades hat Abstand 2. Ehepartner teilen den Abstand ihres Partners. Alle außerhalb dieses Radius erscheinen als kompakte Textzeile am Ende des jeweiligen Abschnitts. Auf 0 setzen, um den gesamten Newsletter als Text darzustellen. Empfänger ohne verknüpftes Baumprofil sehen stets die vollständige Detailansicht.',
|
||||||
@@ -183,6 +199,10 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
|||||||
'Pending' => 'Ausstehend',
|
'Pending' => 'Ausstehend',
|
||||||
'External recipients (%d)' => 'Externe Empfänger (%d)',
|
'External recipients (%d)' => 'Externe Empfänger (%d)',
|
||||||
'Save to schedule delivery.' => 'Speichern, um die Zustellung zu starten.',
|
'Save to schedule delivery.' => 'Speichern, um die Zustellung zu starten.',
|
||||||
|
'Upcoming family events' => 'Anstehende Familienereignisse',
|
||||||
|
'No upcoming family events in the next %d days.'
|
||||||
|
=> 'Keine anstehenden Familienereignisse in den nächsten %d Tagen.',
|
||||||
|
'Living kin celebrating in the next %d days.' => 'Lebende Verwandte, die in den nächsten %d Tagen feiern.',
|
||||||
],
|
],
|
||||||
'nl' => [
|
'nl' => [
|
||||||
'Email Newsletter' => 'E-mailnieuwsbrief',
|
'Email Newsletter' => 'E-mailnieuwsbrief',
|
||||||
@@ -212,30 +232,6 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
|||||||
return $translations[$language] ?? [];
|
return $translations[$language] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Menu ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function defaultMenuOrder(): int
|
|
||||||
{
|
|
||||||
return 99;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMenu(Tree $tree): Menu|null
|
|
||||||
{
|
|
||||||
if (!Auth::check()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Configuration::isEnabled($tree)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Menu(
|
|
||||||
I18N::translate('Newsletter subscription'),
|
|
||||||
route(AccountEdit::class, ['tree' => $tree->name()]),
|
|
||||||
'menu-newsletter-subscription',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Admin config page ──────────────────────────────────────────
|
// ─── Admin config page ──────────────────────────────────────────
|
||||||
|
|
||||||
public function getAdminAction(): ResponseInterface
|
public function getAdminAction(): ResponseInterface
|
||||||
@@ -362,6 +358,203 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
|||||||
return redirect($this->getConfigLink());
|
return redirect($this->getConfigLink());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tree-home block ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an "Upcoming family events" block for the tree home page.
|
||||||
|
* Reuses the same visualisation as the newsletter email (cards,
|
||||||
|
* circular avatars, timeline rail, event icons) but adapted for
|
||||||
|
* web context: avatars resolve to media-file URLs instead of CID
|
||||||
|
* attachments, and relationship labels are computed against the
|
||||||
|
* viewer's tree-linked Individual when available.
|
||||||
|
*
|
||||||
|
* Default look-ahead window is 30 days; admins can override per
|
||||||
|
* block placement via the standard "configure" UI.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $config
|
||||||
|
*/
|
||||||
|
public function getBlock(Tree $tree, int $block_id, string $context, array $config = []): string
|
||||||
|
{
|
||||||
|
$window = (int) ($config['window_days'] ?? self::BLOCK_DEFAULT_WINDOW_DAYS);
|
||||||
|
$window = max(1, min(365, $window));
|
||||||
|
|
||||||
|
// Cache the rendered block — relationship labels and avatar
|
||||||
|
// URL lookups are per-viewer, so the cache key includes the
|
||||||
|
// signed-in user id (0 for guests). 5-minute TTL is short
|
||||||
|
// enough that admin edits propagate within one refresh.
|
||||||
|
$viewer_id = Auth::user() instanceof User ? Auth::user()->id() : 0;
|
||||||
|
$cache_key = sprintf(
|
||||||
|
'email_newsletter_block_%d_%d_%d_%s',
|
||||||
|
$tree->id(),
|
||||||
|
$window,
|
||||||
|
$viewer_id,
|
||||||
|
I18N::languageTag(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$content = Registry::cache()->file()->remember(
|
||||||
|
$cache_key,
|
||||||
|
fn (): string => $this->renderBlockContent($tree, $window),
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($context !== self::CONTEXT_EMBED) {
|
||||||
|
return view('modules/block-template', [
|
||||||
|
'block' => Str::kebab($this->name()),
|
||||||
|
'id' => $block_id,
|
||||||
|
'config_url' => '',
|
||||||
|
'title' => I18N::translate('Upcoming family events'),
|
||||||
|
'content' => $content,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather upcoming-event data and render the inner block HTML. Kept
|
||||||
|
* separate from getBlock() so the result can be wrapped in a
|
||||||
|
* file-cache without re-querying the database on every page load.
|
||||||
|
*/
|
||||||
|
private function renderBlockContent(Tree $tree, int $window): string
|
||||||
|
{
|
||||||
|
$birthdays = $this->event_query_service->upcomingBirthdays($tree, $window);
|
||||||
|
$anniversaries = Configuration::includeAnniversaries($tree)
|
||||||
|
? $this->event_query_service->upcomingAnniversaries($tree, $window)
|
||||||
|
: null;
|
||||||
|
$historical = $this->event_query_service->upcomingHistoricalEvents($tree, $window);
|
||||||
|
|
||||||
|
// Featured individuals — every Individual referenced by any
|
||||||
|
// fact in the block. Used to scope relationship labels and
|
||||||
|
// avatar URLs.
|
||||||
|
$featured = [];
|
||||||
|
foreach ([$birthdays, $anniversaries, $historical] as $facts) {
|
||||||
|
if ($facts === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($facts as $fact) {
|
||||||
|
$record = $fact->record();
|
||||||
|
if ($record instanceof Individual) {
|
||||||
|
$featured[$record->xref()] = $record;
|
||||||
|
} elseif ($record instanceof \Fisharebest\Webtrees\Family) {
|
||||||
|
foreach ([$record->husband(), $record->wife()] as $spouse) {
|
||||||
|
if ($spouse instanceof Individual) {
|
||||||
|
$featured[$spouse->xref()] = $spouse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$relationships = $this->viewerRelationships($tree, $featured);
|
||||||
|
$avatar_srcs = $this->collectBlockAvatarSrcs($featured);
|
||||||
|
|
||||||
|
return view($this->name() . '::block', [
|
||||||
|
'tree' => $tree,
|
||||||
|
'birthdays' => $birthdays,
|
||||||
|
'anniversaries' => $anniversaries,
|
||||||
|
'historical' => $historical,
|
||||||
|
'include_anniversaries' => Configuration::includeAnniversaries($tree),
|
||||||
|
'window_days' => $window,
|
||||||
|
'avatar_srcs' => $avatar_srcs,
|
||||||
|
'relationships' => $relationships,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadAjax(): bool
|
||||||
|
{
|
||||||
|
// Defer the block to an async fetch so the rest of the tree
|
||||||
|
// home page paints before our (cached) HTML arrives. Same
|
||||||
|
// pattern webtrees uses for heavy stats blocks.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isUserBlock(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTreeBlock(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map xref => avatar src URL. Only entries for individuals with a
|
||||||
|
* resolvable highlighted media file are present — the view treats
|
||||||
|
* absence as "render an initials disc".
|
||||||
|
*
|
||||||
|
* @param array<string,Individual> $featured
|
||||||
|
*
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function collectBlockAvatarSrcs(array $featured): array
|
||||||
|
{
|
||||||
|
$srcs = [];
|
||||||
|
|
||||||
|
foreach ($featured as $xref => $individual) {
|
||||||
|
try {
|
||||||
|
$media_file = $individual->findHighlightedMediaFile();
|
||||||
|
} catch (\Throwable $ex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($media_file === null || !$media_file->isImage()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 192 px source so the 56-px-rendered avatar stays crisp
|
||||||
|
// on retina displays — matches the email-side resize.
|
||||||
|
try {
|
||||||
|
$srcs[$xref] = $media_file->imageUrl(192, 192, 'crop');
|
||||||
|
} catch (\Throwable $ex) {
|
||||||
|
// imageUrl can throw on broken file paths; just skip.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $srcs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build xref => "your mother" labels for the current viewer if
|
||||||
|
* they're signed in and linked to an Individual on this tree.
|
||||||
|
*
|
||||||
|
* @param array<string,Individual> $featured
|
||||||
|
*
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function viewerRelationships(Tree $tree, array $featured): array
|
||||||
|
{
|
||||||
|
$viewer = Auth::user();
|
||||||
|
|
||||||
|
if (!$viewer instanceof User) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$self_xref = $tree->getUserPreference($viewer, UserInterface::PREF_TREE_ACCOUNT_XREF);
|
||||||
|
|
||||||
|
if ($self_xref === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$self = Registry::individualFactory()->make($self_xref, $tree);
|
||||||
|
|
||||||
|
if (!$self instanceof Individual) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
foreach ($featured as $xref => $individual) {
|
||||||
|
$label = $this->relationship_finder->label($self, $individual);
|
||||||
|
|
||||||
|
if ($label !== null && $label !== '') {
|
||||||
|
$map[$xref] = $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Cron endpoint (token-gated, anonymous) ─────────────────────
|
// ─── Cron endpoint (token-gated, anonymous) ─────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user