Compare commits

..

21 Commits

Author SHA1 Message Date
4d609118de chore: add cargo shear exception for getrandom 2025-05-08 20:25:46 +02:00
d11bf28625 docs: update readme image 2025-05-08 20:24:38 +02:00
9bbe1dd214 chore: maintenance updates & leptos 0.8 2025-05-08 20:24:14 +02:00
e3ef695069 chore: remove canonicalize migration 2025-04-19 20:54:23 +02:00
5bdfd6835d feat: use on_enter 2025-04-19 20:53:01 +02:00
27716c5ec0 save/saved text 2025-04-06 23:27:11 +02:00
dea8c45939 feat: canonicalize patterns on import 2025-04-06 22:50:07 +02:00
22367f45f2 feat: like button 2025-04-06 22:44:47 +02:00
e5853268de feat: migration canonicalize problems 2025-04-06 21:11:09 +02:00
b37386b9e8 feat: sample from transformations 2025-04-03 23:12:42 +02:00
bd8b0fecf1 feat: transformation buttons 2025-04-01 21:00:31 +02:00
c15db2847d wip 2025-04-01 17:48:19 +02:00
0a95aca872 wip 2025-04-01 15:02:49 +02:00
91bea767d0 wip 2025-04-01 13:46:28 +02:00
ed6aa4b9c9 wip 2025-03-31 22:43:19 +02:00
d11f8510b4 wip 2025-03-31 16:25:20 +02:00
221e15d7ac transformation buttons 2025-03-31 13:20:47 +02:00
e403be8090 feat: refactor to controller component and redesign 2025-03-28 15:20:37 +01:00
58698a1087 better 2025-03-24 23:08:15 +01:00
f1be2dd735 refactor 2025-03-24 13:46:00 +01:00
d9406f98d1 chore: migrate to tailwindcss v4 2025-03-23 20:56:15 +01:00
36 changed files with 1818 additions and 1092 deletions

844
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.7", optional = true } axum = { version = "0.8", 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,18 +23,17 @@ derive_more = { version = "2", features = [
] } ] }
http = "1" http = "1"
image = { version = "0.25", optional = true } image = { version = "0.25", optional = true }
leptos = { version = "0.7.7", features = ["tracing"] } leptos = { version = "0.8", features = ["tracing"] }
leptos_axum = { version = "0.7", optional = true } leptos_axum = { version = "0.8", optional = true }
leptos_meta = { version = "0.7" } leptos_meta = { version = "0.8" }
leptos_router = { version = "0.7.0" } leptos_router = { version = "0.8" }
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.7.4", features = ["cbor"] } server_fn = { version = "0.8", 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"] }
@@ -46,16 +45,12 @@ 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.serde_json] [dev-dependencies]
version = "1" test-try = "0.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"]
@@ -65,7 +60,6 @@ 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",
@@ -77,6 +71,11 @@ 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,9 +42,8 @@ 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::Wall /> <Route path=path!("/wall/:wall_uid") view=pages::wall::Page />
<Route path=path!("/wall/:wall_uid/edit") view=pages::edit_wall::EditWall /> <Route path=path!("/wall/:wall_uid/holds") view=pages::holds::Page />
<Route path=path!("/wall/:wall_uid/routes") view=pages::routes::Routes />
</Routes> </Routes>
</Router> </Router>
} }

View File

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

View File

@@ -0,0 +1,27 @@
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 {
tracing::trace!("Enter"); crate::tracing::on_enter!();
let s = time_ago(date.get()); let s = time_ago(date.get());
@@ -19,9 +19,7 @@ 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(models::Attempt::Flash) => "text-cyan-500", Some(attempt) => attempt.gradient().class_text(),
Some(models::Attempt::Send) => "text-teal-500",
Some(models::Attempt::Attempt) => "text-pink-500",
None => "", None => "",
}; };

View File

