One-shot bilingual intro paragraph with markdown + author avatar

- 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.
This commit is contained in:
2026-05-15 15:32:30 +02:00
parent 9ccc636105
commit 9458867d4d
5 changed files with 258 additions and 11 deletions
+32
View File
@@ -167,6 +167,38 @@ use Illuminate\Support\Collection;
</div>
</div>
<fieldset class="row mb-3">
<legend class="col-sm-3 col-form-label">
<?= I18N::translate('Intro paragraph for the next email') ?>
</legend>
<div class="col-sm-9">
<small class="form-text text-muted d-block mb-2">
<?= I18N::translate('Shown once, above the upcoming events. Cleared automatically after the next successful send.') ?>
<br>
<?= I18N::translate('Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.', '<code>**bold**</code>', '<code>[label](https://example.org)</code>') ?>
<br>
<?= I18N::translate('Personalisation tokens:') ?>
<code>{{first_name}}</code>,
<code>{{last_name}}</code>,
<code>{{username}}</code>,
<code>{{email}}</code>
</small>
<?php foreach (Configuration::supportedSubjectLocales() as $code => $label) : ?>
<?php
$field = 'intro-' . $id . '-' . $code;
$val = Configuration::introForLocale($module, $tree, $code);
?>
<div class="mb-2">
<label class="form-label small text-muted mb-1" for="<?= e($field) ?>">
<?= e($label) ?>
</label>
<textarea class="form-control" rows="6"
id="<?= e($field) ?>" name="<?= e($field) ?>"><?= e($val) ?></textarea>
</div>
<?php endforeach ?>
</div>
</fieldset>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">
<?= I18N::translate('Subscribed users') ?>
+52 -1
View File
@@ -5,8 +5,10 @@ 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;
@@ -22,6 +24,8 @@ use Illuminate\Support\Collection;
* @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 ─────────────────────────────────────
@@ -592,7 +596,20 @@ $timeline_arrow_row = '<tr>'
<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>
<div style="font-size:13px;font-weight:300;color:<?= $palette['ink3'] ?>;">
<?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)) ?>
@@ -600,6 +617,40 @@ $timeline_arrow_row = '<tr>'
</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 = [];