Compare commits

..

2 Commits

Author SHA1 Message Date
e1a705b32f wip 2025-03-21 15:31:01 +01:00
d2312a9fee wip 2025-03-21 14:11:41 +01:00
34 changed files with 1080 additions and 1772 deletions

836
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ publish = false
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
axum = { version = "0.8", optional = true } axum = { version = "0.7", optional = true }
camino = { version = "1.1", optional = true } camino = { version = "1.1", optional = true }
chrono = { version = "0.4.39", features = ["now", "serde"] } chrono = { version = "0.4.39", features = ["now", "serde"] }
clap = { version = "4.5.7", features = ["derive"] } clap = { version = "4.5.7", features = ["derive"] }
@@ -23,17 +23,18 @@ derive_more = { version = "2", features = [
] } ] }
http = "1" http = "1"
image = { version = "0.25", optional = true } image = { version = "0.25", optional = true }
leptos = { version = "0.8", features = ["tracing"] } leptos = { version = "0.7.7", features = ["tracing"] }
leptos_axum = { version = "0.8", optional = true } leptos_axum = { version = "0.7", optional = true }
leptos_meta = { version = "0.8" } leptos_meta = { version = "0.7" }
leptos_router = { version = "0.8" } leptos_router = { version = "0.7.0" }
moonboard-parser = { workspace = true, optional = true } moonboard-parser = { workspace = true, optional = true }
rand = { version = "0.9", default-features = false, features = ["thread_rng"] } rand = { version = "0.9", default-features = false, features = ["thread_rng"] }
ron = { version = "0.8" } ron = { version = "0.8" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
server_fn = { version = "0.8", features = ["cbor"] } server_fn = { version = "0.7.4", features = ["cbor"] }
smart-default = "0.7.1" smart-default = "0.7.1"
tokio = { version = "1", features = ["rt-multi-thread"], optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true } tower-http = { version = "0.5", features = ["fs"], optional = true }
tracing = { version = "0.1" } tracing = { version = "0.1" }
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
@@ -45,12 +46,16 @@ xdg = { version = "2.5", optional = true }
uuid = { version = "1.12", features = ["serde", "v4"] } uuid = { version = "1.12", features = ["serde", "v4"] }
redb = { version = "2.4", optional = true } redb = { version = "2.4", optional = true }
bincode = { version = "1.3", optional = true } bincode = { version = "1.3", optional = true }
serde_json = { version = "1" }
codee = { version = "0.3" } codee = { version = "0.3" }
error_reporter = { version = "1" } error_reporter = { version = "1" }
getrandom = { version = "0.3.1" } getrandom = { version = "0.3.1" }
[dev-dependencies] [dev-dependencies.serde_json]
test-try = "0.1" version = "1"
[dev-dependencies.test-try]
version = "0.1"
[features] [features]
hydrate = ["leptos/hydrate", "getrandom/wasm_js", "uuid/js"] hydrate = ["leptos/hydrate", "getrandom/wasm_js", "uuid/js"]
@@ -60,6 +65,7 @@ ssr = [
"dep:image", "dep:image",
"dep:bincode", "dep:bincode",
"dep:tokio", "dep:tokio",
"dep:tower",
"dep:tower-http", "dep:tower-http",
"dep:leptos_axum", "dep:leptos_axum",
"dep:confik", "dep:confik",
@@ -71,11 +77,6 @@ ssr = [
"leptos_router/ssr", "leptos_router/ssr",
] ]
[package.metadata.cargo-shear]
# getrandom is depended on in order to turn on its wasm feature indirectly
ignored = ["getrandom"]
[package.metadata.leptos] [package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name # The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "ascend" output-name = "ascend"

View File

@@ -42,8 +42,9 @@ pub fn App() -> impl IntoView {
<Router> <Router>
<Routes fallback=|| "Not found"> <Routes fallback=|| "Not found">
<Route path=path!("/") view=Home /> <Route path=path!("/") view=Home />
<Route path=path!("/wall/:wall_uid") view=pages::wall::Page /> <Route path=path!("/wall/:wall_uid") view=pages::wall::Wall />
<Route path=path!("/wall/:wall_uid/holds") view=pages::holds::Page /> <Route path=path!("/wall/:wall_uid/edit") view=pages::edit_wall::EditWall />
<Route path=path!("/wall/:wall_uid/routes") view=pages::routes::Routes />
</Routes> </Routes>
</Router> </Router>
} }

View File

@@ -3,22 +3,19 @@ pub mod ron {
use codee::Decoder; use codee::Decoder;
use codee::Encoder; use codee::Encoder;
use leptos::prelude::FromServerFnError;
use leptos::prelude::ServerFnErrorErr;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use server_fn::ContentType; use server_fn::ServerFnError;
use server_fn::codec::Encoding; use server_fn::codec::Encoding;
use server_fn::codec::FromReq; use server_fn::codec::FromReq;
use server_fn::codec::FromRes; use server_fn::codec::FromRes;
use server_fn::codec::IntoReq; use server_fn::codec::IntoReq;
use server_fn::codec::IntoRes; use server_fn::codec::IntoRes;
use server_fn::error::IntoAppError;
use server_fn::request::ClientReq; use server_fn::request::ClientReq;
use server_fn::request::Req; use server_fn::request::Req;
use server_fn::response::ClientRes; use server_fn::response::ClientRes;
use server_fn::response::TryRes; use server_fn::response::Res;
pub struct Ron; pub struct Ron;
@@ -47,11 +44,8 @@ pub mod ron {
} }
impl Encoding for Ron { impl Encoding for Ron {
const METHOD: http::Method = http::Method::POST;
}
impl ContentType for Ron {
const CONTENT_TYPE: &'static str = "application/ron"; const CONTENT_TYPE: &'static str = "application/ron";
const METHOD: http::Method = http::Method::POST;
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -80,10 +74,9 @@ pub mod ron {
where where
Request: ClientReq<Err>, Request: ClientReq<Err>,
T: Serialize, T: Serialize,
Err: FromServerFnError,
{ {
fn into_req(self, path: &str, accepts: &str) -> Result<Request, Err> { fn into_req(self, path: &str, accepts: &str) -> Result<Request, ServerFnError<Err>> {
let data = Ron::encode(&self.0).map_err(|e| ServerFnErrorErr::Serialization(e.to_string()).into_app_error())?; let data = Ron::encode(&self.0).map_err(|e| ServerFnError::Serialization(e.to_string()))?;
Request::try_new_post(path, Ron::CONTENT_TYPE, accepts, data) Request::try_new_post(path, Ron::CONTENT_TYPE, accepts, data)
} }
} }
@@ -93,25 +86,21 @@ pub mod ron {
where where
Request: Req<Err> + Send, Request: Req<Err> + Send,
T: DeserializeOwned, T: DeserializeOwned,
Err: FromServerFnError,
{ {
async fn from_req(req: Request) -> Result<Self, Err> { async fn from_req(req: Request) -> Result<Self, ServerFnError<Err>> {
let data = req.try_into_string().await?; let data = req.try_into_string().await?;
Ron::decode(&data) Ron::decode(&data).map(RonEncoded).map_err(|e| ServerFnError::Args(e.to_string()))
.map(RonEncoded)
.map_err(|e| ServerFnErrorErr::Args(e.to_string()).into_app_error())
} }
} }
// IntoRes // IntoRes
impl<Err, T, Response> IntoRes<Ron, Response, Err> for RonEncoded<T> impl<CustErr, T, Response> IntoRes<Ron, Response, CustErr> for RonEncoded<T>
where where
Response: TryRes<Err>, Response: Res<CustErr>,
T: Serialize + Send, T: Serialize + Send,
Err: FromServerFnError,
{ {
async fn into_res(self) -> Result<Response, Err> { async fn into_res(self) -> Result<Response, ServerFnError<CustErr>> {
let data = Ron::encode(&self.0).map_err(|e| ServerFnErrorErr::Serialization(e.to_string()).into_app_error())?; let data = Ron::encode(&self.0).map_err(|e| ServerFnError::Serialization(e.to_string()))?;
Response::try_from_string(Ron::CONTENT_TYPE, data) Response::try_from_string(Ron::CONTENT_TYPE, data)
} }
} }
@@ -121,13 +110,12 @@ pub mod ron {
where where
Response: ClientRes<Err> + Send, Response: ClientRes<Err> + Send,
T: DeserializeOwned, T: DeserializeOwned,
Err: FromServerFnError,
{ {
async fn from_res(res: Response) -> Result<Self, Err> { async fn from_res(res: Response) -> Result<Self, ServerFnError<Err>> {
let data = res.try_into_string().await?; let data = res.try_into_string().await?;
Ron::decode(&data) Ron::decode(&data)
.map(RonEncoded) .map(RonEncoded)
.map_err(|e| ServerFnErrorErr::Deserialization(e.to_string()).into_app_error()) .map_err(|e| ServerFnError::Deserialization(e.to_string()))
} }
} }

View File

@@ -1,27 +0,0 @@
pub use attempt::Attempt;
pub use button::Button;
pub use header::StyledHeader;
pub use problem::Problem;
pub use problem_info::ProblemInfo;
pub mod attempt;
pub mod button;
pub mod checkbox;
pub mod header;
pub mod header_v2;
pub mod icons;
pub mod outlined_box;
pub mod problem;
pub mod problem_info;
use leptos::prelude::*;
#[component]
pub fn OnHoverRed(children: Children) -> impl IntoView {
view! {
<div class="relative group">
<div>{children()}</div>
<div class="absolute inset-0 bg-red-500 opacity-0 group-hover:opacity-50" />
</div>
}
}

View File

@@ -7,7 +7,7 @@ use leptos::prelude::*;
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn Attempt(#[prop(into)] date: Signal<DateTime<Utc>>, #[prop(into)] attempt: Signal<Option<models::Attempt>>) -> impl IntoView { pub fn Attempt(#[prop(into)] date: Signal<DateTime<Utc>>, #[prop(into)] attempt: Signal<Option<models::Attempt>>) -> impl IntoView {
crate::tracing::on_enter!(); tracing::trace!("Enter");
let s = time_ago(date.get()); let s = time_ago(date.get());
@@ -19,7 +19,9 @@ pub fn Attempt(#[prop(into)] date: Signal<DateTime<Utc>>, #[prop(into)] attempt:
}; };
let text_color = match attempt.get() { let text_color = match attempt.get() {
Some(attempt) => attempt.gradient().class_text(), Some(models::Attempt::Flash) => "text-cyan-500",
Some(models::Attempt::Send) => "text-teal-500",
Some(models::Attempt::Attempt) => "text-pink-500",
None => "", None => "",
}; };

View File

@@ -7,20 +7,15 @@ use leptos::prelude::*;
pub fn Button( pub fn Button(
#[prop(into, optional)] icon: MaybeProp<Icon>, #[prop(into, optional)] icon: MaybeProp<Icon>,
#[prop(into, optional)] text: MaybeProp<String>, #[prop(into)] text: Signal<String>,
#[prop(optional)] color: Gradient, #[prop(optional)] color: Gradient,
#[prop(into, optional)] highlight: MaybeProp<bool>, #[prop(into, optional)] highlight: MaybeProp<bool>,
#[prop(into, optional)] disabled: MaybeProp<bool>,
#[prop(into, optional)] on_click: MaybeProp<Callback<()>>,
) -> impl IntoView { ) -> impl IntoView {
let margin = "mx-2 my-1 sm:mx-5 sm:my-2.5"; let margin = "mx-2 my-1 sm:mx-5 sm:my-2.5";
let icon_view = move || { let icon_view = icon.get().map(|i| {
icon.get().map(|i| {
let icon_view = i.into_view(); let icon_view = i.into_view();
let mut classes = "self-center".to_string(); let mut classes = "self-center".to_string();
classes.push(' '); classes.push(' ');
@@ -29,11 +24,9 @@ pub fn Button(
classes.push_str(color.class_text()); classes.push_str(color.class_text());
view! { <div class=classes>{icon_view}</div> } view! { <div class=classes>{icon_view}</div> }
}) });
};
let separator = move || { let separator = icon.get().is_some().then(|| {
(icon.read().is_some() && text.read().is_some()).then(|| {
let mut classes = "w-0.5 bg-linear-to-br min-w-0.5".to_string(); let mut classes = "w-0.5 bg-linear-to-br min-w-0.5".to_string();
classes.push(' '); classes.push(' ');
classes.push_str(color.class_from()); classes.push_str(color.class_from());
@@ -41,39 +34,18 @@ pub fn Button(
classes.push_str(color.class_to()); classes.push_str(color.class_to());
view! { <div class=classes /> } view! { <div class=classes /> }
}) });
};
let text_view = move || { let text_view = {
text.get().map(|text| {
let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string(); let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string();
classes.push(' '); classes.push(' ');
classes.push_str(margin); classes.push_str(margin);
view! { <div class=classes>{text}</div> } view! { <div class=classes>{text.get()}</div> }
})
}; };
let class = move || {
let mut classes = vec![];
if disabled.get().unwrap_or_default() {
classes.extend(["brightness-50"]);
} else {
classes.extend(["cursor-pointer", "hover:brightness-125", "active:brightness-90"]);
}
classes.join(" ")
};
let on_click = move |_| {
if let Some(cb) = on_click.get() {
cb.run(());
}
};
let prop_disabled = move || disabled.get();
view! { view! {
<button type="button" class=class prop:disabled=prop_disabled on:click=on_click> <button type="button" class="hover:brightness-125 active:brightness-90">
<OutlinedBox color highlight> <OutlinedBox color highlight>
<div class="flex items-stretch">{icon_view} {separator} {text_view}</div> <div class="flex items-stretch">{icon_view} {separator} {text_view}</div>
</OutlinedBox> </OutlinedBox>

View File

@@ -8,7 +8,7 @@ pub fn Checkbox(checked: RwSignal<bool>, #[prop(into)] text: Signal<String>, #[p
let unique_id = Oco::from(format!("checkbox-{}", uuid::Uuid::new_v4())); let unique_id = Oco::from(format!("checkbox-{}", uuid::Uuid::new_v4()));
let checkbox_view = view! { let checkbox_view = view! {
<div class="self-center my-2.5 mx-5 text-white bg-white rounded-xs aspect-square"> <div class="self-center text-white bg-white rounded-xs aspect-square mx-5 my-2.5">
<span class=("text-gray-950", move || checked.get())> <span class=("text-gray-950", move || checked.get())>
<icons::Check /> <icons::Check />
</span> </span>
@@ -25,7 +25,7 @@ pub fn Checkbox(checked: RwSignal<bool>, #[prop(into)] text: Signal<String>, #[p
}; };
let text_view = view! { let text_view = view! {
<div class="self-center my-2.5 mx-5 w-full text-lg font-thin uppercase"> <div class="self-center mx-5 my-2.5 uppercase w-full text-lg font-thin">
{move || text.get()} {move || text.get()}
</div> </div>
}; };

View File

@@ -1,13 +0,0 @@
use leptos::prelude::*;
#[component]
#[tracing::instrument(skip_all)]
pub fn Header(children: Children) -> impl IntoView {
crate::tracing::on_enter!();
view! {
<div class="w-full text-black bg-orange-300 border-b-2 border-b-orange-400">
{children()}
</div>
}
}

View File

@@ -7,16 +7,12 @@ pub enum Icon {
WrenchSolid, WrenchSolid,
ForwardSolid, ForwardSolid,
Check, Check,
Heart,
HeartOutline, HeartOutline,
ArrowPath, ArrowPath,
PaperAirplaneSolid, PaperAirplaneSolid,
NoSymbol, NoSymbol,
Trophy, Trophy,
ArrowTrendingUp, ArrowTrendingUp,
ChevronLeft,
ChevronRight,
CodeBracketSquare,
} }
impl Icon { impl Icon {
// TODO: Actually impl IntoView for Icon instead // TODO: Actually impl IntoView for Icon instead
@@ -27,16 +23,12 @@ impl Icon {
Icon::WrenchSolid => view! { <WrenchSolid /> }.into_any(), Icon::WrenchSolid => view! { <WrenchSolid /> }.into_any(),
Icon::ForwardSolid => view! { <ForwardSolid /> }.into_any(), Icon::ForwardSolid => view! { <ForwardSolid /> }.into_any(),
Icon::Check => view! { <Check /> }.into_any(), Icon::Check => view! { <Check /> }.into_any(),
Icon::Heart => view! { <Heart /> }.into_any(),
Icon::HeartOutline => view! { <HeartOutline /> }.into_any(), Icon::HeartOutline => view! { <HeartOutline /> }.into_any(),
Icon::ArrowPath => view! { <ArrowPath /> }.into_any(), Icon::ArrowPath => view! { <ArrowPath /> }.into_any(),
Icon::PaperAirplaneSolid => view! { <PaperAirplaneSolid /> }.into_any(), Icon::PaperAirplaneSolid => view! { <PaperAirplaneSolid /> }.into_any(),
Icon::NoSymbol => view! { <NoSymbol /> }.into_any(), Icon::NoSymbol => view! { <NoSymbol /> }.into_any(),
Icon::Trophy => view! { <Trophy /> }.into_any(), Icon::Trophy => view! { <Trophy /> }.into_any(),
Icon::ArrowTrendingUp => view! { <ArrowTrendingUp /> }.into_any(), Icon::ArrowTrendingUp => view! { <ArrowTrendingUp /> }.into_any(),
Icon::ChevronLeft => view! { <ChevronLeft /> }.into_any(),
Icon::ChevronRight => view! { <ChevronRight /> }.into_any(),
Icon::CodeBracketSquare => view! { <CodeBracketSquare /> }.into_any(),
} }
} }
} }
@@ -125,20 +117,6 @@ pub fn Check() -> impl IntoView {
} }
} }
#[component]
pub fn Heart() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
</svg>
}
}
#[component] #[component]
pub fn HeartOutline() -> impl IntoView { pub fn HeartOutline() -> impl IntoView {
view! { view! {
@@ -248,55 +226,3 @@ pub fn ArrowTrendingUp() -> impl IntoView {
</svg> </svg>
} }
} }
#[component]
pub fn ChevronLeft() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
}
}
#[component]
pub fn ChevronRight() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
}
}
#[component]
pub fn CodeBracketSquare() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.25 9.75 16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z"
/>
</svg>
}
}

