544 lines
18 KiB
Rust

use crate::codec::ron::RonEncoded;
use crate::components::OnHoverRed;
use crate::components::ProblemInfo;
use crate::components::attempt::Attempt;
use crate::components::button::Button;
use crate::components::header::HeaderItem;
use crate::components::header::HeaderItems;
use crate::components::header::StyledHeader;
use crate::components::icons::Icon;
use crate::gradient::Gradient;
use crate::models;
use crate::models::HoldRole;
use crate::server_functions;
use leptos::Params;
use leptos::prelude::*;
use leptos_router::params::Params;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
#[derive(Params, PartialEq, Clone)]
struct RouteParams {
wall_uid: Option<models::WallUid>,
}
#[component]
#[tracing::instrument(skip_all)]
pub fn Wall() -> impl IntoView {
tracing::debug!("Enter");
let route_params = leptos_router::hooks::use_params::<RouteParams>();
let wall_uid = Signal::derive(move || {
route_params
.get()
.expect("gets wall_uid from URL")
.wall_uid
.expect("wall_uid param is never None")
});
let wall = crate::resources::wall_by_uid(wall_uid);
let problems = crate::resources::problems_for_wall(wall_uid);
let user_interactions = crate::resources::user_interactions(wall_uid);
let header_items = move || HeaderItems {
left: vec![],
middle: vec![HeaderItem {
text: "ASCEND".to_string(),
link: None,
}],
right: vec![
HeaderItem {
text: "Routes".to_string(),
link: Some(format!("/wall/{}/routes", wall_uid.get())),
},
HeaderItem {
text: "Holds".to_string(),
link: Some(format!("/wall/{}/edit", wall_uid.get())),
},
],
};
leptos::view! {
<div class="min-h-screen min-w-screen bg-neutral-950">
<StyledHeader items=Signal::derive(header_items) />
<div class="m-2">
<Transition fallback=|| ()>
{move || Suspend::new(async move {
tracing::info!("executing main suspend");
let wall = wall.await?;
let problems = problems.await?;
let user_interactions = user_interactions.await?;
let v = view! { <WithWall wall problems user_interactions /> };
Ok::<_, ServerFnError>(v)
})}
</Transition>
</div>
</div>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn WithProblem(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
tracing::trace!("Enter");
view! { <ProblemInfo problem /> }
}
#[component]
#[tracing::instrument(skip_all)]
fn WithWall(
#[prop(into)] wall: Signal<models::Wall>,
#[prop(into)] problems: Signal<Vec<models::Problem>>,
#[prop(into)] user_interactions: Signal<BTreeMap<models::ProblemUid, models::UserInteraction>>,
) -> impl IntoView {
tracing::trace!("Enter");
let wall_uid = Signal::derive(move || wall.read().uid);
let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem");
// Filter
let (filter_holds, set_filter_holds) = signal(BTreeSet::new());
let _filter_add_hold = move |hold_pos: models::HoldPosition| {
set_filter_holds.update(move |set| {
set.insert(hold_pos);
});
};
let filter_remove_hold = move |hold_pos: models::HoldPosition| {
set_filter_holds.update(move |set| {
set.remove(&hold_pos);
});
};
let filtered_problems = Memo::new(move |_prev_val| {
let filter_holds = filter_holds.get();
problems.with(|problems| {
problems
.iter()
.filter(|problem| filter_holds.iter().all(|hold_pos| problem.holds.contains_key(hold_pos)))
.cloned()
.collect::<Vec<models::Problem>>()
})
});
let problem = crate::resources::problem_by_uid_optional(wall_uid, problem_uid.into());
let user_interaction = Signal::derive(move || {
let Some(problem_uid) = problem_uid.get() else {
return None;
};
let user_interactions = user_interactions.read();
user_interactions.get(&problem_uid).cloned()
});
let fn_next_problem = move || {
let problems = filtered_problems.read();
use rand::seq::IteratorRandom;
let mut rng = rand::rng();
let problem = problems.iter().choose(&mut rng);
let problem_uid = problem.map(|p| p.uid);
set_problem_uid.set(problem_uid);
};
// Set a problem when wall is set (loaded)
Effect::new(move |_prev_value| {
if problem_uid.get().is_none() {
tracing::debug!("Setting next problem");
fn_next_problem();
}
});
// merge outer option (resource hasn't resolved yet) with inner option (there is no problem for the wall)
let problem_signal = Signal::derive(move || problem.get().transpose().map(Option::flatten));
let on_click_hold = move |hold_position: models::HoldPosition| {
// Add/Remove hold position to problem filter
set_filter_holds.update(|set| {
if !set.remove(&hold_position) {
set.insert(hold_position);
}
});
};
let grid = view! {
<Transition fallback=|| ()>
{move || {
Suspend::new(async move {
tracing::debug!("executing grid suspend");
let view = view! {
<Grid wall=wall.get() problem=problem_signal on_click_hold />
};
Ok::<_, ServerFnError>(view)
})
}}
</Transition>
};
let filter = move || {
let mut cells = vec![];
for hold_pos in filter_holds.get() {
let w = &*wall.read();
if let Some(hold) = w.holds.get(&hold_pos).cloned() {
let onclick = move |_| {
filter_remove_hold(hold_pos);
};
let v = view! {
<button on:click=onclick>
<OnHoverRed>
<Hold hold />
</OnHoverRed>
</button>
};
cells.push(v);
}
}
let problems_counter = {
let name = view! { <p class="font-light mr-4 text-right text-orange-300">{"Problems:"}</p> };
let value = view! { <p class="text-white">{filtered_problems.read().len()}</p> };
view! {
<div class="grid grid-rows-none gap-y-1 gap-x-0.5 grid-cols-[auto,1fr]">
{name} {value}
</div>
}
};
let sep = (!cells.is_empty()).then_some(view! { <Separator /> });
#[derive(Default)]
struct InteractionCounters {
flash: u64,
send: u64,
attempt: u64,
}
let mut interaction_counters = InteractionCounters::default();
let interaction_counters_view = {
let user_ints = user_interactions.read();
for problem in filtered_problems.read().iter() {
if let Some(user_int) = user_ints.get(&problem.uid) {
match user_int.best_attempt().map(|da| da.attempt) {
Some(models::Attempt::Flash) => interaction_counters.flash += 1,
Some(models::Attempt::Send) => interaction_counters.send += 1,
Some(models::Attempt::Attempt) => interaction_counters.attempt += 1,
None => {}
}
}
}
let flash = (interaction_counters.flash > 0).then(|| {
let class = Gradient::CyanBlue.class_text();
view! {
<span class="mx-1">
<span class=class>{interaction_counters.flash}</span>
</span>
}
});
let send = (interaction_counters.send > 0).then(|| {
let class = Gradient::TealLime.class_text();
view! {
<span class="mx-1">
<span class=class>{interaction_counters.send}</span>
</span>
}
});
let attempt = (interaction_counters.attempt > 0).then(|| {
let class = Gradient::PinkOrange.class_text();
view! {
<span class="mx-1">
<span class=class>{interaction_counters.attempt}</span>
</span>
}
});
if flash.is_some() || send.is_some() || attempt.is_some() {
view! {
<span>{"("}</span>
{flash}
{send}
{attempt}
<span>{")"}</span>
}
.into_any()
} else {
().into_any()
}
};
view! {
<div class="grid grid-cols-5 gap-1">{cells}</div>
{sep}
{problems_counter}
{interaction_counters_view}
}
};
view! {
<div class="inline-grid grid-cols-1 gap-8 md:grid-cols-[auto,1fr]">
<div>{grid}</div>
<div class="flex flex-col" style="width:38rem">
<Section title="Filter">{filter}</Section>
<Separator />
<div class="flex flex-col">
<div class="self-center">
<Button
icon=Icon::ArrowPath
text="Next problem"
on:click=move |_| fn_next_problem()
/>
</div>
</div>
<Separator />
<Section title="Problem">
<Transition fallback=|| ()>
{move || Suspend::new(async move {
tracing::info!("executing problem suspend");
let problem = problem.await?;
let view = problem
.map(|problem| {
view! { <WithProblem problem /> }
});
Ok::<_, ServerFnError>(view)
})}
</Transition>
</Section>
<Separator />
{move || {
let Some(problem_uid) = problem_uid.get() else {
return view! {}.into_any();
};
view! { <WithUserInteraction wall_uid problem_uid user_interaction /> }
.into_any()
}}
</div>
</div>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn WithUserInteraction(
#[prop(into)] wall_uid: Signal<models::WallUid>,
#[prop(into)] problem_uid: Signal<models::ProblemUid>,
#[prop(into)] user_interaction: Signal<Option<models::UserInteraction>>,
) -> impl IntoView {
tracing::debug!("Enter WithUserInteraction");
let parent_user_interaction = user_interaction;
let user_interaction = RwSignal::new(None);
Effect::new(move || {
let i = parent_user_interaction.get();
tracing::info!("setting user interaction to parent user interaction value: {i:?}");
user_interaction.set(i);
});
let submit_attempt = ServerAction::<RonEncoded<server_functions::UpsertTodaysAttempt>>::new();
let submit_attempt_value = submit_attempt.value();
Effect::new(move || {
tracing::info!("flaf");
if let Some(Ok(v)) = submit_attempt_value.get() {
tracing::info!("setting user interaction to action return value: {v:?}");
user_interaction.set(Some(v.into_inner()));
}
});
let todays_attempt = signals::todays_attempt(user_interaction.into());
let mut attempt_radio_buttons = vec![];
for variant in [models::Attempt::Flash, models::Attempt::Send, models::Attempt::Attempt] {
let ui_toggle = Signal::derive(move || todays_attempt.get() == Some(variant));
let onclick = move |_| {
let attempt = if ui_toggle.get() { None } else { Some(variant) };
submit_attempt.dispatch(RonEncoded(server_functions::UpsertTodaysAttempt {
wall_uid: wall_uid.get(),
problem_uid: problem_uid.get(),
attempt,
}));
};
attempt_radio_buttons.push(view! { <AttemptRadioButton on:click=onclick variant selected=ui_toggle /> });
}
view! {
<AttemptRadio>{attempt_radio_buttons}</AttemptRadio>
<Separator />
<History user_interaction />
}
}
#[component]
#[tracing::instrument(skip_all)]
fn AttemptRadio(children: Children) -> impl IntoView {
tracing::debug!("Enter");
view! {
<div class="gap-2 flex flex-row justify-evenly md:flex-col 2xl:flex-row">{children()}</div>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn AttemptRadioButton(variant: models::Attempt, #[prop(into)] selected: Signal<bool>) -> impl IntoView {
tracing::debug!("Enter");
let text = variant.to_string();
let icon = variant.icon();
let color = match variant {
models::Attempt::Attempt => Gradient::PinkOrange,
models::Attempt::Send => Gradient::TealLime,
models::Attempt::Flash => Gradient::CyanBlue,
};
view! { <Button text icon color highlight=selected /> }
}
#[component]
#[tracing::instrument(skip_all)]
fn History(#[prop(into)] user_interaction: Signal<Option<models::UserInteraction>>) -> impl IntoView {
tracing::debug!("Enter");
let latest_attempt = signals::latest_attempt(user_interaction);
let attempts = move || {
user_interaction
.read()
.as_ref()
.iter()
.flat_map(|x| x.attempted_on())
.map(|dated_attempt| {
let date = dated_attempt.date_time;
let attempt = dated_attempt.attempt;
view! { <Attempt date attempt /> }
})
.collect_view()
};
let placeholder = move || {
latest_attempt.read().is_none().then(|| {
let today = chrono::Utc::now();
view! { <Attempt date=today attempt=None /> }
})
};
view! { <Section title="History">{placeholder} {attempts}</Section> }
}
#[component]
#[tracing::instrument(skip_all)]
fn Grid(
wall: models::Wall,
#[prop(into)] problem: Signal<Result<Option<models::Problem>, ServerFnError>>,
on_click_hold: impl Fn(models::HoldPosition) + 'static,
) -> impl IntoView {
tracing::debug!("Enter");
let on_click_hold = std::rc::Rc::new(on_click_hold);
let mut cells = vec![];
for (&hold_position, hold) in &wall.holds {
let role = move || problem.get().map(|o| o.and_then(|p| p.holds.get(&hold_position).copied()));
let role = Signal::derive(role);
let on_click = {
let on_click_hold = std::rc::Rc::clone(&on_click_hold);
move |_| {
on_click_hold(hold_position);
}
};
let cell = view! {
<div class="cursor-pointer">
<Hold on:click=on_click role hold=hold.clone() />
</div>
};
cells.push(cell);
}
let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-1", wall.rows, wall.cols,);
view! {
<div class="grid grid-cols-[auto,1fr]">
<div style="max-height: 90vh; max-width: 90vh;" class=move || { grid_classes.clone() }>
{cells}
</div>
</div>
}
}
// TODO: refactor this to use the Problem component
#[component]
#[tracing::instrument(skip_all)]
fn Hold(
hold: models::Hold,
#[prop(optional)]
#[prop(into)]
role: Option<Signal<Result<Option<HoldRole>, ServerFnError>>>,
) -> impl IntoView {
tracing::trace!("Enter");
move || {
let mut class = "bg-sky-100 aspect-square rounded hover:brightness-125".to_string();
if let Some(role) = role {
let role = role.get()?;
let role_classes = match role {
Some(HoldRole::Start) => Some("outline outline-offset-2 outline-green-500"),
Some(HoldRole::Normal) => Some("outline outline-offset-2 outline-blue-500"),
Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"),
Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"),
None => Some("brightness-50"),
};
if let Some(c) = role_classes {
class.push(' ');
class.push_str(c);
}
}
let img = hold.image.as_ref().map(|img| {
let srcset = img.srcset();
view! { <img class="object-cover w-full h-full" srcset=srcset /> }
});
let view = view! { <div class=class>{img}</div> };
Ok::<_, ServerFnError>(view)
}
}
#[component]
fn Separator() -> impl IntoView {
view! { <div class="m-2 sm:m-3 md:m-4 h-4" /> }
}
#[component]
fn Section(children: Children, #[prop(into)] title: MaybeProp<String>) -> impl IntoView {
view! {
<div class="bg-neutral-900 px-5 pt-3 pb-8 rounded-lg">
<div class="py-3 text-lg text-center text-orange-400 border-t border-orange-400">
{move || title.get()}
</div>
{children()}
</div>
}
}
mod signals {
use crate::models;
use leptos::prelude::*;
pub fn latest_attempt(user_interaction: Signal<Option<models::UserInteraction>>) -> Signal<Option<models::DatedAttempt>> {
Signal::derive(move || user_interaction.read().as_ref().and_then(models::UserInteraction::latest_attempt))
}
pub fn todays_attempt(latest_attempt: Signal<Option<models::UserInteraction>>) -> Signal<Option<models::Attempt>> {
Signal::derive(move || latest_attempt.read().as_ref().and_then(models::UserInteraction::todays_attempt))
}
}