544 lines
18 KiB
Rust
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))
|
|
}
|
|
}
|