View File

@@ -22,14 +22,11 @@ pub fn OutlinedBox(children: Children, color: Gradient, #[prop(optional)] highli
let mut c = "py-1.5 rounded-md".to_string(); let mut c = "py-1.5 rounded-md".to_string();
if highlight() { if highlight() {
let bg = match color { let bg = match color {
Gradient::PinkOrange => "bg-rose-900", Gradient::PinkOrange => "bg-pink-900",
Gradient::CyanBlue => "bg-cyan-800", Gradient::CyanBlue => "bg-cyan-800",
Gradient::TealLime => "bg-emerald-700", Gradient::TealLime => "bg-teal-700",
Gradient::PurplePink => "bg-fuchsia-950", Gradient::PurplePink => "bg-purple-900",
Gradient::PurpleBlue => "bg-purple-900", Gradient::PurpleBlue => "bg-purple-900",
Gradient::Orange => "bg-orange-900",
Gradient::Pink => "bg-pink-900",
Gradient::PinkRed => "bg-fuchsia-950",
}; };
c.push(' '); c.push(' ');

View File

@@ -19,7 +19,7 @@ pub fn Problem(
for row in 0..dim.get().rows { for row in 0..dim.get().rows {
for col in 0..dim.get().cols { for col in 0..dim.get().cols {
let hold_position = models::HoldPosition { row, col }; let hold_position = models::HoldPosition { row, col };
let role = move || problem.read().pattern.pattern.get(&hold_position).copied(); let role = move || problem.get().holds.get(&hold_position).copied();
let role = Signal::derive(role); let role = Signal::derive(role);
let hold = view! { <Hold role /> }; let hold = view! { <Hold role /> };
holds.push(hold); holds.push(hold);
@@ -28,17 +28,11 @@ pub fn Problem(
holds.into_iter().collect_view() holds.into_iter().collect_view()
}; };
let style = move || { let grid_classes = move || format!("grid grid-rows-{} grid-cols-{} gap-3", dim.get().rows, dim.get().cols);
let grid_rows = crate::css::grid_rows_n(dim.get().rows);
let grid_cols = crate::css::grid_cols_n(dim.get().cols);
[grid_rows, grid_cols].join(" ")
};
view! { view! {
<div class="grid gap-8 grid-cols-[auto_1fr]"> <div class="grid gap-8 grid-cols-[auto_1fr]">
<div style=style class="grid gap-3"> <div class=move || { grid_classes }>{holds}</div>
{holds}
</div>
</div> </div>
} }
} }

View File

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

View File

@@ -1,52 +1,40 @@
#[derive(Debug, Copy, Clone, Default)] #[derive(Debug, Copy, Clone, Default)]
pub enum Gradient { pub enum Gradient {
#[default]
PurpleBlue, PurpleBlue,
PinkOrange, PinkOrange,
CyanBlue, CyanBlue,
TealLime, TealLime,
PurplePink, PurplePink,
#[default]
Orange,
Pink,
PinkRed,
} }
impl Gradient { impl Gradient {
pub fn class_from(&self) -> &'static str { pub fn class_from(&self) -> &str {
match self { match self {
Gradient::PinkOrange => "from-pink-500", Gradient::PinkOrange => "from-pink-500",
Gradient::CyanBlue => "from-cyan-500", Gradient::CyanBlue => "from-cyan-500",
Gradient::TealLime => "from-teal-300", Gradient::TealLime => "from-teal-300",
Gradient::PurplePink => "from-purple-500", Gradient::PurplePink => "from-purple-500",
Gradient::PurpleBlue => "from-purple-600", Gradient::PurpleBlue => "from-purple-600",
Gradient::Orange => "from-orange-400",
Gradient::Pink => "from-pink-400",
Gradient::PinkRed => "from-pink-400",
} }
} }
pub fn class_to(&self) -> &'static str { pub fn class_to(&self) -> &str {
match self { match self {
Gradient::PinkOrange => "to-orange-400", Gradient::PinkOrange => "to-orange-400",
Gradient::CyanBlue => "to-blue-500", Gradient::CyanBlue => "to-blue-500",
Gradient::TealLime => "to-lime-300", Gradient::TealLime => "to-lime-300",
Gradient::PurplePink => "to-pink-500", Gradient::PurplePink => "to-pink-500",
Gradient::PurpleBlue => "to-blue-500", Gradient::PurpleBlue => "to-blue-500",
Gradient::Orange => "to-orange-500",
Gradient::Pink => "to-pink-500",
Gradient::PinkRed => "to-red-500",
} }
} }
pub fn class_text(&self) -> &'static str { pub fn class_text(&self) -> &str {
match self { match self {
Gradient::PinkOrange => "text-rose-400", Gradient::PinkOrange => "text-pink-500",
Gradient::CyanBlue => "text-cyan-500", Gradient::CyanBlue => "text-cyan-500",
Gradient::TealLime => "text-emerald-300", Gradient::TealLime => "text-teal-300",
Gradient::PurplePink => "text-fuchsia-500", Gradient::PurplePink => "text-purple-500",
Gradient::PurpleBlue => "text-purple-600", Gradient::PurpleBlue => "text-purple-600",
Gradient::Orange => "text-orange-400",
Gradient::Pink => "text-pink-400",
Gradient::PinkRed => "text-pink-400",
} }
} }
} }

