1 Commits
Author SHA1 Message Date
Ultradesu 7f49b1f314 Reworked client portal
Build and Publish / Build and Publish Docker Image (push) Successful in 3m23s
2026-09-01 10:51:22 +01:00
5 changed files with 411 additions and 58 deletions
Generated
+1 -1
View File
@@ -4763,7 +4763,7 @@ dependencies = [
[[package]]
name = "web-petting"
version = "1.0.4"
version = "1.0.5"
dependencies = [
"async-trait",
"aws-sdk-s3",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "web-petting"
version = "1.0.5"
version = "1.0.6"
edition = "2024"
default-run = "web-petting"
+3
View File
@@ -330,6 +330,7 @@ pub struct Translations {
pub portal_feedback_submit: &'static str,
pub portal_feedback_thanks: &'static str,
pub portal_link: &'static str,
pub portal_settings: &'static str,
pub portal_notifications: &'static str,
pub portal_notifications_text: &'static str,
pub portal_notifications_enable: &'static str,
@@ -504,6 +505,7 @@ static RU: Translations = Translations {
portal_feedback_submit: "Отправить",
portal_feedback_thanks: "Спасибо за отзыв!",
portal_link: "Ссылка клиента",
portal_settings: "Настройки",
portal_notifications: "Уведомления",
portal_notifications_text: "Получайте уведомления о завершённых визитах, даже когда страница закрыта. На iPhone сначала добавьте сайт на экран «Домой» и откройте его оттуда.",
portal_notifications_enable: "Включить уведомления",
@@ -779,6 +781,7 @@ static EN: Translations = Translations {
portal_feedback_submit: "Submit",
portal_feedback_thanks: "Thank you for your feedback!",
portal_link: "Client link",
portal_settings: "Settings",
portal_notifications: "Notifications",
portal_notifications_text: "Receive completed-visit notifications even when this page is closed. On iPhone, first add this site to the Home Screen and open it from there.",
portal_notifications_enable: "Enable notifications",
+45 -10
View File
@@ -323,11 +323,13 @@ struct ClientPortalTemplate<'a> {
t: &'a Translations,
lang: Lang,
client: Client,
header_media: Vec<PortalMediaView>,
upcoming: Vec<PortalVisit>,
past: Vec<PortalVisit>,
feedback_sent: bool,
turnstile_site_key: String,
notifications_enabled: bool,
show_portal_settings: bool,
vapid_public_key: String,
calendar_months: Vec<CalendarMonth>,
page: usize,
@@ -337,6 +339,7 @@ struct ClientPortalTemplate<'a> {
}
const PORTAL_VISITS_PER_PAGE: usize = 10;
const PORTAL_HEADER_MEDIA_LIMIT: usize = 20;
fn query_page(request: &Request) -> usize {
request
@@ -444,24 +447,53 @@ async fn client_portal(
let users = User::objects().all(&db).await?;
let all_media = Media::objects().all(&db).await?;
let storage = crate::uploads::Storage::load(&db).await?;
let portal_visit_ids: HashSet<i64> = past_visits
.iter()
.chain(upcoming_visits.iter())
.map(|visit| visit.id.unwrap())
.collect();
let mut client_media: Vec<_> = all_media
.into_iter()
.filter(|media| {
media.status == "active"
&& media.client_id.primary_key().unwrap() == client_id
&& media
.visit_id
.as_ref()
.map(|foreign_key| {
portal_visit_ids.contains(&foreign_key.primary_key().unwrap())
})
.unwrap_or(false)
})
.collect();
client_media.sort_by(|left, right| {
right
.created_at
.cmp(&left.created_at)
.then_with(|| right.id.unwrap().cmp(&left.id.unwrap()))
});
let mut media_by_visit: HashMap<i64, Vec<PortalMediaView>> = HashMap::new();
for media in all_media {
if media.status != "active" || media.client_id.primary_key().unwrap() != client_id {
continue;
}
let Some(visit_id) = media
let mut header_media = Vec::with_capacity(client_media.len().min(PORTAL_HEADER_MEDIA_LIMIT));
for media in client_media {
let visit_id = media
.visit_id
.as_ref()
.map(|foreign_key| foreign_key.primary_key().unwrap())
else {
continue;
};
if !visible_visit_ids.contains(&visit_id) {
.expect("client portal media was filtered to visit media");
let include_in_header = header_media.len() < PORTAL_HEADER_MEDIA_LIMIT;
let include_in_visit = visible_visit_ids.contains(&visit_id);
if !include_in_header && !include_in_visit {
continue;
}
let media_view = portal_media_view(&storage, media, &client.media_token).await?;
if include_in_header {
header_media.push(media_view.clone());
}
if include_in_visit {
media_by_visit.entry(visit_id).or_default().push(media_view);
}
}
let build_portal_visit = |v: Visit| -> PortalVisit {
let uid: i64 = v.user_id.primary_key().unwrap();
@@ -498,7 +530,7 @@ async fn client_portal(
month_keys.sort();
month_keys.dedup();
month_keys.reverse();
let calendar_months = month_keys
let calendar_months: Vec<CalendarMonth> = month_keys
.into_iter()
.map(|(year, month)| {
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
@@ -570,15 +602,18 @@ async fn client_portal(
.unwrap_or(false);
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
let show_portal_settings = notifications_enabled || !calendar_months.is_empty();
let body = ClientPortalTemplate {
t: lang.t(),
lang,
client,
header_media,
upcoming,
past,
feedback_sent,
turnstile_site_key,
notifications_enabled,
show_portal_settings,
vapid_public_key,
calendar_months,
page,
+357 -42
View File
@@ -19,16 +19,93 @@
color: #2d2b55; line-height: 1.6; background: #f8f7ff;
padding: 0 0 2rem;
}
body.portal-overlay-open { overflow: hidden; }
.portal-header {
position: relative;
background: linear-gradient(135deg, #7c6cff, #b06cff);
color: #fff; padding: 2rem 1.5rem 1.5rem; text-align: center;
position: relative; isolation: isolate; overflow: visible; min-height: 150px;
display: grid; place-items: center; padding: 2rem 1.5rem 1.5rem;
color: #2d2b55; text-align: center;
background: rgba(255,255,255,.38);
border-block: 1px solid rgba(124,108,255,.1);
}
.portal-header::after {
content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
background:
radial-gradient(circle at 18% 15%, rgba(255,255,255,.7), transparent 35%),
linear-gradient(135deg, rgba(124,108,255,.06), rgba(176,108,255,.08));
}
.portal-header.has-media { color: #fff; }
.portal-header.has-media::after {
background: linear-gradient(135deg, rgba(30,25,65,.32), rgba(78,45,105,.2));
}
.portal-header-backdrop {
position: absolute; inset: 0; z-index: 0; overflow: hidden; pointer-events: none;
}
.portal-header-content {
position: absolute; left: 50%; top: 50%; z-index: 2;
width: max-content; max-width: calc(100% - 2rem);
transform: translate(-50%, -50%);
}
.portal-header.has-media .portal-header-content {
padding: .5rem 1.25rem; border-radius: 14px;
background: rgba(18,13,42,.62); box-shadow: 0 8px 32px rgba(18,13,42,.2);
text-shadow: 0 2px 12px rgba(0,0,0,.55);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
}
.header-media-grid {
width: 100%; height: 100%; display: grid;
grid-template-columns: repeat(var(--header-cols, 5), minmax(0, 1fr));
grid-template-rows: repeat(var(--header-rows, 2), minmax(0, 1fr));
gap: 3px; background: #d9d4ee; perspective: 900px;
}
.header-media-tile {
position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #ddd8ed;
transform-style: preserve-3d;
}
.header-media-tile[hidden] { display: none; }
.header-media-tile.is-flipping { animation: header-tile-flip .8s ease-in-out; }
.header-media-image { display: block; width: 100%; height: 100%; object-fit: cover; }
.header-media-image.header-video-preview { position: absolute; max-width: none; object-fit: unset; }
.header-media-image.header-video-preview:not(.is-fitted) { opacity: 0; }
@keyframes header-tile-flip {
0%, 100% { transform: rotateY(0deg); }
49% { transform: rotateY(88deg); }
50% { transform: rotateY(-88deg); }
}
.header-decorations { position: absolute; inset: 0; overflow: hidden; }
.header-deco-item {
position: absolute; width: 72px; height: 72px; opacity: .25;
animation: header-icon-drift 58s ease-in-out infinite alternate;
}
.header-deco-item svg { width: 100%; height: 100%; animation: header-icon-spin 110s linear infinite; }
.header-deco-item:nth-child(1) { left: 3%; top: 10%; color: #7c6cff; }
.header-deco-item:nth-child(2) { left: 18%; bottom: -12%; width: 88px; height: 88px; color: #ff8c26; animation-duration: 71s; animation-delay: -18s; }
.header-deco-item:nth-child(3) { left: 38%; top: -20%; width: 96px; height: 96px; color: #00a98f; animation-duration: 83s; animation-delay: -31s; }
.header-deco-item:nth-child(4) { right: 35%; bottom: -28%; width: 104px; height: 104px; color: #b06cff; animation-duration: 67s; animation-delay: -9s; }
.header-deco-item:nth-child(5) { right: 18%; top: 8%; width: 78px; height: 78px; color: #ff5287; animation-duration: 76s; animation-delay: -42s; }
.header-deco-item:nth-child(6) { right: 3%; bottom: -5%; width: 92px; height: 92px; color: #4d90e8; animation-duration: 89s; animation-delay: -25s; }
.header-deco-item:nth-child(even) svg { animation-direction: reverse; animation-duration: 135s; }
@keyframes header-icon-drift {
from { transform: translate3d(-8px, -4px, 0); }
to { transform: translate3d(14px, 10px, 0); }
}
@keyframes header-icon-spin { to { transform: rotate(360deg); } }
.portal-header h1 { font-size: 1.5rem; font-weight: 700; }
.portal-header .sub { opacity: 0.85; font-size: 0.9rem; margin-top: 0.25rem; }
.portal-header .sub { opacity: 0.88; font-size: 0.9rem; margin-top: 0.25rem; }
.portal-header.has-media .sub { opacity: .95; }
.container { max-width: 1100px; margin: 0 auto; padding: 0 1rem; }
.portal-grid { display: grid; grid-template-columns: minmax(0, 700px) 320px; gap: 1.25rem; align-items: start; }
.portal-settings { position: absolute; right: 1rem; bottom: 1rem; width: 38px; height: 38px; border: 0; border-radius: 50%; background: rgba(255,255,255,.2); color: #fff; font-size: 1.1rem; cursor: pointer; }
.portal-settings {
position: absolute; right: 1rem; bottom: 1rem; z-index: 3;
display: grid; place-items: center; width: 42px; height: 42px;
border: 1px solid rgba(124,108,255,.2); border-radius: 50%;
background: rgba(255,255,255,.72); color: #6255c7; font-size: 1.15rem;
box-shadow: 0 8px 24px rgba(57,44,126,.14); cursor: pointer;
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
}
.portal-header.has-media .portal-settings { color: #fff; background: rgba(30,24,60,.34); border-color: rgba(255,255,255,.3); }
.portal-settings.mobile-only-settings { display: none; }
.portal-settings:hover { transform: translateY(-1px); }
.portal-settings:focus-visible { outline: 3px solid rgba(124,108,255,.35); outline-offset: 3px; }
.section-title {
font-size: 1.15rem; font-weight: 700; margin: 1.5rem 0 0.75rem;
padding-bottom: 0.4rem; border-bottom: 2px solid #ede7f6;
@@ -119,9 +196,11 @@
font-weight: 700; min-width: 5.5rem;
}
.upcoming-row .up-time { color: #7a7599; }
.calendar-panel { position: sticky; top: 1rem; margin-top: 1.5rem; background: #fff; border: 1px solid #eee; border-radius: 12px; padding: .85rem; }
.calendar-panel { position: sticky; top: 1rem; margin-top: 1.5rem; }
.calendar-surface { background: #fff; border: 1px solid #eee; border-radius: 12px; padding: .85rem; }
.calendar-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .65rem; }
.calendar-head button { border: 0; background: #f0edff; color: #6255c7; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; }
.calendar-close { display: none; }
.calendar-title { font-size: .95rem; font-weight: 700; }
.calendar-weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; text-align: center; }
.calendar-weekdays { color: #999; font-size: .68rem; margin-bottom: 3px; }
@@ -131,20 +210,87 @@
.calendar-day.future { background: #f3f3f3; color: #bbb; border: 1px dashed #ddd; }
.calendar-month[hidden] { display: none; }
.calendar-legend { margin-top: .65rem; font-size: .72rem; color: #999; }
.calendar-notification-settings {
position: relative; margin-top: .9rem; padding-top: .9rem;
border-top: 1px solid #ece9f5;
}
.calendar-notification-settings:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
.notification-setting-head {
display: flex; align-items: center; justify-content: space-between;
gap: .75rem; margin-bottom: .65rem;
}
.notification-setting-title { font-size: .88rem; font-weight: 700; }
.notification-info-wrap { position: relative; display: inline-flex; }
.notification-info-button {
display: grid; place-items: center; width: 24px; height: 24px;
border: 1px solid #cfc9ed; border-radius: 50%; background: #f6f4ff;
color: #6558c8; font: 700 .78rem/1 Georgia, serif; cursor: help;
}
.notification-info-button:focus-visible { outline: 3px solid rgba(124,108,255,.25); outline-offset: 2px; }
.notification-help {
display: none; position: absolute; right: 0; bottom: calc(100% + .55rem); z-index: 20;
width: min(280px, calc(100vw - 3rem)); padding: .7rem .8rem; border-radius: 10px;
color: #fff; background: #302b52; box-shadow: 0 12px 32px rgba(20,18,40,.28);
font-size: .76rem; font-weight: 400; line-height: 1.45; text-align: left;
}
.notification-help::after {
content: ""; position: absolute; right: 7px; top: 100%;
border: 6px solid transparent; border-top-color: #302b52;
}
.notification-info-wrap:hover .notification-help,
.notification-info-wrap:focus-within .notification-help,
.notification-info-wrap.is-open .notification-help { display: block; }
.notification-toggle {
width: 100%; border: 0; border-radius: 9px; padding: .58rem .75rem;
color: #fff; background: #7567e8; font: inherit; font-size: .8rem;
font-weight: 600; cursor: pointer;
}
.notification-toggle:disabled { cursor: not-allowed; opacity: .55; }
.notification-status { display: none; margin-top: .5rem; font-size: .74rem; line-height: 1.4; }
.pagination { display: flex; justify-content: center; align-items: center; gap: .75rem; margin: 1rem 0; font-size: .85rem; }
.pagination a { color: #6558c8; text-decoration: none; padding: .35rem .7rem; background: #fff; border: 1px solid #e5e1ff; border-radius: 8px; }
.modal-bg { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(20,18,40,.55); align-items: center; justify-content: center; padding: 1rem; }
.modal-bg.open { display: flex; }
.notification-modal { width: min(420px, 100%); background: #fff; border-radius: 14px; padding: 1.2rem; box-shadow: 0 15px 50px rgba(0,0,0,.25); }
.notification-modal h2 { font-size: 1.15rem; margin-bottom: .45rem; }
.notification-modal p { font-size: .88rem; color: #777; }
.notification-actions { display: flex; gap: .5rem; margin-top: 1rem; }
.notification-actions button { border: 0; border-radius: 8px; padding: .55rem .9rem; cursor: pointer; }
.notification-primary { background: #7567e8; color: #fff; }
@media (max-width: 800px) {
.portal-header { min-height: 178px; padding: 2.2rem 4rem 1.8rem; }
.header-deco-item { opacity: .2; }
.portal-grid { display: flex; flex-direction: column; }
.calendar-panel { position: static; order: -1; width: 100%; margin-top: 1rem; }
.visits-column { width: 100%; }
.portal-settings,
.portal-settings.mobile-only-settings {
position: fixed; right: max(1rem, env(safe-area-inset-right));
bottom: max(1rem, env(safe-area-inset-bottom)); z-index: 900;
display: grid; width: 50px; height: 50px;
color: #fff; background: rgba(88,72,190,.9); border-color: rgba(255,255,255,.45);
box-shadow: 0 10px 30px rgba(57,44,126,.28);
}
.portal-header.has-media .portal-settings { background: rgba(88,72,190,.9); }
.calendar-panel {
display: none; position: fixed; inset: 0; z-index: 1000; width: auto;
margin: 0; padding: 1rem max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
align-items: flex-end; justify-content: center; overflow-y: auto;
background: rgba(20,18,40,.58); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
}
.calendar-panel.open { display: flex; }
.calendar-surface {
width: min(440px, 100%); max-height: min(88dvh, 680px); overflow-y: auto;
margin: auto auto 0; padding: 1rem; border: 0; border-radius: 20px;
box-shadow: 0 22px 70px rgba(20,18,40,.28);
}
.calendar-head { gap: .55rem; }
.calendar-title { flex: 1; text-align: center; }
.calendar-close {
display: grid; place-items: center; margin-left: .2rem;
background: #f5f3fa !important; color: #756f8f !important;
}
.calendar-day { font-size: .84rem; }
.calendar-notification-settings { margin-top: 1rem; padding-top: 1rem; }
.notification-setting-title { font-size: .92rem; }
.notification-toggle { padding: .68rem 1rem; font-size: .86rem; }
.notification-help { font-size: .8rem; }
}
@media (prefers-reduced-motion: reduce) {
.header-media-tile.is-flipping,
.header-deco-item,
.header-deco-item svg { animation: none !important; }
}
</style>
</head>
@@ -154,10 +300,34 @@
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
</div>
<div class="portal-header">
<div class="portal-header{% if !header_media.is_empty() %} has-media{% endif %}">
<div class="portal-header-backdrop" aria-hidden="true">
{% if header_media.is_empty() %}
<div class="header-decorations">
<span class="header-deco-item"><svg viewBox="0 0 80 80"><path d="M20 15 10 2 18 18M60 15 70 2 62 18M40 70C18 70 8 52 8 38 8 20 22 8 40 8s32 12 32 30c0 14-10 32-32 32Z" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="28" cy="36" r="3.5" fill="currentColor"/><circle cx="52" cy="36" r="3.5" fill="currentColor"/><ellipse cx="40" cy="48" rx="4" ry="2.5" fill="currentColor"/><path d="M36 48q-4 6-8 4m16-4q4 6 8 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
<span class="header-deco-item"><svg viewBox="0 0 80 50"><path d="M8 18q0 28 32 28t32-28Z" fill="currentColor" opacity=".2"/><ellipse cx="40" cy="18" rx="34" ry="10" fill="none" stroke="currentColor" stroke-width="3"/><path d="M8 18q0 28 32 28t32-28" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round"/></svg></span>
<span class="header-deco-item"><svg viewBox="0 0 60 70"><ellipse cx="30" cy="46" rx="16" ry="18" fill="currentColor" opacity=".55"/><ellipse cx="14" cy="22" rx="8" ry="10" fill="currentColor" opacity=".55" transform="rotate(-10 14 22)"/><ellipse cx="46" cy="22" rx="8" ry="10" fill="currentColor" opacity=".55" transform="rotate(10 46 22)"/><ellipse cx="24" cy="8" rx="6" ry="8" fill="currentColor" opacity=".55"/><ellipse cx="38" cy="8" rx="6" ry="8" fill="currentColor" opacity=".55"/></svg></span>
<span class="header-deco-item"><svg viewBox="0 0 80 40"><path d="M60 20Q48 2 28 8 10 14 10 20t18 12q20 6 32-12Z" fill="currentColor" opacity=".25" stroke="currentColor" stroke-width="2.5"/><path d="m60 20 14-14v28Z" fill="currentColor" opacity=".25" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/><circle cx="24" cy="18" r="2.5" fill="currentColor"/></svg></span>
<span class="header-deco-item"><svg viewBox="0 0 60 55"><path d="M30 50C10 35 0 22 0 14 0 5 7 0 15 0c6 0 11 3 15 9 4-6 9-9 15-9 8 0 15 5 15 14 0 8-10 21-30 36Z" fill="currentColor" opacity=".5"/></svg></span>
<span class="header-deco-item"><svg viewBox="0 0 70 50"><ellipse cx="32" cy="28" rx="22" ry="16" fill="currentColor" opacity=".2"/><ellipse cx="32" cy="28" rx="22" ry="16" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M12 22Q2 10 10 6m2 28Q2 40 6 46" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/><circle cx="22" cy="24" r="2" fill="currentColor"/><path d="M54 28q14-10 10 4-4 8-8-2" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg></span>
</div>
{% else %}
<div class="header-media-grid" data-header-gallery>
{% for m in &header_media %}
<div class="header-media-tile" data-media-src="{{ m.thumbnail_url }}" data-media-kind="{{ m.file_type }}">
<img class="header-media-image{% if m.file_type == "video" %} header-video-preview{% endif %}" src="{{ m.thumbnail_url }}" alt="">
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="portal-header-content">
<h1>{{ t.portal_title }}</h1>
<div class="sub">{{ client.name }}</div>
{% if notifications_enabled %}<button class="portal-settings" type="button" onclick="openNotificationSettings()" aria-label="{{ t.portal_notifications }}"></button>{% endif %}
</div>
{% if show_portal_settings %}
<button class="portal-settings mobile-only-settings" type="button" onclick="openPortalSettings()" aria-label="{{ t.portal_calendar }}" aria-expanded="false">📅</button>
{% endif %}
</div>
<div class="container">
@@ -266,12 +436,15 @@
{% endif %}
</main>
{% if show_portal_settings %}
<aside class="calendar-panel" id="calendarPanel" onclick="if(event.target===this) closePortalSettings()">
<div class="calendar-surface">
{% if !calendar_months.is_empty() %}
<aside class="calendar-panel">
<div class="calendar-head">
<button type="button" onclick="moveCalendar(1)" aria-label="{{ t.portal_previous }}"></button>
<span class="calendar-title" id="calendarTitle">{{ t.portal_calendar }}</span>
<button type="button" onclick="moveCalendar(-1)" aria-label="{{ t.portal_next }}"></button>
<button class="calendar-close" type="button" onclick="closePortalSettings()" aria-label="{{ t.portal_settings }}">×</button>
</div>
<div class="calendar-weekdays"><span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span></div>
{% for month in &calendar_months %}
@@ -289,27 +462,171 @@
</div>
{% endfor %}
<div class="calendar-legend">{{ t.portal_future_visit }} — ···</div>
{% else %}
<div class="calendar-head">
<span class="calendar-title">{{ t.portal_settings }}</span>
<button class="calendar-close" type="button" onclick="closePortalSettings()" aria-label="{{ t.portal_settings }}">×</button>
</div>
{% endif %}
{% if notifications_enabled %}
<section class="calendar-notification-settings" aria-labelledby="notificationSettingTitle">
<div class="notification-setting-head">
<h2 class="notification-setting-title" id="notificationSettingTitle">{{ t.portal_notifications }}</h2>
<span class="notification-info-wrap">
<button class="notification-info-button" type="button" onclick="toggleNotificationHelp(event)" aria-label="{{ t.portal_notifications_text }}" aria-expanded="false" aria-describedby="notificationHelp">i</button>
<span class="notification-help" id="notificationHelp" role="tooltip">{{ t.portal_notifications_text }}</span>
</span>
</div>
<button class="notification-toggle" type="button" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
<p class="notification-status" id="notificationStatus"></p>
</section>
{% endif %}
</div>
</aside>
{% endif %}
</div>
</div>
{% if notifications_enabled %}
<div class="modal-bg" id="notificationModal" onclick="if(event.target===this) closeNotificationSettings()">
<div class="notification-modal">
<h2>{{ t.portal_notifications }}</h2>
<p id="notificationText">{{ t.portal_notifications_text }}</p>
<p id="notificationStatus" style="display:none;margin-top:0.65rem;font-weight:600;"></p>
<div class="notification-actions">
<button type="button" class="notification-primary" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
<button type="button" onclick="closeNotificationSettings()"></button>
</div>
</div>
</div>
{% endif %}
<script>
(function() {
var grid = document.querySelector('[data-header-gallery]');
if (!grid) return;
var tiles = Array.from(grid.querySelectorAll('.header-media-tile'));
var sources = tiles.map(function(tile) {
return { src: tile.dataset.mediaSrc, kind: tile.dataset.mediaKind };
});
var mobile = window.matchMedia('(max-width: 800px)');
function resetHeaderImageBox(image) {
['width', 'height', 'left', 'top'].forEach(function(property) {
image.style.removeProperty(property);
});
image.classList.remove('is-fitted');
}
function fitHeaderMediaImage(image) {
resetHeaderImageBox(image);
if (!image.classList.contains('header-video-preview')) return;
var tile = image.closest('.header-media-tile');
if (!tile || !image.naturalWidth || !image.naturalHeight || !tile.clientWidth || !tile.clientHeight) return;
var frameWidth = image.naturalWidth / 4;
var scale = Math.max(tile.clientWidth / frameWidth, tile.clientHeight / image.naturalHeight);
var renderedFrameWidth = frameWidth * scale;
var renderedHeight = image.naturalHeight * scale;
image.style.width = image.naturalWidth * scale + 'px';
image.style.height = renderedHeight + 'px';
image.style.left = (tile.clientWidth - renderedFrameWidth) / 2 + 'px';
image.style.top = (tile.clientHeight - renderedHeight) / 2 + 'px';
image.classList.add('is-fitted');
}
function configureGrid() {
var limit = mobile.matches ? 6 : 10;
var visible = Math.min(limit, tiles.length);
var columns = visible === 1 ? 1 : Math.min(mobile.matches ? 3 : 5, visible);
var rows = Math.max(1, Math.ceil(visible / columns));
grid.style.setProperty('--header-cols', columns);
grid.style.setProperty('--header-rows', rows);
tiles.forEach(function(tile, index) { tile.hidden = index >= visible; });
window.requestAnimationFrame(function() {
tiles.forEach(function(tile) { fitHeaderMediaImage(tile.querySelector('img')); });
});
return visible;
}
var visibleCount = configureGrid();
tiles.forEach(function(tile) {
var image = tile.querySelector('img');
image.addEventListener('load', function() { fitHeaderMediaImage(image); });
if (image.complete) fitHeaderMediaImage(image);
});
mobile.addEventListener && mobile.addEventListener('change', configureGrid);
var resizeFrame;
window.addEventListener('resize', function() {
window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(function() {
tiles.forEach(function(tile) { fitHeaderMediaImage(tile.querySelector('img')); });
});
}, { passive: true });
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || sources.length <= visibleCount) return;
var nextSource = visibleCount;
function chooseSource(tile) {
var source = sources[nextSource % sources.length];
nextSource += 1;
if (source.src === tile.dataset.mediaSrc && sources.length > 1) {
source = sources[nextSource % sources.length];
nextSource += 1;
}
return source;
}
function scheduleSwap(tile, index, initial) {
var delay = (initial ? 10000 : 19000) + index * 1700 + Math.floor(Math.random() * 6500);
window.setTimeout(function() {
if (document.hidden || tile.hidden) {
scheduleSwap(tile, index, false);
return;
}
var source = chooseSource(tile);
var image = tile.querySelector('img');
tile.classList.add('is-flipping');
window.setTimeout(function() {
image.classList.toggle('header-video-preview', source.kind === 'video');
resetHeaderImageBox(image);
image.src = source.src;
tile.dataset.mediaSrc = source.src;
tile.dataset.mediaKind = source.kind;
if (image.complete) fitHeaderMediaImage(image);
}, 390);
window.setTimeout(function() {
tile.classList.remove('is-flipping');
scheduleSwap(tile, index, false);
}, 820);
}, delay);
}
tiles.slice(0, visibleCount).forEach(function(tile, index) {
scheduleSwap(tile, index, true);
});
})();
function setSettingsButtonExpanded(expanded) {
var button = document.querySelector('.portal-settings');
if (button) button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
function openPortalSettings() {
var calendar = document.getElementById('calendarPanel');
if (window.matchMedia('(max-width: 800px)').matches && calendar) {
calendar.classList.add('open');
document.body.classList.add('portal-overlay-open');
setSettingsButtonExpanded(true);
}
}
function closePortalSettings() {
var calendar = document.getElementById('calendarPanel');
if (calendar) calendar.classList.remove('open');
document.body.classList.remove('portal-overlay-open');
setSettingsButtonExpanded(false);
}
function toggleNotificationHelp(event) {
event.stopPropagation();
var button = event.currentTarget;
var wrap = button.closest('.notification-info-wrap');
var open = !wrap.classList.contains('is-open');
wrap.classList.toggle('is-open', open);
button.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function closeNotificationHelp() {
var wrap = document.querySelector('.notification-info-wrap');
if (!wrap) return;
wrap.classList.remove('is-open');
var button = wrap.querySelector('.notification-info-button');
if (button) button.setAttribute('aria-expanded', 'false');
}
document.addEventListener('click', function(event) {
if (!event.target.closest('.notification-info-wrap')) closeNotificationHelp();
});
function showFbEdit(id) {
document.getElementById('fb-view-' + id).style.display = 'none';
document.getElementById('fb-form-' + id).style.display = '';
@@ -349,7 +666,7 @@ renderCalendar();
}
function showStatus(message, error) {
status.textContent = message;
status.style.display = '';
status.style.display = 'block';
status.style.color = error ? '#b42318' : '#067647';
}
async function saveSubscription(value) {
@@ -386,14 +703,6 @@ renderCalendar();
}
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
}
window.openNotificationSettings = function() {
document.getElementById('notificationModal').classList.add('open');
refresh().catch(function(error) {
console.error(error);
showStatus('{{ t.portal_notifications_error }}', true);
});
};
window.closeNotificationSettings = function() { document.getElementById('notificationModal').classList.remove('open'); };
toggle.addEventListener('click', async function() {
toggle.disabled = true;
try {
@@ -405,10 +714,11 @@ renderCalendar();
});
await subscription.unsubscribe();
subscription = null;
status.style.display = 'none';
} else {
var permission = await Notification.requestPermission();
if (permission !== 'granted') {
document.getElementById('notificationText').textContent = '{{ t.portal_notifications_denied }}';
showStatus('{{ t.portal_notifications_denied }}', true);
return;
}
subscription = await registration.pushManager.subscribe({
@@ -432,6 +742,11 @@ renderCalendar();
});
})();
{% endif %}
document.addEventListener('keydown', function(event) {
if (event.key !== 'Escape') return;
closeNotificationHelp();
closePortalSettings();
});
</script>
{% include "partials/lightbox.html" %}
</body>