Files
web-petting/templates/client_portal.html
T
Ultradesu 7f49b1f314
Build and Publish / Build and Publish Docker Image (push) Successful in 3m23s
Reworked client portal
2026-09-01 10:51:22 +01:00

754 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="{{ lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.portal_title }} — {{ client.name }}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="manifest" href="/client/{{ client.media_token }}/manifest.webmanifest">
<meta name="theme-color" content="#7c6cff">
<meta name="apple-mobile-web-app-capable" content="yes">
{% if !turnstile_site_key.is_empty() %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<style>
:root { color-scheme: light; }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #2d2b55; line-height: 1.6; background: #f8f7ff;
padding: 0 0 2rem;
}
body.portal-overlay-open { overflow: hidden; }
.portal-header {
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.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; 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;
}
.visit-card {
background: #fff; border-radius: 12px; padding: 1rem;
margin-bottom: 0.75rem; border: 1px solid #eee;
}
.visit-card-head {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 0.3rem;
}
.visit-card-head .date { font-weight: 700; font-size: 0.95rem; }
.visit-card-head .time { color: #7a7599; font-size: 0.85rem; }
.visit-card .admin { color: #999; font-size: 0.82rem; }
.badge-sm {
display: inline-block; padding: 0.1rem 0.5rem; border-radius: 99px;
font-size: 0.7rem; font-weight: 600;
}
.badge-completed { background: #d1fae5; color: #065f46; }
.badge-scheduled { background: #dbeafe; color: #1e40af; }
.media-row {
display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;
}
.media-row a { display: block; cursor: pointer; }
.media-row img {
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
}
.media-row .media-thumb-frame {
width: 80px; height: 60px; border-radius: 6px;
}
.media-row .vid-thumb {
position: relative; width: 80px; height: 60px; border-radius: 6px;
overflow: hidden; background: #111;
}
.media-row .vid-thumb video {
width: 100%; height: 100%; display: block; object-fit: cover;
}
.media-row .video-play {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
color: white; font-size: 1.35rem; line-height: 1;
text-shadow: 0 1px 4px #000; pointer-events: none;
}
.feedback-form { margin-top: 0.6rem; }
.feedback-form textarea {
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
font-size: 0.85rem; font-family: inherit; resize: vertical; min-height: 50px;
background-color: #fff; color: #333;
}
.feedback-form textarea:focus { outline: none; border-color: #7c6cff; }
.feedback-form button {
margin-top: 0.3rem; padding: 0.35rem 1rem; border: none; border-radius: 8px;
background: #7c6cff; color: #fff; font-size: 0.82rem; font-weight: 600;
cursor: pointer;
}
.feedback-text {
margin-top: 0.4rem; padding: 0.5rem 0.75rem; background: #f0f0ff;
border-radius: 8px; font-size: 0.85rem; color: #4a4570;
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
}
.fb-edit-btn {
background: none; border: none; cursor: pointer; font-size: 0.85rem;
opacity: 0.35; padding: 0; line-height: 1; flex-shrink: 0;
}
.fb-edit-btn:hover { opacity: 0.7; }
.fb-cancel-btn {
padding: 0.35rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
background: #fff; font-size: 0.82rem; cursor: pointer; color: #999;
}
.success-msg {
background: #d1fae5; color: #065f46; padding: 0.6rem 1rem;
border-radius: 8px; margin: 1rem 0; font-size: 0.9rem; text-align: center;
}
.empty-msg { color: #999; font-size: 0.9rem; padding: 0.5rem 0; }
.lang-switch { text-align: right; padding: 0.5rem 1rem 0; }
.lang-switch a { color: #7c6cff; font-size: 0.85rem; text-decoration: none; }
/* Compact upcoming schedule */
.upcoming-list {
display: flex; flex-direction: column; gap: 0.35rem;
}
.upcoming-row {
display: flex; align-items: center; gap: 0.75rem;
background: #fff; border-radius: 8px; padding: 0.5rem 0.75rem;
border: 1px solid #eee; font-size: 0.85rem;
}
.upcoming-row .up-date {
font-weight: 700; min-width: 5.5rem;
}
.upcoming-row .up-time { color: #7a7599; }
.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; }
.calendar-day { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border-radius: 7px; font-size: .78rem; color: #aaa; }
.calendar-day.past { background: #ede9ff; color: #5145a6; font-weight: 700; text-decoration: none; }
.calendar-day.past:hover { background: #dcd5ff; }
.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; }
@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; }
.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>
<body>
<div class="lang-switch">
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
</div>
<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>
</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">
{% if feedback_sent %}
<div class="success-msg">{{ t.portal_feedback_thanks }}</div>
{% endif %}
<div class="portal-grid">
<main class="visits-column">
<!-- Past visits with media first -->
<h2 class="section-title">{{ t.portal_past }}</h2>
{% if past.is_empty() %}
<p class="empty-msg">{{ t.portal_no_past }}</p>
{% else %}
{% for pv in &past %}
<div class="visit-card" id="visit-{{ pv.visit.id.unwrap() }}">
<div class="visit-card-head">
<span class="date">{{ pv.visit.visit_date }}</span>
<span class="badge-sm badge-{{ pv.visit.status }}">
{{ t.visit_status(&pv.visit.status) }}
</span>
</div>
<div class="visit-card-head" style="margin-bottom:0;">
<span class="time">{{ pv.visit.time_start }} — {{ pv.visit.time_end }}</span>
<span class="admin">{{ pv.admin_name }}</span>
</div>
{% if let Some(pn) = pv.visit.public_notes.as_deref() %}
<div style="margin-top:0.4rem;padding:0.5rem 0.75rem;background:#f5f0ff;border-radius:8px;font-size:0.85rem;color:#4a4570;">{{ pn }}</div>
{% endif %}
{% if !pv.media.is_empty() %}
<div class="media-row">
{% for m in &pv.media %}
{% if m.file_type == "photo" %}
<a href="{{ m.url }}" data-lightbox="photo">
<span class="media-thumb-frame media-loading-frame is-loading">
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
</span>
</a>
{% else %}
<a href="{{ m.url }}" data-lightbox="video">
<div class="vid-thumb media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
{% if !m.thumbnail_url.is_empty() %}
<img class="video-preview-sprite" src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
{% endif %}
<span class="video-play"></span>
</div>
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if pv.visit.status == "completed" %}
{% if let Some(fb) = pv.visit.client_feedback.as_deref() %}
<div class="feedback-view" id="fb-view-{{ pv.visit.id }}">
<div class="feedback-text">
<span>{{ fb }}</span>
<button class="fb-edit-btn" onclick="showFbEdit({{ pv.visit.id }})" title="Edit">✏️</button>
</div>
</div>
<form class="feedback-form" id="fb-form-{{ pv.visit.id }}" style="display:none;" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
<textarea name="feedback" required>{{ fb }}</textarea>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
{% endif %}
<div style="display:flex;gap:0.4rem;">
<button type="submit">{{ t.portal_feedback_submit }}</button>
<button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})"></button>
</div>
</form>
{% else %}
<form class="feedback-form" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
<textarea name="feedback" placeholder="{{ t.portal_feedback_placeholder }}" required></textarea>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
{% endif %}
<button type="submit">{{ t.portal_feedback_submit }}</button>
</form>
{% endif %}
{% endif %}
</div>
{% endfor %}
{% if total_pages > 1 %}
<nav class="pagination">
{% if has_previous_page %}<a href="?page={{ page - 1 }}">← {{ t.portal_previous }}</a>{% endif %}
<span>{{ page }} / {{ total_pages }}</span>
{% if has_next_page %}<a href="?page={{ page + 1 }}">{{ t.portal_next }} →</a>{% endif %}
</nav>
{% endif %}
{% endif %}
<!-- Compact upcoming schedule -->
{% if !upcoming.is_empty() %}
<h2 class="section-title">{{ t.portal_upcoming }}</h2>
<div class="upcoming-list">
{% for pv in &upcoming %}
<div class="upcoming-row">
<span class="up-date">{{ pv.visit.visit_date }}</span>
<span class="up-time">{{ t.portal_future_visit }}</span>
</div>
{% endfor %}
</div>
{% 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() %}
<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 %}
<div class="calendar-month" data-label="{{ month.label }}"{% if !loop.first %} hidden{% endif %}>
<div class="calendar-days">
{% for _blank in &month.leading_blanks %}<span></span>{% endfor %}
{% for day in &month.days %}
{% if let Some(href) = day.href.as_deref() %}
<a class="calendar-day {{ day.class_name }}" href="{{ href }}" title="{{ day.title }}">{{ day.number }}</a>
{% else %}
<span class="calendar-day {{ day.class_name }}" title="{{ day.title }}">{{ day.number }}</span>
{% endif %}
{% endfor %}
</div>
</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>
<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 = '';
}
function hideFbEdit(id) {
document.getElementById('fb-form-' + id).style.display = 'none';
document.getElementById('fb-view-' + id).style.display = '';
}
var calendarIndex = 0;
function renderCalendar() {
var months = document.querySelectorAll('.calendar-month');
if (!months.length) return;
months.forEach(function(month, index) { month.hidden = index !== calendarIndex; });
document.getElementById('calendarTitle').textContent = months[calendarIndex].dataset.label;
}
function moveCalendar(delta) {
var months = document.querySelectorAll('.calendar-month');
calendarIndex = Math.max(0, Math.min(months.length - 1, calendarIndex + delta));
renderCalendar();
}
renderCalendar();
{% if notifications_enabled %}
(function() {
var toggle = document.getElementById('notificationToggle');
var registration;
var subscription;
var status = document.getElementById('notificationStatus');
function decodeKey(value) {
var padding = '='.repeat((4 - value.length % 4) % 4);
var raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, function(char) { return char.charCodeAt(0); });
}
function sameKey(left, right) {
if (!left || left.byteLength !== right.byteLength) return false;
var a = new Uint8Array(left), b = new Uint8Array(right);
return a.every(function(value, index) { return value === b[index]; });
}
function showStatus(message, error) {
status.textContent = message;
status.style.display = 'block';
status.style.color = error ? '#b42318' : '#067647';
}
async function saveSubscription(value) {
var payload = value.toJSON();
payload.language = '{{ lang.code() }}';
var response = await fetch('/client/{{ client.media_token }}/push/subscribe', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Subscription API returned HTTP ' + response.status);
}
async function refresh() {
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
toggle.disabled = true;
showStatus('{{ t.portal_notifications_unsupported }}', true);
return;
}
if (!'{{ vapid_public_key }}') {
toggle.disabled = true;
showStatus('{{ t.portal_notifications_error }}', true);
return;
}
registration = await navigator.serviceWorker.register('/service-worker.js');
await registration.update();
await navigator.serviceWorker.ready;
subscription = await registration.pushManager.getSubscription();
var expectedKey = decodeKey('{{ vapid_public_key }}');
if (subscription && !sameKey(subscription.options.applicationServerKey, expectedKey)) {
await subscription.unsubscribe();
subscription = null;
}
if (subscription) {
await saveSubscription(subscription);
showStatus('{{ t.portal_notifications_active }}', false);
}
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
}
toggle.addEventListener('click', async function() {
toggle.disabled = true;
try {
if (!registration) await refresh();
if (subscription) {
await fetch('/client/{{ client.media_token }}/push/unsubscribe', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({endpoint: subscription.endpoint})
});
await subscription.unsubscribe();
subscription = null;
status.style.display = 'none';
} else {
var permission = await Notification.requestPermission();
if (permission !== 'granted') {
showStatus('{{ t.portal_notifications_denied }}', true);
return;
}
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: decodeKey('{{ vapid_public_key }}')
});
await saveSubscription(subscription);
showStatus('{{ t.portal_notifications_active }}', false);
}
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
} catch (error) {
console.error(error);
showStatus('{{ t.portal_notifications_error }}', true);
} finally {
toggle.disabled = false;
}
});
refresh().catch(function(error) {
console.error(error);
showStatus('{{ t.portal_notifications_error }}', true);
});
})();
{% endif %}
document.addEventListener('keydown', function(event) {
if (event.key !== 'Escape') return;
closeNotificationHelp();
closePortalSettings();
});
</script>
{% include "partials/lightbox.html" %}
</body>
</html>