@@ -7,45 +7,73 @@ use leptos::prelude::*;
pub fn Button( pub fn Button(
#[prop(into, optional)] icon: MaybeProp<Icon>, #[prop(into, optional)] icon: MaybeProp<Icon>,
#[prop(into)] text: Signal<String>, #[prop(into, optional)] text: MaybeProp<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 = icon.get().map(|i| { let icon_view = move || {
let icon_view = i.into_view(); icon.get().map(|i| {
let mut classes = "self-center".to_string(); let icon_view = i.into_view();
classes.push(' '); let mut classes = "self-center".to_string();
classes.push_str(margin); classes.push(' ');
classes.push(' '); classes.push_str(margin);
classes.push_str(color.class_text()); classes.push(' ');
classes.push_str(color.class_text());
view! { <div class=classes>{icon_view}</div> } view! { <div class=classes>{icon_view}</div> }
}); })
let separator = icon.get().is_some().then(|| {
let mut classes = "w-0.5 bg-gradient-to-br min-w-0.5".to_string();
classes.push(' ');
classes.push_str(color.class_from());
classes.push(' ');
classes.push_str(color.class_to());
view! { <div class=classes /> }
});
let text_view = {
let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string();
classes.push(' ');
classes.push_str(margin);
view! { <div class=classes>{text.get()}</div> }
}; };
let separator = move || {
(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();
classes.push(' ');
classes.push_str(color.class_from());
classes.push(' ');
classes.push_str(color.class_to());
view! { <div class=classes /> }
})
};
let text_view = move || {
text.get().map(|text| {
let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string();
classes.push(' ');
classes.push_str(margin);
view! { <div class=classes>{text}</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="hover:brightness-125 active:brightness-90"> <button type="button" class=class prop:disabled=prop_disabled on:click=on_click>
<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 text-white bg-white rounded-sm aspect-square mx-5 my-2.5"> <div class="self-center my-2.5 mx-5 text-white bg-white rounded-xs aspect-square">
<span class=("text-gray-950", move || checked.get())> <span class=("text-gray-950", move || checked.get())>
<icons::Check /> <icons::Check />
</span> </span>
@@ -16,7 +16,7 @@ pub fn Checkbox(checked: RwSignal<bool>, #[prop(into)] text: Signal<String>, #[p
}; };
let separator = { let separator = {
let mut classes = "w-0.5 bg-gradient-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());
classes.push(' '); classes.push(' ');
@@ -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 mx-5 my-2.5 uppercase w-full text-lg font-thin"> <div class="self-center my-2.5 mx-5 w-full text-lg font-thin uppercase">
{move || text.get()} {move || text.get()}
</div> </div>
}; };

View File

@@ -0,0 +1,13 @@
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,12 +7,16 @@ 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
@@ -23,12 +27,16 @@ 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(),
} }
} }
} }
@@ -117,6 +125,20 @@ 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! {
@@ -226,3 +248,55 @@ 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

@@ -6,7 +6,7 @@ pub fn OutlinedBox(children: Children, color: Gradient, #[prop(optional)] highli
let highlight = move || highlight.get().unwrap_or(false); let highlight = move || highlight.get().unwrap_or(false);
let outer_classes = move || { let outer_classes = move || {
let mut c = "p-0.5 bg-gradient-to-br rounded-lg".to_string(); let mut c = "p-0.5 bg-linear-to-br rounded-lg".to_string();
c.push(' '); c.push(' ');
c.push_str(color.class_from()); c.push_str(color.class_from());
c.push(' '); c.push(' ');
@@ -22,11 +22,14 @@ 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-pink-900", Gradient::PinkOrange => "bg-rose-900",
Gradient::CyanBlue => "bg-cyan-800", Gradient::CyanBlue => "bg-cyan-800",
Gradient::TealLime => "bg-teal-700", Gradient::TealLime => "bg-emerald-700",
Gradient::PurplePink => "bg-purple-900", Gradient::PurplePink => "bg-fuchsia-950",
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.get().holds.get(&hold_position).copied(); let role = move || problem.read().pattern.pattern.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,11 +28,17 @@ pub fn Problem(
holds.into_iter().collect_view() holds.into_iter().collect_view()
}; };
let grid_classes = move || format!("grid grid-rows-{} grid-cols-{} gap-3", dim.get().rows, dim.get().cols); let style = move || {
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 class=move || { grid_classes }>{holds}</div> <div style=style class="grid gap-3">
{holds}
</div>
</div> </div>
} }
} }
@@ -48,7 +54,7 @@ fn Hold(#[prop(into)] role: Signal<Option<HoldRole>>) -> impl IntoView {
Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"), Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"),
None => Some("brightness-50"), None => Some("brightness-50"),
}; };
let mut s = "min-w-2 bg-sky-100 aspect-square rounded".to_string(); let mut s = "min-w-2 bg-sky-100 aspect-square rounded-sm".to_string();
if let Some(c) = role_classes { if let Some(c) = role_classes {
s.push(' '); s.push(' ');
s.push_str(c); s.push_str(c);

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-y-1 gap-x-0.5 grid-cols-[auto,1fr]"> <div class="grid grid-rows-none gap-x-0.5 gap-y-1 grid-cols-[auto_1fr]">
<NameValue name="Name:" value=name />
<NameValue name="Method:" value=method /> <NameValue name="Method:" value=method />
<NameValue name="Set By:" value=set_by /> // <NameValue name="Name:" value=name />
// <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="font-light mr-4 text-right text-orange-300">{name.get()}</p> <p class="mr-4 font-light text-right text-orange-300">{name.get()}</p>
<p class="text-white">{value.get()}</p> <p class="text-white">{value.get()}</p>
} }
} }

9
crates/ascend/src/css.rs Normal file
View File

@@ -0,0 +1,9 @@
/// Tailwind's grid-rows-<n>
pub fn grid_rows_n(n: u64) -> String {
format!("grid-template-rows: repeat({n}, minmax(0, 1fr));")
}
/// Tailwind's grid-cols-<n>
pub fn grid_cols_n(n: u64) -> String {
format!("grid-template-columns: repeat({n}, minmax(0, 1fr));")
}

View File

