From 10858423a18a4c848e43fb25ba5e5e4f3f8b534c Mon Sep 17 00:00:00 2001 From: Grant Azure Date: Sat, 1 Aug 2026 07:39:36 -0700 Subject: [PATCH] feat: more xmas presentation stuff --- christmas/src/pages/home.rs | 47 ++++++++++++++++++++++++++++++++++--- christmas/src/pages/pool.rs | 9 +++---- christmas/src/storage.rs | 7 ++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/christmas/src/pages/home.rs b/christmas/src/pages/home.rs index fd4de75..b043df6 100644 --- a/christmas/src/pages/home.rs +++ b/christmas/src/pages/home.rs @@ -1,11 +1,14 @@ +use std::collections::HashSet; + use dioxus::prelude::*; use super::current_year; use crate::{ app::Route, + auth::Role, components::CycleBoard, model::{Exchange, Pool}, - server, + server, storage, }; #[component] @@ -37,6 +40,30 @@ fn HomeBody(year: i32, pools: Vec, draws: Vec) -> Element { .max_by_key(|d| d.participants.len()) .cloned(); + // Same rule as the pool page: a "revision 5" badge tells a viewer there are + // earlier draws they cannot see, which is exactly what hiding them is meant + // to avoid. + let role = use_resource(server::my_role); + let is_manager = matches!(&*role.read(), Some(Ok(Role::Manager))); + + // Which draws this browser has already sat through. + // + // `None` until the browser has been consulted. Local storage cannot be read + // while rendering on the server, so the first paint must show no rings at + // all — otherwise the front page hands over every name before the pool + // page's gate ever gets the chance to offer the ceremony. + let mut watched = use_signal(|| None::>); + let drawn: Vec = draws.iter().filter(|d| !d.pairings.is_empty()).map(|d| d.id).collect(); + + use_effect(move || { + let seen = drawn.iter().copied().filter(|id| storage::has_watched(*id)).collect(); + watched.set(Some(seen)); + }); + + // Reads the signal rather than caching a bool, so the rings appear as soon + // as the effect has run. + let is_revealed = move |id: i32| watched.read().as_ref().is_some_and(|seen| seen.contains(&id)); + rsx! { header { class: "hero", div { class: "hero-copy", @@ -116,12 +143,26 @@ fn HomeBody(year: i32, pools: Vec, draws: Vec) -> Element { section { key: "draw{draw.id}", class: "section", div { class: "section-head", h2 { "{draw.pool_name}" } - if draw.revision > 1 { + if is_manager && draw.revision > 1 { span { class: "badge", "revision {draw.revision}" } } span { class: "count", "{draw.participants.len()} people" } } - CycleBoard { cycles: draw.cycles(), letter: draw.letter } + if is_revealed(draw.id) { + CycleBoard { cycles: draw.cycles(), letter: draw.letter } + } else { + div { class: "empty-cta", + strong { "Not opened yet" } + "The {draw.year} draw is in. The names are waiting on the pool page." + div { + Link { + class: "reveal-cta", + to: Route::PoolPage { slug: draw.pool_slug.clone() }, + "Watch the reveal →" + } + } + } + } } } } diff --git a/christmas/src/pages/pool.rs b/christmas/src/pages/pool.rs index cf22b35..5555041 100644 --- a/christmas/src/pages/pool.rs +++ b/christmas/src/pages/pool.rs @@ -35,10 +35,11 @@ fn PoolBody(detail: PoolDetail) -> Element { // during server rendering, so the first paint must not reveal anything — // otherwise a returning visitor flashes the results before the check runs, // and a new one has the whole draw spoiled. - // `None` until the browser has been consulted. Local storage is unavailable - // during server rendering, so the first paint must not reveal anything — - // otherwise a returning visitor flashes the gate, and a new one has the - // whole draw spoiled. + // + // This is a courtesy, not a boundary: `pool_detail` has already sent every + // pairing to the browser, so anyone who opens devtools can read them. Making + // it real would mean the server knowing who has watched what, and with one + // shared password there is no identity to hang that on — see `storage`. let mut watched = use_signal(|| None::); let mut ceremony_open = use_signal(|| false); diff --git a/christmas/src/storage.rs b/christmas/src/storage.rs index 73f2abe..5270cf3 100644 --- a/christmas/src/storage.rs +++ b/christmas/src/storage.rs @@ -3,6 +3,13 @@ //! Local storage rather than anything server-side: with one shared password //! there is no identity to hang it on, and "have I seen the 2026 Pets reveal" //! is a property of the device, not the account. +//! +//! Consequently this holds back the *presentation* of a draw, not the data. The +//! pairings are already in the page the server sent, so a determined viewer can +//! always read them early. That is the right trade for a family exchange — +//! nobody is attacking it, they just want the reveal to land — but callers +//! should not mistake these helpers for access control. Real enforcement lives +//! in `auth`, which gates whole endpoints by role. /// Storage key for one recorded draw. #[cfg_attr(