diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3362c7a..3e97151 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,14 @@ jobs: - uses: Swatinem/rust-cache@v2 with: cache-all-crates: "true" + cache-on-failure: "true" + + - name: Install npm + uses: actions/setup-node@v4 + + - name: npm install + working-directory: app + run: npm ci; # dotenvy requires this - run: cp .env.example .env @@ -40,4 +48,4 @@ jobs: run: cargo +nightly fmt --all -- --check - name: cargo clippy - run: cargo +stable clippy --all --all-features -- -D warnings + run: cargo +stable clippy --all-features -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index 90a5445..ea5c08e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,11 +426,9 @@ dependencies = [ "dioxus-sdk", "dotenvy_macro", "getrandom", - "gloo-net 0.5.0", + "gloo-net 0.6.0", "once_cell", - "postgrest", "rand", - "reqwest 0.11.27", "serde", "serde-querystring", "serde_json", @@ -1794,6 +1792,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 1.1.0", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "gloo-render" version = "0.1.1" @@ -2140,20 +2159,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.30", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.2" @@ -2164,10 +2169,10 @@ dependencies = [ "http 1.1.0", "hyper 1.4.1", "hyper-util", - "rustls 0.23.12", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.0", + "tokio-rustls", "tower-service", ] @@ -2995,15 +3000,6 @@ dependencies = [ "serde", ] -[[package]] -name = "postgrest" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a966c650b47a064e7082170b4be74fca08c088d893244fc4b70123e3c1f3ee7" -dependencies = [ - "reqwest 0.11.27", -] - [[package]] name = "ppv-lite86" version = "0.2.20" @@ -3127,7 +3123,6 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.30", - "hyper-rustls 0.24.2", "hyper-tls 0.5.0", "ipnet", "js-sys", @@ -3138,7 +3133,6 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.21.12", "rustls-pemfile 1.0.4", "serde", "serde_json", @@ -3147,7 +3141,6 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls 0.24.1", "tokio-util", "tower-service", "url", @@ -3155,7 +3148,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", "winreg 0.50.0", ] @@ -3176,7 +3168,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.4.1", - "hyper-rustls 0.27.2", + "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", "ipnet", @@ -3286,18 +3278,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.12" @@ -3306,7 +3286,7 @@ checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" dependencies = [ "once_cell", "rustls-pki-types", - "rustls-webpki 0.102.6", + "rustls-webpki", "subtle", "zeroize", ] @@ -3336,16 +3316,6 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.102.6" @@ -3384,16 +3354,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "security-framework" version = "2.11.1" @@ -4125,23 +4085,13 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.23.12", + "rustls", "rustls-pki-types", "tokio", ] @@ -4179,7 +4129,7 @@ dependencies = [ "futures-util", "log", "native-tls", - "rustls 0.23.12", + "rustls", "tokio", "tokio-native-tls", "tungstenite 0.23.0", @@ -4678,12 +4628,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - [[package]] name = "whoami" version = "1.5.1" diff --git a/app/Cargo.toml b/app/Cargo.toml index 59f8a32..62e7182 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -16,11 +16,9 @@ dioxus = { workspace = true } dioxus-logger = { workspace = true } dioxus-sdk = { workspace = true } dotenvy_macro = "0.15.7" -gloo-net = { version = "0.5.0", features = ["json"] } +gloo-net = { version = "0.6.0", features = ["json"] } once_cell = "1.19.0" -postgrest = "1.6.0" rand = "0.8.5" -reqwest = { version = "0.11.27", features = ["json"] } serde = { workspace = true } serde-querystring = "0.2.1" serde_json = { workspace = true } diff --git a/app/assets/aviary.png b/app/assets/aviary.png new file mode 100644 index 0000000..c4dd69e Binary files /dev/null and b/app/assets/aviary.png differ diff --git a/app/src/bird.rs b/app/src/bird.rs index d801ea3..3db65a2 100644 --- a/app/src/bird.rs +++ b/app/src/bird.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::supabase::{self, Error, Result, SupabaseResource}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq)] pub struct Bird { pub id: u64, pub common_name: String, @@ -12,7 +12,7 @@ pub struct Bird { pub sounds: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct Sound { pub path: String, pub default_: bool, @@ -24,6 +24,12 @@ impl PartialEq for Bird { } } +impl std::hash::Hash for Bird { + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} + impl Bird { /// Get image URL hosted by Supabase storage, e.g. http://127.0.0.1:54321/storage/v1/object/public/bird_images/cardinalis-cardinalis/unlicensed-optimized.jpg pub fn image_url(&self) -> String { @@ -33,6 +39,21 @@ impl Bird { pub fn default_sound_url(&self) -> String { supabase::storage_object_url(&self.sounds[0].path) } + + /// Query db for birds by id + // TODO: enforce global limit? I think supabase limits 1000 by default. + pub async fn fetch_by_ids(ids: I) -> Result> + where + I: IntoIterator, + { + Self::request().select("*").in_("id", ids).execute().await + } +} + +impl SupabaseResource for Bird { + fn table_name() -> &'static str { + "birds_detailed" + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -68,12 +89,17 @@ impl BirdPack { .ok_or_else(|| Error::from(format!("No pack found with id {id} 🙈"))) } - /// Query db for pack of the day (respects local time) + /// Query db for pack of today (respects local time) pub async fn fetch_today() -> Result { let day = chrono::offset::Local::now().date_naive(); + Self::fetch_by_day(day).await + } + + /// Query db for pack of a given day (respects local time) + pub async fn fetch_by_day(day: NaiveDate) -> Result { Self::request() .select("*") - .eq("day", day.format("%Y-%m-%d").to_string()) + .eq("day", day.format("%Y-%m-%d")) .execute() .await? .pop() diff --git a/app/src/main.rs b/app/src/main.rs index 70fb7d1..65f0995 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -6,10 +6,12 @@ use tracing::Level; mod bird; mod conf; +mod pack; mod stats; mod supabase; mod sync; mod ui; +mod utils; fn main() { // Init storage diff --git a/app/src/pack.rs b/app/src/pack.rs new file mode 100644 index 0000000..d9e084d --- /dev/null +++ b/app/src/pack.rs @@ -0,0 +1,139 @@ +use std::fmt::{self, Display}; + +use chrono::NaiveDate; + +use crate::{ + bird::{Bird, BirdPack}, + supabase::Result, + utils, +}; + +/// This type is used during a play session, which could be an ad-hoc list of birds selected to +/// review rather than a db-defined [`BirdPack`]. +/// +/// Note we maintain an internal invariant that the pack identifier matches the birds here, +/// so equality just compares equality of identifiers. +#[derive(Debug, Clone)] +pub struct Pack { + /// An identifier for the set of birds to play. This could be an actual BirdPack id, a pack of + /// the day date, or an ad-hoc list of bird ids. + pub id: PackIdentifier, + /// The actual birds to play. This should always match the sibling identifier. + pub birds: Vec, + /// If this is not an ad-hoc list of birds, this is the id of the birdpack in the database. We + /// store this so we can record stats per birdpack after completion. + pub birdpack_id: Option, +} + +impl PartialEq for Pack { + fn eq(&self, other: &Self) -> bool { + match (self.birdpack_id, other.birdpack_id) { + // Allow equality even if our pack identifier is different (e.g. Id vs Date) + (Some(id1), Some(id2)) => id1 == id2, + _ => self.id == other.id, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PackIdentifier { + Id(u64), + Date(NaiveDate), + Birds(Vec), +} + +impl Default for PackIdentifier { + fn default() -> Self { + PackIdentifier::Date(chrono::offset::Local::now().date_naive()) + } +} + +impl From for Pack { + fn from(pack: BirdPack) -> Self { + let id = match pack.day { + Some(day) => PackIdentifier::Date(day), + None => PackIdentifier::Id(pack.id), + }; + Self { + id, + birds: pack.birds, + birdpack_id: Some(pack.id), + } + } +} + +impl From> for Pack { + fn from(birds: Vec) -> Self { + let bird_ids = birds.iter().map(|b| b.id).collect(); + Self { + id: PackIdentifier::Birds(bird_ids), + birds, + birdpack_id: None, + } + } +} + +impl Pack { + pub async fn fetch_by_id(id: &PackIdentifier) -> Result { + match id { + PackIdentifier::Id(pid) => BirdPack::fetch_by_id(*pid).await.map(|p| Pack { + id: id.clone(), + ..p.into() + }), + PackIdentifier::Date(day) => BirdPack::fetch_by_day(*day).await.map(|p| Pack { + id: id.clone(), + ..p.into() + }), + PackIdentifier::Birds(bids) => { + Bird::fetch_by_ids(bids.iter().copied()) + .await + .map(|birds| Pack { + id: id.clone(), + birds, + birdpack_id: None, + }) + } + } + } +} + +const LIST_DELIM: char = '.'; + +impl From<&str> for PackIdentifier { + fn from(query: &str) -> Self { + let id = query.parse().ok().map(PackIdentifier::Id); + let date = NaiveDate::parse_from_str(query, "%Y-%m-%d") + .ok() + .map(PackIdentifier::Date); + let birds = query + .split(LIST_DELIM) + .map(|s| s.parse().ok()) + .collect::>>() + .filter(|ids| { + let mut ids = ids.clone(); + ids.sort(); + ids.dedup(); + ids.len() >= 10 + }) + .map(PackIdentifier::Birds); + id.or(date).or(birds).unwrap_or_else(|| { + if !query.is_empty() { + tracing::error!("Failed to parse pack identifier from query: {query}"); + tracing::info!("Defaulting to pack of the day"); + } + Self::default() + }) + } +} + +impl Display for PackIdentifier { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + PackIdentifier::Id(id) => write!(f, "{id}"), + PackIdentifier::Date(date) => write!(f, "{date}"), + PackIdentifier::Birds(birds) => { + write!(f, "{}", utils::join(birds, LIST_DELIM)) + } + } + } +} diff --git a/app/src/stats.rs b/app/src/stats.rs index aadd148..42c1f12 100644 --- a/app/src/stats.rs +++ b/app/src/stats.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; -use crate::bird::BirdPack; +use crate::pack::{Pack, PackIdentifier}; /// The number of times a bird must be correctly identified consecutively to be considered learned. pub const LEARN_THRESHOLD: u32 = 3; @@ -52,7 +52,14 @@ impl Stats { .sum() } - pub fn birds_learned(&self) -> u32 { + pub fn birds_learned(&self) -> Vec { + self.bird_stats + .iter() + .filter_map(|(id, bs)| if bs.learned { Some(*id) } else { None }) + .collect() + } + + pub fn total_birds_learned(&self) -> u32 { self.bird_stats.values().filter(|bs| bs.learned).count() as u32 } @@ -76,12 +83,14 @@ impl Stats { bird_stat.mistaken += 1; } - pub fn add_pack_completed(&mut self, pack: &BirdPack) { - let pack_stat = self.pack_stats.entry(pack.id).or_default(); - pack_stat.times_completed += 1; + pub fn add_pack_completed(&mut self, pack: &Pack) { + if let Some(pack_id) = pack.birdpack_id { + let pack_stat = self.pack_stats.entry(pack_id).or_default(); + pack_stat.times_completed += 1; + } // If this is a daily pack - if let Some(day) = pack.day { + if let PackIdentifier::Date(day) = pack.id { // that is actually today's pack (or yesterday's, allowing for fetched/finished // before/after midnight) let today = chrono::offset::Local::now().date_naive(); diff --git a/app/src/supabase/db.rs b/app/src/supabase/db.rs index bfc3748..736069a 100644 --- a/app/src/supabase/db.rs +++ b/app/src/supabase/db.rs @@ -1,32 +1,18 @@ -// TODO: after everything's working, see if replacing reqwest & postgrest-rs with just gloo_net affects wasm size. +use std::fmt::Display; -use once_cell::sync::Lazy; -use postgrest::{Builder, Postgrest}; +use gloo_net::http::RequestBuilder; use serde::{de::DeserializeOwned, Serialize}; use thiserror::Error; -use crate::conf::{SUPABASE_ANON_KEY, SUPABASE_API_URL}; - -static POSTGREST_CLIENT: Lazy = Lazy::new(|| { - Postgrest::new(format!("{SUPABASE_API_URL}/rest/v1")) - .insert_header("apikey", SUPABASE_ANON_KEY) - .insert_header("Authorization", format!("Bearer {SUPABASE_ANON_KEY}")) -}); - -pub fn rpc(function: T, params: U) -> Builder -where - T: AsRef, - U: Serialize, -{ - POSTGREST_CLIENT.rpc(function, serde_json::to_string(¶ms).unwrap()) -} +use crate::{ + conf::{SUPABASE_ANON_KEY, SUPABASE_API_URL}, + utils, +}; #[derive(Error, Debug)] pub enum Error { #[error(transparent)] Gloo(#[from] gloo_net::Error), - #[error(transparent)] - Reqwest(#[from] reqwest::Error), #[error("Uh oh! We couldn't find today's pack!")] NoDailyPack, #[allow(clippy::enum_variant_names)] @@ -51,7 +37,6 @@ impl Clone for Error { fn clone(&self) -> Self { match self { Self::Gloo(e) => Self::ErrorMessage(e.to_string()), - Self::Reqwest(e) => Self::ErrorMessage(format!("{:#?}", e)), Self::NoDailyPack => Self::NoDailyPack, Self::ErrorMessage(msg) => Self::ErrorMessage(msg.clone()), } @@ -60,24 +45,53 @@ impl Clone for Error { pub type Result = std::result::Result; -pub struct SupabaseRequest { - builder: Builder, +pub struct SupabaseRequest { + builder: RequestBuilder, + /// Gloonet annoyingly turns a RequestBuilder into a Request when setting the body, so we + /// postpone this until the end (to support adding filters before executing). + body: Option, _response_type: std::marker::PhantomData, } impl SupabaseRequest { - pub fn new(table_name: &str) -> Self { - let builder = POSTGREST_CLIENT.from(table_name); + /// Create a REST request _from_ the specified Supabase table/view. + pub fn from(table_name: &str) -> Self { + let builder = gloo_net::http::RequestBuilder::new(&format!( + "{SUPABASE_API_URL}/rest/v1/{table_name}" + )) + .header("apikey", SUPABASE_ANON_KEY) + .header("Authorization", &format!("Bearer {SUPABASE_ANON_KEY}")); SupabaseRequest { builder, + body: None, _response_type: std::marker::PhantomData, } } + /// Call a Postgres function. + pub fn rpc(function: F, params: &U) -> Result + where + F: Display, + U: Serialize, + { + let builder = gloo_net::http::RequestBuilder::new(&format!( + "{SUPABASE_API_URL}/rest/v1/rpc/{function}" + )) + .header("apikey", SUPABASE_ANON_KEY) + .header("Authorization", &format!("Bearer {SUPABASE_ANON_KEY}")); + let json = serde_json::to_string(params).map_err(gloo_net::Error::from)?; + Ok(SupabaseRequest { + builder, + body: Some(json), + _response_type: std::marker::PhantomData, + }) + } + /// If doing some join or complex query, this can be used to manually cast to the expected type. pub fn cast(self) -> SupabaseRequest { SupabaseRequest { builder: self.builder, + body: self.body, _response_type: std::marker::PhantomData, } } @@ -87,45 +101,60 @@ impl SupabaseRequest { self.cast() } - /// See [`Builder::select`] + /// Select columns pub fn select(mut self, columns: C) -> Self where - C: Into, + C: AsRef, { - self.builder = self.builder.select(columns); + self.builder = self.builder.query([("select", columns)]); self } - /// See [`Builder::order`] - pub fn order(mut self, columns: C) -> Self + /// Add equality filter + pub fn eq(mut self, column: C, filter: D) -> Self where - C: Into, + C: AsRef, + D: Display, { - self.builder = self.builder.order(columns); + self.builder = self + .builder + .query([(column.as_ref(), &format!("eq.{}", filter))]); self } - pub fn eq(mut self, column: C, filter: D) -> Self + /// Add IN array filter + pub fn in_(mut self, column: C, values: I) -> Self where C: AsRef, - D: AsRef, + I: IntoIterator, + D: Display, { - self.builder = self.builder.eq(column, filter); + self.builder = self.builder.query([( + column.as_ref(), + &format!("in.({})", utils::join(values, ",")), + )]); self } + /// Execute request pub async fn execute(self) -> Result { - let rsp = self.builder.execute().await?.json().await?; + let req = if let Some(body) = self.body { + self.builder.body(body) + } else { + self.builder.build() + }?; + let rsp = req.send().await?.json().await?; Ok(rsp) } } +/// Indicates a type corresponds to a RESTful resource, i.e. a table in Supabase. pub trait SupabaseResource: Sized + DeserializeOwned { fn table_name() -> &'static str; // TODO: When auth is implemented, there will probably be some state to reference the correct // auth token. fn request() -> SupabaseRequest> { - SupabaseRequest::new(Self::table_name()) + SupabaseRequest::from(Self::table_name()) } } diff --git a/app/src/sync.rs b/app/src/sync.rs index ddab808..fa40a5f 100644 --- a/app/src/sync.rs +++ b/app/src/sync.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::{ stats::Stats, - supabase::{self, AuthState, Result, SupabaseResource}, + supabase::{AuthState, Result, SupabaseRequest, SupabaseResource}, }; #[derive(Clone)] @@ -119,7 +119,9 @@ impl UserStats { tracing::debug!("Pushing stats for user_id {:?}", self.user_id); self.updated_at = Utc::now(); if !self.user_id.is_empty() { - let _rsp = supabase::rpc("upsert_stats", self).execute().await?; + SupabaseRequest::<()>::rpc("upsert_stats", self)? + .execute() + .await?; } Ok(()) } diff --git a/app/src/ui/components/bird.rs b/app/src/ui/components/bird.rs new file mode 100644 index 0000000..90643d6 --- /dev/null +++ b/app/src/ui/components/bird.rs @@ -0,0 +1,152 @@ +use dioxus::prelude::*; + +use crate::bird::Bird; + +#[derive(PartialEq, Props, Clone)] +pub struct BirdCardProps { + extra_classes: Option, + #[props(default = true)] + text_selection: bool, + #[props(default = true)] + responsive: bool, + bird: Bird, + children: Element, +} + +pub fn BirdCard(props: BirdCardProps) -> Element { + let BirdCardProps { + extra_classes, + responsive, + bird, + children, + text_selection, + } = props; + let extra_classes = extra_classes.unwrap_or_default(); + let select_class = if text_selection { + "select-all" + } else { + "select-none" + }; + rsx! { + div { + class: "flex flex-row justify-between border rounded-xl shadow py-3 sm:py-4 {extra_classes}", + + // left + div { + class: "uppercase max-h-full self-end whitespace-nowrap text-ellipsis overflow-hidden {select_class}", + class: if responsive { + "hidden sm:block" + }, + text_orientation: "upright", + writing_mode: "vertical-lr", + "{bird.scientific_name.split_whitespace().next().unwrap()}" + } + + // center + div { + class: "flex items-center", + class: if responsive { + "flex-row sm:flex-col gap-1 sm:gap-4 px-2 sm:px-0 w-full" + } else { + "flex-col gap-4" + }, + img { + class: "border-2 w-24 h-24 rounded-full object-cover flex-none overflow-hidden", + src: bird.image_url(), + alt: "", + } + div { + class: "text-lg text-center mx-auto {select_class}", + "{bird.common_name}" + } + {children} + } + + div { + class: "uppercase max-h-full self-start whitespace-nowrap text-ellipsis overflow-hidden {select_class}", + class: if responsive { + "hidden sm:block" + }, + text_orientation: "upright", + writing_mode: "vertical-lr", + "{bird.scientific_name.split_whitespace().last().unwrap()}" + } + } + } +} + +#[derive(PartialEq, Props, Clone)] +pub struct BirdCardPlaceholderProps { + extra_classes: Option, + extra_scientific_first_class: Option, + extra_scientific_second_class: Option, + #[props(default = true)] + responsive: bool, + children: Element, +} + +#[component] +pub fn BirdCardPlaceholder(props: BirdCardPlaceholderProps) -> Element { + let BirdCardPlaceholderProps { + extra_classes, + responsive, + children, + extra_scientific_first_class, + extra_scientific_second_class, + } = props; + let extra_classes = extra_classes.unwrap_or_default(); + let extra_scientific_first_class = extra_scientific_first_class.unwrap_or_default(); + let extra_scientific_second_class = extra_scientific_second_class.unwrap_or_default(); + rsx! { + div { + class: "bg-offwhite-2 border border-black/10 rounded-xl py-3 sm:py-4 flex flex-row justify-between {extra_classes}", + + // left + div { + class: "ml-2 w-2 h-32 self-end bg-black/10 rounded-full {extra_scientific_first_class}", + class: if responsive { + "hidden sm:block" + }, + } + + // center + div { + class: "flex items-center", + class: if responsive { + "flex-row sm:flex-col gap-1 sm:gap-4 px-2 sm:px-0 w-full" + } else { + "flex-col gap-4" + }, + div { class: "w-24 h-24 rounded-full flex-none bg-black/10" } + div { + class: "flex flex-col justify-center items-center gap-4 mx-auto", + div { + class: "h-2.5 bg-black/20 rounded-full", + class: if responsive { + "w-40 sm:w-24" + } else { + "w-24" + } + } + div { + class: "h-2.5 bg-black/20 rounded-full", + class: if responsive { + "w-36 sm:w-28" + } else { + "w-28" + } + } + } + {children} + } + + // right + div { + class: "mr-2 w-2 {extra_scientific_second_class} self-start bg-black/10 rounded-full", + class: if responsive { + "hidden sm:block" + } + } + } + } +} diff --git a/app/src/ui/components/birdpack.rs b/app/src/ui/components/birdpack.rs index b219e50..0beb596 100644 --- a/app/src/ui/components/birdpack.rs +++ b/app/src/ui/components/birdpack.rs @@ -1,8 +1,10 @@ use dioxus::prelude::*; +use super::{bird::BirdCard, icons::ArrowUturnRightIcon}; use crate::{ bird::{Bird, BirdPack}, - ui::{components::icons::ArrowUturnRightIcon, Route, PLAY_STATUS}, + pack::Pack, + ui::{pages::PLAY_STATUS, Route}, }; /// Pack of the day @@ -51,7 +53,7 @@ fn PackOfTheDayInner(pack: BirdPack) -> Element { rsx! { div { - class: "grid grid-cols-5 items-center mx-auto overflow-x-clip sm:overflow-x-visible", + class: "grid grid-cols-5 items-center mx-auto", button { class: "col-span-1 w-12 h-12 focus:outline-none focus-visible:ring focus-visible:ring-black font-semibold bg-offwhite text-black border-2 rounded-full shadow sm:hover:shadow-xl sm:hover:scale-110 transition-transform flex justify-center items-center z-40 justify-self-end sm:justify-self-center order-last sm:order-first", onclick: move |_| { @@ -65,14 +67,16 @@ fn PackOfTheDayInner(pack: BirdPack) -> Element { ul { class: "w-56 h-96 relative", for (ix, bird) in pack.birds.clone().into_iter().enumerate() { - Card { bird, playing, ix, pack_size, position } + CardContainer { bird, playing, ix, pack_size, position } } } button { class: "px-12 py-4 mt-2 border-2 border-green-extra-dark focus:outline-none focus-visible:ring focus-visible:ring-green-dark font-semibold text-base bg-green-dark text-white rounded-xl shadow sm:hover:shadow-xl sm:hover:scale-125 sm:hover:bg-gradient-to-r from-green to-green-dark transition-transform uppercase text-xl z-40", onclick: move |_| { - *PLAY_STATUS.write() = Some(pack.clone()); - navigator().push(Route::Play { pack_id: pack.id }); + let pack = Pack::from(pack.clone()); + let pack_id = pack.id.clone(); + *PLAY_STATUS.write() = Some(pack); + navigator().push(Route::Play {pack_id}); }, "play" } @@ -88,30 +92,33 @@ fn PackOfTheDayInner(pack: BirdPack) -> Element { /// - `position` is the index of the card from the user's perspective. /// This changes when the user clicks to view the next card. #[component] -fn Card( +fn CardContainer( bird: Bird, playing: Signal, ix: usize, pack_size: usize, position: Signal, ) -> Element { - let bg_color = |ix: usize| match ix % 8 { + let bg_color = |ix: usize| match ix % 10 { 0 => "bg-green", - 1 => "bg-yellow", - 2 => "bg-blue-light", - 3 => "bg-orange", - 4 => "bg-purple", - 5 => "bg-red", + 1 => "bg-blue-light", + 2 => "bg-yellow", + 3 => "bg-purple", + 4 => "bg-orange", + 5 => "bg-brown", 6 => "bg-chartreuse", - 7 => "bg-pink", + 7 => "bg-red", + 8 => "bg-chartreuse-dark", + 9 => "bg-pink", _ => unreachable!(), }; let pos = use_memo(move || (ix + pack_size - position()) % pack_size); let visible = use_memo(move || pos() == 0); + let sound_url = bird.default_sound_url(); rsx! { li { key: ix, - class: "absolute inset-0 border rounded-xl shadow py-3 sm:py-4 text-black {bg_color(ix)} flex flex-row justify-between transition-transform transform-gpu duration-700 origin-bottom select-none", + class: "absolute inset-0 transition-transform transform-gpu duration-700 origin-bottom select-none", // NOTE: this overwrites transform-gpu :/ I could make another closure // to compute hardcoded transform strings, so that its tailwind all the way down. transform: "rotate({degree(pos())}deg) translateX({degree(pos())}px)", @@ -133,37 +140,15 @@ fn Card( "animate-card-slide-out" }, - div { - class: "uppercase max-h-full self-end whitespace-nowrap text-ellipsis overflow-hidden", - text_orientation: "upright", - writing_mode: "vertical-lr", - "{bird.scientific_name.split_whitespace().next().unwrap()}" - } - - // center - div { - class: "flex flex-col gap-4 items-center", - img { - class: "border-2 w-24 h-24 rounded-full object-cover flex-none overflow-hidden", - src: bird.image_url(), - alt: "", - } - div { - class: "text-lg text-center select-all", - "{bird.common_name}" - } + BirdCard { + extra_classes: "h-full w-full {bg_color(ix)}", + responsive: false, + bird, div { class: "mt-auto mb-8", - Audio { url: bird.default_sound_url(), user_playing: playing, visible } + Audio { url: sound_url, user_playing: playing, visible } } } - - div { - class: "uppercase max-h-full self-start whitespace-nowrap text-ellipsis overflow-hidden", - text_orientation: "upright", - writing_mode: "vertical-lr", - "{bird.scientific_name.split_whitespace().last().unwrap()}" - } } } } @@ -177,7 +162,7 @@ fn Card( /// We use effects to change the play status on changes to these signals, rather than the signals /// themselves. This is just to allow a nice transition from one card to the next. #[component] -pub fn Audio(url: String, user_playing: Signal, visible: ReadOnlySignal) -> Element { +fn Audio(url: String, user_playing: Signal, visible: ReadOnlySignal) -> Element { use wasm_bindgen::JsCast; use web_sys::HtmlAudioElement; @@ -301,7 +286,7 @@ fn PackOfTheDayPlaceholder() -> Element { let pack_size = 10; rsx! { div { - class: "animate-pulse grid grid-cols-5 items-center mx-auto overflow-x-clip sm:overflow-x-visible", + class: "animate-pulse grid grid-cols-5 items-center mx-auto", div { class: "col-span-1 w-12 h-12 bg-offwhite-2 border border-black/10 rounded-full z-40 justify-self-end sm:justify-self-center order-last sm:order-first" } @@ -324,7 +309,7 @@ fn PackOfTheDayPlaceholder() -> Element { #[component] fn CardPlaceholder(ix: usize, pack_size: usize) -> Element { - let scientific_second_width_class = match ix { + let scientific_second_height_class = match ix { 0 => "h-40", 1 => "h-32", 2 => "h-48", @@ -356,7 +341,7 @@ fn CardPlaceholder(ix: usize, pack_size: usize) -> Element { } // right - div { class: "mr-2 w-2 {scientific_second_width_class} self-start bg-black/10 rounded-full" } + div { class: "mr-2 w-2 {scientific_second_height_class} self-start bg-black/10 rounded-full" } } } } diff --git a/app/src/ui/components/icons.rs b/app/src/ui/components/icons.rs index e58c341..c838a3a 100644 --- a/app/src/ui/components/icons.rs +++ b/app/src/ui/components/icons.rs @@ -136,3 +136,43 @@ pub fn ArrowUturnRightIcon() -> Element { } } } + +#[component] +pub fn CheckedCircle(extra_classes: Option) -> Element { + let extra_classes = extra_classes.unwrap_or_default(); + rsx! { + svg { + class: "w-6 h-6 {extra_classes}", + view_box: "0 0 24 24", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + stroke_width: "1.5", + stroke: "currentColor", + path { + d: "M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z", + stroke_linecap: "round", + stroke_linejoin: "round" + } + } + } +} + +#[component] +pub fn UncheckedCircle(extra_classes: Option) -> Element { + let extra_classes = extra_classes.unwrap_or_default(); + rsx! { + svg { + class: "w-6 h-6 {extra_classes}", + view_box: "0 0 24 24", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + stroke_width: "1", + stroke: "currentColor", + path { + d: "M9 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z", + stroke_linecap: "round", + stroke_linejoin: "round" + } + } + } +} diff --git a/app/src/ui/components/mod.rs b/app/src/ui/components/mod.rs index 4219de9..ab61967 100644 --- a/app/src/ui/components/mod.rs +++ b/app/src/ui/components/mod.rs @@ -1,10 +1,12 @@ //! General purpose UI components mod auth; +mod bird; mod birdpack; -mod icons; +pub mod icons; mod modal; pub use auth::*; +pub use bird::*; pub use birdpack::*; pub use modal::*; diff --git a/app/src/ui/game/game_over.rs b/app/src/ui/game/game_over.rs index 73f20a7..c31587f 100644 --- a/app/src/ui/game/game_over.rs +++ b/app/src/ui/game/game_over.rs @@ -5,7 +5,8 @@ use crate::{ ui::{ components::{Login, Modal}, game::GameCtx, - AppCtx, Route, PLAY_STATUS, + pages::PLAY_STATUS, + AppCtx, Route, }, }; @@ -19,7 +20,7 @@ pub fn GameOverModal() -> Element { // async_std::task::sleep(std::time::Duration::from_millis(500)).await; tracing::debug!("Game over! Resetting game status..."); *PLAY_STATUS.write() = None; - navigator().push(Route::Index {}); + navigator().push(Route::Birds {}); }); }); @@ -37,7 +38,7 @@ pub fn GameOverModal() -> Element { table { class: "table-auto text-lg", tbody { Stat { name: "XP", f: Stats::xp } - Stat { name: "Birds Learned", f: Stats::birds_learned } + Stat { name: "Birds Learned", f: Stats::total_birds_learned } Stat { name: "Daily Pack Streak", f: Stats::daily_pack_streak } } } diff --git a/app/src/ui/game/mod.rs b/app/src/ui/game/mod.rs index 4b07fe8..11d15ff 100644 --- a/app/src/ui/game/mod.rs +++ b/app/src/ui/game/mod.rs @@ -8,11 +8,7 @@ pub mod quiz; use dioxus::prelude::*; use rand::prelude::SliceRandom; -use crate::{ - bird::{Bird, BirdPack}, - stats::Stats, - ui::AppCtx, -}; +use crate::{bird::Bird, pack::Pack, stats::Stats, ui::AppCtx}; use audio::AudioPlayer; use card::{MultipleChoiceCard, MultipleChoiceCardPlaceholder}; use game_over::GameOverModal; @@ -22,8 +18,8 @@ use quiz::{Game, MULTIPLE_CHOICE_SIZE}; struct GameCtx { /// Game state game: Signal, - /// Birdpack - birdpack: CopyValue, + /// Pack + pack: CopyValue, /// Storage backed stats state stats: Signal, /// Value of `stats` at the game start (so we can diff at the end). @@ -40,10 +36,10 @@ struct GameCtx { impl GameCtx { /// Initialize a new game context (and provide it to children). - fn init(birdpack: BirdPack) -> Self { + fn init(pack: Pack) -> Self { let app_ctx = use_context::(); - let game = use_signal(|| Game::init(birdpack.birds.clone(), true)); - let birdpack = CopyValue::new(birdpack); + let game = use_signal(|| Game::init(pack.birds.clone(), true)); + let pack = use_hook(|| CopyValue::new(pack)); let stats = *app_ctx.stats; let stats_original = stats.with_peek(|og| CopyValue::new(og.clone())); let correct_chosen = use_signal(|| false); @@ -52,7 +48,7 @@ impl GameCtx { game, stats, correct_chosen, - birdpack, + pack, game_completed, stats_original, }) @@ -103,7 +99,7 @@ impl GameCtx { async fn next(&mut self) { if self.game.read().is_complete() { - self.stats.write().add_pack_completed(&self.birdpack.read()); + self.stats.write().add_pack_completed(&self.pack.read()); self.game_completed.set(true); // TODO: sync stats on game completed } else { @@ -125,7 +121,7 @@ impl GameCtx { } #[component] -pub fn GameView(pack: BirdPack) -> Element { +pub fn GameView(pack: Pack) -> Element { let game_ctx = GameCtx::init(pack); let shuffle = game_ctx.shuffle_memo(); let correct_bird = game_ctx.correct_bird_memo(); diff --git a/app/src/ui/mod.rs b/app/src/ui/mod.rs index 152717f..5792e47 100644 --- a/app/src/ui/mod.rs +++ b/app/src/ui/mod.rs @@ -8,15 +8,13 @@ mod pages; use dioxus::prelude::*; use crate::{ - bird::BirdPack, + pack::PackIdentifier, stats::Stats, supabase::AuthState, sync::Sync, ui::pages::{Birds, Index, Play}, }; -pub static PLAY_STATUS: GlobalSignal> = Signal::global(|| None); - #[derive(Clone, Copy)] pub struct AppCtx { pub auth_state: AuthState, @@ -47,7 +45,7 @@ pub fn App() -> Element { } } -#[derive(Clone, Routable, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Routable, Debug, PartialEq)] #[rustfmt::skip] enum Route { #[layout(HeaderFooter)] @@ -59,9 +57,9 @@ enum Route { #[route("/")] Index {}, - #[route("/play/:pack_id")] + #[route("/play/?:..pack_id")] Play { - pack_id: u64 + pack_id: PackIdentifier, }, #[route("/birds")] @@ -70,28 +68,66 @@ enum Route { #[component] fn HeaderFooter() -> Element { + let route: Route = use_route(); + let is_index = matches!(route, Route::Index {}); rsx! { div { - class: "flex flex-col sm:h-dvh pb-2", + class: "flex flex-col sm:h-dvh selection:bg-purple-dark overflow-x-clip sm:overflow-x-visible", header { id: "header", - class: "text-green-dark shrink h-20 py-2 w-full flex flex-row justify-between items-center", - div {} + class: "text-green-dark shrink px-1 py-2 w-full flex flex-row justify-between sm:justify-center items-center gap-4", + class: if is_index { + "h-20 text-5xl" + } else { + "h-16 sm:h-20 text-4xl sm:text-5xl" + }, + // TODO: hambuger menu for mobile (with nice animation 3 bars to X) + div { + class: "shrink-0", + Link { + class: "outline-purple-dark", + to: Route::Birds {}, + // TODO: use hover:bg-url-[highlighted] to use the yellow fill on hover + img { + class: if is_index { + "h-12" + } else { + "h-10 sm:h-12" + }, + src: asset!("assets/aviary.png"), + alt: "Your Aviary", + } + span { class: "sr-only", "Your Aviary" } + } + } + div { + class: "font-arcade font-semibold uppercase", + h1 { + Link { + class: "outline-none focus-visible:ring", + to: Route::Index {}, "birdtalk" + } + } + } + // Just jank until another icon is here div { - class: "text-5xl font-arcade font-semibold uppercase", - h1 { "birdtalk" } + class: "shrink-0", + class: if is_index { + "w-[41px]" + } else { + "w-[34px] sm:w-[41px]" + }, } - div {} } div { id: "content", - class: "no-shrink", + class: "shrink-0", Outlet:: { } } footer { id: "footer", - class: "shrink sticky top-[100vh] hidden sm:flex justify-items-center justify-center", + class: "h-6 shrink sticky top-[100vh] hidden sm:flex justify-items-center justify-center", div { "© 2024 birdtalk" } diff --git a/app/src/ui/pages/birds.rs b/app/src/ui/pages/birds.rs index 4ca3545..884d4b9 100644 --- a/app/src/ui/pages/birds.rs +++ b/app/src/ui/pages/birds.rs @@ -1,12 +1,246 @@ +use std::collections::{HashSet, VecDeque}; + use dioxus::prelude::*; +use crate::{ + bird::Bird, + pack::{Pack, PackIdentifier}, + ui::{ + components::{ + icons::{CheckedCircle, UncheckedCircle}, + BirdCard, BirdCardPlaceholder, + }, + pages::PLAY_STATUS, + AppCtx, Route, + }, +}; + +// TODO: save these settings in local storage +static SIMULTANEOUS_CALLS: GlobalSignal = Signal::global(|| 1); +static LOOP_AUDIO: GlobalSignal = Signal::global(|| true); + +#[derive(Clone, Copy)] +struct AviaryCtx { + /// Selected birds for review + selected: Signal>, + /// Birds whose audio is currently playing + playing: Signal>, + /// Read only list of learned birds + /// This should pretty much always remain static, but might change if someone learns new birds + /// in a different tab/window. + bird_ids: Memo>, +} + +impl AviaryCtx { + /// Initialize a new game context (and provide it to children). + fn init() -> Self { + let stats = use_context::().stats; + let bird_ids = use_memo(move || stats.read().birds_learned()); + let selected = use_signal(HashSet::new); + let playing = use_signal(VecDeque::new); + use_context_provider(|| Self { + selected, + playing, + bird_ids, + }) + } +} + #[component] pub fn Birds() -> Element { + let ctx = AviaryCtx::init(); + let selected = ctx.selected; + let num_selected = use_memo(move || selected.read().len()); + const MINIMUM_BIRDS: usize = 10; + let select_to_review_text = use_memo(move || match num_selected() { + 0 => format!("Select {MINIMUM_BIRDS} birds to review"), + x if x > 0 && x < MINIMUM_BIRDS - 1 => format!("Select {} more birds", MINIMUM_BIRDS - x), + x if x == MINIMUM_BIRDS - 1 => format!("Select {} more bird", MINIMUM_BIRDS - x), + _ => "".to_string(), + }); + rsx! { + div { + class: "flex flex-col sm:flex-row gap-4 p-4 sm:p-8 sm:pb-0", + div { + class: "text-center sm:text-left text-lg flex flex-col gap-4 sm:max-w-xs", + h2 { + class: "text-3xl", + "Your Aviary" + } + div { + span { + "Here are all the birds you've learned so far! 🐦 Continue to play the " + } + Link { + class: "font-semibold underline text-purple-dark outline-none focus-visible:ring", + to: Route::Play { pack_id: PackIdentifier::default() }, + "Pack of the Day" + } + span { + " to learn more!" + } + } + div { + class: "fixed bottom-0 left-0 right-0 z-10 pt-2 pb-4 border-t bg-offwhite sm:static sm:mt-auto flex flex-col gap-2 items-center", + span { "{select_to_review_text}" } + button { + class: "px-12 py-4 mt-2 border-2 border-green-extra-dark focus:outline-none focus-visible:ring focus-visible:ring-green-dark font-semibold text-base bg-green-dark text-white rounded-xl shadow sm:enabled:hover:shadow-xl sm:enabled:hover:scale-125 sm:enabled:hover:bg-gradient-to-r disabled:opacity-75 from-green to-green-dark transition-transform uppercase text-xl z-40", + disabled: num_selected() < MINIMUM_BIRDS, + onclick: move |_| { + let birds = selected().into_iter().collect::>(); + let pack = Pack::from(birds); + let pack_id = pack.id.clone(); + *PLAY_STATUS.write() = Some(pack); + navigator().push(Route::Play {pack_id}); + }, + "review" + } + } + } + BirdCollection {} + } + } +} + +#[component] +fn BirdCollection() -> Element { + rsx! { + div { + class: "flex flex-col gap-4 w-full", + // TODO: unhide and add controls for listening, sorting, etc. + div {class: "hidden sticky top-0", "Some controls here etc."} + div {BirdGrid {}} + } + } +} + +// TODO: handle case where user has no birds yet (or less than 10). +#[component] +fn BirdGrid() -> Element { + let bird_ids = use_context::().bird_ids; + // TODO: paginate! Use scroll events to load more birds. + // Can probably do something nice where birds being fetched are placeholder cards and then they + // fill in (maybe a hashmap of Options?) + + let birds = + use_resource( + move || async move { Bird::fetch_by_ids(bird_ids.read().iter().copied()).await }, + ); + + match &*birds.read_unchecked() { + None => rsx! { BirdsPlaceholder {bird_ids} }, + Some(Ok(birds)) => rsx! { BirdsInner {birds: birds.clone()} }, + // TODO: check to make sure this error looks OK in the finished layout + Some(Err(e)) => rsx! { + div { + class: "text-red-dark text-center flex flex-col items-center justify-center gap-6 mb-auto", + div { class: "text-3xl", "Uh oh! 😱" } + div { + class: "text-lg", + span { + "Something went wrong fetching your birds! Please open a " + } + a { + class: "underline text-purple-dark", + href: "https://github.com/samtay/birdtalk/issues/new", + target: "_blank", + "GitHub issue" + } + span { " with the following error:" } + } + div { + code { + class: "select-all", + "{e}" + } + } + } + }, + } +} + +const BIRD_GRID_HEIGHT: &str = "sm:h-[calc(100vh-120px)]"; + +#[component] +fn BirdsInner(birds: Vec) -> Element { + // NOTE: might be better to use form values with a memo + rsx! { + ul { + tabindex: -1, + class: "grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,_minmax(14rem,_1fr))] gap-4 sm:gap-8 sm:overflow-auto {BIRD_GRID_HEIGHT} sm:pt-2 sm:pr-2 mb-[8.25rem] sm:mb-0", + for bird in birds { + BirdInner { bird } + } + } + } +} + +#[component] +fn BirdInner(bird: Bird) -> Element { + let mut selected = use_context::().selected; + let id = bird.id; + rsx! { + li { + key: id, + class: "flex justify-center", + label { + class: "relative w-full sm:w-56 sm:h-72 sm:hover:-translate-y-2 shadow sm:hover:shadow-lg transition-transform", + input { + class: "absolute opacity-0 peer", + r#type: "checkbox", + id: id as i64, + name: id as i64, + onchange: { + move |e: Event| { + if e.data().checked() { + let inserted = selected.write().insert(bird.clone()); + if !inserted { + tracing::warn!("Bird {} was already selected! Form data: {:?}", bird.id, e.data()); + } + } else { + let removed = selected.write().remove(&bird); + if !removed { + tracing::warn!("Bird {} wasn't selected! Form data: {:?}", bird.id, e.data()); + } + } + } + } + } + BirdCard { + bird: bird.clone(), + extra_classes: "w-full h-full bg-yellow cursor-pointer peer-checked:bg-green peer-checked:border-green-dark peer-checked:text-green-extra-dark peer-focus-visible:ring peer-focus-visible:ring-yellow-dark peer-checked:peer-focus-visible:ring-green-dark", + text_selection: false, + } + CheckedCircle {extra_classes: "text-green-extra-dark inline-block absolute top-2 right-2 sm:top-auto sm:bottom-2 sm:right-[calc(50%-0.75rem)] invisible peer-checked:visible"} + UncheckedCircle {extra_classes: "inline-block absolute top-2 right-2 sm:top-auto sm:bottom-2 sm:right-[calc(50%-0.75rem)] peer-checked:invisible"} + } + } + } +} + +#[component] +fn BirdsPlaceholder(bird_ids: ReadOnlySignal>) -> Element { + let height_first = |ix| match ix % 3 { + 0 => "h-40", + 1 => "h-32", + _ => "h-48", + }; + let height_second = |ix| match ix % 4 { + 0 => "h-48", + 1 => "h-36", + 2 => "h-44", + _ => "h-40", + }; rsx! { div { - class: "flex flex-col items-center justify-center h-full w-full", - h1 { "Your Aviary" } - p { "Coming soon!" } + class: "animate-pulse grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,_minmax(14rem,_1fr))] gap-4 sm:gap-8 sm:overflow-auto {BIRD_GRID_HEIGHT} sm:pt-2 sm:pr-2 mb-[8.25rem] sm:mb-0", + for (ix, _id) in bird_ids.iter().enumerate() { + BirdCardPlaceholder { + extra_classes: "sm:h-72 sm:w-56", + extra_scientific_first_class: height_first(ix), + extra_scientific_second_class: height_second(ix), + } + } } } } diff --git a/app/src/ui/pages/index.rs b/app/src/ui/pages/index.rs index 40e5692..5c9370c 100644 --- a/app/src/ui/pages/index.rs +++ b/app/src/ui/pages/index.rs @@ -6,31 +6,42 @@ use crate::ui::components::PackOfTheDay; pub fn Index() -> Element { rsx! { div { - class: "m-auto grid grid-cols-5", + class: "m-auto grid grid-cols-10", div { - class: "col-span-5 sm:col-span-3 flex flex-col justify-between p-2 pb-4 sm:p-6 gap-5", + class: "col-span-10 sm:col-span-6 lg:col-span-5 p-2 pb-4 sm:p-6", div { - class: "text-4xl text-center uppercase", - "10 new birds every day" + // screen-lg == 1024px, 5/10 of the lg-screen is 512px + class: "lg:ml-auto lg:max-w-[512px] flex flex-col justify-between gap-5", + div { + class: "text-4xl text-center uppercase", + "10 new birds every day" + } + PackOfTheDay { } } - PackOfTheDay { } } div { - class: "col-span-5 sm:col-span-2 bg-red p-8 h-full flex flex-col justify-center uppercase items-start sm:items-center", + class: "col-span-10 sm:col-span-4 lg:col-span-5 bg-red p-8 h-full flex flex-col items-start sm:items-center justify-center", div { - class: "w-full sm:w-56 text-5xl text-left text-bold leading-normal sm:leading-tight", - "A game that helps you memorize bird calls." + // screen-lg == 1024px, 5/10 of the lg-screen is 512px + class: "lg:mr-auto lg:ml-48 lg:max-w-[512px] uppercase", + div { + class: "w-full sm:w-56 text-5xl text-left text-bold leading-normal sm:leading-tight", + "A game that helps you memorize bird calls." + } } } div { - class: "text-5xl col-span-5 text-left bg-yellow-dark text p-8 sm:p-16", - span { - class: "text-5xl", - "The wild speaks. " - } - span { - class: "text-3xl", - "One of the best ways to spot a bird is to hear it first. Learn to recognize new calls here for your next adventure out in the field." + class: "text-5xl col-span-10 text-left bg-yellow-dark text p-8 sm:p-16", + div { + class: "max-w-screen-lg mx-auto", + span { + class: "text-5xl", + "The wild speaks. " + } + span { + class: "text-3xl", + "One of the best ways to spot a bird is to hear it first. Learn to recognize new calls here for your next adventure out in the field." + } } } } diff --git a/app/src/ui/pages/play.rs b/app/src/ui/pages/play.rs index 8a12430..3496a00 100644 --- a/app/src/ui/pages/play.rs +++ b/app/src/ui/pages/play.rs @@ -1,16 +1,17 @@ use dioxus::prelude::*; use crate::{ - bird::BirdPack, - ui::{ - game::{GameView, GameViewPlaceholder}, - PLAY_STATUS, - }, + pack::{Pack, PackIdentifier}, + ui::game::{GameView, GameViewPlaceholder}, }; +pub static PLAY_STATUS: GlobalSignal> = Signal::global(|| None); + #[component] -pub fn Play(pack_id: u64) -> Element { +pub fn Play(pack_id: PackIdentifier) -> Element { // Do I need reactivity on pack_id? https://docs.rs/dioxus-hooks/0.6.0-alpha.2/dioxus_hooks/fn.use_effect.html#with-non-reactive-dependencies + // TODO: Enforce ad-hoc bird ids learned in user's stats! + let pack_id = use_hook(|| CopyValue::new(pack_id)); // Typically PLAY_STATUS is already loaded with the proper birdpack (if a user has navigated to // this route from within the app). @@ -18,7 +19,7 @@ pub fn Play(pack_id: u64) -> Element { PLAY_STATUS .read() .as_ref() - .filter(|p| p.id == pack_id) + .filter(|p| p.id == *pack_id.read()) .cloned() }); let mut error = use_signal(|| None); @@ -27,7 +28,7 @@ pub fn Play(pack_id: u64) -> Element { use_effect(move || { if pack_to_play.read().is_none() { spawn(async move { - let result = BirdPack::fetch_by_id(pack_id).await; + let result = Pack::fetch_by_id(&pack_id.read()).await; match result { Ok(pack) => *PLAY_STATUS.write() = Some(pack), Err(e) => error.set(Some(e)), diff --git a/app/src/utils.rs b/app/src/utils.rs new file mode 100644 index 0000000..50d012c --- /dev/null +++ b/app/src/utils.rs @@ -0,0 +1,15 @@ +use std::fmt::Display; + +pub fn join(values: impl IntoIterator, sep: impl Display) -> String { + use std::fmt::Write; + + let mut s = String::new(); + let mut iter = values.into_iter(); + if let Some(v) = iter.next() { + write!(s, "{v}").unwrap(); + for v in iter { + write!(s, "{sep}{v}").unwrap(); + } + } + s +} diff --git a/app/tailwind.config.js b/app/tailwind.config.js index 127a742..3061170 100644 --- a/app/tailwind.config.js +++ b/app/tailwind.config.js @@ -397,7 +397,13 @@ module.exports = { transform: 'translateY(-50px) rotate(8deg) translateX(55px) scale(0.95)' } } - } + }, + ringColor: (theme) => ({ + DEFAULT: theme('colors.purple-dark'), + }), + outlineColor: (theme) => ({ + DEFAULT: theme('colors.purple-dark'), + }) }, }, plugins: [],