@@ -1,40 +1,52 @@
#[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) -> &str { pub fn class_from(&self) -> &'static 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) -> &str { pub fn class_to(&self) -> &'static 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) -> &str { pub fn class_text(&self) -> &'static str {
match self { match self {
Gradient::PinkOrange => "text-pink-500", Gradient::PinkOrange => "text-rose-400",
Gradient::CyanBlue => "text-cyan-500", Gradient::CyanBlue => "text-cyan-500",
Gradient::TealLime => "text-teal-300", Gradient::TealLime => "text-emerald-300",
Gradient::PurplePink => "text-purple-500", Gradient::PurplePink => "text-fuchsia-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,46 +1,13 @@
pub mod app; pub mod app;
pub mod pages {
pub mod edit_wall;
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 resources;
pub mod codec; pub mod codec;
pub mod components;
pub mod css;
pub mod gradient;
pub mod models; pub mod models;
pub mod pages;
pub mod resources;
pub mod server_functions; pub mod server_functions;
pub mod tracing;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub mod server; pub mod server;
@@ -55,12 +22,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,22 +8,68 @@ 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 {
@@ -85,7 +131,6 @@ 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,
@@ -93,7 +138,7 @@ pub mod v2 {
pub problems: BTreeSet<ProblemUid>, pub problems: BTreeSet<ProblemUid>,
} }
#[derive(Debug, Copy, Clone, PartialEq, Eq)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WallDimensions { pub struct WallDimensions {
pub rows: u64, pub rows: u64,
pub cols: u64, pub cols: u64,
@@ -115,16 +160,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)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Display, Copy, Hash)]
pub enum Method { pub enum Method {
#[display("Feet follow hands")] #[display("Feet follow hands")]
FeetFollowHands, FeetFollowHands,
#[display("Footless")]
Footless,
#[display("Footless plus kickboard")] #[display("Footless plus kickboard")]
FootlessPlusKickboard, FootlessPlusKickboard,
#[display("Footless")]
Footless,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]

View File

@@ -1,16 +1,136 @@
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_uid: ProblemUid) -> Self { pub(crate) fn new(wall_uid: WallUid, problem: Problem) -> Self {
Self { Self {
wall_uid, wall_uid,
problem_uid, problem,
is_favorite: false, is_favorite: false,
attempted_on: BTreeMap::new(), attempted_on: BTreeMap::new(),
is_saved: false,
} }
} }
@@ -59,18 +179,15 @@ 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())
} }
@@ -101,4 +218,78 @@ 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

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

View File