View File

@@ -1,13 +1,48 @@
pub mod app; pub mod app;
pub mod codec; pub mod pages {
pub mod components; pub mod edit_wall;
pub mod css; pub mod routes;
pub mod settings;
pub mod wall;
}
pub mod components {
pub use attempt::Attempt;
pub use button::Button;
pub use header::StyledHeader;
pub use problem::Problem;
pub use problem_info::ProblemInfo;
pub mod attempt;
pub mod button;
pub mod checkbox;
pub mod header;
pub mod icons;
pub mod outlined_box;
pub mod problem;
pub mod problem_info;
use leptos::prelude::*;
#[component]
pub fn OnHoverRed(children: Children) -> impl IntoView {
view! {
<div class="group relative">
<div>{children()}</div>
<div class="absolute inset-0 bg-red-500 opacity-0 group-hover:opacity-50"></div>
</div>
}
}
}
pub mod gradient; pub mod gradient;
pub mod models;
pub mod pages;
pub mod resources; pub mod resources;
pub mod css;
pub mod codec;
pub mod models;
pub mod server_functions; pub mod server_functions;
pub mod tracing;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub mod server; pub mod server;
@@ -22,12 +57,12 @@ pub fn hydrate() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::builder() tracing_subscriber::EnvFilter::builder()
.with_default_directive(::tracing::level_filters::LevelFilter::DEBUG.into()) .with_default_directive(tracing::level_filters::LevelFilter::DEBUG.into())
.from_env_lossy(), .from_env_lossy(),
) )
.with_writer( .with_writer(
// To avoide trace events in the browser from showing their JS backtrace // To avoide trace events in the browser from showing their JS backtrace
tracing_subscriber_wasm::MakeConsoleWriter::default().map_trace_level_to(::tracing::Level::DEBUG), tracing_subscriber_wasm::MakeConsoleWriter::default().map_trace_level_to(tracing::Level::DEBUG),
) )
// For some reason, if we don't do this in the browser, we get a runtime error. // For some reason, if we don't do this in the browser, we get a runtime error.
.without_time() .without_time()

View File

