This commit is contained in:
2025-03-06 22:21:05 +01:00
parent 3b232b048d
commit bcfc155266
8 changed files with 266 additions and 127 deletions

View File

@@ -3,17 +3,17 @@ use leptos::prelude::*;
#[component]
#[tracing::instrument(skip_all)]
pub fn ProblemInfo(problem: models::Problem) -> impl IntoView {
pub fn ProblemInfo(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
tracing::trace!("Enter problem info");
let name = problem.name;
let set_by = problem.set_by;
let method = problem.method;
let name = Signal::derive(move || problem.read().name.clone());
let set_by = Signal::derive(move || problem.read().set_by.clone());
let method = Signal::derive(move || problem.read().method.to_string());
view! {
<div class="grid grid-rows-none gap-y-1 gap-x-0.5 grid-cols-[auto,1fr]">
<NameValue name="Name:" value=name />
<NameValue name="Method:" value=method.to_string() />
<NameValue name="Method:" value=method />
<NameValue name="Set By:" value=set_by />
</div>
}
@@ -21,9 +21,9 @@ pub fn ProblemInfo(problem: models::Problem) -> impl IntoView {
#[component]
#[tracing::instrument(skip_all)]
fn NameValue(#[prop(into)] name: String, #[prop(into)] value: String) -> impl IntoView {
fn NameValue(#[prop(into)] name: Signal<String>, #[prop(into)] value: Signal<String>) -> impl IntoView {
view! {
<p class="text-sm font-light mr-4 text-right text-orange-300">{name}</p>
<p class="text-white">{value}</p>
<p class="text-sm font-light mr-4 text-right text-orange-300">{name.get()}</p>
<p class="text-white">{value.get()}</p>
}
}

View File

@@ -0,0 +1,30 @@
use leptos::prelude::*;
#[component]
#[tracing::instrument(skip_all)]
pub fn ShowSome<T, C, IV>(#[prop(into)] sig: Signal<Option<T>>, foo: C) -> impl IntoView
where
T: Clone + Send + Sync + 'static,
C: Fn(Signal<T>) -> IV + Sync + Send + 'static,
IV: IntoView,
{
tracing::trace!("Enter");
view! {
// <Show when=move || sig.get().is_some() fallback=|| ()>
// {move || {
// let new = signal()
// sig
// .with(|opt| {
// if let Some(inner) = opt.clone() {
// let new_signal = Signal::derive(move || sig.get().unwrap());
// .into_any()
// } else {
// view! {}.into_any()
// }
// })
// }}
// </Show>
}
}

View File

@@ -20,6 +20,7 @@ pub mod components {
pub mod outlined_box;
pub mod problem;
pub mod problem_info;
pub mod show_some;
}
pub mod gradient;

View File

@@ -105,7 +105,7 @@ fn Hold(wall_uid: models::WallUid, hold: models::Hold) -> impl IntoView {
}
};
let upload = Action::from(ServerAction::<SetImage>::new());
let upload = ServerAction::<SetImage>::new();
let hold = Signal::derive(move || {
let refreshed = upload.value().get().map(Result::unwrap);

View File

@@ -32,7 +32,7 @@ pub fn Settings() -> impl IntoView {
#[component]
#[tracing::instrument(skip_all)]
fn Import(wall_uid: WallUid) -> impl IntoView {
let import_from_mini_moonboard = Action::from(ServerAction::<ImportFromMiniMoonboard>::new());
let import_from_mini_moonboard = ServerAction::<ImportFromMiniMoonboard>::new();
let onclick = move |_mouse_event| {
import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid });
@@ -44,7 +44,7 @@ fn Import(wall_uid: WallUid) -> impl IntoView {
}
}
#[server(name = ImportFromMiniMoonboard)]
#[server]
#[tracing::instrument]
async fn import_from_mini_moonboard(wall_uid: WallUid) -> Result<(), ServerFnError> {
use crate::server::config::Config;

View File

@@ -30,6 +30,7 @@
// | 14 days ago: <Attempt> |
// +--------------------------------------+
use crate::codec::ron::RonEncoded;
use crate::components::ProblemInfo;
use crate::components::attempt::Attempt;
use crate::components::button::Button;
@@ -40,6 +41,7 @@ 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;
@@ -57,8 +59,6 @@ pub fn Wall() -> impl IntoView {
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()
@@ -68,28 +68,75 @@ pub fn Wall() -> impl IntoView {
});
let wall = crate::resources::wall_by_uid(wall_uid);
let problem = crate::resources::problem_by_uid(wall_uid, problem_uid.into());
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 v = view! { <WithWall wall /> };
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>) -> impl IntoView {
tracing::trace!("Enter");
let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem");
let wall_uid = Signal::derive(move || wall.read().uid);
let problem = crate::resources::problem_by_uid_optional(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 submit_attempt = ServerAction::<RonEncoded<server_functions::UpsertTodaysAttempt>>::new();
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);
}
Effect::new(move |_prev_value| {
if problem_uid.get().is_none() {
tracing::debug!("Setting next problem");
fn_next_problem(&wall.read());
}
Some(Err(err)) => {
tracing::error!("Error getting wall: {err}");
}
None => {}
});
let (attempt_today, set_attempt_today) = signal(None);
@@ -112,24 +159,6 @@ pub fn Wall() -> impl IntoView {
}
});
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())),
},
],
};
// onclick handler helper
let set_or_deselect = |s: WriteSignal<Option<models::Attempt>>, v: models::Attempt| {
s.update(|s| match s {
@@ -143,6 +172,11 @@ pub fn Wall() -> impl IntoView {
};
let onclick_flash = move |_| {
set_or_deselect(set_attempt_today, models::Attempt::Flash);
submit_attempt.dispatch(RonEncoded(server_functions::UpsertTodaysAttempt {
wall_uid: wall_uid.get(),
problem_uid: problem.get().uid,
attempt: models::Attempt::Flash,
}));
};
let onclick_send = move |_| {
set_or_deselect(set_attempt_today, models::Attempt::Send);
@@ -196,103 +230,83 @@ pub fn Wall() -> impl IntoView {
})
};
leptos::view! {
<div class="min-h-screen min-w-screen bg-neutral-950">
<StyledHeader items=Signal::derive(header_items) />
// 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));
<div class="m-2">
<Transition fallback=|| ()>
{move || Suspend::new(async move {
tracing::info!("executing main suspend");
let wall = wall.await?;
let grid = {
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();
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="inline-grid grid-cols-1 gap-8 md:grid-cols-[auto,1fr]">
<div>{grid}</div>
tracing::info!("executing grid suspend");
let view = view! { <Grid wall=wall.get() problem=problem_signal /> };
Ok::<_, ServerFnError>(view)
})
}
}
</Transition>
}
};
<div class="flex flex-col">
<Section title="Filter">{}</Section>
let v = view! {
<div class="inline-grid grid-cols-1 gap-8 md:grid-cols-[auto,1fr]">
<div>{grid}</div>
<Separator />
<div class="flex flex-col">
<Section title="Filter">{}</Section>
<div class="flex flex-col">
<div class="self-center">
<Button
icon=Icon::ArrowPath
text="Next problem"
onclick=move |_| fn_next_problem(&wall)
/>
</div>
</div>
<Separator />
<Separator />
<div class="flex flex-col">
<div class="self-center">
<Button
icon=Icon::ArrowPath
text="Next problem"
onclick=move |_| fn_next_problem(&wall.read())
/>
</div>
</div>
<Section title="Problem">
<Transition fallback=|| ()>
{move || Suspend::new(async move {
tracing::info!("executing probleminfo suspend");
let problem = problem.await?;
let view = problem
.map(|problem| {
view! { <ProblemInfo problem /> }
});
Ok::<_, ServerFnError>(view)
})}
</Transition>
</Section>
<Separator />
<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! { <ProblemInfo problem /> }
});
Ok::<_, ServerFnError>(view)
})}
</Transition>
</Section>
<AttemptRadio
flash=ui_is_flash
send=ui_is_send
attempt=ui_is_attempt
onclick_flash
onclick_send
onclick_attempt
/>
<Separator />
<Separator />
<AttemptRadio
flash=ui_is_flash
send=ui_is_send
attempt=ui_is_attempt
onclick_flash
onclick_send
onclick_attempt
/>
<Suspense fallback=move || ()>{foo()}</Suspense>
</div>
</div>
};
Ok::<_, ServerFnError>(v)
})}
</Transition>
<Separator />
<Suspense fallback=move || ()>{foo()}</Suspense>
</div>
</div>
}
};
}
// TODO: refactor along these lines to limit suspend nesting in a single component?
// #[component]
// #[tracing::instrument(skip_all)]
// fn WithWall(#[prop(into)] wall: Signal<models::Wall>) -> impl IntoView {
// tracing::trace!("Enter");
// }
#[component]
#[tracing::instrument(skip_all)]
fn AttemptRadio(

View File

@@ -16,7 +16,26 @@ pub fn wall_by_uid(wall_uid: Signal<models::WallUid>) -> RonResource<models::Wal
)
}
pub fn problem_by_uid(wall_uid: Signal<models::WallUid>, problem_uid: Signal<Option<models::ProblemUid>>) -> RonResource<Option<models::Problem>> {
pub fn problem_by_uid(wall_uid: Signal<models::WallUid>, problem_uid: Signal<models::ProblemUid>) -> RonResource<models::Problem> {
Resource::new_with_options(
move || (wall_uid.get(), problem_uid.get()),
move |(wall_uid, problem_uid)| async move {
let Some(problem_uid) = problem_uid else {
return Ok(None);
};
crate::server_functions::get_problem_by_uid(wall_uid, problem_uid)
.await
.map(RonEncoded::into_inner)
},
false,
)
}
/// Version of [problem_by_uid] that short circuits if the input problem_uid signal is None.
pub fn problem_by_uid_optional(
wall_uid: Signal<models::WallUid>,
problem_uid: Signal<Option<models::ProblemUid>>,
) -> RonResource<Option<models::Problem>> {
Resource::new_with_options(
move || (wall_uid.get(), problem_uid.get()),
move |(wall_uid, problem_uid)| async move {

View File

@@ -2,6 +2,10 @@ use crate::codec::ron::Ron;
use crate::codec::ron::RonEncoded;
use crate::models;
use crate::models::UserInteraction;
use derive_more::Display;
use derive_more::Error;
use derive_more::From;
use leptos::prelude::*;
use leptos::server;
use server_fn::ServerFnError;
@@ -202,3 +206,74 @@ pub(crate) async fn get_problem_by_uid(
Ok(RonEncoded::new(problem))
}
/// Inserts or updates today's attempt.
#[server(
input = Ron,
output = Ron,
custom = RonEncoded
)]
#[tracing::instrument(err(Debug))]
pub(crate) async fn upsert_todays_attempt(
wall_uid: models::WallUid,
problem_uid: models::ProblemUid,
attempt: models::Attempt,
) -> Result<RonEncoded<models::UserInteraction>, ServerFnError> {
use crate::server::db::Database;
use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context;
tracing::trace!("Enter");
#[derive(Debug, Error, Display, From)]
enum Error {
#[display("Wall not found: {_0:?}")]
WallNotFound(#[error(not(source))] models::WallUid),
DatabaseOperation(DatabaseOperationError),
}
async fn inner(wall_uid: models::WallUid, problem_uid: models::ProblemUid, attempt: models::Attempt) -> Result<UserInteraction, Error> {
let db = expect_context::<Database>();
let user_interaction = db
.write(|txn| {
let mut user_table = txn.open_table(crate::server::db::current::TABLE_USER)?;
let key = (wall_uid, problem_uid);
// Pop or default
let mut user_interaction = user_table
.remove(key)?
.map(|guard| guard.value())
.unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem_uid));
// If the last entry is from today, remove it
if let Some(entry) = user_interaction.attempted_on.last_entry() {
let today_local_naive = chrono::Local::now().date_naive();
let entry_date = entry.key();
let entry_date_local_naive = entry_date.with_timezone(&chrono::Local).date_naive();
if entry_date_local_naive == today_local_naive {
entry.remove();
}
}
user_interaction.attempted_on.insert(chrono::Utc::now(), attempt);
user_table.insert(key, user_interaction.clone())?;
Ok(user_interaction)
})
.await?;
Ok(user_interaction)
}
inner(wall_uid, problem_uid, attempt)
.await
.map_err(error_reporter::Report::new)
.map_err(ServerFnError::new)
.map(RonEncoded::new)
}