360 lines
17 KiB
Rust
360 lines
17 KiB
Rust
// +--------------- Filter ----------- ↓ -+
|
|
// | |
|
|
// | |
|
|
// | |
|
|
// | |
|
|
// | |
|
|
// | |
|
|
// | |
|
|
// +--------------------------------------+
|
|
//
|
|
// +---------------------------+
|
|
// | Next Problem |
|
|
// +---------------------------+
|
|
//
|
|
// +--------------- Problem --------------+
|
|
// | Name: ... |
|
|
// | Method: ... |
|
|
// | Set by: ... |
|
|
// | |
|
|
// | | Flash | Top | Attempt | |
|
|
// | |
|
|
// +--------------------------------------+
|
|
|
|
// + ------- + + ------ -+ +---------+
|
|
// | Flash | | Top | | Attempt |
|
|
// + ------- + + ------ -+ +---------+
|
|
//
|
|
// +---------- <Latest attempt> ----------+
|
|
// | Today: <Attempt> |
|
|
// | 14 days ago: <Attempt> |
|
|
// +--------------------------------------+
|
|
|
|
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;
|
|
use crate::components::icons::Icon;
|
|
use crate::models;
|
|
use crate::models::HoldRole;
|
|
use leptos::Params;
|
|
use leptos::prelude::*;
|
|
use leptos_router::params::Params;
|
|
|
|
#[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 (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem");
|
|
|
|
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 problem = crate::resources::problem_by_uid(wall_uid, problem_uid.into());
|
|
let user_interaction = crate::resources::user_interaction(wall_uid, problem_uid.into());
|
|
|
|
// merge outer option (resource hasn't resolved yet) with inner option (there is no problem for the wall)
|
|
let problem_sig2 = Signal::derive(move || problem.get().transpose().map(Option::flatten));
|
|
|
|
let fn_next_problem = move |wall: &models::Wall| {
|
|
set_problem_uid.set(wall.random_problem());
|
|
};
|
|
|
|
// Set a problem when wall is set (loaded)
|
|
Effect::new(move |_prev_value| match &*wall.read() {
|
|
Some(Ok(wall)) => {
|
|
if problem_uid.get().is_none() {
|
|
tracing::debug!("Setting next problem");
|
|
fn_next_problem(wall);
|
|
}
|
|
}
|
|
Some(Err(err)) => {
|
|
tracing::error!("Error getting wall: {err}");
|
|
}
|
|
None => {}
|
|
});
|
|
|
|
let ui_is_flash = RwSignal::new(false);
|
|
let ui_is_climbed = RwSignal::new(false);
|
|
let ui_is_favorite = RwSignal::new(false);
|
|
|
|
// On reception of user interaction state, set UI signals
|
|
Effect::new(move |_prev_value| {
|
|
if let Some(user_interaction) = user_interaction.get() {
|
|
let user_interaction = user_interaction.ok().flatten();
|
|
|
|
if let Some(user_interaction) = user_interaction {
|
|
ui_is_favorite.set(user_interaction.is_favorite);
|
|
} else {
|
|
ui_is_favorite.set(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
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 grid = {
|
|
let wall = wall.clone();
|
|
view! {
|
|
<Transition fallback=|| ()>
|
|
{
|
|
let wall = wall.clone();
|
|
move || {
|
|
let wall = wall.clone();
|
|
Suspend::new(async move {
|
|
let wall = wall.clone();
|
|
tracing::info!("executing grid suspend");
|
|
let view = view! {
|
|
<Grid wall=wall.clone() problem=problem_sig2 />
|
|
};
|
|
Ok::<_, ServerFnError>(view)
|
|
})
|
|
}
|
|
}
|
|
</Transition>
|
|
}
|
|
};
|
|
let v = view! {
|
|
<div class="grid grid-cols-1 gap-8 md:grid-cols-[auto,1fr]">
|
|
<div>{grid}</div>
|
|
|
|
<div>
|
|
<Button
|
|
icon=Icon::ArrowPath
|
|
text="Next problem"
|
|
onclick=move |_| fn_next_problem(&wall)
|
|
/>
|
|
|
|
<div class="m-4" />
|
|
|
|
<Transition fallback=|| ()>
|
|
{move || Suspend::new(async move {
|
|
tracing::info!("executing probleminfo suspend");
|
|
let problem = problem.await?;
|
|
let problem_view = problem
|
|
.map(|problem| view! { <ProblemInfo problem /> });
|
|
let view = view! { {problem_view} };
|
|
Ok::<_, ServerFnError>(view)
|
|
})}
|
|
</Transition>
|
|
|
|
<div class="m-4" />
|
|
|
|
<div class="inline-flex overflow-hidden relative justify-center items-center p-0.5 mb-2 text-sm font-medium text-white bg-gradient-to-br from-cyan-500 to-blue-500 rounded-lg me-2 group hover:brightness-125">
|
|
<input
|
|
type="checkbox"
|
|
id="flash-option"
|
|
value=""
|
|
class="hidden peer"
|
|
required=""
|
|
bind:checked=ui_is_flash
|
|
/>
|
|
<label for="flash-option" class="cursor-pointer">
|
|
<div
|
|
class="flex relative gap-2 items-center py-3.5 px-5 bg-gray-900 rounded-md transition-all duration-75 ease-in"
|
|
class:bg-transparent=move || ui_is_flash.get()
|
|
>
|
|
<div class="text-white bg-white rounded-sm border-gray-500 ring-offset-gray-700 aspect-square">
|
|
<span class=("text-cyan-500", move || ui_is_flash.get())>
|
|
<icons::Check />
|
|
</span>
|
|
</div>
|
|
// <icons::Bolt />
|
|
<div class="w-full text-lg font-semibold">Flash</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="inline-flex overflow-hidden relative justify-center items-center p-0.5 mb-2 text-sm font-medium text-white bg-gradient-to-br from-teal-300 to-lime-300 rounded-lg me-2 group hover:brightness-125">
|
|
<input
|
|
type="checkbox"
|
|
id="climbed-option"
|
|
value=""
|
|
class="hidden peer"
|
|
required=""
|
|
bind:checked=ui_is_climbed
|
|
/>
|
|
<label for="climbed-option" class="cursor-pointer">
|
|
<div
|
|
class="flex relative gap-2 items-center py-3.5 px-5 text-white bg-gray-900 rounded-md transition-all duration-75 ease-in"
|
|
class:bg-transparent=move || ui_is_climbed.get()
|
|
>
|
|
<div class="bg-white rounded-sm border-gray-500 ring-offset-gray-700 aspect-square">
|
|
<span class=("text-black", move || ui_is_climbed.get())>
|
|
<icons::Check />
|
|
</span>
|
|
</div>
|
|
<div
|
|
class="w-full text-lg font-semibold"
|
|
class=("text-black", move || ui_is_climbed.get())
|
|
>
|
|
Climbed
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="inline-flex overflow-hidden relative justify-center items-center p-0.5 mb-2 text-sm font-medium text-white bg-gradient-to-br from-pink-500 to-orange-400 rounded-lg me-2 group hover:brightness-125">
|
|
<input
|
|
type="checkbox"
|
|
id="favorite-option"
|
|
value=""
|
|
class="hidden peer"
|
|
required=""
|
|
bind:checked=ui_is_favorite
|
|
/>
|
|
<label for="favorite-option" class="cursor-pointer">
|
|
<div
|
|
class="flex relative gap-2 items-center py-3.5 px-5 bg-gray-900 rounded-md transition-all duration-75 ease-in"
|
|
class:bg-transparent=move || ui_is_favorite.get()
|
|
>
|
|
<div class="text-pink-500 rounded-sm border-gray-500 ring-offset-gray-700 aspect-square">
|
|
<span class=("text-white", move || ui_is_favorite.get())>
|
|
<icons::HeartOutline />
|
|
</span>
|
|
</div>
|
|
<div class="w-full text-lg font-semibold">Favorite</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<Suspense fallback=move || {
|
|
view! {}
|
|
}>
|
|
{move || {
|
|
let x = 10;
|
|
let attempt_suspend = Suspend::new(async move {
|
|
let user_interaction = user_interaction.await;
|
|
let user_interaction = user_interaction.ok().flatten();
|
|
let best_attempt = user_interaction
|
|
.and_then(|x| x.best_attempt());
|
|
let best_attempt_date = move || {
|
|
best_attempt.map(|pair| pair.0)
|
|
};
|
|
let best_attempt_attempt = move || {
|
|
best_attempt.map(|pair| pair.1)
|
|
};
|
|
view! {
|
|
<Attempt attempt=Signal::derive(best_attempt_attempt) />
|
|
}
|
|
});
|
|
attempt_suspend
|
|
}}
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
};
|
|
Ok::<_, ServerFnError>(v)
|
|
})}
|
|
</Transition>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
// #[component]
|
|
// #[tracing::instrument(skip_all)]
|
|
// fn WithWall(#[prop(into)] wall: Signal<models::Wall>) -> impl IntoView {
|
|
// tracing::trace!("Enter");
|
|
// }
|
|
|
|
#[component]
|
|
#[tracing::instrument(skip_all)]
|
|
fn Grid(wall: models::Wall, #[prop(into)] problem: Signal<Result<Option<models::Problem>, ServerFnError>>) -> impl IntoView {
|
|
tracing::debug!("Enter");
|
|
|
|
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 cell = view! { <Hold role hold=hold.clone() /> };
|
|
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, role: Signal<Result<Option<HoldRole>, ServerFnError>>) -> impl IntoView {
|
|
tracing::trace!("Enter");
|
|
|
|
move || {
|
|
let role = role.get()?;
|
|
|
|
let class = {
|
|
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"),
|
|
};
|
|
let mut s = "bg-sky-100 aspect-square rounded hover:brightness-125".to_string();
|
|
if let Some(c) = role_classes {
|
|
s.push(' ');
|
|
s.push_str(c);
|
|
}
|
|
s
|
|
};
|
|
|
|
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)
|
|
}
|
|
}
|