@@ -8,68 +8,22 @@ pub use v2::ImageFilename;
pub use v2::ImageResolution; pub use v2::ImageResolution;
pub use v2::ImageUid; pub use v2::ImageUid;
pub use v2::Method; pub use v2::Method;
pub use v2::Problem;
pub use v2::ProblemUid;
pub use v2::Root; pub use v2::Root;
pub use v2::Wall;
pub use v2::WallDimensions; pub use v2::WallDimensions;
pub use v2::WallUid; pub use v2::WallUid;
pub use v3::Attempt; pub use v3::Attempt;
pub use v3::UserInteraction;
pub use v4::DatedAttempt; pub use v4::DatedAttempt;
pub use v4::Pattern;
pub use v4::Problem;
pub use v4::Transformation;
pub use v4::UserInteraction;
pub use v4::Wall;
mod semantics; mod semantics;
pub mod v4 { pub mod v4 {
use super::v1;
use super::v2;
use super::v3; use super::v3;
use chrono::DateTime; use chrono::DateTime;
use chrono::Utc; use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Wall {
pub uid: v2::WallUid,
pub wall_dimensions: v2::WallDimensions,
pub holds: BTreeMap<v1::HoldPosition, v2::Hold>,
/// Canonicalized.
pub problems: BTreeSet<Problem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Problem {
pub pattern: Pattern,
pub method: v2::Method,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Pattern {
pub pattern: BTreeMap<v1::HoldPosition, v1::HoldRole>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Copy, Default)]
pub struct Transformation {
pub shift_right: u64,
pub mirror: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UserInteraction {
pub wall_uid: v2::WallUid,
pub problem: Problem,
/// Dates on which this problem was attempted, and how it went
pub attempted_on: BTreeMap<chrono::DateTime<chrono::Utc>, v3::Attempt>,
/// Is among favorite problems
pub is_favorite: bool,
}
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct DatedAttempt { pub struct DatedAttempt {
@@ -131,6 +85,7 @@ pub mod v2 {
pub struct Wall { pub struct Wall {
pub uid: WallUid, pub uid: WallUid,
// TODO: Replace by walldimensions
pub rows: u64, pub rows: u64,
pub cols: u64, pub cols: u64,
@@ -138,7 +93,7 @@ pub mod v2 {
pub problems: BTreeSet<ProblemUid>, pub problems: BTreeSet<ProblemUid>,
} }
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct WallDimensions { pub struct WallDimensions {
pub rows: u64, pub rows: u64,
pub cols: u64, pub cols: u64,
@@ -160,16 +115,16 @@ pub mod v2 {
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)] #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
pub struct ProblemUid(pub uuid::Uuid); pub struct ProblemUid(pub uuid::Uuid);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Display, Copy, Hash)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Display)]
pub enum Method { pub enum Method {
#[display("Feet follow hands")] #[display("Feet follow hands")]
FeetFollowHands, FeetFollowHands,
#[display("Footless plus kickboard")]
FootlessPlusKickboard,
#[display("Footless")] #[display("Footless")]
Footless, Footless,
#[display("Footless plus kickboard")]
FootlessPlusKickboard,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]

View File

@@ -1,136 +1,16 @@
use super::*; use super::*;
use crate::gradient::Gradient;
use chrono::DateTime; use chrono::DateTime;
use chrono::Utc; use chrono::Utc;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::HashSet;
impl Problem {
/// Returns all possible transformations for the pattern. Not for the method.
#[must_use]
pub fn transformations(&self, wall_dimensions: WallDimensions) -> HashSet<Self> {
self.pattern
.transformations(wall_dimensions)
.into_iter()
.map(|pattern| Self {
pattern,
method: self.method,
})
.collect()
}
}
impl Pattern {
#[must_use]
pub fn canonicalize(&self) -> Self {
let mut pattern = self.clone();
let min_col = pattern.pattern.keys().map(|hold_position| hold_position.col).min().unwrap_or(0);
pattern.pattern = pattern
.pattern
.iter()
.map(|(hold_position, hold_role)| {
let hold_position = HoldPosition {
row: hold_position.row,
col: hold_position.col - min_col,
};
(hold_position, *hold_role)
})
.collect();
std::cmp::min(pattern.mirror(), pattern)
}
#[must_use]
pub fn shift_left(&self, shift: u64) -> Option<Self> {
// Out of bounds check
if let Some(min_col) = self.pattern.keys().map(|hold_position| hold_position.col).min() {
if shift > min_col {
return None;
}
}
let pattern: BTreeMap<HoldPosition, HoldRole> = self
.pattern
.iter()
.map(|(hold_position, hold_role)| {
let mut hold_position = *hold_position;
hold_position.col -= shift;
(hold_position, *hold_role)
})
.collect();
Some(Self { pattern })
}
#[must_use]
pub fn shift_right(&self, wall_dimensions: WallDimensions, shift: u64) -> Option<Self> {
// Out of bounds check
if let Some(max_col) = self.pattern.keys().map(|hold_position| hold_position.col).max() {
if max_col + shift >= wall_dimensions.cols {
return None;
}
}
let pattern: BTreeMap<HoldPosition, HoldRole> = self
.pattern
.iter()
.map(|(hold_position, hold_role)| {
let mut hold_position = *hold_position;
hold_position.col += shift;
(hold_position, *hold_role)
})
.collect();
Some(Self { pattern })
}
#[must_use]
pub fn mirror(&self) -> Self {
let mut pattern = self.clone();
let min_col = pattern.pattern.keys().map(|hold_position| hold_position.col).min().unwrap_or(0);
let max_col = pattern.pattern.keys().map(|hold_position| hold_position.col).max().unwrap_or(0);
pattern.pattern = pattern
.pattern
.iter()
.map(|(hold_position, hold_role)| {
let HoldPosition { row, col } = *hold_position;
let mut mirrored_col = col;
mirrored_col += 2 * (max_col - col);
mirrored_col -= max_col - min_col;
let hold_position = HoldPosition { row, col: mirrored_col };
(hold_position, *hold_role)
})
.collect();
pattern
}
/// Returns all possible transformations for the pattern
#[must_use]
pub fn transformations(&self, wall_dimensions: WallDimensions) -> HashSet<Self> {
let mut transformations = HashSet::new();
let pattern = self.canonicalize();
for mut pat in [pattern.mirror(), pattern] {
transformations.insert(pat.clone());
while let Some(p) = pat.shift_right(wall_dimensions, 1) {
transformations.insert(p.clone());
pat = p;
}
}
transformations
}
}
impl UserInteraction { impl UserInteraction {
pub(crate) fn new(wall_uid: WallUid, problem: Problem) -> Self { pub(crate) fn new(wall_uid: WallUid, problem_uid: ProblemUid) -> Self {
Self { Self {
wall_uid, wall_uid,
problem, problem_uid,
is_favorite: false, is_favorite: false,
attempted_on: BTreeMap::new(), attempted_on: BTreeMap::new(),
is_saved: false,
} }
} }
@@ -179,15 +59,18 @@ impl From<(&DateTime<Utc>, &Attempt)> for DatedAttempt {
} }
impl WallUid { impl WallUid {
#[expect(dead_code)]
pub(crate) fn create() -> Self { pub(crate) fn create() -> Self {
Self(uuid::Uuid::new_v4()) Self(uuid::Uuid::new_v4())
} }
#[expect(dead_code)] }
impl ProblemUid {
pub(crate) fn create() -> Self {
Self(uuid::Uuid::new_v4())
}
pub(crate) fn min() -> Self { pub(crate) fn min() -> Self {
Self(uuid::Uuid::nil()) Self(uuid::Uuid::nil())
} }
#[expect(dead_code)]
pub(crate) fn max() -> Self { pub(crate) fn max() -> Self {
Self(uuid::Uuid::max()) Self(uuid::Uuid::max())
} }
@@ -218,78 +101,4 @@ impl Attempt {
Attempt::Flash => Icon::BoltSolid, Attempt::Flash => Icon::BoltSolid,
} }
} }
pub(crate) fn gradient(&self) -> Gradient {
match self {
Attempt::Attempt => Gradient::PinkOrange,
Attempt::Send => Gradient::TealLime,
Attempt::Flash => Gradient::CyanBlue,
}
}
}
impl std::str::FromStr for Problem {
type Err = ron::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let problem = ron::from_str(s)?;
Ok(problem)
}
}
impl std::fmt::Display for Problem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = ron::to_string(self).unwrap();
write!(f, "{s}")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test_try::test_try]
fn canonicalize_empty_pattern() {
let pattern = Pattern {
pattern: [].into_iter().collect(),
};
let canonicalized = pattern.canonicalize();
assert_eq!(pattern, canonicalized);
let mirrored = pattern.mirror();
assert_eq!(pattern, mirrored);
}
#[test_try::test_try]
fn canonicalize_pattern() {
let pattern = Pattern {
pattern: [
(HoldPosition { row: 0, col: 1 }, HoldRole::End),
(HoldPosition { row: 7, col: 6 }, HoldRole::Start),
]
.into_iter()
.collect(),
};
let canonicalized = pattern.canonicalize();
assert_eq!(canonicalized.pattern[&HoldPosition { row: 0, col: 0 }], HoldRole::End);
assert_eq!(canonicalized.pattern[&HoldPosition { row: 7, col: 5 }], HoldRole::Start);
}
#[test_try::test_try]
fn mirror_pattern() {
let pattern = Pattern {
pattern: [
(HoldPosition { row: 0, col: 1 }, HoldRole::End),
(HoldPosition { row: 7, col: 6 }, HoldRole::Start),
]
.into_iter()
.collect(),
};
let mirrored = pattern.mirror();
assert_eq!(mirrored.pattern[&HoldPosition { row: 0, col: 6 }], HoldRole::End);
assert_eq!(mirrored.pattern[&HoldPosition { row: 7, col: 1 }], HoldRole::Start);
}
} }

View File

@@ -1,3 +0,0 @@
pub mod holds;
pub mod settings;
pub mod wall;

View File

@@ -24,7 +24,7 @@ struct RouteParams {
} }
#[component] #[component]
pub fn Page() -> impl IntoView { pub fn EditWall() -> impl IntoView {
let params = leptos_router::hooks::use_params::<RouteParams>(); let params = leptos_router::hooks::use_params::<RouteParams>();
let wall_uid = Signal::derive(move || { let wall_uid = Signal::derive(move || {
params params
@@ -84,18 +84,12 @@ fn Ready(wall: models::Wall) -> impl IntoView {
holds.push(view! { <Hold wall_uid=wall.uid hold /> }); holds.push(view! { <Hold wall_uid=wall.uid hold /> });
} }
let style = { let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-3", wall.rows, wall.cols);
let grid_rows = crate::css::grid_rows_n(wall.wall_dimensions.rows);
let grid_cols = crate::css::grid_cols_n(wall.wall_dimensions.cols);
[grid_rows, grid_cols].join(" ")
};
view! { view! {
<div> <div>
<p class="my-4 font-semibold">"Click hold to replace image"</p> <p class="my-4 font-semibold">"Click hold to replace image"</p>
<div style=style class="grid gap-3"> <div class=move || { grid_classes.clone() }>{holds}</div>
{holds}
</div>
</div> </div>
} }
} }

View File

@@ -0,0 +1,102 @@
use crate::components;
use crate::components::header::HeaderItem;
use crate::components::header::HeaderItems;
use crate::components::header::StyledHeader;
use crate::models;
use leptos::Params;
use leptos::prelude::*;
use leptos_router::params::Params;
#[derive(Params, PartialEq, Clone)]
struct RouteParams {
// Is never None
wall_uid: Option<models::WallUid>,
}
#[component]
#[tracing::instrument(skip_all)]
pub fn Routes() -> impl IntoView {
tracing::debug!("Enter");
let params = leptos_router::hooks::use_params::<RouteParams>();
let wall_uid = Signal::derive(move || {
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 header_items = move || HeaderItems {
left: vec![HeaderItem {
text: "← Ascend".to_string(),
link: Some(format!("/wall/{}", wall_uid.get())),
}],
middle: vec![HeaderItem {
text: "Routes".to_string(),
link: None,
}],
right: vec![],
};
let suspend = move || {
Suspend::new(async move {
let wall = wall.await;
let problems = problems.await;
let v = move || -> Result<_, ServerFnError> {
let wall = wall.clone()?;
let problems = problems.clone()?;
let wall_dimensions = models::WallDimensions {
rows: wall.rows,
cols: wall.cols,
};
let problems_sample = move || problems.iter().take(10).cloned().collect::<Vec<_>>();
Ok(view! {
<div>
<For
each=problems_sample
key=|problem| problem.uid
children=move |problem: models::Problem| {
view! {
<Problem dim=wall_dimensions problem />
<hr class="my-8 h-px bg-gray-700 border-0" />
}
}
/>
</div>
})
};
view! { <ErrorBoundary fallback=|_errors| "error">{v}</ErrorBoundary> }
})
};
view! {
<div class="min-h-screen min-w-screen bg-neutral-950">
<StyledHeader items=Signal::derive(header_items) />
<div class="container mx-auto mt-6">
<Suspense fallback=|| view! { <p>"loading"</p> }>{suspend}</Suspense>
</div>
</div>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn Problem(#[prop(into)] dim: Signal<models::WallDimensions>, #[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
view! {
<div class="flex items-start">
<div class="flex-none">
<components::Problem dim problem />
</div>
<components::ProblemInfo problem=problem.get() />
</div>
}
}

View File

@@ -29,18 +29,20 @@ pub fn Settings() -> impl IntoView {
} }
} }
// #[component] #[component]
// #[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
// fn Import(wall_uid: WallUid) -> impl IntoView { fn Import(wall_uid: WallUid) -> impl IntoView {
// let import_from_mini_moonboard = ServerAction::<ImportFromMiniMoonboard>::new(); let import_from_mini_moonboard = ServerAction::<ImportFromMiniMoonboard>::new();
// let onclick = move |_mouse_event| {
// import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid }); let onclick = move |_mouse_event| {
// }; import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid });
// view! { };
// <p>"Import problems from"</p>
// <button on:click=onclick>"Mini Moonboard"</button> view! {
// } <p>"Import problems from"</p>
// } <button on:click=onclick>"Mini Moonboard"</button>
}
}
#[server] #[server]
#[tracing::instrument] #[tracing::instrument]

View File

@@ -3,19 +3,19 @@ use crate::components::OnHoverRed;
use crate::components::ProblemInfo; use crate::components::ProblemInfo;
use crate::components::attempt::Attempt; use crate::components::attempt::Attempt;
use crate::components::button::Button; 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::components::icons::Icon;
use crate::gradient::Gradient; use crate::gradient::Gradient;
use crate::models; use crate::models;
use crate::models::HoldRole; use crate::models::HoldRole;
use crate::server_functions; use crate::server_functions;
use crate::server_functions::SetIsFavorite;
use leptos::Params; use leptos::Params;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::params::Params; use leptos_router::params::Params;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::collections::HashSet;
use std::ops::Deref;
#[derive(Params, PartialEq, Clone)] #[derive(Params, PartialEq, Clone)]
struct RouteParams { struct RouteParams {
@@ -24,8 +24,8 @@ struct RouteParams {
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn Page() -> impl IntoView { pub fn Wall() -> impl IntoView {
crate::tracing::on_enter!(); tracing::debug!("Enter");
let route_params = leptos_router::hooks::use_params::<RouteParams>(); let route_params = leptos_router::hooks::use_params::<RouteParams>();
@@ -38,344 +38,151 @@ pub fn Page() -> impl IntoView {
}); });
let wall = crate::resources::wall_by_uid(wall_uid); let wall = crate::resources::wall_by_uid(wall_uid);
let user_interactions = crate::resources::user_interactions_for_wall(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! { leptos::view! {
<div class="min-h-screen min-w-screen bg-neutral-950"> <div class="min-h-screen min-w-screen bg-neutral-950">
<Suspense fallback=|| { <StyledHeader items=Signal::derive(header_items) />
"loading"
}> <div class="m-2">
<Transition fallback=|| ()>
{move || Suspend::new(async move { {move || Suspend::new(async move {
tracing::debug!("executing main suspend"); tracing::info!("executing main suspend");
let wall = wall.await?; let wall = wall.await?;
let problems = problems.await?;
let user_interactions = user_interactions.await?; let user_interactions = user_interactions.await?;
let user_interactions = RwSignal::new(user_interactions); let v = view! { <WithWall wall problems user_interactions /> };
Ok::<_, ServerFnError>(view! { <Controller wall user_interactions /> }) Ok::<_, ServerFnError>(v)
})} })}
</Suspense> </Transition>
</div>
</div> </div>
} }
} }
#[derive(Debug, Clone, Copy)] #[component]
struct Context { #[tracing::instrument(skip_all)]
wall: Signal<models::Wall>, fn WithProblem(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
user_interactions: Signal<BTreeMap<models::Problem, models::UserInteraction>>, tracing::trace!("Enter");
problem: Signal<Option<models::Problem>>,
filtered_problem_transformations: Signal<Vec<HashSet<models::Problem>>>, view! { <ProblemInfo problem /> }
user_interaction: Signal<Option<models::UserInteraction>>,
todays_attempt: Signal<Option<models::Attempt>>,
latest_attempt: Signal<Option<models::DatedAttempt>>,
filter_holds: Signal<BTreeSet<models::HoldPosition>>,
cb_click_hold: Callback<models::HoldPosition>,
cb_remove_hold_from_filter: Callback<models::HoldPosition>,
cb_next_problem: Callback<()>,
cb_set_problem: Callback<models::Problem>,
cb_upsert_todays_attempt: Callback<server_functions::UpsertTodaysAttempt>,
cb_set_is_favorite: Callback<bool>,
} }
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn Controller( fn WithWall(
#[prop(into)] wall: Signal<models::Wall>, #[prop(into)] wall: Signal<models::Wall>,
#[prop(into)] user_interactions: RwSignal<BTreeMap<models::Problem, models::UserInteraction>>, #[prop(into)] problems: Signal<Vec<models::Problem>>,
#[prop(into)] user_interactions: Signal<BTreeMap<models::ProblemUid, models::UserInteraction>>,
) -> impl IntoView { ) -> impl IntoView {
crate::tracing::on_enter!(); tracing::trace!("Enter");
// Extract data from URL let wall_uid = Signal::derive(move || wall.read().uid);
let (problem, set_problem) = leptos_router::hooks::query_signal::<models::Problem>("problem");
let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem");
// Filter // Filter
let (filter_holds, set_filter_holds) = signal(BTreeSet::new()); let (filter_holds, set_filter_holds) = signal(BTreeSet::new());
let cb_remove_hold_from_filter: Callback<models::HoldPosition> = Callback::new(move |hold_pos: models::HoldPosition| { 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_filter_holds.update(move |set| {
set.remove(&hold_pos); set.remove(&hold_pos);
}); });
});
// Derive signals
let user_interaction = signals::user_interaction(user_interactions.into(), problem.into());
let problem_transformations = signals::problem_transformations(wall);
let filtered_problem_transformations = signals::filtered_problem_transformations(problem_transformations.into(), filter_holds.into());
let todays_attempt = signals::todays_attempt(user_interaction);
let latest_attempt = signals::latest_attempt(user_interaction);
// Submit attempt action
let upsert_todays_attempt = ServerAction::<RonEncoded<server_functions::UpsertTodaysAttempt>>::new();
let cb_upsert_todays_attempt = Callback::new(move |attempt| {
upsert_todays_attempt.dispatch(RonEncoded(attempt));
});
// Set favorite
let set_is_favorite = ServerAction::<RonEncoded<server_functions::SetIsFavorite>>::new();
let cb_set_is_favorite = Callback::new(move |is_favorite| {
let wall_uid = wall.read().uid;
let Some(problem) = problem.get() else {
return;
}; };
set_is_favorite.dispatch(RonEncoded(SetIsFavorite {
wall_uid, let filtered_problems = Memo::new(move |_prev_val| {
problem, let filter_holds = filter_holds.get();
is_favorite, problems.with(|problems| {
})); problems
.iter()
.filter(|problem| filter_holds.iter().all(|hold_pos| problem.holds.contains_key(hold_pos)))
.cloned()
.collect::<Vec<models::Problem>>()
})
}); });
// Callback: Set specific problem let problem = crate::resources::problem_by_uid_optional(wall_uid, problem_uid.into());
let cb_set_problem: Callback<models::Problem> = Callback::new(move |problem| { let user_interaction = signals::user_interaction(user_interactions, problem_uid.into());
set_problem.set(Some(problem));
});
// Callback: Set next problem to a random problem let fn_next_problem = move || {
let cb_set_random_problem: Callback<()> = Callback::new(move |_| { let problems = filtered_problems.read();
// TODO: remove current problem from population
let population = filtered_problem_transformations.read();
let population = population.deref();
use rand::seq::IteratorRandom; use rand::seq::IteratorRandom;
let mut rng = rand::rng(); let mut rng = rand::rng();
let problem = problems.iter().choose(&mut rng);
let problem_uid = problem.map(|p| p.uid);
// Pick pattern set_problem_uid.set(problem_uid);
let Some(problem_set) = population.iter().choose(&mut rng) else {
return;
}; };
// Pick problem out of pattern transformations // Set a problem when wall is set (loaded)
let Some(problem) = problem_set.iter().choose(&mut rng) else { Effect::new(move |_prev_value| {
return; if problem_uid.get().is_none() {
}; tracing::debug!("Setting next problem");
fn_next_problem();
set_problem.set(Some(problem.clone())); }
}); });
// Callback: On click hold, Add/Remove hold position to problem filter // merge outer option (resource hasn't resolved yet) with inner option (there is no problem for the wall)
let cb_click_hold: Callback<models::HoldPosition> = Callback::new(move |hold_position| { 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| { set_filter_holds.update(|set| {
if !set.remove(&hold_position) { if !set.remove(&hold_position) {
set.insert(hold_position); set.insert(hold_position);
} }
}); });
}); };
// Set a problem when wall is set (loaded) let grid = view! {
Effect::new(move |_prev_value| { <Transition fallback=|| ()>
if problem.read().is_none() { {move || {
tracing::debug!("Setting initial problem"); Suspend::new(async move {
cb_set_random_problem.run(()); tracing::debug!("executing grid suspend");
} let view = view! {
}); <Grid wall=wall.get() problem=problem_signal on_click_hold />
};
Ok::<_, ServerFnError>(view)
})
}}
</Transition>
};
// Update user interactions after submitting an attempt let filter = move || {
Effect::new(move || {
if let Some(Ok(v)) = upsert_todays_attempt.value().get() {
let v = v.into_inner();
user_interactions.update(|map| {
map.insert(v.problem.clone(), v);
});
}
});
// Update user interactions after setting favorite
Effect::new(move || {
if let Some(Ok(v)) = set_is_favorite.value().get() {
let v = v.into_inner();
user_interactions.update(|map| {
map.insert(v.problem.clone(), v);
});
}
});
provide_context(Context {
wall,
problem: problem.into(),
cb_click_hold,
user_interaction,
latest_attempt,
cb_upsert_todays_attempt,
cb_remove_hold_from_filter,
cb_next_problem: cb_set_random_problem,
cb_set_problem,
cb_set_is_favorite,
todays_attempt,
filter_holds: filter_holds.into(),
filtered_problem_transformations: filtered_problem_transformations.into(),
user_interactions: user_interactions.into(),
});
view! { <View /> }
}
#[component]
#[tracing::instrument(skip_all)]
fn View() -> impl IntoView {
crate::tracing::on_enter!();
let ctx = use_context::<Context>().unwrap();
view! {
<div class="flex">
<div class="flex-initial">
<Wall />
</div>
<div class="flex flex-col px-2 pt-3" style="width:38rem">
<Section title="Problems">
<Filter />
<Separator />
<div class="flex flex-row justify-between">
<NextProblemButton />
</div>
</Section>
<Separator />
<Section title="Current problem">
{move || ctx.problem.get().map(|problem| view! { <ProblemInfo problem /> })}
<Separator /> <div class="flex flex-row gap-2 justify-between">
<Transformations />
<FavoriteButton />
</div> <Separator /> <AttemptRadioGroup /> <Separator /> <History />
</Section>
</div>
<div class="flex flex-col px-2 pt-3 gap-4">
<HoldsButton />
</div>
</div>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn Transformations() -> impl IntoView {
crate::tracing::on_enter!();
let ctx = use_context::<Context>().unwrap();
let left = Signal::derive(move || {
let mut problem = ctx.problem.get()?;
let new_pattern = problem.pattern.shift_left(1)?;
problem.pattern = new_pattern;
Some(problem)
});
let right = Signal::derive(move || {
let mut problem = ctx.problem.get()?;
let wall_dimensions = ctx.wall.read().wall_dimensions;
let new_pattern = problem.pattern.shift_right(wall_dimensions, 1)?;
problem.pattern = new_pattern;
Some(problem)
});
let on_click_left = Callback::new(move |()| {
tracing::debug!("left");
if let Some(problem) = left.get() {
ctx.cb_set_problem.run(problem);
}
});
let on_click_mirror = Callback::new(move |()| {
tracing::debug!("mirror");
if let Some(mut problem) = ctx.problem.get() {
problem.pattern = problem.pattern.mirror();
ctx.cb_set_problem.run(problem);
}
});
let on_click_right = Callback::new(move |()| {
tracing::debug!("right");
if let Some(problem) = right.get() {
ctx.cb_set_problem.run(problem);
}
});
let left_disabled = Signal::derive(move || left.read().is_none());
let right_disabled = Signal::derive(move || right.read().is_none());
view! {
<div class="flex flex-row gap-2 justify-center">
<Button icon=Icon::ChevronLeft disabled=left_disabled on_click=on_click_left />
<Button icon=Icon::CodeBracketSquare on_click=on_click_mirror />
<Button icon=Icon::ChevronRight disabled=right_disabled on_click=on_click_right />
</div>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn LikedButton() -> impl IntoView {
crate::tracing::on_enter!();
let _ctx = use_context::<Context>().unwrap();
view! { <Button text="Saved" icon=Icon::HeartOutline color=Gradient::PinkRed /> }
}
#[component]
#[tracing::instrument(skip_all)]
fn HoldsButton() -> impl IntoView {
crate::tracing::on_enter!();
let ctx = use_context::<Context>().unwrap();
let link = move || format!("/wall/{}/edit", ctx.wall.read().uid);
view! {
<a href=link>
<Button text="Holds" icon=Icon::WrenchSolid />
</a>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn FavoriteButton() -> impl IntoView {
crate::tracing::on_enter!();
let ctx = use_context::<Context>().unwrap();
let ui_toggle = Signal::derive(move || {
let guard = ctx.user_interaction.read();
guard.as_ref().map(|user_interaction| user_interaction.is_favorite).unwrap_or(false)
});
let on_click = Callback::new(move |_| {
ctx.cb_set_is_favorite.run(!ui_toggle.get());
});
let icon = Signal::derive(move || if ui_toggle.get() { Icon::Heart } else { Icon::HeartOutline });
let text = Signal::derive(move || if ui_toggle.get() { "Saved" } else { "Save" }.to_string());
view! { <Button text icon on_click color=Gradient::PinkRed /> }
}
#[component]
#[tracing::instrument(skip_all)]
fn NextProblemButton() -> impl IntoView {
crate::tracing::on_enter!();
let ctx = use_context::<Context>().unwrap();
let on_click = Callback::new(move |_| ctx.cb_next_problem.run(()));
view! { <Button icon=Icon::ArrowPath text="Randomize" on_click color=Gradient::PurpleBlue /> }
}
#[component]
#[tracing::instrument(skip_all)]
fn Filter() -> impl IntoView {
crate::tracing::on_enter!();
let ctx = use_context::<Context>().unwrap();
move || {
let mut cells = vec![]; let mut cells = vec![];
for hold_pos in ctx.filter_holds.get() { for hold_pos in filter_holds.get() {
let w = &*ctx.wall.read(); let w = &*wall.read();
if let Some(hold) = w.holds.get(&hold_pos).cloned() { if let Some(hold) = w.holds.get(&hold_pos).cloned() {
let onclick = move |_| { let onclick = move |_| {
ctx.cb_remove_hold_from_filter.run(hold_pos); filter_remove_hold(hold_pos);
}; };
let v = view! { let v = view! {
<button on:click=onclick class="cursor-pointer"> <button on:click=onclick>
<OnHoverRed> <OnHoverRed>
<Hold hold /> <Hold hold />
</OnHoverRed> </OnHoverRed>
@@ -385,13 +192,11 @@ fn Filter() -> impl IntoView {
} }
} }
let problems_count = ctx.filtered_problem_transformations.read().iter().map(|set| set.len()).sum::<usize>();
let problems_counter = { let problems_counter = {
let name = view! { <p class="mr-4 font-light text-right text-orange-300">{"Problems:"}</p> }; let name = view! { <p class="font-light mr-4 text-right text-orange-300">{"Problems:"}</p> };
let value = view! { <p class="text-white">{problems_count}</p> }; let value = view! { <p class="text-white">{filtered_problems.read().len()}</p> };
view! { view! {
<div class="grid grid-rows-none gap-x-0.5 gap-y-1 grid-cols-[auto_1fr]"> <div class="grid grid-rows-none gap-y-1 gap-x-0.5 grid-cols-[auto_1fr]">
{name} {value} {name} {value}
</div> </div>
} }
@@ -407,10 +212,9 @@ fn Filter() -> impl IntoView {
} }
let mut interaction_counters = InteractionCounters::default(); let mut interaction_counters = InteractionCounters::default();
let interaction_counters_view = { let interaction_counters_view = {
let user_ints = ctx.user_interactions.read(); let user_ints = user_interactions.read();
for problem_set in ctx.filtered_problem_transformations.read().iter() { for problem in filtered_problems.read().iter() {
for problem in problem_set { if let Some(user_int) = user_ints.get(&problem.uid) {
if let Some(user_int) = user_ints.get(problem) {
match user_int.best_attempt().map(|da| da.attempt) { match user_int.best_attempt().map(|da| da.attempt) {
Some(models::Attempt::Flash) => interaction_counters.flash += 1, Some(models::Attempt::Flash) => interaction_counters.flash += 1,
Some(models::Attempt::Send) => interaction_counters.send += 1, Some(models::Attempt::Send) => interaction_counters.send += 1,
@@ -419,7 +223,6 @@ fn Filter() -> impl IntoView {
} }
} }
} }
}
let flash = (interaction_counters.flash > 0).then(|| { let flash = (interaction_counters.flash > 0).then(|| {
let class = Gradient::CyanBlue.class_text(); let class = Gradient::CyanBlue.class_text();
view! { view! {
@@ -465,43 +268,121 @@ fn Filter() -> impl IntoView {
{problems_counter} {problems_counter}
{interaction_counters_view} {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] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn AttemptRadioGroup() -> impl IntoView { fn WithUserInteraction(
crate::tracing::on_enter!(); #[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 ctx = use_context::<Context>().unwrap(); let parent_user_interaction = user_interaction;
let problem = ctx.problem; 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 || {
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![]; let mut attempt_radio_buttons = vec![];
for variant in [models::Attempt::Flash, models::Attempt::Send, models::Attempt::Attempt] { for variant in [models::Attempt::Flash, models::Attempt::Send, models::Attempt::Attempt] {
let ui_toggle = Signal::derive(move || ctx.todays_attempt.get() == Some(variant)); let ui_toggle = Signal::derive(move || todays_attempt.get() == Some(variant));
let onclick = move |_| { let onclick = move |_| {
let attempt = if ui_toggle.get() { None } else { Some(variant) }; let attempt = if ui_toggle.get() { None } else { Some(variant) };
submit_attempt.dispatch(RonEncoded(server_functions::UpsertTodaysAttempt {
if let Some(problem) = problem.get() { wall_uid: wall_uid.get(),
ctx.cb_upsert_todays_attempt.run(server_functions::UpsertTodaysAttempt { problem_uid: problem_uid.get(),
wall_uid: ctx.wall.read().uid,
problem,
attempt, attempt,
}); }));
}
}; };
attempt_radio_buttons.push(view! { <AttemptRadioButton on:click=onclick variant selected=ui_toggle /> }); attempt_radio_buttons.push(view! { <AttemptRadioButton on:click=onclick variant selected=ui_toggle /> });
} }
view! { <div class="flex flex-col gap-2 justify-evenly 2xl:flex-row">{attempt_radio_buttons}</div> } 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] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn AttemptRadioButton(variant: models::Attempt, #[prop(into)] selected: Signal<bool>) -> impl IntoView { fn AttemptRadioButton(variant: models::Attempt, #[prop(into)] selected: Signal<bool>) -> impl IntoView {
crate::tracing::on_enter!(); tracing::debug!("Enter");
let text = variant.to_string(); let text = variant.to_string();
let icon = variant.icon(); let icon = variant.icon();
@@ -515,13 +396,13 @@ fn AttemptRadioButton(variant: models::Attempt, #[prop(into)] selected: Signal<b
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn History() -> impl IntoView { fn History(#[prop(into)] user_interaction: Signal<Option<models::UserInteraction>>) -> impl IntoView {
crate::tracing::on_enter!(); tracing::debug!("Enter");
let ctx = use_context::<Context>().unwrap(); let latest_attempt = signals::latest_attempt(user_interaction);
let attempts = move || { let attempts = move || {
ctx.user_interaction user_interaction
.read() .read()
.as_ref() .as_ref()
.iter() .iter()
@@ -535,56 +416,56 @@ fn History() -> impl IntoView {
}; };
let placeholder = move || { let placeholder = move || {
ctx.latest_attempt.read().is_none().then(|| { latest_attempt.read().is_none().then(|| {
let today = chrono::Utc::now(); let today = chrono::Utc::now();
view! { <Attempt date=today attempt=None /> } view! { <Attempt date=today attempt=None /> }
}) })
}; };
view! { view! { <Section title="History">{placeholder} {attempts}</Section> }
{placeholder}
{attempts}
}
} }
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn Wall() -> impl IntoView { fn Grid(
crate::tracing::on_enter!(); 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 ctx = use_context::<Context>().unwrap(); let on_click_hold = std::rc::Rc::new(on_click_hold);
move || {
let wall = ctx.wall.read();
let mut cells = vec![]; let mut cells = vec![];
for (&hold_position, hold) in &wall.holds { for (&hold_position, hold) in &wall.holds {
let hold_role = signals::hold_role(ctx.problem, hold_position); 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 = move |_| { let on_click = {
ctx.cb_click_hold.run(hold_position); let on_click_hold = std::rc::Rc::clone(&on_click_hold);
move |_| {
on_click_hold(hold_position);
}
}; };
let cell = view! { let cell = view! {
<div class="cursor-pointer"> <div class="cursor-pointer">
<Hold on:click=on_click role=hold_role hold=hold.clone() /> <Hold on:click=on_click role hold=hold.clone() />
</div> </div>
}; };
cells.push(cell); cells.push(cell);
} }
let style = { let style = {
let grid_rows = crate::css::grid_rows_n(wall.wall_dimensions.rows); let grid_rows = crate::css::grid_rows_n(wall.rows);
let grid_cols = crate::css::grid_cols_n(wall.wall_dimensions.cols); let grid_cols = crate::css::grid_cols_n(wall.cols);
let max_width = format!("{}vh", wall.wall_dimensions.cols as f64 / wall.wall_dimensions.rows as f64 * 100.); format!("max-height: 90vh; max-width: 90vh; {}", [grid_rows, grid_cols].join(" "))
format!("max-height: 100vh; max-width: {max_width}; {}", [grid_rows, grid_cols].join(" "))
}; };
view! { view! {
<div style=style class="grid gap-1 p-1"> <div class="grid grid-cols-[auto_1fr]">
<div style=style class="grid gap-1">
{cells} {cells}
</div> </div>
} </div>
} }
} }
@@ -596,20 +477,20 @@ fn Hold(
#[prop(optional)] #[prop(optional)]
#[prop(into)] #[prop(into)]
role: Option<Signal<Option<HoldRole>>>, role: Option<Signal<Result<Option<HoldRole>, ServerFnError>>>,
) -> impl IntoView { ) -> impl IntoView {
crate::tracing::on_enter!(); tracing::trace!("Enter");
move || { move || {
let mut class = "bg-sky-100 max-w-full max-h-full aspect-square rounded-sm hover:brightness-125".to_string(); let mut class = "bg-sky-100 aspect-square rounded-sm hover:brightness-125".to_string();
if let Some(role) = role { if let Some(role) = role {
let role = role.get(); let role = role.get()?;
let role_classes = match role { let role_classes = match role {
Some(HoldRole::Start) => Some("outline outline-3 outline-green-500"), Some(HoldRole::Start) => Some("outline outline-offset-2 outline-green-500"),
Some(HoldRole::Normal) => Some("outline outline-3 outline-blue-500"), Some(HoldRole::Normal) => Some("outline outline-offset-2 outline-blue-500"),
Some(HoldRole::Zone) => Some("outline outline-3 outline-amber-500"), Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"),
Some(HoldRole::End) => Some("outline outline-3 outline-red-500"), Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"),
None => Some("brightness-50"), None => Some("brightness-50"),
}; };
if let Some(c) = role_classes { if let Some(c) = role_classes {
@@ -623,19 +504,20 @@ fn Hold(
view! { <img class="object-cover w-full h-full" srcset=srcset /> } view! { <img class="object-cover w-full h-full" srcset=srcset /> }
}); });
view! { <div class=class>{img}</div> } let view = view! { <div class=class>{img}</div> };
Ok::<_, ServerFnError>(view)
} }
} }
#[component] #[component]
fn Separator() -> impl IntoView { fn Separator() -> impl IntoView {
view! { <div class="m-2 h-4 sm:m-3 md:m-4" /> } view! { <div class="m-2 sm:m-3 md:m-4 h-4" /> }
} }
#[component] #[component]
fn Section(children: Children, #[prop(into)] title: MaybeProp<String>) -> impl IntoView { fn Section(children: Children, #[prop(into)] title: MaybeProp<String>) -> impl IntoView {
view! { view! {
<div class="px-5 pt-3 pb-8 rounded-lg bg-neutral-900"> <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"> <div class="py-3 text-lg text-center text-orange-400 border-t border-orange-400">
{move || title.get()} {move || title.get()}
</div> </div>
@@ -648,8 +530,6 @@ mod signals {
use crate::models; use crate::models;
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
pub fn latest_attempt(user_interaction: Signal<Option<models::UserInteraction>>) -> Signal<Option<models::DatedAttempt>> { 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)) Signal::derive(move || user_interaction.read().as_ref().and_then(models::UserInteraction::latest_attempt))
@@ -659,73 +539,14 @@ mod signals {
Signal::derive(move || latest_attempt.read().as_ref().and_then(models::UserInteraction::todays_attempt)) Signal::derive(move || latest_attempt.read().as_ref().and_then(models::UserInteraction::todays_attempt))
} }
#[expect(dead_code)]
pub fn wall_uid(wall: Signal<models::Wall>) -> Signal<models::WallUid> {
Signal::derive(move || wall.read().uid)
}
pub fn user_interaction( pub fn user_interaction(
user_interactions: Signal<BTreeMap<models::Problem, models::UserInteraction>>, user_interactions: Signal<BTreeMap<models::ProblemUid, models::UserInteraction>>,
problem: Signal<Option<models::Problem>>, problem_uid: Signal<Option<models::ProblemUid>>,
) -> Signal<Option<models::UserInteraction>> { ) -> Signal<Option<models::UserInteraction>> {
Signal::derive(move || { Signal::derive(move || {
let problem = problem.get()?; let problem_uid = problem_uid.get()?;
let user_interactions = user_interactions.read(); let user_interactions = user_interactions.read();
user_interactions.get(&problem).cloned() user_interactions.get(&problem_uid).cloned()
}) })
} }
/// Maps each problem to a set of problems comprising all transformation of the problem pattern.
pub(crate) fn problem_transformations(wall: Signal<models::Wall>) -> Memo<Vec<HashSet<models::Problem>>> {
Memo::new(move |_prev_val| {
let wall = wall.read();
wall.problems
.iter()
.map(|problem| problem.transformations(wall.wall_dimensions))
.collect()
})
}
#[expect(dead_code)]
pub(crate) fn filtered_problems(
wall: Signal<models::Wall>,
filter_holds: Signal<BTreeSet<models::HoldPosition>>,
) -> Memo<BTreeSet<models::Problem>> {
Memo::new(move |_prev_val| {
let filter_holds = filter_holds.read();
wall.with(|wall| {
wall.problems
.iter()
.filter(|problem| filter_holds.iter().all(|hold_pos| problem.pattern.pattern.contains_key(hold_pos)))
.cloned()
.collect::<BTreeSet<models::Problem>>()
})
})
}
pub(crate) fn filtered_problem_transformations(
problem_transformations: Signal<Vec<HashSet<models::Problem>>>,
filter_holds: Signal<BTreeSet<models::HoldPosition>>,
) -> Memo<Vec<HashSet<models::Problem>>> {
Memo::new(move |_prev_val| {
let filter_holds = filter_holds.read();
let problem_transformations = problem_transformations.read();
problem_transformations
.iter()
.map(|problem_set| {
problem_set
.iter()
.filter(|problem| filter_holds.iter().all(|hold_pos| problem.pattern.pattern.contains_key(hold_pos)))
.cloned()
.collect::<HashSet<models::Problem>>()
})
.filter(|set| !set.is_empty())
.collect()
})
}
pub(crate) fn hold_role(problem: Signal<Option<models::Problem>>, hold_position: models::HoldPosition) -> Signal<Option<models::HoldRole>> {
Signal::derive(move || problem.get().and_then(|p| p.pattern.pattern.get(&hold_position).copied()))
}
} }

View File

@@ -17,15 +17,47 @@ pub fn wall_by_uid(wall_uid: Signal<models::WallUid>) -> RonResource<models::Wal
) )
} }
/// Returns user interaction for a single problem /// Version of [problem_by_uid] that short circuits if the input problem_uid signal is None.
pub fn user_interaction(wall_uid: Signal<models::WallUid>, problem: Signal<Option<models::Problem>>) -> RonResource<Option<models::UserInteraction>> { 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( Resource::new_with_options(
move || (wall_uid.get(), problem.get()), move || (wall_uid.get(), problem_uid.get()),
move |(wall_uid, problem)| async move { move |(wall_uid, problem_uid)| async move {
let Some(problem) = problem else { let Some(problem_uid) = problem_uid else {
return Ok(None); return Ok(None);
}; };
crate::server_functions::get_user_interaction(wall_uid, problem) crate::server_functions::get_problem_by_uid(wall_uid, problem_uid)
.await
.map(RonEncoded::into_inner)
.map(Some)
},
false,
)
}
/// Returns all problems for a wall
pub fn problems_for_wall(wall_uid: Signal<models::WallUid>) -> RonResource<Vec<models::Problem>> {
Resource::new_with_options(
move || wall_uid.get(),
move |wall_uid| async move { crate::server_functions::get_problems_for_wall(wall_uid).await.map(RonEncoded::into_inner) },
false,
)
}
/// Returns user interaction for a single problem
pub fn user_interaction(
wall_uid: Signal<models::WallUid>,
problem_uid: Signal<Option<models::ProblemUid>>,
) -> RonResource<Option<models::UserInteraction>> {
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_user_interaction(wall_uid, problem_uid)
.await .await
.map(RonEncoded::into_inner) .map(RonEncoded::into_inner)
}, },
@@ -34,14 +66,10 @@ pub fn user_interaction(wall_uid: Signal<models::WallUid>, problem: Signal<Optio
} }
/// Returns all user interactions for a wall /// Returns all user interactions for a wall
pub fn user_interactions_for_wall(wall_uid: Signal<models::WallUid>) -> RonResource<BTreeMap<models::Problem, models::UserInteraction>> { pub fn user_interactions(wall_uid: Signal<models::WallUid>) -> RonResource<BTreeMap<models::ProblemUid, models::UserInteraction>> {
Resource::new_with_options( Resource::new_with_options(
move || wall_uid.get(), move || wall_uid.get(),
move |wall_uid| async move { move |wall_uid| async move { crate::server_functions::get_user_interactions(wall_uid).await.map(RonEncoded::into_inner) },
crate::server_functions::get_user_interactions_for_wall(wall_uid)
.await
.map(RonEncoded::into_inner)
},
false, false,
) )
} }

View File

@@ -121,6 +121,13 @@ pub async fn init_at_current_version(db: &Database) -> Result<(), DatabaseOperat
assert!(table.is_empty()?); assert!(table.is_empty()?);
} }
// Problems table
{
// Opening the table creates the table
let table = txn.open_table(current::TABLE_PROBLEMS)?;
assert!(table.is_empty()?);
}
// User table // User table
{ {
// Opening the table creates the table // Opening the table creates the table
@@ -140,23 +147,11 @@ use crate::models;
pub mod current { pub mod current {
use super::v2; use super::v2;
use super::v3; use super::v3;
use super::v4; pub use v2::TABLE_PROBLEMS;
pub use v2::TABLE_ROOT; pub use v2::TABLE_ROOT;
pub use v2::TABLE_WALLS;
pub use v3::TABLE_USER;
pub use v3::VERSION; pub use v3::VERSION;
pub use v4::TABLE_USER;
pub use v4::TABLE_WALLS;
}
pub mod v4 {
use crate::models;
use crate::server::db::bincode::Bincode;
use redb::TableDefinition;
pub const VERSION: u64 = 4;
pub const TABLE_WALLS: TableDefinition<Bincode<models::v2::WallUid>, Bincode<models::v4::Wall>> = TableDefinition::new("walls");
pub const TABLE_USER: TableDefinition<Bincode<(models::v2::WallUid, models::v4::Problem)>, Bincode<models::v4::UserInteraction>> =
TableDefinition::new("user");
} }
pub mod v3 { pub mod v3 {

View File

@@ -1,118 +1,12 @@
use super::db::Database; use super::db::Database;
use super::db::DatabaseOperationError; use super::db::DatabaseOperationError;
use super::db::{self}; use super::db::{self};
use crate::models;
use redb::ReadableTable;
use std::collections::BTreeMap;
#[tracing::instrument(skip_all, err)] #[tracing::instrument(skip_all, err)]
pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Error>> { pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
if is_at_version(db, 2).await? { if is_at_version(db, 2).await? {
migrate_to_v3(db).await?; migrate_to_v3(db).await?;
} }
if is_at_version(db, 3).await? {
migrate_to_v4(db).await?;
}
Ok(())
}
/// migrate: walls table
/// migrate: user table
/// remove: problems table
#[tracing::instrument(skip_all, err)]
pub async fn migrate_to_v4(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
tracing::warn!("MIGRATING TO VERSION 4");
db.write(|txn| {
let walls_dump = txn
.open_table(db::v2::TABLE_WALLS)?
.iter()?
.map(|el| {
let (k, v) = el.unwrap();
(k.value(), v.value())
})
.collect::<BTreeMap<_, _>>();
let problems_dump = txn
.open_table(db::v2::TABLE_PROBLEMS)?
.iter()?
.map(|el| {
let (k, v) = el.unwrap();
(k.value(), v.value())
})
.collect::<BTreeMap<_, _>>();
let user_dump = txn
.open_table(db::v3::TABLE_USER)?
.iter()?
.map(|el| {
let (k, v) = el.unwrap();
(k.value(), v.value())
})
.collect::<BTreeMap<_, _>>();
txn.delete_table(db::v2::TABLE_WALLS)?;
txn.delete_table(db::v2::TABLE_PROBLEMS)?;
txn.delete_table(db::v3::TABLE_USER)?;
let mut new_walls_table = txn.open_table(db::current::TABLE_WALLS)?;
let mut new_user_table = txn.open_table(db::current::TABLE_USER)?;
for (wall_uid, wall) in walls_dump.into_iter() {
let models::v2::Wall {
uid: _,
rows,
cols,
holds,
problems,
} = wall;
let problems = problems
.into_iter()
.map(|problem_uid| {
let old_prob = &problems_dump[&(wall_uid, problem_uid)];
let method = old_prob.method;
models::Problem {
pattern: models::Pattern {
pattern: old_prob.holds.clone(),
},
method,
}
})
.collect();
let wall = models::Wall {
uid: wall_uid,
wall_dimensions: models::WallDimensions { rows, cols },
holds,
problems,
};
new_walls_table.insert(wall_uid, wall)?;
}
for ((wall_uid, problem_uid), user_interaction) in user_dump.into_iter() {
let old_prob = &problems_dump[&(wall_uid, problem_uid)];
let problem = models::Problem {
pattern: models::Pattern {
pattern: old_prob.holds.clone(),
},
method: old_prob.method,
};
let key = (wall_uid, problem.clone());
let value = models::UserInteraction {
wall_uid,
problem,
attempted_on: user_interaction.attempted_on,
is_favorite: user_interaction.is_favorite,
};
new_user_table.insert(key, value)?;
}
Ok(())
})
.await?;
db.set_version(db::Version { version: db::v4::VERSION }).await?;
Ok(()) Ok(())
} }

View File

@@ -20,9 +20,11 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Database
tracing::info!("Parsing mini moonboard problems from {file_path}"); tracing::info!("Parsing mini moonboard problems from {file_path}");
let set_by = "mini-mb-2020-parser";
let mini_moonboard = mini_moonboard::parse(file_path.as_std_path()).await?; let mini_moonboard = mini_moonboard::parse(file_path.as_std_path()).await?;
for mini_mb_problem in mini_moonboard.problems { for mini_mb_problem in mini_moonboard.problems {
let mut pattern = BTreeMap::<HoldPosition, HoldRole>::new(); let mut holds = BTreeMap::<HoldPosition, HoldRole>::new();
for mv in mini_mb_problem.moves { for mv in mini_mb_problem.moves {
let row = mv.description.row(); let row = mv.description.row();
let col = mv.description.column(); let col = mv.description.column();
@@ -34,27 +36,43 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Database
(false, true) => HoldRole::End, (false, true) => HoldRole::End,
(false, false) => HoldRole::Normal, (false, false) => HoldRole::Normal,
}; };
pattern.insert(hold_position, role); holds.insert(hold_position, role);
} }
let name = mini_mb_problem.name;
let method = match mini_mb_problem.method { let method = match mini_mb_problem.method {
mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands, mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands,
mini_moonboard::Method::Footless => models::Method::Footless, mini_moonboard::Method::Footless => models::Method::Footless,
mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard, mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard,
}; };
let pattern = models::Pattern { pattern }.canonicalize(); let problem_id = models::ProblemUid::create();
let problem = models::Problem { pattern, method };
let problem = models::Problem {
uid: problem_id,
name,
set_by: set_by.to_owned(),
holds,
method,
date_added: chrono::Utc::now(),
};
problems.push(problem); problems.push(problem);
} }
db.write(|txn| { db.write(|txn| {
let mut walls_table = txn.open_table(db::current::TABLE_WALLS)?; let mut walls_table = txn.open_table(db::current::TABLE_WALLS)?;
let mut problems_table = txn.open_table(db::current::TABLE_PROBLEMS)?;
let mut wall = walls_table.get(wall_uid)?.unwrap().value(); let mut wall = walls_table.get(wall_uid)?.unwrap().value();
wall.problems.extend(problems); wall.problems.extend(problems.iter().map(|p| p.uid));
walls_table.insert(wall_uid, wall)?; walls_table.insert(wall_uid, wall)?;
for problem in problems {
let key = (wall_uid, problem.uid);
problems_table.insert(key, problem)?;
}
Ok(()) Ok(())
}) })
.await?; .await?;

View File

@@ -20,7 +20,7 @@ pub async fn get_walls() -> Result<RonEncoded<Vec<models::Wall>>, ServerFnError>
use crate::server::db::Database; use crate::server::db::Database;
use leptos::prelude::expect_context; use leptos::prelude::expect_context;
use redb::ReadableTable; use redb::ReadableTable;
crate::tracing::on_enter!(); tracing::trace!("Enter");
let db = expect_context::<Database>(); let db = expect_context::<Database>();
@@ -45,7 +45,7 @@ pub(crate) async fn get_wall_by_uid(wall_uid: models::WallUid) -> Result<RonEnco
use crate::server::db::Database; use crate::server::db::Database;
use crate::server::db::DatabaseOperationError; use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context; use leptos::prelude::expect_context;
crate::tracing::on_enter!(); tracing::trace!("Enter");
#[derive(Debug, derive_more::Error, derive_more::Display)] #[derive(Debug, derive_more::Error, derive_more::Display)]
enum Error { enum Error {
@@ -70,21 +70,17 @@ pub(crate) async fn get_wall_by_uid(wall_uid: models::WallUid) -> Result<RonEnco
Ok(RonEncoded::new(wall)) Ok(RonEncoded::new(wall))
} }
/// Returns user interaction for a single wall problem
#[server( #[server(
input = Ron, input = Ron,
output = Ron, output = Ron,
custom = RonEncoded custom = RonEncoded
)] )]
#[tracing::instrument(err(Debug))] #[tracing::instrument(err(Debug))]
pub(crate) async fn get_user_interaction( pub(crate) async fn get_problems_for_wall(wall_uid: models::WallUid) -> Result<RonEncoded<Vec<models::Problem>>, ServerFnError> {
wall_uid: models::WallUid,
problem: models::Problem,
) -> Result<RonEncoded<Option<models::UserInteraction>>, ServerFnError> {
use crate::server::db::Database; use crate::server::db::Database;
use crate::server::db::DatabaseOperationError; use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context; use leptos::prelude::expect_context;
crate::tracing::on_enter!(); tracing::trace!("Enter");
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)] #[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
enum Error { enum Error {
@@ -94,13 +90,74 @@ pub(crate) async fn get_user_interaction(
DatabaseOperation(DatabaseOperationError), DatabaseOperation(DatabaseOperationError),
} }
async fn inner(wall_uid: models::WallUid, problem: models::Problem) -> Result<Option<UserInteraction>, Error> { async fn inner(wall_uid: models::WallUid) -> Result<Vec<models::Problem>, Error> {
let db = expect_context::<Database>();
let problems = db
.read(|txn| {
let walls_table = txn.open_table(crate::server::db::current::TABLE_WALLS)?;
tracing::debug!("getting wall");
let wall = walls_table
.get(wall_uid)?
.ok_or(Error::WallNotFound(wall_uid))
.map_err(DatabaseOperationError::custom)?
.value();
tracing::debug!("got wall");
drop(walls_table);
tracing::debug!("open problems table");
let problems_table = txn.open_table(crate::server::db::current::TABLE_PROBLEMS)?;
tracing::debug!("opened problems table");
let mut problems = Vec::new();
for &problem_uid in &wall.problems {
if let Some(problem) = problems_table.get((wall_uid, problem_uid))? {
problems.push(problem.value());
}
}
Ok(problems)
})
.await?;
Ok(problems)
}
let problems = inner(wall_uid).await.map_err(error_reporter::Report::new).map_err(ServerFnError::new)?;
Ok(RonEncoded::new(problems))
}
/// Returns user interaction for a single wall problem
#[server(
input = Ron,
output = Ron,
custom = RonEncoded
)]
#[tracing::instrument(err(Debug))]
pub(crate) async fn get_user_interaction(
wall_uid: models::WallUid,
problem_uid: models::ProblemUid,
) -> Result<RonEncoded<Option<models::UserInteraction>>, ServerFnError> {
use crate::server::db::Database;
use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context;
tracing::trace!("Enter");
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::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) -> Result<Option<UserInteraction>, Error> {
let db = expect_context::<Database>(); let db = expect_context::<Database>();
let user_interaction = db let user_interaction = db
.read(|txn| { .read(|txn| {
let user_table = txn.open_table(crate::server::db::current::TABLE_USER)?; let user_table = txn.open_table(crate::server::db::current::TABLE_USER)?;
let user_interaction = user_table.get(&(wall_uid, problem))?.map(|guard| guard.value()); let user_interaction = user_table.get((wall_uid, problem_uid))?.map(|guard| guard.value());
Ok(user_interaction) Ok(user_interaction)
}) })
.await?; .await?;
@@ -108,7 +165,7 @@ pub(crate) async fn get_user_interaction(
Ok(user_interaction) Ok(user_interaction)
} }
let user_interaction = inner(wall_uid, problem) let user_interaction = inner(wall_uid, problem_uid)
.await .await
.map_err(error_reporter::Report::new) .map_err(error_reporter::Report::new)
.map_err(ServerFnError::new)?; .map_err(ServerFnError::new)?;
@@ -122,42 +179,31 @@ pub(crate) async fn get_user_interaction(
custom = RonEncoded custom = RonEncoded
)] )]
#[tracing::instrument(err(Debug))] #[tracing::instrument(err(Debug))]
pub(crate) async fn get_user_interactions_for_wall( pub(crate) async fn get_user_interactions(
wall_uid: models::WallUid, wall_uid: models::WallUid,
) -> Result<RonEncoded<BTreeMap<models::Problem, models::UserInteraction>>, ServerFnError> { ) -> Result<RonEncoded<BTreeMap<models::ProblemUid, models::UserInteraction>>, ServerFnError> {
use crate::server::db::Database; use crate::server::db::Database;
use crate::server::db::DatabaseOperationError; use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context; use leptos::prelude::expect_context;
use redb::ReadableTable; tracing::trace!("Enter");
crate::tracing::on_enter!();
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)] #[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
enum Error { enum Error {
DatabaseOperation(DatabaseOperationError), DatabaseOperation(DatabaseOperationError),
} }
async fn inner(wall_uid: models::WallUid) -> Result<BTreeMap<models::Problem, models::UserInteraction>, Error> { async fn inner(wall_uid: models::WallUid) -> Result<BTreeMap<models::ProblemUid, models::UserInteraction>, Error> {
let db = expect_context::<Database>(); let db = expect_context::<Database>();
let user_interactions = db let user_interactions = db
.read(|txn| { .read(|txn| {
let user_table = txn.open_table(crate::server::db::current::TABLE_USER)?; let user_table = txn.open_table(crate::server::db::current::TABLE_USER)?;
let user_interactions = user_table let range = user_table.range((wall_uid, models::ProblemUid::min())..=(wall_uid, models::ProblemUid::max()))?;
.iter()? let user_interactions = range
.filter(|guard| {
guard
.as_ref()
.map(|(key, _val)| {
let (wall_uid, _problem) = key.value();
wall_uid
})
.map(|wall_uid_| wall_uid_ == wall_uid)
.unwrap_or(false)
})
.map(|guard| { .map(|guard| {
guard.map(|(_key, val)| { guard.map(|(_key, val)| {
let val = val.value(); let val = val.value();
(val.problem.clone(), val) (val.problem_uid, val)
}) })
}) })
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
@@ -172,6 +218,43 @@ pub(crate) async fn get_user_interactions_for_wall(
Ok(RonEncoded::new(user_interaction)) Ok(RonEncoded::new(user_interaction))
} }
#[server(
input = Ron,
output = Ron,
custom = RonEncoded
)]
#[tracing::instrument(skip_all, err(Debug))]
pub(crate) async fn get_problem_by_uid(
wall_uid: models::WallUid,
problem_uid: models::ProblemUid,
) -> Result<RonEncoded<models::Problem>, ServerFnError> {
use crate::server::db::Database;
use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context;
tracing::trace!("Enter");
#[derive(Debug, derive_more::Error, derive_more::Display)]
enum Error {
#[display("Problem not found: {_0:?}")]
NotFound(#[error(not(source))] models::ProblemUid),
}
let db = expect_context::<Database>();
let problem = db
.read(|txn| {
let table = txn.open_table(crate::server::db::current::TABLE_PROBLEMS)?;
let problem = table
.get((wall_uid, problem_uid))?
.ok_or(Error::NotFound(problem_uid))
.map_err(DatabaseOperationError::custom)?
.value();
Ok(problem)
})
.await?;
Ok(RonEncoded::new(problem))
}
/// Inserts or updates today's attempt. /// Inserts or updates today's attempt.
#[server( #[server(
input = Ron, input = Ron,
@@ -181,14 +264,14 @@ pub(crate) async fn get_user_interactions_for_wall(
#[tracing::instrument(err(Debug))] #[tracing::instrument(err(Debug))]
pub(crate) async fn upsert_todays_attempt( pub(crate) async fn upsert_todays_attempt(
wall_uid: models::WallUid, wall_uid: models::WallUid,
problem: models::Problem, problem_uid: models::ProblemUid,
attempt: Option<models::Attempt>, attempt: Option<models::Attempt>,
) -> Result<RonEncoded<models::UserInteraction>, ServerFnError> { ) -> Result<RonEncoded<models::UserInteraction>, ServerFnError> {
use crate::server::db::Database; use crate::server::db::Database;
use crate::server::db::DatabaseOperationError; use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context; use leptos::prelude::expect_context;
crate::tracing::on_enter!(); tracing::trace!("Enter");
#[derive(Debug, Error, Display, From)] #[derive(Debug, Error, Display, From)]
enum Error { enum Error {
@@ -198,20 +281,20 @@ pub(crate) async fn upsert_todays_attempt(
DatabaseOperation(DatabaseOperationError), DatabaseOperation(DatabaseOperationError),
} }
async fn inner(wall_uid: models::WallUid, problem: models::Problem, attempt: Option<models::Attempt>) -> Result<UserInteraction, Error> { async fn inner(wall_uid: models::WallUid, problem_uid: models::ProblemUid, attempt: Option<models::Attempt>) -> Result<UserInteraction, Error> {
let db = expect_context::<Database>(); let db = expect_context::<Database>();
let user_interaction = db let user_interaction = db
.write(|txn| { .write(|txn| {
let mut user_table = txn.open_table(crate::server::db::current::TABLE_USER)?; let mut user_table = txn.open_table(crate::server::db::current::TABLE_USER)?;
let key = (wall_uid, problem.clone()); let key = (wall_uid, problem_uid);
// Pop or default // Pop or default
let mut user_interaction = user_table let mut user_interaction = user_table
.remove(&key)? .remove(key)?
.map(|guard| guard.value()) .map(|guard| guard.value())
.unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem)); .unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem_uid));
// If the last entry is from today, remove it // If the last entry is from today, remove it
if let Some(entry) = user_interaction.attempted_on.last_entry() { if let Some(entry) = user_interaction.attempted_on.last_entry() {
@@ -238,66 +321,7 @@ pub(crate) async fn upsert_todays_attempt(
Ok(user_interaction) Ok(user_interaction)
} }
inner(wall_uid, problem, attempt) inner(wall_uid, problem_uid, attempt)
.await
.map_err(error_reporter::Report::new)
.map_err(ServerFnError::new)
.map(RonEncoded::new)
}
/// Sets is_favorite field for a problem
#[server(
input = Ron,
output = Ron,
custom = RonEncoded
)]
#[tracing::instrument(err(Debug))]
pub(crate) async fn set_is_favorite(
wall_uid: models::WallUid,
problem: models::Problem,
is_favorite: bool,
) -> Result<RonEncoded<models::UserInteraction>, ServerFnError> {
use crate::server::db::Database;
use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context;
crate::tracing::on_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: models::Problem, is_favorite: bool) -> 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.clone());
// Pop or default
let mut user_interaction = user_table
.remove(&key)?
.map(|guard| guard.value())
.unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem));
user_interaction.is_favorite = is_favorite;
user_table.insert(key, user_interaction.clone())?;
Ok(user_interaction)
})
.await?;
Ok(user_interaction)
}
inner(wall_uid, problem, is_favorite)
.await .await
.map_err(error_reporter::Report::new) .map_err(error_reporter::Report::new)
.map_err(ServerFnError::new) .map_err(ServerFnError::new)

View File

@@ -1,22 +0,0 @@
macro_rules! where_am_i {
() => {{
fn f() {}
fn type_name_of<T>(_: T) -> &'static str {
std::any::type_name::<T>()
}
let name = type_name_of(f);
// `3` is the length of the `::f`.
&name[..name.len() - 3]
}};
}
pub(crate) use where_am_i;
macro_rules! on_enter {
() => {
tracing::trace!("Entering {}", crate::tracing::where_am_i!());
};
}
pub(crate) use on_enter;

View File

@@ -1,5 +1,7 @@
@import 'tailwindcss'; @import 'tailwindcss';
@config '../tailwind.config.js';
/* /*
The default border color has changed to `currentColor` in Tailwind CSS v4, The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still so we've added these compatibility styles to make sure everything still

View File

@@ -4,6 +4,18 @@
relative: true, relative: true,
files: ["*.html", "./src/**/*.rs"], files: ["*.html", "./src/**/*.rs"],
}, },
// https://tailwindcss.com/docs/content-configuration#using-regular-expressions
safelist: [
{
pattern: /bg-transparent/,
},
{
pattern: /grid-cols-.+/,
},
{
pattern: /grid-rows-.+/,
},
],
theme: { theme: {
extend: {}, extend: {},
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 160 KiB

18
flake.lock generated
View File

@@ -10,11 +10,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1746696290, "lastModified": 1741078851,
"narHash": "sha256-YokYinNgGIu80OErVMuFoIELhetzb45aWKTiKYNXvWA=", "narHash": "sha256-1Qu/Uu+yPUDhHM2XjTbwQqpSrYhhHu7TpHHrT7UO/0o=",
"owner": "plul", "owner": "plul",
"repo": "basecamp", "repo": "basecamp",
"rev": "108ef2874fd8f934602cda5bfdc0e58a541c6b4a", "rev": "3e4579d8b4400506e5f53069448b3471608b5281",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -25,11 +25,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1746576598, "lastModified": 1742456341,
"narHash": "sha256-FshoQvr6Aor5SnORVvh/ZdJ1Sa2U4ZrIMwKBX5k2wu0=", "narHash": "sha256-yvdnTnROddjHxoQqrakUQWDZSzVchczfsuuMOxg476c=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "b3582c75c7f21ce0b429898980eddbbf05c68e55", "rev": "7344a3b78128f7b1765dba89060b015fb75431a7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -53,11 +53,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1746671794, "lastModified": 1742524367,
"narHash": "sha256-V+mpk2frYIEm85iYf+KPDmCGG3zBRAEhbv0E3lHdG2U=", "narHash": "sha256-KzTwk/5ETJavJZYV1DEWdCx05M4duFCxCpRbQSKWpng=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "ceec434b8741c66bb8df5db70d7e629a9d9c598f", "rev": "70bf752d176b2ce07417e346d85486acea9040ef",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -8,9 +8,6 @@ fmt:
fd --extension=rs --exec-batch leptosfmt fd --extension=rs --exec-batch leptosfmt
bc-fmt bc-fmt
fix:
bc-fix
serve: serve:
RUST_BACKTRACE=1 cargo leptos watch -- serve RUST_BACKTRACE=1 cargo leptos watch -- serve

View File

@@ -1,16 +0,0 @@
# Random selection: Sample from filtered
[patterns with variations that satisfy filter]
[variations]
# Random selection no filter: Sample equally weighted patterns
[patterns]
[random variation within pattern]
Normalize: shift left, and use minimum of mirrored pattern pair
# Filter stats:
patterns: X
pattern variations: Y