@@ -24,7 +24,7 @@ struct RouteParams {
} }
#[component] #[component]
pub fn EditWall() -> impl IntoView { pub fn Page() -> 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,12 +84,18 @@ 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 grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-3", wall.rows, wall.cols); let style = {
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 class=move || { grid_classes.clone() }>{holds}</div> <div style=style class="grid gap-3">
{holds}
</div>
</div> </div>
} }
} }
@@ -155,7 +161,7 @@ fn Hold(wall_uid: models::WallUid, hold: models::Hold) -> impl IntoView {
view! { view! {
<button on:click=open_camera> <button on:click=open_camera>
<div class="bg-indigo-100 rounded aspect-square">{img}</div> <div class="bg-indigo-100 rounded-sm aspect-square">{img}</div>
</button> </button>
<input <input

View File

@@ -1,102 +0,0 @@
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,20 +29,18 @@ 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| {
let onclick = move |_mouse_event| { // import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid });
import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid }); // };
}; // view! {
// <p>"Import problems from"</p>
view! { // <button on:click=onclick>"Mini Moonboard"</button>
<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 Wall() -> impl IntoView { pub fn Page() -> impl IntoView {
tracing::debug!("Enter"); crate::tracing::on_enter!();
let route_params = leptos_router::hooks::use_params::<RouteParams>(); let route_params = leptos_router::hooks::use_params::<RouteParams>();
@@ -38,42 +38,211 @@ pub fn Wall() -> impl IntoView {
}); });
let wall = crate::resources::wall_by_uid(wall_uid); let wall = crate::resources::wall_by_uid(wall_uid);
let problems = crate::resources::problems_for_wall(wall_uid); let user_interactions = crate::resources::user_interactions_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">
<StyledHeader items=Signal::derive(header_items) /> <Suspense fallback=|| {
"loading"
}>
{move || Suspend::new(async move {
tracing::debug!("executing main suspend");
let wall = wall.await?;
let user_interactions = user_interactions.await?;
let user_interactions = RwSignal::new(user_interactions);
Ok::<_, ServerFnError>(view! { <Controller wall user_interactions /> })
})}
</Suspense>
</div>
}
}
<div class="m-2"> #[derive(Debug, Clone, Copy)]
<Transition fallback=|| ()> struct Context {
{move || Suspend::new(async move { wall: Signal<models::Wall>,
tracing::info!("executing main suspend"); user_interactions: Signal<BTreeMap<models::Problem, models::UserInteraction>>,
let wall = wall.await?; problem: Signal<Option<models::Problem>>,
let problems = problems.await?; filtered_problem_transformations: Signal<Vec<HashSet<models::Problem>>>,
let user_interactions = user_interactions.await?; user_interaction: Signal<Option<models::UserInteraction>>,
let v = view! { <WithWall wall problems user_interactions /> }; todays_attempt: Signal<Option<models::Attempt>>,
Ok::<_, ServerFnError>(v) latest_attempt: Signal<Option<models::DatedAttempt>>,
})} filter_holds: Signal<BTreeSet<models::HoldPosition>>,
</Transition> 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]
#[tracing::instrument(skip_all)]
fn Controller(
#[prop(into)] wall: Signal<models::Wall>,
#[prop(into)] user_interactions: RwSignal<BTreeMap<models::Problem, models::UserInteraction>>,
) -> impl IntoView {
crate::tracing::on_enter!();
// Extract data from URL
let (problem, set_problem) = leptos_router::hooks::query_signal::<models::Problem>("problem");
// Filter
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| {
set_filter_holds.update(move |set| {
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,
problem,
is_favorite,
}));
});
// Callback: Set specific problem
let cb_set_problem: Callback<models::Problem> = Callback::new(move |problem| {
set_problem.set(Some(problem));
});
// Callback: Set next problem to a random problem
let cb_set_random_problem: Callback<()> = Callback::new(move |_| {
// TODO: remove current problem from population
let population = filtered_problem_transformations.read();
let population = population.deref();
use rand::seq::IteratorRandom;
let mut rng = rand::rng();
// Pick pattern
let Some(problem_set) = population.iter().choose(&mut rng) else {
return;
};
// Pick problem out of pattern transformations
let Some(problem) = problem_set.iter().choose(&mut rng) else {
return;
};
set_problem.set(Some(problem.clone()));
});
// Callback: On click hold, Add/Remove hold position to problem filter
let cb_click_hold: Callback<models::HoldPosition> = Callback::new(move |hold_position| {
set_filter_holds.update(|set| {
if !set.remove(&hold_position) {
set.insert(hold_position);
}
});
});
// Set a problem when wall is set (loaded)
Effect::new(move |_prev_value| {
if problem.read().is_none() {
tracing::debug!("Setting initial problem");
cb_set_random_problem.run(());
}
});
// Update user interactions after submitting an attempt
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>
</div> </div>
} }
@@ -81,108 +250,132 @@ pub fn Wall() -> impl IntoView {
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn WithProblem(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoView { fn Transformations() -> impl IntoView {
tracing::trace!("Enter"); crate::tracing::on_enter!();
view! { <ProblemInfo problem /> } 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] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn WithWall( fn LikedButton() -> impl IntoView {
#[prop(into)] wall: Signal<models::Wall>, crate::tracing::on_enter!();
#[prop(into)] problems: Signal<Vec<models::Problem>>,
#[prop(into)] user_interactions: Signal<BTreeMap<models::ProblemUid, models::UserInteraction>>,
) -> impl IntoView {
tracing::trace!("Enter");
let wall_uid = Signal::derive(move || wall.read().uid); let _ctx = use_context::<Context>().unwrap();
let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem"); view! { <Button text="Saved" icon=Icon::HeartOutline color=Gradient::PinkRed /> }
}
// Filter #[component]
let (filter_holds, set_filter_holds) = signal(BTreeSet::new()); #[tracing::instrument(skip_all)]
let _filter_add_hold = move |hold_pos: models::HoldPosition| { fn HoldsButton() -> impl IntoView {
set_filter_holds.update(move |set| { crate::tracing::on_enter!();
set.insert(hold_pos);
});
};
let filter_remove_hold = move |hold_pos: models::HoldPosition| {
set_filter_holds.update(move |set| {
set.remove(&hold_pos);
});
};
let filtered_problems = Memo::new(move |_prev_val| { let ctx = use_context::<Context>().unwrap();
let filter_holds = filter_holds.get();
problems.with(|problems| { let link = move || format!("/wall/{}/edit", ctx.wall.read().uid);
problems
.iter() view! {
.filter(|problem| filter_holds.iter().all(|hold_pos| problem.holds.contains_key(hold_pos))) <a href=link>
.cloned() <Button text="Holds" icon=Icon::WrenchSolid />
.collect::<Vec<models::Problem>>() </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 |_| {
let problem = crate::resources::problem_by_uid_optional(wall_uid, problem_uid.into()); ctx.cb_set_is_favorite.run(!ui_toggle.get());
let user_interaction = signals::user_interaction(user_interactions, problem_uid.into());
let fn_next_problem = move || {
let problems = filtered_problems.read();
use rand::seq::IteratorRandom;
let mut rng = rand::rng();
let problem = problems.iter().choose(&mut rng);
let problem_uid = problem.map(|p| p.uid);
set_problem_uid.set(problem_uid);
};
// Set a problem when wall is set (loaded)
Effect::new(move |_prev_value| {
if problem_uid.get().is_none() {
tracing::debug!("Setting next problem");
fn_next_problem();
}
}); });
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 /> }
}
// merge outer option (resource hasn't resolved yet) with inner option (there is no problem for the wall) #[component]
let problem_signal = Signal::derive(move || problem.get().transpose().map(Option::flatten)); #[tracing::instrument(skip_all)]
fn NextProblemButton() -> impl IntoView {
crate::tracing::on_enter!();
let on_click_hold = move |hold_position: models::HoldPosition| { let ctx = use_context::<Context>().unwrap();
// Add/Remove hold position to problem filter
set_filter_holds.update(|set| {
if !set.remove(&hold_position) {
set.insert(hold_position);
}
});
};
let grid = view! { let on_click = Callback::new(move |_| ctx.cb_next_problem.run(()));
<Transition fallback=|| ()> view! { <Button icon=Icon::ArrowPath text="Randomize" on_click color=Gradient::PurpleBlue /> }
{move || { }
Suspend::new(async move {
tracing::debug!("executing grid suspend");
let view = view! {
<Grid wall=wall.get() problem=problem_signal on_click_hold />
};
Ok::<_, ServerFnError>(view)
})
}}
</Transition>
};
let filter = move || { #[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 filter_holds.get() { for hold_pos in ctx.filter_holds.get() {
let w = &*wall.read(); let w = &*ctx.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 |_| {
filter_remove_hold(hold_pos); ctx.cb_remove_hold_from_filter.run(hold_pos);
}; };
let v = view! { let v = view! {
<button on:click=onclick> <button on:click=onclick class="cursor-pointer">
<OnHoverRed> <OnHoverRed>
<Hold hold /> <Hold hold />
</OnHoverRed> </OnHoverRed>
@@ -192,11 +385,13 @@ fn WithWall(
} }
} }
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="font-light mr-4 text-right text-orange-300">{"Problems:"}</p> }; let name = view! { <p class="mr-4 font-light text-right text-orange-300">{"Problems:"}</p> };
let value = view! { <p class="text-white">{filtered_problems.read().len()}</p> }; let value = view! { <p class="text-white">{problems_count}</p> };
view! { view! {
<div class="grid grid-rows-none gap-y-1 gap-x-0.5 grid-cols-[auto,1fr]"> <div class="grid grid-rows-none gap-x-0.5 gap-y-1 grid-cols-[auto_1fr]">
{name} {value} {name} {value}
</div> </div>
} }
@@ -212,14 +407,16 @@ fn WithWall(
} }
let mut interaction_counters = InteractionCounters::default(); let mut interaction_counters = InteractionCounters::default();
let interaction_counters_view = { let interaction_counters_view = {
let user_ints = user_interactions.read(); let user_ints = ctx.user_interactions.read();
for problem in filtered_problems.read().iter() { for problem_set in ctx.filtered_problem_transformations.read().iter() {
if let Some(user_int) = user_ints.get(&problem.uid) { for problem in problem_set {
match user_int.best_attempt().map(|da| da.attempt) { if let Some(user_int) = user_ints.get(problem) {
Some(models::Attempt::Flash) => interaction_counters.flash += 1, match user_int.best_attempt().map(|da| da.attempt) {
Some(models::Attempt::Send) => interaction_counters.send += 1, Some(models::Attempt::Flash) => interaction_counters.flash += 1,
Some(models::Attempt::Attempt) => interaction_counters.attempt += 1, Some(models::Attempt::Send) => interaction_counters.send += 1,
None => {} Some(models::Attempt::Attempt) => interaction_counters.attempt += 1,
None => {}
}
} }
} }
} }
@@ -268,121 +465,43 @@ fn WithWall(
{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 WithUserInteraction( fn AttemptRadioGroup() -> impl IntoView {
#[prop(into)] wall_uid: Signal<models::WallUid>, crate::tracing::on_enter!();
#[prop(into)] problem_uid: Signal<models::ProblemUid>,
#[prop(into)] user_interaction: Signal<Option<models::UserInteraction>>,
) -> impl IntoView {
tracing::debug!("Enter WithUserInteraction");
let parent_user_interaction = user_interaction; let ctx = use_context::<Context>().unwrap();
let user_interaction = RwSignal::new(None); let problem = ctx.problem;
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 || todays_attempt.get() == Some(variant)); let ui_toggle = Signal::derive(move || ctx.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 {
wall_uid: wall_uid.get(), if let Some(problem) = problem.get() {
problem_uid: problem_uid.get(), ctx.cb_upsert_todays_attempt.run(server_functions::UpsertTodaysAttempt {
attempt, wall_uid: ctx.wall.read().uid,
})); problem,
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! { view! { <div class="flex flex-col gap-2 justify-evenly 2xl:flex-row">{attempt_radio_buttons}</div> }
<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 {
tracing::debug!("Enter"); crate::tracing::on_enter!();
let text = variant.to_string(); let text = variant.to_string();
let icon = variant.icon(); let icon = variant.icon();
@@ -396,13 +515,13 @@ fn AttemptRadioButton(variant: models::Attempt, #[prop(into)] selected: Signal<b
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn History(#[prop(into)] user_interaction: Signal<Option<models::UserInteraction>>) -> impl IntoView { fn History() -> impl IntoView {
tracing::debug!("Enter"); crate::tracing::on_enter!();
let latest_attempt = signals::latest_attempt(user_interaction); let ctx = use_context::<Context>().unwrap();
let attempts = move || { let attempts = move || {
user_interaction ctx.user_interaction
.read() .read()
.as_ref() .as_ref()
.iter() .iter()
@@ -416,52 +535,56 @@ fn History(#[prop(into)] user_interaction: Signal<Option<models::UserInteraction
}; };
let placeholder = move || { let placeholder = move || {
latest_attempt.read().is_none().then(|| { ctx.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! { <Section title="History">{placeholder} {attempts}</Section> } view! {
{placeholder}
{attempts}
}
} }
#[component] #[component]
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn Grid( fn Wall() -> impl IntoView {
wall: models::Wall, crate::tracing::on_enter!();
#[prop(into)] problem: Signal<Result<Option<models::Problem>, ServerFnError>>,
on_click_hold: impl Fn(models::HoldPosition) + 'static,
) -> impl IntoView {
tracing::debug!("Enter");
let on_click_hold = std::rc::Rc::new(on_click_hold); let ctx = use_context::<Context>().unwrap();
let mut cells = vec![]; move || {
for (&hold_position, hold) in &wall.holds { let wall = ctx.wall.read();
let role = move || problem.get().map(|o| o.and_then(|p| p.holds.get(&hold_position).copied()));
let role = Signal::derive(role);
let on_click = { let mut cells = vec![];
let on_click_hold = std::rc::Rc::clone(&on_click_hold); for (&hold_position, hold) in &wall.holds {
move |_| { let hold_role = signals::hold_role(ctx.problem, hold_position);
on_click_hold(hold_position);
} let on_click = move |_| {
ctx.cb_click_hold.run(hold_position);
};
let cell = view! {
<div class="cursor-pointer">
<Hold on:click=on_click role=hold_role hold=hold.clone() />
</div>
};
cells.push(cell);
}
let style = {
let grid_rows = crate::css::grid_rows_n(wall.wall_dimensions.rows);
let grid_cols = crate::css::grid_cols_n(wall.wall_dimensions.cols);
let max_width = format!("{}vh", wall.wall_dimensions.cols as f64 / wall.wall_dimensions.rows as f64 * 100.);
format!("max-height: 100vh; max-width: {max_width}; {}", [grid_rows, grid_cols].join(" "))
}; };
let cell = view! {
<div class="cursor-pointer">
<Hold on:click=on_click role hold=hold.clone() />
</div>
};
cells.push(cell);
}
let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-1", wall.rows, wall.cols,);
view! { view! {
<div class="grid grid-cols-[auto,1fr]"> <div style=style class="grid gap-1 p-1">
<div style="max-height: 90vh; max-width: 90vh;" class=move || { grid_classes.clone() }>
{cells} {cells}
</div> </div>
</div> }
} }
} }
@@ -473,20 +596,20 @@ fn Hold(
#[prop(optional)] #[prop(optional)]
#[prop(into)] #[prop(into)]
role: Option<Signal<Result<Option<HoldRole>, ServerFnError>>>, role: Option<Signal<Option<HoldRole>>>,
) -> impl IntoView { ) -> impl IntoView {
tracing::trace!("Enter"); crate::tracing::on_enter!();
move || { move || {
let mut class = "bg-sky-100 aspect-square rounded hover:brightness-125".to_string(); let mut class = "bg-sky-100 max-w-full max-h-full 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-offset-2 outline-green-500"), Some(HoldRole::Start) => Some("outline outline-3 outline-green-500"),
Some(HoldRole::Normal) => Some("outline outline-offset-2 outline-blue-500"), Some(HoldRole::Normal) => Some("outline outline-3 outline-blue-500"),
Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"), Some(HoldRole::Zone) => Some("outline outline-3 outline-amber-500"),
Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"), Some(HoldRole::End) => Some("outline outline-3 outline-red-500"),
None => Some("brightness-50"), None => Some("brightness-50"),
}; };
if let Some(c) = role_classes { if let Some(c) = role_classes {
@@ -500,20 +623,19 @@ fn Hold(
view! { <img class="object-cover w-full h-full" srcset=srcset /> } view! { <img class="object-cover w-full h-full" srcset=srcset /> }
}); });
let view = view! { <div class=class>{img}</div> }; view! { <div class=class>{img}</div> }
Ok::<_, ServerFnError>(view)
} }
} }
#[component] #[component]
fn Separator() -> impl IntoView { fn Separator() -> impl IntoView {
view! { <div class="m-2 sm:m-3 md:m-4 h-4" /> } view! { <div class="m-2 h-4 sm:m-3 md:m-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="bg-neutral-900 px-5 pt-3 pb-8 rounded-lg"> <div class="px-5 pt-3 pb-8 rounded-lg bg-neutral-900">
<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>
@@ -526,6 +648,8 @@ 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))
@@ -535,14 +659,73 @@ 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::ProblemUid, models::UserInteraction>>, user_interactions: Signal<BTreeMap<models::Problem, models::UserInteraction>>,
problem_uid: Signal<Option<models::ProblemUid>>, problem: Signal<Option<models::Problem>>,
) -> Signal<Option<models::UserInteraction>> { ) -> Signal<Option<models::UserInteraction>> {
Signal::derive(move || { Signal::derive(move || {
let problem_uid = problem_uid.get()?; let problem = problem.get()?;
let user_interactions = user_interactions.read(); let user_interactions = user_interactions.read();
user_interactions.get(&problem_uid).cloned() user_interactions.get(&problem).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,47 +17,15 @@ pub fn wall_by_uid(wall_uid: Signal<models::WallUid>) -> RonResource<models::Wal
) )
} }
/// Version of [problem_by_uid] that short circuits if the input problem_uid signal is None.
pub fn problem_by_uid_optional(
wall_uid: Signal<models::WallUid>,
problem_uid: Signal<Option<models::ProblemUid>>,
) -> RonResource<Option<models::Problem>> {
Resource::new_with_options(
move || (wall_uid.get(), problem_uid.get()),
move |(wall_uid, problem_uid)| async move {
let Some(problem_uid) = problem_uid else {
return Ok(None);
};
crate::server_functions::get_problem_by_uid(wall_uid, problem_uid)
.await
.map(RonEncoded::into_inner)
.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 /// Returns user interaction for a single problem
pub fn user_interaction( pub fn user_interaction(wall_uid: Signal<models::WallUid>, problem: Signal<Option<models::Problem>>) -> RonResource<Option<models::UserInteraction>> {
wall_uid: Signal<models::WallUid>,
problem_uid: Signal<Option<models::ProblemUid>>,
) -> RonResource<Option<models::UserInteraction>> {
Resource::new_with_options( Resource::new_with_options(
move || (wall_uid.get(), problem_uid.get()), move || (wall_uid.get(), problem.get()),
move |(wall_uid, problem_uid)| async move { move |(wall_uid, problem)| async move {
let Some(problem_uid) = problem_uid else { let Some(problem) = problem else {
return Ok(None); return Ok(None);
}; };
crate::server_functions::get_user_interaction(wall_uid, problem_uid) crate::server_functions::get_user_interaction(wall_uid, problem)
.await .await
.map(RonEncoded::into_inner) .map(RonEncoded::into_inner)
}, },
@@ -66,10 +34,14 @@ pub fn user_interaction(
} }
/// Returns all user interactions for a wall /// Returns all user interactions for a wall
pub fn user_interactions(wall_uid: Signal<models::WallUid>) -> RonResource<BTreeMap<models::ProblemUid, models::UserInteraction>> { pub fn user_interactions_for_wall(wall_uid: Signal<models::WallUid>) -> RonResource<BTreeMap<models::Problem, models::UserInteraction>> {
Resource::new_with_options( Resource::new_with_options(
move || wall_uid.get(), move || wall_uid.get(),
move |wall_uid| async move { crate::server_functions::get_user_interactions(wall_uid).await.map(RonEncoded::into_inner) }, move |wall_uid| async move {
crate::server_functions::get_user_interactions_for_wall(wall_uid)
.await
.map(RonEncoded::into_inner)
},
false, false,
) )
} }

View File

@@ -121,13 +121,6 @@ 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
@@ -147,11 +140,23 @@ use crate::models;
pub mod current { pub mod current {
use super::v2; use super::v2;
use super::v3; use super::v3;
pub use v2::TABLE_PROBLEMS; use super::v4;
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,12 +1,118 @@
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,11 +20,9 @@ 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 holds = BTreeMap::<HoldPosition, HoldRole>::new(); let mut pattern = 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();
@@ -36,43 +34,27 @@ 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,
}; };
holds.insert(hold_position, role); pattern.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 problem_id = models::ProblemUid::create(); let pattern = models::Pattern { pattern }.canonicalize();
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.iter().map(|p| p.uid)); wall.problems.extend(problems);
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;
tracing::trace!("Enter"); crate::tracing::on_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;
tracing::trace!("Enter"); crate::tracing::on_enter!();
#[derive(Debug, derive_more::Error, derive_more::Display)] #[derive(Debug, derive_more::Error, derive_more::Display)]
enum Error { enum Error {
@@ -70,63 +70,6 @@ pub(crate) async fn get_wall_by_uid(wall_uid: models::WallUid) -> Result<RonEnco
Ok(RonEncoded::new(wall)) Ok(RonEncoded::new(wall))
} }
#[server(
input = Ron,
output = Ron,
custom = RonEncoded
)]
#[tracing::instrument(err(Debug))]
pub(crate) async fn get_problems_for_wall(wall_uid: models::WallUid) -> Result<RonEncoded<Vec<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, 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) -> 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 /// Returns user interaction for a single wall problem
#[server( #[server(
input = Ron, input = Ron,
@@ -136,12 +79,12 @@ pub(crate) async fn get_problems_for_wall(wall_uid: models::WallUid) -> Result<R
#[tracing::instrument(err(Debug))] #[tracing::instrument(err(Debug))]
pub(crate) async fn get_user_interaction( pub(crate) async fn get_user_interaction(
wall_uid: models::WallUid, wall_uid: models::WallUid,
problem_uid: models::ProblemUid, problem: models::Problem,
) -> Result<RonEncoded<Option<models::UserInteraction>>, ServerFnError> { ) -> 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;
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 {
@@ -151,13 +94,13 @@ pub(crate) async fn get_user_interaction(
DatabaseOperation(DatabaseOperationError), DatabaseOperation(DatabaseOperationError),
} }
async fn inner(wall_uid: models::WallUid, problem_uid: models::ProblemUid) -> Result<Option<UserInteraction>, Error> { async fn inner(wall_uid: models::WallUid, problem: models::Problem) -> 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_uid))?.map(|guard| guard.value()); let user_interaction = user_table.get(&(wall_uid, problem))?.map(|guard| guard.value());
Ok(user_interaction) Ok(user_interaction)
}) })
.await?; .await?;
@@ -165,7 +108,7 @@ pub(crate) async fn get_user_interaction(
Ok(user_interaction) Ok(user_interaction)
} }
let user_interaction = inner(wall_uid, problem_uid) let user_interaction = inner(wall_uid, problem)
.await .await
.map_err(error_reporter::Report::new) .map_err(error_reporter::Report::new)
.map_err(ServerFnError::new)?; .map_err(ServerFnError::new)?;
@@ -179,31 +122,42 @@ 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( pub(crate) async fn get_user_interactions_for_wall(
wall_uid: models::WallUid, wall_uid: models::WallUid,
) -> Result<RonEncoded<BTreeMap<models::ProblemUid, models::UserInteraction>>, ServerFnError> { ) -> Result<RonEncoded<BTreeMap<models::Problem, 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;
tracing::trace!("Enter"); use redb::ReadableTable;
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::ProblemUid, models::UserInteraction>, Error> { async fn inner(wall_uid: models::WallUid) -> Result<BTreeMap<models::Problem, 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 range = user_table.range((wall_uid, models::ProblemUid::min())..=(wall_uid, models::ProblemUid::max()))?; let user_interactions = user_table
let user_interactions = range .iter()?
.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_uid, val) (val.problem.clone(), val)
}) })
}) })
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
@@ -218,43 +172,6 @@ pub(crate) async fn get_user_interactions(
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,
@@ -264,14 +181,14 @@ pub(crate) async fn get_problem_by_uid(
#[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_uid: models::ProblemUid, problem: models::Problem,
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;
tracing::trace!("Enter"); crate::tracing::on_enter!();
#[derive(Debug, Error, Display, From)] #[derive(Debug, Error, Display, From)]
enum Error { enum Error {
@@ -281,20 +198,20 @@ pub(crate) async fn upsert_todays_attempt(
DatabaseOperation(DatabaseOperationError), DatabaseOperation(DatabaseOperationError),
} }
async fn inner(wall_uid: models::WallUid, problem_uid: models::ProblemUid, attempt: Option<models::Attempt>) -> Result<UserInteraction, Error> { async fn inner(wall_uid: models::WallUid, problem: models::Problem, 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_uid); let key = (wall_uid, problem.clone());
// 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_uid)); .unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem));
// 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() {
@@ -321,7 +238,66 @@ pub(crate) async fn upsert_todays_attempt(
Ok(user_interaction) Ok(user_interaction)
} }
inner(wall_uid, problem_uid, attempt) inner(wall_uid, problem, 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

@@ -0,0 +1,22 @@
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,3 +1,19 @@
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities; /*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}

View File

@@ -4,21 +4,9 @@
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: {},
}, },
plugins: [], plugins: [],
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 392 KiB

18
flake.lock generated
View File

@@ -10,11 +10,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1740523129, "lastModified": 1746696290,
"narHash": "sha256-q/k/T9Hf+aCo8/xQnqyw+E7dYx8Nq1u7KQ2ylORcP+M=", "narHash": "sha256-YokYinNgGIu80OErVMuFoIELhetzb45aWKTiKYNXvWA=",
"owner": "plul", "owner": "plul",
"repo": "basecamp", "repo": "basecamp",
"rev": "0882906c106ab0bf193b3417c845c5accbec2419", "rev": "108ef2874fd8f934602cda5bfdc0e58a541c6b4a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -25,11 +25,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1740547748, "lastModified": 1746576598,
"narHash": "sha256-Ly2fBL1LscV+KyCqPRufUBuiw+zmWrlJzpWOWbahplg=", "narHash": "sha256-FshoQvr6Aor5SnORVvh/ZdJ1Sa2U4ZrIMwKBX5k2wu0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3a05eebede89661660945da1f151959900903b6a", "rev": "b3582c75c7f21ce0b429898980eddbbf05c68e55",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -53,11 +53,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1740623427, "lastModified": 1746671794,
"narHash": "sha256-3SdPQrZoa4odlScFDUHd4CUPQ/R1gtH4Mq9u8CBiK8M=", "narHash": "sha256-V+mpk2frYIEm85iYf+KPDmCGG3zBRAEhbv0E3lHdG2U=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "d342e8b5fd88421ff982f383c853f0fc78a847ab", "rev": "ceec434b8741c66bb8df5db70d7e629a9d9c598f",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -47,7 +47,7 @@
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.cargo-leptos pkgs.cargo-leptos
pkgs.dart-sass pkgs.dart-sass
pkgs.tailwindcss pkgs.tailwindcss_4
# For optimizing wasm release builds # For optimizing wasm release builds
pkgs.binaryen pkgs.binaryen
@@ -137,7 +137,7 @@
pkgs.cargo-leptos pkgs.cargo-leptos
pkgs.leptosfmt pkgs.leptosfmt
pkgs.dart-sass pkgs.dart-sass
pkgs.tailwindcss pkgs.tailwindcss_4
pkgs.tailwindcss-language-server pkgs.tailwindcss-language-server
# For optimizing wasm release builds # For optimizing wasm release builds
@@ -146,6 +146,7 @@
env.RUST_LOG = "info,ascend=debug"; env.RUST_LOG = "info,ascend=debug";
env.MOONBOARD_PROBLEMS = "moonboard-problems"; env.MOONBOARD_PROBLEMS = "moonboard-problems";
env.LEPTOS_TAILWIND_VERSION = "v4.0.8";
}; };
}; };
} }

View File

@@ -8,6 +8,9 @@ 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

16
notes.txt Normal file
View File

@@ -0,0 +1,16 @@
# 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