From 7f49b1f314c636fe0ed14142ab62b08c32959b9a Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Tue, 1 Sep 2026 10:51:22 +0100 Subject: [PATCH] Reworked client portal --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/i18n.rs | 3 + src/public.rs | 57 ++++- templates/client_portal.html | 405 +++++++++++++++++++++++++++++++---- 5 files changed, 411 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4691919..9cc2b26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4763,7 +4763,7 @@ dependencies = [ [[package]] name = "web-petting" -version = "1.0.4" +version = "1.0.5" dependencies = [ "async-trait", "aws-sdk-s3", diff --git a/Cargo.toml b/Cargo.toml index 1029196..e6a3f65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "web-petting" -version = "1.0.5" +version = "1.0.6" edition = "2024" default-run = "web-petting" diff --git a/src/i18n.rs b/src/i18n.rs index 01589f8..d1738ed 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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", diff --git a/src/public.rs b/src/public.rs index 7d3009b..9cca9fe 100644 --- a/src/public.rs +++ b/src/public.rs @@ -323,11 +323,13 @@ struct ClientPortalTemplate<'a> { t: &'a Translations, lang: Lang, client: Client, + header_media: Vec, upcoming: Vec, past: Vec, feedback_sent: bool, turnstile_site_key: String, notifications_enabled: bool, + show_portal_settings: bool, vapid_public_key: String, calendar_months: Vec, 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,23 +447,52 @@ 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 = 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> = 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?; - media_by_visit.entry(visit_id).or_default().push(media_view); + 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 { @@ -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 = 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, diff --git a/templates/client_portal.html b/templates/client_portal.html index d612cae..04bd172 100644 --- a/templates/client_portal.html +++ b/templates/client_portal.html @@ -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; } } @@ -154,10 +300,34 @@ {{ lang.other().label() }} -
-

{{ t.portal_title }}

-
{{ client.name }}
- {% if notifications_enabled %}{% endif %} +
+ +
+

{{ t.portal_title }}

+
{{ client.name }}
+
+ {% if show_portal_settings %} + + {% endif %}
@@ -266,12 +436,15 @@ {% endif %} -{% if !calendar_months.is_empty() %} -
{% endif %}
-{% if notifications_enabled %} - -{% endif %} - {% include "partials/lightbox.html" %}